diff --git a/build_scripts/gen_api_md.py b/build_scripts/gen_api_md.py index 41a52c3e76..a3da0fedaa 100644 --- a/build_scripts/gen_api_md.py +++ b/build_scripts/gen_api_md.py @@ -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})" + 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: @@ -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) @@ -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", @@ -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) diff --git a/pyrit/models/conversation_reference.py b/pyrit/models/conversation_reference.py index 33d5e2d880..0915a045c4 100644 --- a/pyrit/models/conversation_reference.py +++ b/pyrit/models/conversation_reference.py @@ -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: @@ -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: diff --git a/pyrit/models/retry_event.py b/pyrit/models/retry_event.py index 79bb2bbb6c..46a6e79fcf 100644 --- a/pyrit/models/retry_event.py +++ b/pyrit/models/retry_event.py @@ -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: @@ -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: diff --git a/tests/unit/build_scripts/test_gen_api_md.py b/tests/unit/build_scripts/test_gen_api_md.py index 5eca97161a..db4472d300 100644 --- a/tests/unit/build_scripts/test_gen_api_md.py +++ b/tests/unit/build_scripts/test_gen_api_md.py @@ -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, ) @@ -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