Skip to content
Closed
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
42 changes: 38 additions & 4 deletions build_scripts/gen_api_md.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,12 +392,11 @@ 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")
if bases:
parts.append(f"Bases: {_format_bases(bases, symbol_index)}\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 @@ -443,6 +442,38 @@ def render_alias(alias: dict) -> str:
return "\n".join(parts)


def _format_bases(bases: list[str], symbol_index: dict[str, list[SymbolEntry]] | None) -> str:
"""Format class bases, linking resolvable symbols."""
rendered_bases = []
for base in bases:
rendered = f"`{base}`"
if symbol_index is not None:
rendered = _rewrite_symbol_refs(rendered, symbol_index)
rendered_bases.append(rendered)
return ", ".join(rendered_bases)


def _format_reexport_alias(mod_name: str, alias_name: str, symbol_index: dict[str, list[SymbolEntry]] | None) -> str:
"""Format a re-export alias name, preferring module-qualified lookup."""
rendered = f"`{alias_name}`"
if symbol_index is None:
return rendered

module_qualified = _resolve_symbol(f"{mod_name}.{alias_name}", symbol_index, current_class=None)
if module_qualified is not None:
return f"[{rendered}](#{module_qualified.anchor})"

return _rewrite_symbol_refs(rendered, symbol_index)


def _format_reexport_target(target: str, symbol_index: dict[str, list[SymbolEntry]] | None) -> str:
"""Format a re-export target, linking resolvable symbols."""
rendered = f"`{target}`"
if symbol_index is None:
return rendered
return _rewrite_symbol_refs(rendered, symbol_index)


def render_module(
data: dict,
*,
Expand All @@ -454,6 +485,7 @@ def render_module(
parts = [
"---",
f"short_title: {short_name}",
f"label: api-{_module_slug(mod_name)}",
"---\n",
f"# {mod_name}\n",
]
Expand All @@ -479,7 +511,9 @@ def render_module(
parts.append("## Re-exports\n")
for a in aliases:
target = a.get("target", "")
parts.append(f"- `{a['name']}` → `{target}`\n")
alias_md = _format_reexport_alias(mod_name, a["name"], symbol_index)
target_md = _format_reexport_target(target, symbol_index)
parts.append(f"- {alias_md} → {target_md}\n")

return "\n".join(parts)

Expand Down
4 changes: 2 additions & 2 deletions pyrit/auxiliary_attacks/gcg/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@

"""Public API for the Greedy Coordinate Gradient (GCG) auxiliary attack.

The primary entry point is :class:`GCG` (alias for :class:`GCGGenerator`), a
:class:`pyrit.executor.promptgen.core.PromptGeneratorStrategy` that produces
The primary entry point is ``GCG`` (alias for ``GCGGenerator``), a
``pyrit.executor.promptgen.core.PromptGeneratorStrategy`` that produces
adversarial suffixes via the GCG algorithm.

Example:
Expand Down
14 changes: 7 additions & 7 deletions pyrit/auxiliary_attacks/gcg/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ class GCGDataConfig:
Used as a typed bundle for AML transport (a job ships its data config as
a separate JSON file alongside the strategy ``GCGConfig``). Library
callers loading goals/targets from a CSV can construct one and pass it to
:func:`pyrit.auxiliary_attacks.gcg.data.load_goals_and_targets`.
``pyrit.auxiliary_attacks.gcg.data.load_goals_and_targets``.

Attributes:
train_data (str): URL or filesystem path to the training-data CSV. Empty
Expand Down Expand Up @@ -100,7 +100,7 @@ def to_json(self) -> str:

@classmethod
def from_json(cls, payload: str) -> GCGDataConfig:
"""Deserialize a config previously produced by :meth:`to_json`."""
"""Deserialize a config previously produced by ``to_json``."""
try:
data = json.loads(payload)
except json.JSONDecodeError as e:
Expand Down Expand Up @@ -240,10 +240,10 @@ class GCGOutputConfig:
class GCGConfig:
"""Top-level strategy configuration for one GCG attack run.

Bundles everything :class:`pyrit.auxiliary_attacks.gcg.GCGGenerator`'s
Bundles everything ``pyrit.auxiliary_attacks.gcg.GCGGenerator``'s
constructor needs. Per-execution data (goals, targets) is **not** here —
those flow through ``GCGGenerator.execute_async``, and for AML transport
they ride alongside this object as a separate :class:`GCGDataConfig` JSON.
they ride alongside this object as a separate ``GCGDataConfig`` JSON.

Attributes:
models (list[GCGModelConfig]): Training models the attack optimizes
Expand Down Expand Up @@ -287,11 +287,11 @@ def to_json(self) -> str:

@classmethod
def from_json(cls, payload: str) -> GCGConfig:
"""Deserialize a config previously produced by :meth:`to_json`.
"""Deserialize a config previously produced by ``to_json``.

Args:
payload (str): JSON document matching the shape produced by
:meth:`to_json`.
``to_json``.

Returns:
GCGConfig: A new ``GCGConfig`` reconstructed from ``payload``.
Expand All @@ -308,7 +308,7 @@ def from_json(cls, payload: str) -> GCGConfig:

@classmethod
def from_json_file(cls, path: str | Path) -> GCGConfig:
"""Load a config from a JSON file produced by :meth:`to_json_file`.
"""Load a config from a JSON file produced by ``to_json_file``.

Args:
path (str | Path): Filesystem path to a JSON config file.
Expand Down
4 changes: 2 additions & 2 deletions pyrit/auxiliary_attacks/gcg/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

"""CSV → goals/targets loader for the GCG attack.

Decoupled from :class:`GCGGenerator` so that callers with goals and targets
Decoupled from ``GCGGenerator`` so that callers with goals and targets
already in memory can pass them straight into ``execute_async`` without going
through ``pandas`` or any filesystem access.
"""
Expand Down Expand Up @@ -34,7 +34,7 @@ def load_goals_and_targets(
``train_data`` falls back to whatever default the legacy loader
returns (an empty list today).
random_seed (int): Seed used to shuffle the training rows. Defaults
to ``42`` to match :class:`GCGAlgorithmConfig`'s default.
to ``42`` to match ``GCGAlgorithmConfig``'s default.

Returns:
tuple[list[str], list[str], list[str], list[str]]:
Expand Down
8 changes: 4 additions & 4 deletions pyrit/auxiliary_attacks/gcg/experiments/run.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

"""Thin CLI wrapper around :meth:`GCGGenerator.execute_async` for AzureML jobs.
"""Thin CLI wrapper around ``GCGGenerator.execute_async`` for AzureML jobs.

The notebook (or any user) builds a :class:`GCGConfig` (strategy) and a
:class:`GCGDataConfig` (data) locally, serializes both with their respective
The notebook (or any user) builds a ``GCGConfig`` (strategy) and a
``GCGDataConfig`` (data) locally, serializes both with their respective
``to_json_file`` methods, ships them to Azure ML as job inputs, and the job's
command line is::

Expand All @@ -14,7 +14,7 @@
--output-dir ${{outputs.results}}

This file deserializes both configs inside the job, loads goals/targets from
the configured CSV, and runs the attack via a fresh :class:`GCGGenerator`.
the configured CSV, and runs the attack via a fresh ``GCGGenerator``.
"""

import argparse
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
12 changes: 6 additions & 6 deletions pyrit/setup/initializers/components/scenario_techniques.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
Scenario technique initializer.

This module owns the canonical catalog of scenario attack techniques as a
flat list of self-describing :class:`AttackTechniqueFactory` instances and
registers them into the singleton :class:`AttackTechniqueRegistry` via
:class:`ScenarioTechniqueInitializer`.
flat list of self-describing ``AttackTechniqueFactory`` instances and
registers them into the singleton ``AttackTechniqueRegistry`` via
``ScenarioTechniqueInitializer``.

Per-name registration is idempotent: pre-existing entries in the registry are
not overwritten.
Expand Down Expand Up @@ -41,14 +41,14 @@ def build_scenario_technique_factories() -> list[AttackTechniqueFactory]:

Factories that need an adversarial chat target do not bake one in; the
default adversarial target is resolved lazily inside
:meth:`AttackTechniqueFactory.create` via
``AttackTechniqueFactory.create`` via
``get_default_adversarial_target()``. Scenarios may also pass
``attack_adversarial_config_override`` at create time (but only when the
factory did not bake one in at construction).

A bare ``PromptSendingAttack`` factory is intentionally omitted from the
catalog: every scenario whose ``BASELINE_ATTACK_POLICY`` is
:attr:`BaselineAttackPolicy.Enabled` already auto-prepends an equivalent
``BaselineAttackPolicy.Enabled`` already auto-prepends an equivalent
baseline atomic attack via ``Scenario._build_baseline_atomic_attack``.

Returns:
Expand Down Expand Up @@ -120,7 +120,7 @@ class ScenarioTechniqueInitializer(PyRITInitializer):
``Scenario._build_baseline_atomic_attack``) already covers that case.

Registration is per-name idempotent: pre-existing entries in
:class:`AttackTechniqueRegistry` are not overwritten.
``AttackTechniqueRegistry`` are not overwritten.
"""

async def initialize_async(self) -> None:
Expand Down
86 changes: 86 additions & 0 deletions tests/unit/build_scripts/test_gen_api_md.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
_method_anchor,
_process_docstring_text,
_rewrite_symbol_refs,
render_class,
render_function,
render_module,
)


Expand Down Expand Up @@ -348,3 +350,87 @@ 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 test_render_class_links_known_base_but_keeps_external_bases_plain() -> None:
cls = {
"name": "AzureBlobStorageTarget",
"kind": "class",
"bases": ["PromptTarget", "str", "Enum"],
}
index = {
"PromptTarget": [
SymbolEntry(
module="pyrit.prompt_target",
kind="class",
name="PromptTarget",
qualname="PromptTarget",
anchor="api-pyrit_prompt_target-PromptTarget",
)
]
}

out = render_class(cls, module="pyrit.prompt_target.azure_blob_storage_target", symbol_index=index)
assert "Bases: [`PromptTarget`](#api-pyrit_prompt_target-PromptTarget), `str`, `Enum`" in out


def test_render_module_emits_page_label_frontmatter() -> None:
data = {"name": "pyrit.prompt_target", "members": []}
out = render_module(data, symbol_index={})

assert out.startswith("---\nshort_title: prompt_target\nlabel: api-pyrit_prompt_target\n---")


def test_render_module_reexports_prefer_module_qualified_alias_and_link_targets() -> None:
data = {
"name": "pyrit.prompt_target",
"members": [
{
"name": "PromptTargetAlias",
"kind": "alias",
"target": "pyrit.prompt_target.PromptTarget",
}
],
}
alias_entry = SymbolEntry(
module="pyrit.prompt_target",
kind="class",
name="PromptTargetAlias",
qualname="PromptTargetAlias",
anchor="api-pyrit_prompt_target-PromptTargetAlias",
)
target_entry = SymbolEntry(
module="pyrit.prompt_target",
kind="class",
name="PromptTarget",
qualname="PromptTarget",
anchor="api-pyrit_prompt_target-PromptTarget",
)
index = {
"PromptTargetAlias": [alias_entry],
"pyrit.prompt_target.PromptTargetAlias": [alias_entry],
"PromptTarget": [target_entry],
"pyrit.prompt_target.PromptTarget": [target_entry],
}

out = render_module(data, symbol_index=index)
assert "- [`PromptTargetAlias`](#api-pyrit_prompt_target-PromptTargetAlias) → " in out
assert "[`pyrit.prompt_target.PromptTarget`](#api-pyrit_prompt_target-PromptTarget)" in out


def test_render_module_reexports_fall_back_to_short_name_alias_lookup() -> None:
data = {
"name": "pyrit.prompt_target",
"members": [{"name": "PromptTargetAlias", "kind": "alias", "target": "external.Type"}],
}
alias_entry = SymbolEntry(
module="pyrit.aliases",
kind="class",
name="PromptTargetAlias",
qualname="PromptTargetAlias",
anchor="api-pyrit_aliases-PromptTargetAlias",
)
index = {"PromptTargetAlias": [alias_entry]}

out = render_module(data, symbol_index=index)
assert "- [`PromptTargetAlias`](#api-pyrit_aliases-PromptTargetAlias) → `external.Type`" in out