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
11 changes: 6 additions & 5 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ jobs:
run: |
python -m build_scripts.resolve_docs_matrix \
--config .github/docs-versions.yml \
--event-name "${{ github.event_name }}" \
--github-sha "${{ github.sha }}" \
--github-base-ref "${{ github.base_ref }}" \
--github-output "$GITHUB_OUTPUT"

# ------------------------------------------------------------------
Expand All @@ -96,11 +99,9 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && matrix.slug == 'latest' && github.event.pull_request.head.sha || matrix.ref }}

- name: Resolve commit SHA
# matrix.ref is a branch name (e.g. "releases/v0.13.0") that can move,
# so resolve to the actual commit SHA we just checked out. This is the
# primary cache key component: frozen release branches that don't move
# produce identical SHAs and hit the cache forever; main produces a
# new SHA on every push so it always rebuilds.
# matrix.ref is usually a branch name (e.g. "releases/v0.13.0") that can
# move; latest pull request builds use the event's merge SHA instead.
# Resolve either form to the actual checked-out SHA for the cache key.
id: sha
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"

Expand Down
58 changes: 45 additions & 13 deletions build_scripts/gen_api_md.py
Original file line number Diff line number Diff line change
Expand Up @@ -654,24 +654,56 @@ def _resolve_aliases(modules: list[dict], definition_index: dict, name_to_module


def _expand_module(module: dict) -> list[dict]:
"""Recursively expand pure-aggregate modules into their children.
"""Expand an aggregate module to the public API page frontier.

A pure-aggregate module has only submodule members and no direct public API
(classes, functions, aliases). Its children are returned instead, recursing
further if a child is also a pure aggregate.
A module with unique direct API keeps its own page. If that module also
contains submodules, each submodule branch is expanded until it reaches the
first module with direct API. Direct members already represented on that
frontier are omitted, and submodule objects are removed from the retained
parent so each module and symbol is represented only once.
"""
members = module.get("members", [])
has_api = any(m.get("kind") in ("class", "function", "alias") for m in members)
submodules = [m for m in members if m.get("kind") == "module"]
direct_members = [member for member in members if member.get("kind") != "module"]
submodules = [member for member in members if member.get("kind") == "module"]

if not submodules:
has_api = any(member.get("kind") in ("class", "function", "alias") for member in direct_members)
return [module] if has_api else []

frontier: list[dict] = []
for submodule in submodules:
frontier.extend(_expand_module_frontier(submodule))

descendant_api = {
(member.get("kind"), member.get("name"))
for descendant in frontier
for member in descendant.get("members", [])
if member.get("kind") in ("class", "function", "alias")
}
unique_direct_members = [
member
for member in direct_members
if member.get("kind") not in ("class", "function", "alias")
or (member.get("kind"), member.get("name")) not in descendant_api
]
has_unique_api = any(member.get("kind") in ("class", "function", "alias") for member in unique_direct_members)
if has_unique_api:
return [{**module, "members": unique_direct_members}, *frontier]
return frontier


def _expand_module_frontier(module: dict) -> list[dict]:
"""Expand pure aggregates, stopping at the first module with direct API."""
members = module.get("members", [])
has_api = any(member.get("kind") in ("class", "function", "alias") for member in members)
submodules = [member for member in members if member.get("kind") == "module"]

if has_api or not submodules:
# Module has its own API, or is a leaf – keep it (filter empty later)
if has_api:
return [module]

# Pure aggregate – recurse into children
result: list[dict] = []
for sub in submodules:
result.extend(_expand_module(sub))
for submodule in submodules:
result.extend(_expand_module_frontier(submodule))
return result


Expand All @@ -683,8 +715,8 @@ def collect_top_level_modules(api_json_dir: Path) -> list[dict]:
for the public packages users import from, not for deeply nested internal
submodules whose content is re-exported by the parent.

Pure-aggregate modules (those with only submodule members) are recursively
expanded so their children with real API surface get their own pages.
Aggregate modules are expanded to the first public API package on each
submodule branch. A mixed aggregate also keeps a page for its direct API.
"""
modules: list[dict] = []
for jf in sorted(api_json_dir.glob("*.json")):
Expand Down
55 changes: 51 additions & 4 deletions build_scripts/resolve_docs_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,50 @@ def load_config(config_path: Path) -> dict[str, Any]:
return cfg


def build_outputs(cfg: dict[str, Any]) -> dict[str, str]:
def _resolve_build_ref(
*,
version: dict[str, Any],
event_name: str | None,
github_sha: str | None,
github_base_ref: str | None,
) -> str:
"""Resolve the source revision for one docs matrix entry."""
if event_name != "pull_request" or version["ref"] != github_base_ref:
return version["ref"]
if not github_sha:
raise ValueError("github_sha is required when resolving a pull request docs build")
return github_sha


def build_outputs(
cfg: dict[str, Any],
*,
event_name: str | None = None,
github_sha: str | None = None,
github_base_ref: str | None = None,
) -> dict[str, str]:
"""Return the four GitHub Actions step outputs (all values are strings)."""
versions = cfg["versions"]
matrix = {"include": [{"slug": v["slug"], "ref": v["ref"]} for v in versions]}
if event_name == "pull_request":
if not github_base_ref:
raise ValueError("github_base_ref is required when resolving a pull request docs build")
if not any(version["ref"] == github_base_ref for version in versions):
raise ValueError(f"pull request base ref {github_base_ref!r} is not configured in docs versions")

matrix = {
"include": [
{
"slug": version["slug"],
"ref": _resolve_build_ref(
version=version,
event_name=event_name,
github_sha=github_sha,
github_base_ref=github_base_ref,
),
}
for version in versions
]
}
versions_json = {
"default": cfg["default"],
"stable": cfg["stable"],
Expand Down Expand Up @@ -95,16 +135,23 @@ def main(argv: list[str] | None = None) -> int:
type=Path,
help="Path to the $GITHUB_OUTPUT file. If omitted, outputs are written to stdout.",
)
parser.add_argument("--event-name", help="GitHub event name used to resolve event-specific build refs.")
parser.add_argument("--github-sha", help="GitHub event commit SHA used for a pull request build.")
parser.add_argument("--github-base-ref", help="Pull request base branch matched to a configured docs version.")
args = parser.parse_args(argv)

try:
cfg = load_config(args.config.resolve())
outputs = build_outputs(
cfg,
event_name=args.event_name,
github_sha=args.github_sha,
github_base_ref=args.github_base_ref,
)
except (FileNotFoundError, ValueError) as e:
print(f"error: {e}", file=sys.stderr)
return 1

outputs = build_outputs(cfg)

if args.github_output is not None:
with args.github_output.open("a", encoding="utf-8") as f:
write_outputs(f, outputs)
Expand Down
29 changes: 29 additions & 0 deletions tests/unit/build_scripts/test_gen_api_md.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
_build_symbol_index,
_class_anchor,
_example_link_path,
_expand_module,
_format_bases,
_format_reexport_alias,
_format_reexport_target,
Expand Down Expand Up @@ -45,6 +46,34 @@ def _fake_module(name: str, members: list[dict]) -> dict:
return {"name": name, "kind": "module", "members": members}


def test_expand_module_preserves_mixed_direct_api_and_public_frontier() -> None:
module = _fake_module(
"pyrit",
[
_fake_function("show_versions"),
_fake_module(
"pyrit.common",
[
_fake_function("resolve_lazy_export"),
_fake_class("CommonConfig"),
_fake_module("pyrit.common.internal", [_fake_class("InternalHelper")]),
],
),
_fake_function("resolve_lazy_export"),
_fake_module(
"pyrit.executor",
[_fake_module("pyrit.executor.attack", [_fake_class("AttackStrategy")])],
),
],
)

expanded = _expand_module(module)

assert [item["name"] for item in expanded] == ["pyrit", "pyrit.common", "pyrit.executor.attack"]
assert expanded[0]["members"] == [_fake_function("show_versions")]
assert len({item["name"] for item in expanded}) == len(expanded)


def test_anchor_helpers_produce_unique_labels() -> None:
assert _class_anchor("pyrit.prompt_target", "PromptTarget") == "api-pyrit_prompt_target-PromptTarget"
assert _function_anchor("pyrit.common", "validate_log_level") == "api-pyrit_common-validate_log_level"
Expand Down
79 changes: 79 additions & 0 deletions tests/unit/build_scripts/test_resolve_docs_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,85 @@ def test_build_outputs_shape(module, tmp_path):
assert len(payload["versions"]) == 3


def test_build_outputs_uses_merge_sha_for_main_target_pull_request(module, tmp_path):
cfg = module.load_config(_write_yaml(tmp_path, _VALID_YAML))

outputs = module.build_outputs(
cfg,
event_name="pull_request",
github_sha="abc123",
github_base_ref="main",
)

assert json.loads(outputs["matrix"]) == {
"include": [
{"slug": "latest", "ref": "abc123"},
{"slug": "0.13.0", "ref": "releases/v0.13.0"},
{"slug": "0.12.1", "ref": "releases/v0.12.1"},
]
}


def test_build_outputs_uses_merge_sha_for_release_target_pull_request(module, tmp_path):
cfg = module.load_config(_write_yaml(tmp_path, _VALID_YAML))

outputs = module.build_outputs(
cfg,
event_name="pull_request",
github_sha="abc123",
github_base_ref="releases/v0.13.0",
)

assert json.loads(outputs["matrix"]) == {
"include": [
{"slug": "latest", "ref": "main"},
{"slug": "0.13.0", "ref": "abc123"},
{"slug": "0.12.1", "ref": "releases/v0.12.1"},
]
}


@pytest.mark.parametrize("event_name", ["push", "workflow_dispatch"])
def test_build_outputs_uses_configured_refs_outside_pull_requests(module, tmp_path, event_name):
cfg = module.load_config(_write_yaml(tmp_path, _VALID_YAML))

outputs = module.build_outputs(
cfg,
event_name=event_name,
github_sha="abc123",
github_base_ref="releases/v0.13.0",
)

matrix = json.loads(outputs["matrix"])
assert matrix["include"][0] == {"slug": "latest", "ref": "main"}


def test_build_outputs_requires_sha_for_pull_request(module, tmp_path):
cfg = module.load_config(_write_yaml(tmp_path, _VALID_YAML))

with pytest.raises(ValueError, match="github_sha is required"):
module.build_outputs(cfg, event_name="pull_request", github_base_ref="main")


def test_build_outputs_requires_base_ref_for_pull_request(module, tmp_path):
cfg = module.load_config(_write_yaml(tmp_path, _VALID_YAML))

with pytest.raises(ValueError, match="github_base_ref is required"):
module.build_outputs(cfg, event_name="pull_request", github_sha="abc123")


def test_build_outputs_rejects_unconfigured_pull_request_base_ref(module, tmp_path):
cfg = module.load_config(_write_yaml(tmp_path, _VALID_YAML))

with pytest.raises(ValueError, match="'releases/v9.9.9' is not configured"):
module.build_outputs(
cfg,
event_name="pull_request",
github_sha="abc123",
github_base_ref="releases/v9.9.9",
)


def test_outputs_are_single_line(module, tmp_path):
"""GH Actions step outputs are key=value lines; no embedded newlines."""
cfg = module.load_config(_write_yaml(tmp_path, _VALID_YAML))
Expand Down
Loading