Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 62 additions & 5 deletions build_scripts/gen_api_md.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,57 @@ def _rewrite_param_table(params: list[dict], index: dict[str, list[SymbolEntry]]
p["desc"] = _rewrite_symbol_refs(p["desc"], index, current_class=current_class)


def _format_bases(bases: list[str], symbol_index: dict[str, list[SymbolEntry]] | None) -> str:
"""Render each base class as an individually-linkable code span.

Each base is wrapped in single backticks and run through the symbol
rewriter separately so that known PyRIT bases become MyST cross-reference
links while external bases (e.g. ``str``, ``Enum``) stay as plain code
spans. The comma-joined output keeps the rendered ``Bases:`` line readable
even when only some bases resolve.
"""
if not bases:
return ""
if symbol_index is None:
return ", ".join(f"`{b}`" for b in bases if b)
return ", ".join(_rewrite_symbol_refs(f"`{b}`", symbol_index) for b in bases if b)


def _format_reexport_alias(
mod_name: str,
name: str,
symbol_index: dict[str, list[SymbolEntry]] | None,
) -> str:
"""Render a re-export alias name as a MyST link when unambiguous.

Aliases usually live on the current module, so the module-qualified path
is tried first. If that lookup is unambiguous we link directly to it;
otherwise we fall back to the regular short-name rewriter so unresolvable
aliases get the same plain code-span treatment as the rest of the docs.
"""
if not name:
return ""
if symbol_index is None:
return f"`{name}`"
fqn = f"{mod_name}.{name}" if mod_name else name
entries = symbol_index.get(fqn)
if entries and len(entries) == 1:
return f"[`{name}`](#{entries[0].anchor})"
Comment thread
ValbuenaVC marked this conversation as resolved.
return _rewrite_symbol_refs(f"`{name}`", symbol_index)


def _format_reexport_target(
target: str,
symbol_index: dict[str, list[SymbolEntry]] | None,
) -> str:
"""Render a re-export target FQN as a MyST link when it resolves."""
if not target:
return ""
if symbol_index is None:
return f"`{target}`"
return _rewrite_symbol_refs(f"`{target}`", symbol_index)


def _rewrite_returns_or_raises(
items: list[dict], index: dict[str, list[SymbolEntry]], current_class: str | None
) -> None:
Expand Down Expand Up @@ -392,12 +443,12 @@ def render_class(
"""Render a class as markdown."""
name = cls["name"]
bases = cls.get("bases", [])
bases_str = f"({', '.join(bases)})" if bases else ""

anchor = _class_anchor(module, name)
parts = [f"({anchor})=", f"## `{name}`\n"]
if bases_str:
parts.append(f"Bases: `{bases_str[1:-1]}`\n")
bases_md = _format_bases(bases, symbol_index)
if bases_md:
parts.append(f"Bases: {bases_md}\n")

ds = cls.get("docstring", {})
text = _process_docstring_text(ds.get("text") if ds else None, symbol_index, current_class=name)
Expand Down Expand Up @@ -451,8 +502,10 @@ def render_module(
"""Render a full module page."""
mod_name = data["name"]
short_name = mod_name.rsplit(".", 1)[-1]
mod_label = f"api-{_module_slug(mod_name)}"
parts = [
"---",
f"label: {mod_label}",
f"short_title: {short_name}",
"---\n",
f"# {mod_name}\n",
Expand All @@ -478,8 +531,12 @@ def render_module(
if aliases:
parts.append("## Re-exports\n")
for a in aliases:
target = a.get("target", "")
parts.append(f"- `{a['name']}` → `{target}`\n")
name_md = _format_reexport_alias(mod_name, a.get("name", ""), symbol_index)
target_md = _format_reexport_target(a.get("target", ""), symbol_index)
if target_md:
parts.append(f"- {name_md} → {target_md}\n")
else:
parts.append(f"- {name_md}\n")

return "\n".join(parts)

Expand Down
4 changes: 2 additions & 2 deletions pyrit/models/conversation_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def to_dict(self) -> dict[str, str | None]:
Serialize to a JSON-compatible dictionary.

.. deprecated::
Use :meth:`model_dump` with ``mode="json"`` instead. This method
Use ``model_dump`` with ``mode="json"`` instead. This method
will be removed in version 0.16.0.

Returns:
Expand All @@ -76,7 +76,7 @@ def from_dict(cls, data: dict[str, str | None]) -> ConversationReference:
Reconstruct a ConversationReference from a dictionary.

.. deprecated::
Use :meth:`model_validate` instead. This method will be removed
Use ``model_validate`` instead. This method will be removed
in version 0.16.0.

Args:
Expand Down
4 changes: 2 additions & 2 deletions pyrit/models/retry_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def to_dict(self) -> dict:
Serialize to a dictionary suitable for JSON storage.

.. deprecated::
Use :meth:`model_dump` with ``mode="json"`` instead. This method
Use ``model_dump`` with ``mode="json"`` instead. This method
will be removed in version 0.16.0.

Returns:
Expand All @@ -57,7 +57,7 @@ def from_dict(cls, data: dict) -> RetryEvent:
Deserialize from a dictionary.

.. deprecated::
Use :meth:`model_validate` instead. This method will be removed
Use ``model_validate`` instead. This method will be removed
in version 0.16.0.

Args:
Expand Down
193 changes: 193 additions & 0 deletions tests/unit/build_scripts/test_gen_api_md.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@
SymbolEntry,
_build_symbol_index,
_class_anchor,
_format_bases,
_format_reexport_alias,
_format_reexport_target,
_function_anchor,
_method_anchor,
_process_docstring_text,
_rewrite_symbol_refs,
render_class,
render_function,
render_module,
)


Expand Down Expand Up @@ -348,3 +353,191 @@ def test_render_function_uses_method_anchor_when_class_name_given() -> None:
assert "(api-pyrit_prompt_target-PromptTarget-validate)=" in out
assert "#### `validate`" in out
assert "[``send_prompt_async``](#api-pyrit_prompt_target-PromptTarget-send_prompt_async)" in out


def _prompt_target_entry() -> SymbolEntry:
return SymbolEntry(
module="pyrit.prompt_target",
kind="class",
name="PromptTarget",
qualname="PromptTarget",
anchor="api-pyrit_prompt_target-PromptTarget",
)


def test_format_bases_links_known_pyrit_base() -> None:
index = {"PromptTarget": [_prompt_target_entry()]}
out = _format_bases(["PromptTarget"], index)
assert out == "[`PromptTarget`](#api-pyrit_prompt_target-PromptTarget)"


def test_format_bases_keeps_external_base_as_plain_code_span() -> None:
"""Bases not in the symbol index (stdlib types like ``str``/``Enum``) stay
as plain backtick code spans instead of being mangled into broken links."""
out = _format_bases(["str", "Enum"], {})
assert out == "`str`, `Enum`"


def test_format_bases_links_mixed_pyrit_and_external() -> None:
"""A mix of resolvable and external bases produces a clean
comma-separated list with only the known names linked."""
index = {"PromptTarget": [_prompt_target_entry()]}
out = _format_bases(["PromptTarget", "ABC", "Identifiable"], index)
assert out == "[`PromptTarget`](#api-pyrit_prompt_target-PromptTarget), `ABC`, `Identifiable`"


def test_format_bases_empty_or_none_returns_empty_string() -> None:
assert _format_bases([], {}) == ""
# Without a symbol index we still emit plain code spans.
assert _format_bases(["str"], None) == "`str`"


def test_render_class_emits_linked_bases_line() -> None:
"""End-to-end: a class with a known PyRIT base renders the ``Bases:`` line
as a MyST link rather than a plain code span."""
index = {"PromptTarget": [_prompt_target_entry()]}
cls = {"name": "MyTarget", "kind": "class", "bases": ["PromptTarget", "str"]}
out = render_class(cls, module="pyrit.factories", symbol_index=index)

assert "(api-pyrit_factories-MyTarget)=" in out
assert "Bases: [`PromptTarget`](#api-pyrit_prompt_target-PromptTarget), `str`" in out
# No accidental wrapper backticks around the whole comma-joined list.
assert "Bases: `PromptTarget" not in out


def test_render_class_without_bases_omits_bases_line() -> None:
cls = {"name": "Standalone", "kind": "class", "bases": []}
out = render_class(cls, module="pyrit.misc", symbol_index={})
assert "Bases:" not in out


def test_format_reexport_alias_prefers_module_qualified_lookup() -> None:
"""The alias usually lives on the re-exporting module, so the FQN form
(``mod_name.alias_name``) is tried before the short name."""
canonical = SymbolEntry(
module="pyrit.models",
kind="class",
name="SeedPrompt",
qualname="SeedPrompt",
anchor="api-pyrit_models-SeedPrompt",
)
re_exported = SymbolEntry(
module="pyrit",
kind="class",
name="SeedPrompt",
qualname="SeedPrompt",
anchor="api-pyrit-SeedPrompt",
)
index = {
"SeedPrompt": [canonical, re_exported],
"pyrit.SeedPrompt": [re_exported],
"pyrit.models.SeedPrompt": [canonical],
}
out = _format_reexport_alias("pyrit", "SeedPrompt", index)
# Picks the alias's own page rather than the canonical definition page.
assert out == "[`SeedPrompt`](#api-pyrit-SeedPrompt)"


def test_format_reexport_alias_falls_back_to_short_name() -> None:
"""When no module-qualified entry exists, the short-name rewriter still
links unambiguous names so the re-export remains navigable."""
entry = _prompt_target_entry()
index = {"PromptTarget": [entry], "pyrit.prompt_target.PromptTarget": [entry]}
out = _format_reexport_alias("pyrit", "PromptTarget", index)
assert out == "[`PromptTarget`](#api-pyrit_prompt_target-PromptTarget)"


def test_format_reexport_alias_leaves_unresolvable_name_plain() -> None:
out = _format_reexport_alias("pyrit.misc", "Mystery", {})
assert out == "`Mystery`"


def test_format_reexport_target_links_fqn_when_indexed() -> None:
entry = _prompt_target_entry()
index = {"pyrit.prompt_target.PromptTarget": [entry]}
out = _format_reexport_target("pyrit.prompt_target.PromptTarget", index)
assert out == "[`pyrit.prompt_target.PromptTarget`](#api-pyrit_prompt_target-PromptTarget)"


def test_format_reexport_target_leaves_unresolvable_target_plain() -> None:
out = _format_reexport_target("pyrit.unknown.Symbol", {})
assert out == "`pyrit.unknown.Symbol`"


def test_format_reexport_target_empty_returns_empty() -> None:
assert _format_reexport_target("", {}) == ""


def test_render_module_links_both_reexport_sides() -> None:
"""End-to-end: a module with an alias whose FQN target is in the index
renders both the alias name and the target as MyST links."""
canonical = _prompt_target_entry()
re_exported = SymbolEntry(
module="pyrit",
kind="class",
name="PromptTarget",
qualname="PromptTarget",
anchor="api-pyrit-PromptTarget",
)
index = {
"PromptTarget": [canonical, re_exported],
"pyrit.PromptTarget": [re_exported],
"pyrit.prompt_target.PromptTarget": [canonical],
}
module = _fake_module(
"pyrit",
[{"name": "PromptTarget", "kind": "alias", "target": "pyrit.prompt_target.PromptTarget"}],
)
out = render_module(module, symbol_index=index)

assert "## Re-exports" in out
assert "[`PromptTarget`](#api-pyrit-PromptTarget)" in out
assert "[`pyrit.prompt_target.PromptTarget`](#api-pyrit_prompt_target-PromptTarget)" in out
assert " → " in out


def test_render_module_leaves_unresolvable_reexport_target_plain() -> None:
"""When a re-export target points outside the index (e.g. a fake/external
path), it stays as a plain code span instead of becoming a broken link."""
canonical = _prompt_target_entry()
index = {
"PromptTarget": [canonical],
"pyrit.prompt_target.PromptTarget": [canonical],
}
module = _fake_module(
"pyrit",
[{"name": "Mystery", "kind": "alias", "target": "pyrit.unknown.Mystery"}],
)
out = render_module(module, symbol_index=index)

assert "- `Mystery` → `pyrit.unknown.Mystery`" in out
# Plain code spans, not links.
assert "(#" not in out.split("## Re-exports")[1]


def test_render_module_emits_module_level_anchor_in_frontmatter() -> None:
"""The page-level label is emitted as a frontmatter ``label:`` field so
cross-page references like ``[](#api-pyrit_prompt_target)`` target the
page itself. MyST consumes the H1 as the page title and discards any
label placed in the body before it, so frontmatter is the only reliable
place to bind a page-level anchor."""
module = _fake_module("pyrit.prompt_target", [_fake_class("PromptTarget")])
out = render_module(module, symbol_index={})

assert "label: api-pyrit_prompt_target" in out
# Heading still present.
assert "# pyrit.prompt_target" in out
# Frontmatter still wraps the page.
assert out.startswith("---")
assert "short_title: prompt_target" in out
# Label is inside the frontmatter, not after it.
fm_end = out.index("---\n", 4) # skip the opening "---"
assert out.index("label: api-pyrit_prompt_target") < fm_end


def test_render_module_label_uses_module_slug_for_nested_packages() -> None:
module = _fake_module("pyrit.executor.attack", [_fake_class("AttackStrategy")])
out = render_module(module, symbol_index={})

assert "label: api-pyrit_executor_attack" in out
assert "# pyrit.executor.attack" in out
Loading