From d83fffa9f0e9f0b71a36ab39f4c66b08477bce95 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Fri, 26 Jun 2026 19:23:58 +0300 Subject: [PATCH 01/16] feat: resolve documented reference symbols into dotted stems --- great_docs/core.py | 47 ++++++++++++++++++++++++++- tests/test_documented_symbols.py | 55 ++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 tests/test_documented_symbols.py diff --git a/great_docs/core.py b/great_docs/core.py index 15ad6d07..bb686f3c 100644 --- a/great_docs/core.py +++ b/great_docs/core.py @@ -8,8 +8,8 @@ from yaml12 import format_yaml, parse_yaml, read_yaml, write_yaml -from .config import Config from ._subprocess import TEXT_MODE_KWARGS +from .config import Config # Quarto's default input file types, enumerated as render globs. Used to seed # `project.render` whenever we also add `!` exclusions. A recursive `**` glob @@ -8597,6 +8597,51 @@ def _create_api_sections_from_config(self, package_name: str) -> list | None: return sections if sections else None + def documented_symbol_names(self, package_name: str) -> list[str]: + """Dotted reference-page stems for the documented public API + + Each stem names one published reference page: a top-level class or + function, a submodule-qualified class (`scores.CosineScore`), or a + method (`scores.CosineScore.fit`). The set is the same one the rendered + reference documents — the explicit `reference:` config when present, + otherwise auto-discovery — so a versioned snapshot and the live build + describe the same API surface. Empty when the package documents nothing. + + Parameters + ---------- + package_name + The package name (may contain dashes). + + Returns + ------- + list[str] + Dotted stems, deduplicated, in first-occurrence order. + """ + sections = self._create_api_sections_from_config(package_name) + if not sections: + return [] + + names: list[str] = [] + for section in sections: + for item in section.get("contents", []): + if isinstance(item, str): + names.append(item) + elif isinstance(item, dict): + name = item.get("name", "") + if not name: + continue + names.append(name) + for member in item.get("members", []) or []: + names.append(f"{name}.{member}") + + seen: set[str] = set() + unique: list[str] = [] + for name in names: + if name not in seen: + seen.add(name) + unique.append(name) + return unique + def _create_api_sections_with_config(self, package_name: str) -> list | None: """ Create API reference sections, prioritizing explicit config over auto-discovery. diff --git a/tests/test_documented_symbols.py b/tests/test_documented_symbols.py new file mode 100644 index 00000000..559d319a --- /dev/null +++ b/tests/test_documented_symbols.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import textwrap +from pathlib import Path + +from great_docs.core import GreatDocs + + +def _write_pkg(root: Path) -> None: + """A minimal package whose public API lives under a submodule, with a matching reference: config""" + (root / "pyproject.toml").write_text('[project]\nname = "mypkg"\nversion = "0.1.0"\n') + pkg = root / "mypkg" + sub = pkg / "sub" + sub.mkdir(parents=True) + (pkg / "__init__.py").write_text( + textwrap.dedent( + """ + from mypkg import sub + from mypkg.core import TopClass + __all__ = ["TopClass", "sub"] + """ + ) + ) + (pkg / "core.py").write_text("class TopClass:\n def go(self): ...\n") + (sub / "__init__.py").write_text( + "from mypkg.sub.things import Widget\n__all__ = ['Widget']\n" + ) + (sub / "things.py").write_text("class Widget:\n def fit(self): ...\n") + (root / "great-docs.yml").write_text( + textwrap.dedent( + """ + reference: + - title: API + contents: + - TopClass + - name: sub.Widget + members: + - fit + """ + ) + ) + + +def test_flattens_config_into_dotted_stems(tmp_path: Path): + _write_pkg(tmp_path) + gd = GreatDocs(project_path=str(tmp_path)) + names = gd.documented_symbol_names("mypkg") + assert names == ["TopClass", "sub.Widget", "sub.Widget.fit"] + + +def test_returns_empty_without_reference_config(tmp_path: Path): + _write_pkg(tmp_path) + (tmp_path / "great-docs.yml").write_text("logo: assets/logo.png\n") + gd = GreatDocs(project_path=str(tmp_path)) + assert gd.documented_symbol_names("mypkg") == [] From cb5f9714f2e148cf454213e33c132648db67da8b Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Fri, 26 Jun 2026 19:43:04 +0300 Subject: [PATCH 02/16] feat: capture documented nested symbols in API snapshots Add _symbol_info_for_dotted helper to resolve dotted names by walking the griffe tree. Teach snapshot_from_griffe to capture documented_names (nested symbols via dotted names) when provided; default (no documented_names) preserves existing top-level __all__ behavior for back-compat. --- great_docs/_api_diff.py | 53 +++++++++++++++++++++++++++++++++++++++-- tests/test_api_diff.py | 53 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/great_docs/_api_diff.py b/great_docs/_api_diff.py index 27b9d1dd..9515924a 100644 --- a/great_docs/_api_diff.py +++ b/great_docs/_api_diff.py @@ -526,11 +526,47 @@ def _extract_decorators(obj) -> list[str]: return decorators +def _symbol_info_for_dotted(pkg, dotted: str) -> SymbolInfo | None: + """SymbolInfo for the symbol named `dotted` in `pkg`, or None if absent + + None means the symbol does not exist in this source — a name component is + missing, or an alias cannot be resolved to a real object. + """ + import griffe + + obj = pkg + for part in dotted.split("."): + try: + members = obj.members + except (griffe.AliasResolutionError, griffe.CyclicAliasError): + return None + if part not in members: + return None + obj = members[part] + + try: + kind = obj.kind.value + except (griffe.AliasResolutionError, griffe.CyclicAliasError): + return None + + return SymbolInfo( + name=dotted, + kind=kind, + parameters=_extract_parameters(obj) if kind in ("function", "class") else [], + bases=_extract_bases(obj) if kind == "class" else [], + decorators=_extract_decorators(obj), + is_async=getattr(obj, "is_async", False), + return_annotation=_annotation_str( + getattr(obj, "annotation", None) or getattr(obj, "returns", None) + ), + ) + + def snapshot_from_griffe( - package_name: str, version: str, search_paths: list[str] | None = None + package_name: str, version: str, documented_names: list[str] | None = None, search_paths: list[str] | None = None ) -> ApiSnapshot: """ - Build an API snapshot by loading a package with griffe. + Build an API snapshot of a package's public API. Parameters ---------- @@ -538,6 +574,9 @@ def snapshot_from_griffe( The Python package name (e.g., `"great_tables"`). version Version label for this snapshot. + documented_names + Dotted names to capture, including submodule-qualified and method symbols. When `None`, + the snapshot holds only the package's top-level public exports. search_paths Additional paths to search for the package source. When loading a historical version extracted to a temp directory, pass its path here. @@ -545,6 +584,8 @@ def snapshot_from_griffe( Returns ------- ApiSnapshot + Snapshot keyed by symbol name — dotted names when `documented_names` is given, + bare top-level names otherwise. """ import griffe @@ -556,6 +597,14 @@ def snapshot_from_griffe( pkg = griffe.load(normalized, **loader_kwargs) + if documented_names: + symbols: dict[str, SymbolInfo] = {} + for dotted in documented_names: + info = _symbol_info_for_dotted(pkg, dotted) + if info is not None: + symbols[dotted] = info + return ApiSnapshot(version=version, package_name=normalized, symbols=symbols) + # Determine public exports exports: list[str] = [] if hasattr(pkg, "exports") and pkg.exports is not None: diff --git a/tests/test_api_diff.py b/tests/test_api_diff.py index 9899a489..e4e1dde7 100644 --- a/tests/test_api_diff.py +++ b/tests/test_api_diff.py @@ -9,6 +9,7 @@ import pytest from great_docs._api_diff import ( + _EVOLUTION_TABLE_CSS, ApiDiff, ApiSnapshot, CallEdge, @@ -20,7 +21,6 @@ SymbolHistory, SymbolHistoryEntry, SymbolInfo, - _EVOLUTION_TABLE_CSS, _annotation_str, _augment_params_with_separators, _describe_param_change, @@ -2444,3 +2444,54 @@ def test_snapshot_from_griffe_no_exports(): snap = snapshot_from_griffe("mypkg", "v1.0") assert "public_fn" in snap.symbols assert "_private" not in snap.symbols + + +def _write_griffe_pkg(root: Path) -> None: + """A package whose public API lives under a submodule, for snapshot tests""" + pkg = root / "mypkg" + sub = pkg / "sub" + sub.mkdir(parents=True) + (pkg / "__init__.py").write_text( + "from mypkg import sub\nfrom mypkg.core import TopClass\n__all__ = ['TopClass', 'sub']\n" + ) + (pkg / "core.py").write_text("class TopClass:\n def go(self): ...\n") + (sub / "__init__.py").write_text( + "from mypkg.sub.things import Widget\n__all__ = ['Widget']\n" + ) + (sub / "things.py").write_text( + "class Widget:\n def fit(self, x: int) -> None: ...\n" + ) + + +class TestDeepSnapshot: + """Tests for deep symbol capture in snapshot_from_griffe.""" + + def test_captures_documented_nested_symbols(self, tmp_path: Path): + """Captures nested symbols when documented_names is provided.""" + _write_griffe_pkg(tmp_path) + snap = snapshot_from_griffe( + "mypkg", + version="dev", + documented_names=["TopClass", "sub.Widget", "sub.Widget.fit"], + search_paths=[str(tmp_path)], + ) + assert set(snap.symbols) == {"TopClass", "sub.Widget", "sub.Widget.fit"} + assert snap.symbols["sub.Widget"].kind == "class" + assert snap.symbols["sub.Widget.fit"].kind == "function" + + def test_omits_names_that_do_not_resolve(self, tmp_path: Path): + """Omits names from documented_names that cannot be resolved.""" + _write_griffe_pkg(tmp_path) + snap = snapshot_from_griffe( + "mypkg", + version="dev", + documented_names=["sub.Widget", "sub.Nonexistent"], + search_paths=[str(tmp_path)], + ) + assert set(snap.symbols) == {"sub.Widget"} + + def test_default_behavior_unchanged_top_level(self, tmp_path: Path): + """Default behavior (no documented_names) unchanged — captures top-level only.""" + _write_griffe_pkg(tmp_path) + snap = snapshot_from_griffe("mypkg", version="dev", search_paths=[str(tmp_path)]) + assert set(snap.symbols) == {"TopClass", "sub"} From 0e1e8cc687eebb0fd4ec9cb74cea0ed86334e138 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Fri, 26 Jun 2026 19:46:14 +0300 Subject: [PATCH 03/16] feat: thread documented_names through snapshot_at_tag --- great_docs/_api_diff.py | 13 +++++++++-- tests/test_api_diff.py | 50 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/great_docs/_api_diff.py b/great_docs/_api_diff.py index 9515924a..a0e03716 100644 --- a/great_docs/_api_diff.py +++ b/great_docs/_api_diff.py @@ -907,6 +907,7 @@ def snapshot_at_tag( project_root: Path, tag: str, package_name: str, + documented_names: list[str] | None = None, ) -> ApiSnapshot | None: """ Build an API snapshot of a package at a specific git tag. @@ -919,18 +920,26 @@ def snapshot_at_tag( Git tag name (e.g., `"v1.0.0"`). package_name Python package name. + documented_names + Dotted names to capture, including submodule-qualified and method symbols. When `None`, + the snapshot holds only the package's top-level public exports. Returns ------- ApiSnapshot | None - The snapshot, or None if extraction failed. + The snapshot, or None if the tag's source could not be extracted. """ tmp_dir = _extract_package_at_tag(project_root, tag, package_name) if tmp_dir is None: return None try: - snap = snapshot_from_griffe(package_name, version=tag, search_paths=[str(tmp_dir)]) + snap = snapshot_from_griffe( + package_name, + version=tag, + documented_names=documented_names, + search_paths=[str(tmp_dir)], + ) # Attempt CLI introspection from the version-specific source cli_module = _read_cli_module_at_tag(project_root, tag, package_name) diff --git a/tests/test_api_diff.py b/tests/test_api_diff.py index e4e1dde7..9b0a5713 100644 --- a/tests/test_api_diff.py +++ b/tests/test_api_diff.py @@ -2495,3 +2495,53 @@ def test_default_behavior_unchanged_top_level(self, tmp_path: Path): _write_griffe_pkg(tmp_path) snap = snapshot_from_griffe("mypkg", version="dev", search_paths=[str(tmp_path)]) assert set(snap.symbols) == {"TopClass", "sub"} + + +def _git(root: Path, *args: str) -> None: + """Run `git *args` in `root`, raising CalledProcessError on a non-zero exit""" + subprocess.run(["git", *args], cwd=root, check=True, capture_output=True) + + +def _init_two_tag_repo(root: Path) -> None: + """Git repo with two tags: v0.1.0 has sub.Widget(fit); v0.2.0 adds sub.Widget.transform and sub.Gadget""" + _git(root, "init") + _git(root, "config", "user.email", "t@t.co") + _git(root, "config", "user.name", "t") + (root / "pyproject.toml").write_text('[project]\nname = "mypkg"\nversion = "0.2.0"\n') + pkg = root / "mypkg" + (pkg / "sub").mkdir(parents=True) + (pkg / "__init__.py").write_text("from mypkg import sub\n__all__ = ['sub']\n") + (pkg / "sub" / "__init__.py").write_text( + "from mypkg.sub.things import Widget\n__all__ = ['Widget']\n" + ) + (pkg / "sub" / "things.py").write_text("class Widget:\n def fit(self): ...\n") + _git(root, "add", "-A") + _git(root, "commit", "-m", "v010") + _git(root, "tag", "v0.1.0") + + (pkg / "sub" / "__init__.py").write_text( + "from mypkg.sub.things import Widget, Gadget\n__all__ = ['Widget', 'Gadget']\n" + ) + (pkg / "sub" / "things.py").write_text( + "class Widget:\n def fit(self): ...\n def transform(self): ...\n\n" + "class Gadget:\n def run(self): ...\n" + ) + _git(root, "add", "-A") + _git(root, "commit", "-m", "v020") + _git(root, "tag", "v0.2.0") + + +class TestSnapshotAtTagDeep: + """Tests for deep symbol capture in snapshot_at_tag.""" + + def test_tags_differ_with_documented_names(self, tmp_path: Path): + """Two tags with different nested APIs produce different snapshots.""" + _init_two_tag_repo(tmp_path) + documented = ["sub.Widget", "sub.Widget.fit", "sub.Widget.transform", "sub.Gadget"] + s1 = snapshot_at_tag(tmp_path, "v0.1.0", "mypkg", documented_names=documented) + s2 = snapshot_at_tag(tmp_path, "v0.2.0", "mypkg", documented_names=documented) + assert set(s1.symbols) == {"sub.Widget", "sub.Widget.fit"} + assert set(s2.symbols) == { + "sub.Widget", "sub.Widget.fit", "sub.Widget.transform", "sub.Gadget", + } + assert set(s1.symbols) != set(s2.symbols) From 82065f5a0e5de31f58737df6722dd83e8233a836 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Fri, 26 Jun 2026 19:51:15 +0300 Subject: [PATCH 04/16] refactor: validate reference pages by exact snapshot membership Drop the class-prefix heuristic in _is_valid_ref_name in favour of exact set membership: with deep snapshots, every valid page stem (including method stems like Class.method) is present as a snapshot key, so a two-argument name-in-set check is sufficient. Remove the now-unused valid_classes / snapshot_classes path throughout _prune_reference_index, _prune_quarto_sidebar, and _rebuild_api_from_snapshot. --- great_docs/_versioned_build.py | 33 +++------ tests/test_versioned_build.py | 130 ++++++++++++++++++++++----------- 2 files changed, 99 insertions(+), 64 deletions(-) diff --git a/great_docs/_versioned_build.py b/great_docs/_versioned_build.py index 3d881224..51363a3a 100644 --- a/great_docs/_versioned_build.py +++ b/great_docs/_versioned_build.py @@ -596,7 +596,6 @@ def _rebuild_api_from_snapshot( ref_dir = dest_dir / "reference" snapshot_symbols = set(snap.symbols.keys()) - snapshot_classes = {name for name, sym in snap.symbols.items() if sym.kind == "class"} # --- Prune existing pages not in the snapshot --- if ref_dir.exists(): @@ -608,7 +607,7 @@ def _rebuild_api_from_snapshot( stem = qmd_file.stem if stem == "index": continue - if not _is_valid_ref_name(stem, snapshot_symbols, snapshot_classes): + if not _is_valid_ref_name(stem, snapshot_symbols): qmd_file.unlink() else: ref_dir.mkdir(parents=True, exist_ok=True) @@ -679,7 +678,7 @@ def _rebuild_api_from_snapshot( if _has_rich_index: # Preserve the styled index — just remove entries for symbols not in this version - _prune_reference_index(index_path, snapshot_symbols, snapshot_classes) + _prune_reference_index(index_path, snapshot_symbols) else: # No existing index or it's a plain placeholder; generate from snapshot index_lines = [ @@ -711,7 +710,7 @@ def _rebuild_api_from_snapshot( generated.append("reference/index.html") # --- Update _quarto.yml sidebar to remove missing reference entries --- - _prune_quarto_sidebar(dest_dir, "reference", snapshot_symbols, snapshot_classes) + _prune_quarto_sidebar(dest_dir, "reference", snapshot_symbols) return generated @@ -742,20 +741,12 @@ def _format_param(p) -> str: return "".join(parts) -def _is_valid_ref_name(name: str, valid_symbols: set[str], valid_classes: set[str]) -> bool: - """Check if a symbol or method name is valid for this version.""" - if name == "index" or name in valid_symbols: - return True - # Method page: `ClassName.method` (check the class prefix) - if "." in name: - class_name = name.split(".")[0] - return class_name in valid_classes - return False +def _is_valid_ref_name(name: str, valid_symbols: set[str]) -> bool: + """Whether a reference page stem names a symbol documented in this version""" + return name == "index" or name in valid_symbols -def _prune_reference_index( - index_qmd: Path, valid_symbols: set[str], valid_classes: set[str] -) -> None: +def _prune_reference_index(index_qmd: Path, valid_symbols: set[str]) -> None: """Remove links/rows for symbols not in the snapshot from reference/index.qmd. This handles three levels of cleanup: @@ -778,7 +769,7 @@ def _prune_reference_index( qmd_ref = re.search(r"\(([^)]+)\.qmd(?:#[^)]*)?\)", stripped) if qmd_ref: symbol_name = qmd_ref.group(1) - if not _is_valid_ref_name(symbol_name, valid_symbols, valid_classes): + if not _is_valid_ref_name(symbol_name, valid_symbols): remove_indices.add(i) # Also remove the following definition-list description line(s) # Pattern: `: description text` (Pandoc definition list) @@ -800,7 +791,7 @@ def _prune_reference_index( bare_ref = re.match(r"^\s*-\s+(\S+)\.qmd\s*$", stripped) if bare_ref: symbol_name = bare_ref.group(1) - if not _is_valid_ref_name(symbol_name, valid_symbols, valid_classes): + if not _is_valid_ref_name(symbol_name, valid_symbols): remove_indices.add(i) filtered = [line for i, line in enumerate(lines) if i not in remove_indices] @@ -862,9 +853,7 @@ def _prune_reference_index( index_qmd.write_text("\n".join(result), encoding="utf-8") -def _prune_quarto_sidebar( - dest_dir: Path, section: str, valid_symbols: set[str], valid_classes: set[str] -) -> None: +def _prune_quarto_sidebar(dest_dir: Path, section: str, valid_symbols: set[str]) -> None: """Remove sidebar entries for missing symbols/commands from _quarto.yml. Handles both flat string entries (`reference/Name.qmd`) and nested section groups @@ -914,7 +903,7 @@ def _prune_contents(items: list) -> tuple[list, bool]: new_items.append(item) else: stem = Path(item).stem - if _is_valid_ref_name(stem, valid_symbols, valid_classes): + if _is_valid_ref_name(stem, valid_symbols): new_items.append(item) else: changed = True diff --git a/tests/test_versioned_build.py b/tests/test_versioned_build.py index 4b5af66e..521eb847 100644 --- a/tests/test_versioned_build.py +++ b/tests/test_versioned_build.py @@ -9,6 +9,7 @@ from great_docs._versioned_build import ( _compute_excluded_section_dirs, _in_excluded_section, + _is_valid_ref_name, _merge_tree, _prune_cli_pages, _prune_reference_index, @@ -29,7 +30,6 @@ ) from great_docs._versioning import VersionEntry, parse_versions_config - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -846,6 +846,7 @@ def test_no_frontmatter(self): class TestUpdatePageStatusJson: def test_adds_upcoming_pages(self, tmp_path): import json + from great_docs._versioned_build import _update_page_status_json status_path = tmp_path / "_page_status.json" @@ -865,6 +866,7 @@ def test_adds_upcoming_pages(self, tmp_path): def test_preserves_existing_status(self, tmp_path): import json + from great_docs._versioned_build import _update_page_status_json status_path = tmp_path / "_page_status.json" @@ -884,6 +886,7 @@ def test_preserves_existing_status(self, tmp_path): def test_no_version_uses_true(self, tmp_path): import json + from great_docs._versioned_build import _update_page_status_json status_path = tmp_path / "_page_status.json" @@ -1434,10 +1437,10 @@ def test_removes_invalid_commands(self, tmp_path: Path): class TestPruneQuartoCliSidebar: def test_removes_invalid_sidebar_entries(self, tmp_path: Path): - from great_docs._versioned_build import _prune_quarto_cli_sidebar - import yaml + from great_docs._versioned_build import _prune_quarto_cli_sidebar + quarto = tmp_path / "_quarto.yml" config = { "website": { @@ -1604,26 +1607,31 @@ def test_other_kind(self): # --------------------------------------------------------------------------- -class TestIsValidRefName: +class TestIsValidRefNameLegacy: def test_index_always_valid(self): from great_docs._versioned_build import _is_valid_ref_name - assert _is_valid_ref_name("index", set(), set()) is True + assert _is_valid_ref_name("index", set()) is True def test_symbol_in_set(self): from great_docs._versioned_build import _is_valid_ref_name - assert _is_valid_ref_name("MyClass", {"MyClass", "func"}, set()) is True + assert _is_valid_ref_name("MyClass", {"MyClass", "func"}) is True + + def test_method_stem_valid_when_in_snapshot(self): + from great_docs._versioned_build import _is_valid_ref_name + + assert _is_valid_ref_name("MyClass.method", {"MyClass", "MyClass.method"}) is True - def test_method_of_valid_class(self): + def test_method_stem_invalid_when_not_in_snapshot(self): from great_docs._versioned_build import _is_valid_ref_name - assert _is_valid_ref_name("MyClass.method", set(), {"MyClass"}) is True + assert _is_valid_ref_name("MyClass.method", {"MyClass"}) is False def test_unknown_symbol(self): from great_docs._versioned_build import _is_valid_ref_name - assert _is_valid_ref_name("Unknown", {"Known"}, set()) is False + assert _is_valid_ref_name("Unknown", {"Known"}) is False # --------------------------------------------------------------------------- @@ -1647,7 +1655,7 @@ def test_removes_invalid_links(self, tmp_path: Path): "[`MyClass`](MyClass.qmd)\n\n" ) - _prune_reference_index(index, {"MyFunc", "MyClass"}, {"MyClass"}) + _prune_reference_index(index, {"MyFunc", "MyClass"}) content = index.read_text() assert "MyFunc" in content @@ -1668,7 +1676,7 @@ def test_removes_empty_sections(self, tmp_path: Path): "[`kept`](kept.qmd)\n\n" ) - _prune_reference_index(index, {"kept"}, set()) + _prune_reference_index(index, {"kept"}) content = index.read_text() assert "Old Section" not in content @@ -1683,10 +1691,10 @@ def test_removes_empty_sections(self, tmp_path: Path): class TestPruneQuartoSidebar: def test_removes_invalid_reference_entries(self, tmp_path: Path): - from great_docs._versioned_build import _prune_quarto_sidebar - import yaml + from great_docs._versioned_build import _prune_quarto_sidebar + quarto = tmp_path / "_quarto.yml" config = { "website": { @@ -1704,7 +1712,7 @@ def test_removes_invalid_reference_entries(self, tmp_path: Path): } quarto.write_text(yaml.dump(config), encoding="utf-8") - _prune_quarto_sidebar(tmp_path, "reference", {"MyFunc", "MyClass"}, {"MyClass"}) + _prune_quarto_sidebar(tmp_path, "reference", {"MyFunc", "MyClass"}) result = yaml.safe_load(quarto.read_text()) contents = result["website"]["sidebar"][0]["contents"] @@ -1714,10 +1722,10 @@ def test_removes_invalid_reference_entries(self, tmp_path: Path): assert "OldFunc" not in stems def test_removes_empty_section_groups(self, tmp_path: Path): - from great_docs._versioned_build import _prune_quarto_sidebar - import yaml + from great_docs._versioned_build import _prune_quarto_sidebar + quarto = tmp_path / "_quarto.yml" config = { "website": { @@ -1737,7 +1745,7 @@ def test_removes_empty_section_groups(self, tmp_path: Path): } quarto.write_text(yaml.dump(config), encoding="utf-8") - _prune_quarto_sidebar(tmp_path, "reference", {"Kept"}, set()) + _prune_quarto_sidebar(tmp_path, "reference", {"Kept"}) result = yaml.safe_load(quarto.read_text()) contents = result["website"]["sidebar"][0]["contents"] @@ -1760,8 +1768,8 @@ def test_invalid_pattern_rejected(self, tmp_path: Path): def test_valid_pattern_accepted_format(self, tmp_path: Path): # v0.3.0 matches the pattern but we can't verify git without a repo - from unittest.mock import patch import subprocess + from unittest.mock import patch mock_result = type("R", (), {"returncode": 0, "stdout": "v0.3.0\n"})() with patch("subprocess.run", return_value=mock_result): @@ -1775,8 +1783,8 @@ def test_tag_not_found(self, tmp_path: Path): assert _validate_git_ref_is_tag(tmp_path, "v0.2.0") is False def test_timeout_returns_false(self, tmp_path: Path): - from unittest.mock import patch import subprocess + from unittest.mock import patch with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("git", 10)): assert _validate_git_ref_is_tag(tmp_path, "v1.0") is False @@ -1893,6 +1901,7 @@ def test_merges_files(self, tmp_path: Path): class TestRenderSingleVersion: def test_success(self, tmp_path: Path): from unittest.mock import patch + from great_docs._versioned_build import _render_single_version mock_result = type("R", (), {"returncode": 0, "stdout": "ok", "stderr": ""})() @@ -1902,8 +1911,9 @@ def test_success(self, tmp_path: Path): assert build_dir == str(tmp_path) def test_timeout(self, tmp_path: Path): - from unittest.mock import patch import subprocess + from unittest.mock import patch + from great_docs._versioned_build import _render_single_version with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("quarto", 600)): @@ -1913,6 +1923,7 @@ def test_timeout(self, tmp_path: Path): def test_exception(self, tmp_path: Path): from unittest.mock import patch + from great_docs._versioned_build import _render_single_version with patch("subprocess.run", side_effect=OSError("No quarto")): @@ -1929,6 +1940,7 @@ def test_exception(self, tmp_path: Path): class TestRenderVersionsParallel: def test_streaming_mode_with_callback(self, tmp_path: Path): from unittest.mock import MagicMock, patch + from great_docs._versioned_build import render_versions_parallel d1 = tmp_path / "v1" @@ -2091,6 +2103,7 @@ def test_no_cache_file_does_nothing(self, tmp_path: Path): def test_loads_snapshot_and_prunes(self, tmp_path: Path): from unittest.mock import MagicMock, patch + from great_docs._versioned_build import _prune_cli_pages_for_version entry = VersionEntry(tag="0.2", label="0.2", latest=False, git_ref="v0.2.0") @@ -2370,7 +2383,7 @@ def test_removes_definition_list_descriptions(self, tmp_path: Path): ": Another kept description.\n" ) - _prune_reference_index(index, {"kept_func", "another_kept"}, set()) + _prune_reference_index(index, {"kept_func", "another_kept"}) content = index.read_text() assert "kept_func" in content @@ -2384,7 +2397,7 @@ def test_removes_bare_ref_links(self, tmp_path: Path): index = tmp_path / "index.qmd" index.write_text("---\ntitle: API\n---\n\n- OldFunc.qmd\n- KeptFunc.qmd\n") - _prune_reference_index(index, {"KeptFunc"}, set()) + _prune_reference_index(index, {"KeptFunc"}) content = index.read_text() assert "KeptFunc" in content @@ -2399,10 +2412,10 @@ def test_removes_bare_ref_links(self, tmp_path: Path): class TestPruneQuartoSidebarSubPath: def test_keeps_sub_paths(self, tmp_path: Path): """Sub-paths like reference/cli/build.qmd are kept (not pruned).""" - from great_docs._versioned_build import _prune_quarto_sidebar - import yaml + from great_docs._versioned_build import _prune_quarto_sidebar + quarto = tmp_path / "_quarto.yml" config = { "website": { @@ -2420,7 +2433,7 @@ def test_keeps_sub_paths(self, tmp_path: Path): } quarto.write_text(yaml.dump(config), encoding="utf-8") - _prune_quarto_sidebar(tmp_path, "reference", {"MyFunc"}, set()) + _prune_quarto_sidebar(tmp_path, "reference", {"MyFunc"}) result = yaml.safe_load(quarto.read_text()) contents = result["website"]["sidebar"][0]["contents"] @@ -2429,15 +2442,15 @@ def test_keeps_sub_paths(self, tmp_path: Path): assert "reference/Removed.qmd" not in contents def test_no_matching_sidebar_does_nothing(self, tmp_path: Path): - from great_docs._versioned_build import _prune_quarto_sidebar - import yaml + from great_docs._versioned_build import _prune_quarto_sidebar + quarto = tmp_path / "_quarto.yml" config = {"website": {"sidebar": [{"id": "other", "contents": ["guide.qmd"]}]}} quarto.write_text(yaml.dump(config), encoding="utf-8") - _prune_quarto_sidebar(tmp_path, "reference", {"func"}, set()) + _prune_quarto_sidebar(tmp_path, "reference", {"func"}) # File should be unchanged since no sidebar matches result = yaml.safe_load(quarto.read_text()) @@ -2446,13 +2459,13 @@ def test_no_matching_sidebar_does_nothing(self, tmp_path: Path): def test_no_quarto_yml_does_nothing(self, tmp_path: Path): from great_docs._versioned_build import _prune_quarto_sidebar - _prune_quarto_sidebar(tmp_path, "reference", {"func"}, set()) + _prune_quarto_sidebar(tmp_path, "reference", {"func"}) def test_empty_yaml_does_nothing(self, tmp_path: Path): from great_docs._versioned_build import _prune_quarto_sidebar (tmp_path / "_quarto.yml").write_text("", encoding="utf-8") - _prune_quarto_sidebar(tmp_path, "reference", {"func"}, set()) + _prune_quarto_sidebar(tmp_path, "reference", {"func"}) # --------------------------------------------------------------------------- @@ -2463,6 +2476,7 @@ def test_empty_yaml_does_nothing(self, tmp_path: Path): class TestRebuildApiFromGitRefCache: def test_cache_hit_uses_cached_snapshot(self, tmp_path: Path): from unittest.mock import patch + from great_docs._versioned_build import _rebuild_api_from_git_ref entry = VersionEntry(tag="0.2", label="0.2", latest=False, git_ref="v0.2.0") @@ -2489,6 +2503,7 @@ def test_cache_hit_uses_cached_snapshot(self, tmp_path: Path): def test_cache_miss_calls_snapshot_at_tag(self, tmp_path: Path): from unittest.mock import MagicMock, patch + from great_docs._versioned_build import _rebuild_api_from_git_ref entry = VersionEntry(tag="0.2", label="0.2", latest=False, git_ref="v0.2.0") @@ -2519,6 +2534,7 @@ def test_cache_miss_calls_snapshot_at_tag(self, tmp_path: Path): def test_cache_miss_no_package_returns_empty(self, tmp_path: Path): from unittest.mock import patch + from great_docs._versioned_build import _rebuild_api_from_git_ref entry = VersionEntry(tag="0.2", label="0.2", latest=False, git_ref="v0.2.0") @@ -2538,6 +2554,7 @@ def test_cache_miss_no_package_returns_empty(self, tmp_path: Path): def test_cache_miss_snapshot_fails_returns_empty(self, tmp_path: Path): from unittest.mock import patch + from great_docs._versioned_build import _rebuild_api_from_git_ref entry = VersionEntry(tag="0.2", label="0.2", latest=False, git_ref="v0.2.0") @@ -2618,9 +2635,10 @@ def test_no_quarto_yml_does_nothing(self, tmp_path: Path): _rewrite_quarto_yml_for_version(tmp_path, entry, "0.3") # no crash def test_non_latest_title_gets_version_suffix(self, tmp_path: Path): - from great_docs._versioned_build import _rewrite_quarto_yml_for_version from yaml12 import read_yaml, write_yaml + from great_docs._versioned_build import _rewrite_quarto_yml_for_version + dest = tmp_path / "vdir" dest.mkdir() config = { @@ -2641,9 +2659,10 @@ def test_non_latest_title_gets_version_suffix(self, tmp_path: Path): def test_include_in_header_str_converted_to_list(self, tmp_path: Path): """If include-in-header is a string, it's converted to a list.""" - from great_docs._versioned_build import _rewrite_quarto_yml_for_version from yaml12 import read_yaml, write_yaml + from great_docs._versioned_build import _rewrite_quarto_yml_for_version + dest = tmp_path / "vdir" dest.mkdir() config = { @@ -2883,6 +2902,7 @@ def mock_render(build_dirs, **kwargs): class TestRenderSingleVersionStreaming: def test_successful_render_with_progress(self, tmp_path: Path): from unittest.mock import MagicMock, patch + from great_docs._versioned_build import _render_single_version_streaming # Mock subprocess.Popen @@ -2907,6 +2927,7 @@ def test_successful_render_with_progress(self, tmp_path: Path): def test_popen_failure_returns_error(self, tmp_path: Path): from unittest.mock import patch + from great_docs._versioned_build import _render_single_version_streaming with patch("subprocess.Popen", side_effect=OSError("not found")): @@ -2918,6 +2939,7 @@ def test_popen_failure_returns_error(self, tmp_path: Path): def test_with_env_vars(self, tmp_path: Path): from unittest.mock import MagicMock, patch + from great_docs._versioned_build import _render_single_version_streaming mock_proc = MagicMock() @@ -2942,6 +2964,7 @@ def test_with_env_vars(self, tmp_path: Path): class TestRenderVersionsParallelStreaming: def test_streaming_mode_ordered_results(self, tmp_path: Path): from unittest.mock import MagicMock, patch + from great_docs._versioned_build import render_versions_parallel d1 = tmp_path / "v1" @@ -3013,8 +3036,8 @@ def test_keeps_non_qmd_href(self, tmp_path: Path): class TestPruneCliPagesForVersionFull: def test_prunes_cli_pages_via_snapshot(self, tmp_path: Path): - from great_docs._versioned_build import _prune_cli_pages_for_version from great_docs._api_diff import ApiSnapshot, CliCommandInfo + from great_docs._versioned_build import _prune_cli_pages_for_version entry = VersionEntry(tag="0.2", label="0.2", latest=False, git_ref="v0.2.0") @@ -3172,6 +3195,7 @@ class TestRenderVersionsParallelNonStreaming: def test_multi_dir_uses_process_pool(self, tmp_path: Path): from concurrent.futures import Future from unittest.mock import MagicMock, patch + from great_docs._versioned_build import render_versions_parallel d1 = tmp_path / "v1" @@ -3249,10 +3273,10 @@ def test_git_ref_strategy_in_preprocess(self, tmp_path: Path): class TestPruneQuartoSidebarNestedSectionGroup: def test_removes_empty_section_group(self, tmp_path: Path): - from great_docs._versioned_build import _prune_quarto_sidebar - import yaml + from great_docs._versioned_build import _prune_quarto_sidebar + quarto = tmp_path / "_quarto.yml" config = { "website": { @@ -3275,7 +3299,7 @@ def test_removes_empty_section_group(self, tmp_path: Path): } quarto.write_text(yaml.dump(config), encoding="utf-8") - _prune_quarto_sidebar(tmp_path, "reference", {"kept"}, set()) + _prune_quarto_sidebar(tmp_path, "reference", {"kept"}) result = yaml.safe_load(quarto.read_text()) contents = result["website"]["sidebar"][0]["contents"] @@ -3284,10 +3308,10 @@ def test_removes_empty_section_group(self, tmp_path: Path): assert contents[0] == "reference/kept.qmd" def test_partially_prunes_section_group(self, tmp_path: Path): - from great_docs._versioned_build import _prune_quarto_sidebar - import yaml + from great_docs._versioned_build import _prune_quarto_sidebar + quarto = tmp_path / "_quarto.yml" config = { "website": { @@ -3309,7 +3333,7 @@ def test_partially_prunes_section_group(self, tmp_path: Path): } quarto.write_text(yaml.dump(config), encoding="utf-8") - _prune_quarto_sidebar(tmp_path, "reference", {"kept"}, set()) + _prune_quarto_sidebar(tmp_path, "reference", {"kept"}) result = yaml.safe_load(quarto.read_text()) contents = result["website"]["sidebar"][0]["contents"] @@ -3327,6 +3351,7 @@ def test_partially_prunes_section_group(self, tmp_path: Path): class TestRenderVersionsParallelSingleNonStreaming: def test_single_dir_no_callback(self, tmp_path: Path): from unittest.mock import patch + from great_docs._versioned_build import render_versions_parallel d1 = tmp_path / "v1" @@ -3349,9 +3374,10 @@ def test_single_dir_no_callback(self, tmp_path: Path): class TestPruneCliPagesFull: def test_prunes_and_rewrites_index(self, tmp_path: Path): - from great_docs._versioned_build import _prune_cli_pages from unittest.mock import MagicMock + from great_docs._versioned_build import _prune_cli_pages + # Create a mock snapshot with cli_commands snap = MagicMock() sub1 = MagicMock() @@ -3385,17 +3411,19 @@ def test_prunes_and_rewrites_index(self, tmp_path: Path): assert "old-cmd" not in index_content def test_no_cli_dir_returns_early(self, tmp_path: Path): - from great_docs._versioned_build import _prune_cli_pages from unittest.mock import MagicMock + from great_docs._versioned_build import _prune_cli_pages + snap = MagicMock() snap.cli_commands = None _prune_cli_pages(tmp_path, snap) # No crash def test_no_cli_commands_returns_early(self, tmp_path: Path): - from great_docs._versioned_build import _prune_cli_pages from unittest.mock import MagicMock + from great_docs._versioned_build import _prune_cli_pages + cli_dir = tmp_path / "reference" / "cli" cli_dir.mkdir(parents=True) (cli_dir / "build.qmd").write_text("Build") @@ -3584,3 +3612,21 @@ def mock_render(build_dirs, **kwargs): # Render should NOT have been called assert render_called == [] + + +# --------------------------------------------------------------------------- +# _is_valid_ref_name +# --------------------------------------------------------------------------- + + +class TestIsValidRefName: + def test_index_always_valid(self): + assert _is_valid_ref_name("index", set()) + + def test_member_stem_valid_when_in_snapshot(self): + syms = {"scores.CosineScore", "scores.CosineScore.fit"} + assert _is_valid_ref_name("scores.CosineScore", syms) + assert _is_valid_ref_name("scores.CosineScore.fit", syms) + + def test_absent_stem_invalid(self): + assert not _is_valid_ref_name("scores.CosineScore", {"TopClass"}) From aa2654ce188d5934f2cf8d8735321d64a8684a77 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Sat, 27 Jun 2026 01:11:03 +0300 Subject: [PATCH 05/16] fix: keep documented submodule pages in tagged versions (#190) --- great_docs/_versioned_build.py | 8 ++++- tests/test_versioned_build.py | 64 ++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/great_docs/_versioned_build.py b/great_docs/_versioned_build.py index 51363a3a..49c040c7 100644 --- a/great_docs/_versioned_build.py +++ b/great_docs/_versioned_build.py @@ -1032,7 +1032,13 @@ def _rebuild_api_from_git_ref( if not pkg_name: return [] - snap = snapshot_at_tag(project_root, git_ref, pkg_name) + from great_docs.core import GreatDocs + + documented = GreatDocs(project_path=str(project_root)).documented_symbol_names(pkg_name) + + snap = snapshot_at_tag( + project_root, git_ref, pkg_name, documented_names=documented or None + ) if snap is None: return [] diff --git a/tests/test_versioned_build.py b/tests/test_versioned_build.py index 521eb847..42813277 100644 --- a/tests/test_versioned_build.py +++ b/tests/test_versioned_build.py @@ -3630,3 +3630,67 @@ def test_member_stem_valid_when_in_snapshot(self): def test_absent_stem_invalid(self): assert not _is_valid_ref_name("scores.CosineScore", {"TopClass"}) + + +# --------------------------------------------------------------------------- +# TestGitRefRebuildKeepsSubmodulePages +# --------------------------------------------------------------------------- + +import subprocess as _subprocess +import textwrap + + +def _git(root, *a): + """Run `git *a` in `root`, raising CalledProcessError on a non-zero exit""" + _subprocess.run(["git", *a], cwd=root, check=True, capture_output=True) + + +def _repo_with_submodule_tag(root: Path) -> None: + """Git repo tagged v0.1.0 whose documented API lives under a submodule""" + _git(root, "init") + _git(root, "config", "user.email", "t@t.co") + _git(root, "config", "user.name", "t") + (root / "pyproject.toml").write_text('[project]\nname = "mypkg"\nversion = "0.1.0"\n') + pkg = root / "mypkg" + (pkg / "sub").mkdir(parents=True) + (pkg / "__init__.py").write_text("from mypkg import sub\n__all__ = ['sub']\n") + (pkg / "sub" / "__init__.py").write_text( + "from mypkg.sub.things import Widget\n__all__ = ['Widget']\n" + ) + (pkg / "sub" / "things.py").write_text("class Widget:\n def fit(self): ...\n") + (root / "great-docs.yml").write_text( + textwrap.dedent( + """ + reference: + - title: API + contents: + - name: sub.Widget + members: [fit] + """ + ) + ) + _git(root, "add", "-A") + _git(root, "commit", "-m", "v010") + _git(root, "tag", "v0.1.0") + + +class TestGitRefRebuildKeepsSubmodulePages: + def test_submodule_pages_survive(self, tmp_path: Path): + _repo_with_submodule_tag(tmp_path) + # Simulate the dev build's reference pages copied into the version dir. + ref = tmp_path / "build" / "reference" + ref.mkdir(parents=True) + for stem in ("sub.Widget", "sub.Widget.fit"): + (ref / f"{stem}.qmd").write_text( + f"---\ntitle: {stem}\n---\n# {stem} {{.doc-heading}}\n" + ) + (ref / "index.qmd").write_text("---\ntitle: API\n---\n") + + entry = VersionEntry(label="0.1.0", tag="0.1.0", git_ref="v0.1.0") + from great_docs._versioned_build import _rebuild_api_from_git_ref + + pages = _rebuild_api_from_git_ref(tmp_path / "build", tmp_path, entry) + + assert (ref / "sub.Widget.qmd").exists() + assert (ref / "sub.Widget.fit.qmd").exists() + assert "reference/sub.Widget.html" in pages From 1af9c71cf1797bdb019d922ef6dde95f33825f0f Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Sat, 27 Jun 2026 01:15:20 +0300 Subject: [PATCH 06/16] fix: make api-snapshot/api-diff capture documented nested symbols (#190) --- great_docs/cli.py | 24 +++++++++++++++++---- tests/test_cli.py | 54 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/great_docs/cli.py b/great_docs/cli.py index 03bd129a..7eea52c9 100644 --- a/great_docs/cli.py +++ b/great_docs/cli.py @@ -2657,11 +2657,23 @@ def api_diff_cmd( # --- Graph mode --- if graph: + from great_docs.core import GreatDocs + + documented = ( + GreatDocs(project_path=str(project_root)).documented_symbol_names( + result.package_name + ) + or None + ) # Build graph for the new version if new_version.upper() == "HEAD": - snap = snapshot_from_griffe(result.package_name, version="HEAD") + snap = snapshot_from_griffe( + result.package_name, version="HEAD", documented_names=documented + ) else: - snap = snapshot_at_tag(project_root, new_version, result.package_name) + snap = snapshot_at_tag( + project_root, new_version, result.package_name, documented_names=documented + ) if snap is None: click.echo("Could not build snapshot for graph.", err=True) sys.exit(1) @@ -2916,6 +2928,10 @@ def api_snapshot_cmd( ) sys.exit(1) + from great_docs.core import GreatDocs + + documented = GreatDocs(project_path=str(project_root)).documented_symbol_names(pkg_name) or None + # Default snapshot directory snap_dir = project_root / ".great-docs" / "snapshots" @@ -2948,9 +2964,9 @@ def api_snapshot_cmd( try: if tag == "HEAD": - snap = snapshot_from_griffe(pkg_name, version="dev") + snap = snapshot_from_griffe(pkg_name, version="dev", documented_names=documented) else: - snap = snapshot_at_tag(project_root, tag, pkg_name) + snap = snapshot_at_tag(project_root, tag, pkg_name, documented_names=documented) if snap is None: click.echo(f" ✗ {tag}: could not build snapshot", err=True) diff --git a/tests/test_cli.py b/tests/test_cli.py index 68cd5a51..d5d9f2c1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,6 +2,7 @@ import json import re +import subprocess import tempfile from pathlib import Path from unittest.mock import MagicMock, patch @@ -1503,3 +1504,56 @@ def test_api_snapshot_force_overwrite(mock_detect, mock_snap): result = runner.invoke(cli, ["api-snapshot", "--force", "--project-path", "."]) assert result.exit_code == 0 assert "3 symbols" in result.output + + +def _git(root, *a): + """Run `git *a` in `root`, raising CalledProcessError on a non-zero exit""" + subprocess.run(["git", *a], cwd=root, check=True, capture_output=True) + + +def _two_tag_repo(root: Path) -> None: + """Git repo whose documented submodule API grows between tags v0.1.0 and v0.2.0""" + _git(root, "init") + _git(root, "config", "user.email", "t@t.co") + _git(root, "config", "user.name", "t") + (root / "pyproject.toml").write_text('[project]\nname = "mypkg"\nversion = "0.2.0"\n') + pkg = root / "mypkg" + (pkg / "sub").mkdir(parents=True) + (pkg / "__init__.py").write_text("from mypkg import sub\n__all__ = ['sub']\n") + (pkg / "sub" / "__init__.py").write_text( + "from mypkg.sub.things import Widget\n__all__ = ['Widget']\n" + ) + (pkg / "sub" / "things.py").write_text("class Widget:\n def fit(self): ...\n") + (root / "great-docs.yml").write_text( + "reference:\n - title: API\n contents:\n" + " - name: sub.Widget\n members: [fit]\n" + ) + _git(root, "add", "-A") + _git(root, "commit", "-m", "v010") + _git(root, "tag", "v0.1.0") + (pkg / "sub" / "things.py").write_text( + "class Widget:\n def fit(self): ...\n def transform(self): ...\n" + ) + (root / "great-docs.yml").write_text( + "reference:\n - title: API\n contents:\n" + " - name: sub.Widget\n members: [fit, transform]\n" + ) + _git(root, "add", "-A") + _git(root, "commit", "-m", "v020") + _git(root, "tag", "v0.2.0") + + +def test_api_snapshot_all_tags_differ(tmp_path: Path): + """api-snapshot --all-tags produces snapshots that differ when the reference config grows.""" + _two_tag_repo(tmp_path) + runner = CliRunner() + result = runner.invoke( + cli, ["api-snapshot", "--all-tags", "--project-path", str(tmp_path)] + ) + assert result.exit_code == 0, result.output + snaps = tmp_path / ".great-docs" / "snapshots" + s1 = json.loads((snaps / "v0.1.0.json").read_text())["symbols"] + s2 = json.loads((snaps / "v0.2.0.json").read_text())["symbols"] + assert set(s1) == {"sub.Widget", "sub.Widget.fit"} + assert "sub.Widget.transform" in s2 + assert set(s1) != set(s2) From 16ace47d9827f3ad5810cc1bf420ce2149f7e591 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Sat, 27 Jun 2026 01:18:59 +0300 Subject: [PATCH 07/16] test: guard resolver/renderer stem parity Add test_resolver_matches_renderer_sections to assert documented_symbol_names output equals the flattened _create_api_sections_from_config contents, so the resolver and renderer can never silently diverge. Add test_deduplication_preserves_first_occurrence_order to exercise the dedup path: a config with the same symbol in two sections asserts the result has no duplicates and preserves first-occurrence order. --- tests/test_documented_symbols.py | 46 ++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/test_documented_symbols.py b/tests/test_documented_symbols.py index 559d319a..635cb762 100644 --- a/tests/test_documented_symbols.py +++ b/tests/test_documented_symbols.py @@ -53,3 +53,49 @@ def test_returns_empty_without_reference_config(tmp_path: Path): (tmp_path / "great-docs.yml").write_text("logo: assets/logo.png\n") gd = GreatDocs(project_path=str(tmp_path)) assert gd.documented_symbol_names("mypkg") == [] + + +def test_resolver_matches_renderer_sections(tmp_path: Path): + """Resolver output equals flattened renderer sections — the two cannot silently diverge.""" + _write_pkg(tmp_path) + gd = GreatDocs(project_path=str(tmp_path)) + sections = gd._create_api_sections_from_config("mypkg") + expected: list[str] = [] + for section in sections or []: + for item in section.get("contents", []): + if isinstance(item, str): + expected.append(item) + elif isinstance(item, dict): + name = item["name"] + expected.append(name) + expected.extend(f"{name}.{m}" for m in item.get("members", []) or []) + assert gd.documented_symbol_names("mypkg") == list(dict.fromkeys(expected)) + + +def test_deduplication_preserves_first_occurrence_order(tmp_path: Path): + """Symbols that appear more than once are deduplicated, keeping first-occurrence order.""" + _write_pkg(tmp_path) + # Overwrite config with a reference block that repeats TopClass and sub.Widget.fit. + (tmp_path / "great-docs.yml").write_text( + textwrap.dedent( + """ + reference: + - title: Section A + contents: + - TopClass + - name: sub.Widget + members: + - fit + - title: Section B + contents: + - TopClass + - sub.Widget.fit + """ + ) + ) + gd = GreatDocs(project_path=str(tmp_path)) + names = gd.documented_symbol_names("mypkg") + # Duplicates removed; first-occurrence order is: TopClass, sub.Widget, sub.Widget.fit. + assert names == ["TopClass", "sub.Widget", "sub.Widget.fit"] + # No duplicates. + assert len(names) == len(set(names)) From 0021612be280517e3723150d11ab9e81553fc020 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Sat, 27 Jun 2026 01:22:25 +0300 Subject: [PATCH 08/16] test: anchor stem-parity test to independent ground truth --- tests/test_documented_symbols.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/tests/test_documented_symbols.py b/tests/test_documented_symbols.py index 635cb762..b785aff6 100644 --- a/tests/test_documented_symbols.py +++ b/tests/test_documented_symbols.py @@ -56,20 +56,33 @@ def test_returns_empty_without_reference_config(tmp_path: Path): def test_resolver_matches_renderer_sections(tmp_path: Path): - """Resolver output equals flattened renderer sections — the two cannot silently diverge.""" + """Resolver and renderer each match an independent ground-truth stem list. + + Both sides are verified against a hard-coded expectation derived from the + `_write_pkg` fixture config, so divergence bugs cannot hide behind a + circular comparison. + """ _write_pkg(tmp_path) gd = GreatDocs(project_path=str(tmp_path)) + + # Ground truth: stems that the _write_pkg fixture config declares. + expected = ["TopClass", "sub.Widget", "sub.Widget.fit"] + + # Resolver must match ground truth. + assert gd.documented_symbol_names("mypkg") == expected + + # Renderer sections, flattened independently, must also match ground truth. sections = gd._create_api_sections_from_config("mypkg") - expected: list[str] = [] + renderer_stems: list[str] = [] for section in sections or []: for item in section.get("contents", []): if isinstance(item, str): - expected.append(item) + renderer_stems.append(item) elif isinstance(item, dict): name = item["name"] - expected.append(name) - expected.extend(f"{name}.{m}" for m in item.get("members", []) or []) - assert gd.documented_symbol_names("mypkg") == list(dict.fromkeys(expected)) + renderer_stems.append(name) + renderer_stems.extend(f"{name}.{m}" for m in item.get("members", []) or []) + assert list(dict.fromkeys(renderer_stems)) == expected def test_deduplication_preserves_first_occurrence_order(tmp_path: Path): From 86728610de01f82a5501f73a982e76b2066ff2b1 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Sat, 27 Jun 2026 01:37:50 +0300 Subject: [PATCH 09/16] fix: resolve documented stems via nodoc-filtered renderer resolution `documented_symbol_names` now calls `_create_api_sections_with_config` instead of `_create_api_sections_from_config`, so it consumes the same nodoc-filtered section set the dev renderer uses. Symbols marked with `%nodoc` are therefore excluded from the resolver output, eliminating the over-inclusion bug where a versioned snapshot could render pages the dev build hides. Also routes diagnostic warning prints that appear in the discovery pipeline to `sys.stderr` and suppresses all build-pipeline output inside `documented_symbol_names` (a programmatic query that owns no output stream), keeping the `api-diff --json` CLI path clean. --- great_docs/core.py | 43 +++++++++++++++----- tests/test_documented_symbols.py | 69 ++++++++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 13 deletions(-) diff --git a/great_docs/core.py b/great_docs/core.py index bb686f3c..adf1ed23 100644 --- a/great_docs/core.py +++ b/great_docs/core.py @@ -2,6 +2,7 @@ import os import re import shutil +import sys from datetime import datetime from importlib import resources from pathlib import Path @@ -6264,11 +6265,18 @@ def _parse_package_exports(self, package_name: str) -> list | None: normalized = package_name.replace("-", "_") if normalized != package_name: print( - f"Could not locate __init__.py for package '{package_name}' (module name: '{normalized}')" + f"Could not locate __init__.py for package '{package_name}' (module name: '{normalized}')", + file=sys.stderr, + ) + print( + f"Tip: Ensure a '{normalized}/' directory exists with an __init__.py file", + file=sys.stderr, ) - print(f"Tip: Ensure a '{normalized}/' directory exists with an __init__.py file") else: - print(f"Could not locate __init__.py for package '{package_name}'") + print( + f"Could not locate __init__.py for package '{package_name}'", + file=sys.stderr, + ) return None print(f"Found package __init__.py at: {init_file.relative_to(self.project_root)}") @@ -6381,10 +6389,14 @@ def _discover_package_exports(self, package_name: str) -> list | None: try: pkg = self._get_griffe_package(normalized_name) except Exception as e: - print(f"Warning: Could not load package with griffe ({type(e).__name__})") + print( + f"Warning: Could not load package with griffe ({type(e).__name__})", + file=sys.stderr, + ) if package_name != normalized_name: print( - f" (Looking for module '{normalized_name}' from project '{package_name}')" + f" (Looking for module '{normalized_name}' from project '{package_name}')", + file=sys.stderr, ) return None @@ -6828,7 +6840,7 @@ def _get_package_exports(self, package_name: str) -> list | None: """ exports = self._discover_package_exports(package_name) if exports is None: - print("Falling back to __all__ discovery") + print("Falling back to __all__ discovery", file=sys.stderr) return self._parse_package_exports(package_name) return exports @@ -7264,7 +7276,10 @@ def _categorize_api_objects(self, package_name: str, exports: list) -> dict: try: pkg = self._get_griffe_package(normalized_name) except Exception as e: - print(f"Warning: Could not load package with griffe ({type(e).__name__})") + print( + f"Warning: Could not load package with griffe ({type(e).__name__})", + file=sys.stderr, + ) # Fallback: use importlib + inspect to categorize exports return self._categorize_api_objects_fallback(normalized_name, exports) @@ -8039,7 +8054,10 @@ def _extract_all_directives(self, package_name: str) -> dict: try: pkg = self._get_griffe_package(normalized_name) except Exception as e: - print(f"Warning: Could not load package with griffe ({type(e).__name__})") + print( + f"Warning: Could not load package with griffe ({type(e).__name__})", + file=sys.stderr, + ) return {} directive_map = {} @@ -8617,7 +8635,13 @@ def documented_symbol_names(self, package_name: str) -> list[str]: list[str] Dotted stems, deduplicated, in first-occurrence order. """ - sections = self._create_api_sections_from_config(package_name) + import contextlib + import io + + # Suppress diagnostic prints from the resolution/filtering pipeline — this is a + # programmatic query method and its callers own their own output streams. + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + sections = self._create_api_sections_with_config(package_name) if not sections: return [] @@ -8666,7 +8690,6 @@ def _create_api_sections_with_config(self, package_name: str) -> list | None: sections = config_sections else: # Fall back to auto-generated sections from discovered exports - print("No reference config found, using auto-discovery") sections = self._create_api_sections(package_name) # Apply %nodoc filtering to remove excluded items diff --git a/tests/test_documented_symbols.py b/tests/test_documented_symbols.py index b785aff6..888019c5 100644 --- a/tests/test_documented_symbols.py +++ b/tests/test_documented_symbols.py @@ -49,10 +49,17 @@ def test_flattens_config_into_dotted_stems(tmp_path: Path): def test_returns_empty_without_reference_config(tmp_path: Path): - _write_pkg(tmp_path) + """Without a reference config, the resolver delegates to auto-discovery (same as the renderer). + + A completely empty package (no exports) must still yield an empty list. + """ + # Bare package with no exports so auto-discovery produces nothing. + (tmp_path / "pyproject.toml").write_text('[project]\nname = "emptypkg"\nversion = "0.1.0"\n') + (tmp_path / "emptypkg").mkdir() + (tmp_path / "emptypkg" / "__init__.py").write_text("__all__ = []\n") (tmp_path / "great-docs.yml").write_text("logo: assets/logo.png\n") gd = GreatDocs(project_path=str(tmp_path)) - assert gd.documented_symbol_names("mypkg") == [] + assert gd.documented_symbol_names("emptypkg") == [] def test_resolver_matches_renderer_sections(tmp_path: Path): @@ -72,7 +79,7 @@ def test_resolver_matches_renderer_sections(tmp_path: Path): assert gd.documented_symbol_names("mypkg") == expected # Renderer sections, flattened independently, must also match ground truth. - sections = gd._create_api_sections_from_config("mypkg") + sections = gd._create_api_sections_with_config("mypkg") renderer_stems: list[str] = [] for section in sections or []: for item in section.get("contents", []): @@ -85,6 +92,62 @@ def test_resolver_matches_renderer_sections(tmp_path: Path): assert list(dict.fromkeys(renderer_stems)) == expected +def _write_pkg_with_nodoc(root: Path) -> None: + """A package with two documented classes, one of them marked %nodoc""" + (root / "pyproject.toml").write_text('[project]\nname = "mypkg"\nversion = "0.1.0"\n') + pkg = root / "mypkg" + pkg.mkdir(parents=True) + (pkg / "__init__.py").write_text( + textwrap.dedent( + """ + from mypkg.core import VisibleClass, HiddenClass + __all__ = ["VisibleClass", "HiddenClass"] + """ + ) + ) + (pkg / "core.py").write_text( + textwrap.dedent( + ''' + class VisibleClass: + """A documented class.""" + ... + + class HiddenClass: + """An internal class. + + %nodoc + """ + ... + ''' + ) + ) + (root / "great-docs.yml").write_text( + textwrap.dedent( + """ + reference: + - title: API + contents: + - VisibleClass + - HiddenClass + """ + ) + ) + + +def test_nodoc_symbol_excluded_from_documented_symbol_names(tmp_path: Path): + """A symbol whose docstring contains %nodoc must be absent from the resolver output. + + This verifies that `documented_symbol_names` honours the same nodoc filter that + the dev-build renderer applies, so a tagged versioned build cannot generate a + page the dev renderer hides. + """ + _write_pkg_with_nodoc(tmp_path) + gd = GreatDocs(project_path=str(tmp_path)) + names = gd.documented_symbol_names("mypkg") + assert "VisibleClass" in names + assert "HiddenClass" not in names + + def test_deduplication_preserves_first_occurrence_order(tmp_path: Path): """Symbols that appear more than once are deduplicated, keeping first-occurrence order.""" _write_pkg(tmp_path) From 38fd11e389b71cef76ffd983e9abb870394403cf Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 29 Jun 2026 18:33:55 +0300 Subject: [PATCH 10/16] build(deps): bump griffe to ~=2.1.0 Raise the floor from <2.0.0 to ~=2.1.0 and drop the now-dead version-conditional code that supported older griffe: - Remove `_patch_griffe`; griffe 2.x always exports `CyclicAliasError` and `AliasResolutionError` at the top level, so it was a no-op. - Detect PEP 695 `type X = ...` aliases in `is_typealias` via the dedicated `TypeAlias` kind (resolves the standing TODO); old-style `X: TypeAlias = ...` attributes still use the annotation heuristic. --- great_docs/_renderer/_type_checks.py | 12 ++++++--- great_docs/core.py | 39 ++-------------------------- pyproject.toml | 2 +- tests/test_great_docs.py | 37 -------------------------- 4 files changed, 11 insertions(+), 79 deletions(-) diff --git a/great_docs/_renderer/_type_checks.py b/great_docs/_renderer/_type_checks.py index 186caa15..d5e528c9 100644 --- a/great_docs/_renderer/_type_checks.py +++ b/great_docs/_renderer/_type_checks.py @@ -19,11 +19,15 @@ def is_typealias(obj: gf.Object | gf.Alias) -> bool: """ - Return True if obj is a declaration of a TypeAlias + True when obj is a type alias + + Covers both PEP 695 ``type X = ...`` aliases, which griffe models as a + dedicated `TypeAlias`, and explicit ``X: TypeAlias = ...`` attributes. """ - # TODO: - # Figure out if this handles new-style typealiases introduced - # in python 3.12 to handle + # `isinstance` avoids resolving aliases, which can raise for unresolved + # targets; PEP 695 aliases are a distinct type rather than an Attribute. + if isinstance(obj, gf.TypeAlias): + return True if not (isinstance(obj, gf.Attribute) and obj.annotation): return False elif isinstance(obj.annotation, gf.ExprName): diff --git a/great_docs/core.py b/great_docs/core.py index 15ad6d07..28baacf2 100644 --- a/great_docs/core.py +++ b/great_docs/core.py @@ -27,28 +27,6 @@ ] -def _patch_griffe(): - """Ensure griffe has CyclicAliasError and AliasResolutionError at top level. - - Older griffe versions don't re-export these from the top-level package. This patches them in so - `griffe.CyclicAliasError` etc. work everywhere. - """ - import griffe - - if hasattr(griffe, "CyclicAliasError") and hasattr(griffe, "AliasResolutionError"): - return - - try: - from griffe.exceptions import AliasResolutionError, CyclicAliasError - except ImportError: - from griffe._internal.exceptions import AliasResolutionError, CyclicAliasError - - if not hasattr(griffe, "CyclicAliasError"): - griffe.CyclicAliasError = CyclicAliasError - if not hasattr(griffe, "AliasResolutionError"): - griffe.AliasResolutionError = AliasResolutionError - - class QuartoNotFoundError(RuntimeError): """Raised when the Quarto CLI is not available on the system.""" @@ -180,8 +158,6 @@ def _get_griffe_package(self, package_name: str): """ import griffe - _patch_griffe() - if self._griffe_pkg_cache is None: self._griffe_pkg_cache = {} @@ -6372,8 +6348,6 @@ def _discover_package_exports(self, package_name: str) -> list | None: try: import griffe - _patch_griffe() - # Normalize package name (replace dashes with underscores) normalized_name = package_name.replace("-", "_") @@ -6610,8 +6584,6 @@ def _detect_docstring_style(self, package_name: str) -> str: try: import griffe - _patch_griffe() - # Normalize package name normalized_name = package_name.replace("-", "_") @@ -6753,8 +6725,6 @@ def _detect_dynamic_mode(self, package_name: str) -> bool: try: import griffe - _patch_griffe() - from great_docs._renderer.introspection import get_object as qd_get_object except ImportError: # pragma: no cover # If renderer isn't available, default to True (will fail at build time anyway) @@ -7027,7 +6997,8 @@ def _sub_classify_attribute(obj) -> str: except Exception: # pragma: no cover labels = set() # pragma: no cover - # griffe >= 0.40 has a dedicated "type alias" kind + # griffe gives PEP 695 type aliases a dedicated "type alias" kind. + # Reading `.kind` can raise for an unresolved alias, hence the guard. try: if obj.kind.value == "type alias": return "type_alias" @@ -7226,8 +7197,6 @@ def _categorize_api_objects(self, package_name: str, exports: list) -> dict: try: import griffe - _patch_griffe() - # Load the package using griffe normalized_name = package_name.replace("-", "_") @@ -8032,8 +8001,6 @@ def _extract_all_directives(self, package_name: str) -> dict: try: import griffe - _patch_griffe() - normalized_name = package_name.replace("-", "_") try: @@ -8211,8 +8178,6 @@ def _categorize_referenced_objects(self, package_name: str, reference_config: li """ import griffe - _patch_griffe() - normalized_name = package_name.replace("-", "_") try: diff --git a/pyproject.toml b/pyproject.toml index 2da05da7..c108d57a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ "jupyter>=1.1.1", "py-yaml12>=0.1.0", "click>=8.0.0", - "griffe>=0.35.0,<2.0.0", + "griffe~=2.1.0", "pygments>=2.0.0", "requests>=2.25.0", "Pillow>=9.0.0", diff --git a/tests/test_great_docs.py b/tests/test_great_docs.py index 6a6fa0fa..fd455ed8 100644 --- a/tests/test_great_docs.py +++ b/tests/test_great_docs.py @@ -21142,43 +21142,6 @@ def test_detect_docstring_style_no_docstrings(): assert result == "numpy" -def test_patch_griffe_adds_missing_exceptions(): - """Test _patch_griffe patches CyclicAliasError and AliasResolutionError onto griffe.""" - import griffe - - from great_docs.core import _patch_griffe - - # Save originals (they may already exist) - had_cyclic = hasattr(griffe, "CyclicAliasError") - had_alias = hasattr(griffe, "AliasResolutionError") - orig_cyclic = getattr(griffe, "CyclicAliasError", None) - orig_alias = getattr(griffe, "AliasResolutionError", None) - - try: - # Remove to simulate older griffe - if hasattr(griffe, "CyclicAliasError"): - delattr(griffe, "CyclicAliasError") - if hasattr(griffe, "AliasResolutionError"): - delattr(griffe, "AliasResolutionError") - - _patch_griffe() - - assert hasattr(griffe, "CyclicAliasError") - assert hasattr(griffe, "AliasResolutionError") - assert issubclass(griffe.CyclicAliasError, Exception) - assert issubclass(griffe.AliasResolutionError, Exception) - finally: - # Restore originals - if had_cyclic: - griffe.CyclicAliasError = orig_cyclic - elif hasattr(griffe, "CyclicAliasError"): - delattr(griffe, "CyclicAliasError") - if had_alias: - griffe.AliasResolutionError = orig_alias - elif hasattr(griffe, "AliasResolutionError"): - delattr(griffe, "AliasResolutionError") - - def test_detect_docstring_style_griffe_unavailable(): """Test _detect_docstring_style defaults to numpy when griffe import fails.""" From 9666dc30a4889d78995b5f4af4ce78e14cd5e6e2 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 29 Jun 2026 16:26:22 -0400 Subject: [PATCH 11/16] Resolve the alias-error classes more defensively --- great_docs/_api_diff.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/great_docs/_api_diff.py b/great_docs/_api_diff.py index a0e03716..2f4eee25 100644 --- a/great_docs/_api_diff.py +++ b/great_docs/_api_diff.py @@ -532,13 +532,25 @@ def _symbol_info_for_dotted(pkg, dotted: str) -> SymbolInfo | None: None means the symbol does not exist in this source — a name component is missing, or an alias cannot be resolved to a real object. """ - import griffe + # Resolve the alias-error classes defensively: older griffe exposes them + # only under `griffe.exceptions`, and this module never runs the top-level + # `_patch_griffe()` shim that `core` relies on. + try: + from griffe import AliasResolutionError, CyclicAliasError + except ImportError: # pragma: no cover - depends on installed griffe version + try: + from griffe.exceptions import AliasResolutionError, CyclicAliasError + except ImportError: + from griffe._internal.exceptions import ( + AliasResolutionError, + CyclicAliasError, + ) obj = pkg for part in dotted.split("."): try: members = obj.members - except (griffe.AliasResolutionError, griffe.CyclicAliasError): + except (AliasResolutionError, CyclicAliasError): return None if part not in members: return None @@ -546,7 +558,7 @@ def _symbol_info_for_dotted(pkg, dotted: str) -> SymbolInfo | None: try: kind = obj.kind.value - except (griffe.AliasResolutionError, griffe.CyclicAliasError): + except (AliasResolutionError, CyclicAliasError): return None return SymbolInfo( From 04b3edc13892186f64a3e693b9ecf0216ebafa38 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 29 Jun 2026 16:31:27 -0400 Subject: [PATCH 12/16] Ensure documented_symbol_names() doesn't write build artifacts --- great_docs/core.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/great_docs/core.py b/great_docs/core.py index adf1ed23..0623e011 100644 --- a/great_docs/core.py +++ b/great_docs/core.py @@ -151,6 +151,10 @@ def __init__(self, project_path: str | None = None): # Whether API reference was successfully configured (set during build) self._has_api_reference = True + # When True, suppress writes of build artifacts (e.g. `_object_types.json`) + # so read-only queries like `documented_symbol_names()` leave no files behind. + self._suppress_artifact_writes = False + # Whether MCP pages were actually generated (None = not yet determined) self._mcp_pages_generated: bool | None = None @@ -7141,6 +7145,11 @@ def _write_object_types_json(self, categories: dict) -> None: categories Dictionary returned by `_categorize_api_objects`. """ + # Read-only callers (e.g. `documented_symbol_names`) must not drop + # build artifacts into the project root. + if self._suppress_artifact_writes: + return + import json # Map each category key to the type label used by post-render @@ -8640,8 +8649,17 @@ def documented_symbol_names(self, package_name: str) -> list[str]: # Suppress diagnostic prints from the resolution/filtering pipeline — this is a # programmatic query method and its callers own their own output streams. - with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): - sections = self._create_api_sections_with_config(package_name) + # `_suppress_artifact_writes` additionally prevents the pipeline from writing + # `_object_types.json` (and its sidecar) into the project root. + self._suppress_artifact_writes = True + try: + with ( + contextlib.redirect_stdout(io.StringIO()), + contextlib.redirect_stderr(io.StringIO()), + ): + sections = self._create_api_sections_with_config(package_name) + finally: + self._suppress_artifact_writes = False if not sections: return [] From d3b398fe74549c0c1d3440e11ad464cd5fa009ac Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 29 Jun 2026 16:32:07 -0400 Subject: [PATCH 13/16] non-destructive pruning when the documented set can't be resolved --- great_docs/_versioned_build.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/great_docs/_versioned_build.py b/great_docs/_versioned_build.py index 49c040c7..c4606df9 100644 --- a/great_docs/_versioned_build.py +++ b/great_docs/_versioned_build.py @@ -742,8 +742,27 @@ def _format_param(p) -> str: def _is_valid_ref_name(name: str, valid_symbols: set[str]) -> bool: - """Whether a reference page stem names a symbol documented in this version""" - return name == "index" or name in valid_symbols + """Whether a reference page stem names a symbol documented in this version. + + A *deep* snapshot lists every valid stem explicitly (including method + stems like `Class.method` and submodule-qualified names like `sub.Widget`) + so exact set membership is sufficient and authoritative. + + A *shallow* snapshot holds only the package's top-level exports; it is the + back-compat fallback used when no documented set can be resolved (e.g. + `documented_symbol_names()` returned nothing). Such a snapshot carries no + dotted keys, so a `Class.method` page would be wrongly pruned under exact + membership. For that case only, fall back to validating a dotted stem by + its top-level prefix, matching the pre-deep-snapshot behavior and keeping + the failure mode non-destructive. + """ + if name == "index" or name in valid_symbols: + return True + # Shallow-snapshot safety net: a snapshot with no dotted keys cannot speak + # to method/submodule pages, so keep `Prefix.rest` when `Prefix` is known. + if "." in name and not any("." in symbol for symbol in valid_symbols): + return name.split(".")[0] in valid_symbols + return False def _prune_reference_index(index_qmd: Path, valid_symbols: set[str]) -> None: @@ -1036,9 +1055,7 @@ def _rebuild_api_from_git_ref( documented = GreatDocs(project_path=str(project_root)).documented_symbol_names(pkg_name) - snap = snapshot_at_tag( - project_root, git_ref, pkg_name, documented_names=documented or None - ) + snap = snapshot_at_tag(project_root, git_ref, pkg_name, documented_names=documented or None) if snap is None: return [] From fdb5d59148bbc9b35e50c7bee2e5acd875e82fc1 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 29 Jun 2026 16:32:25 -0400 Subject: [PATCH 14/16] Update test_documented_symbols.py --- tests/test_documented_symbols.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/test_documented_symbols.py b/tests/test_documented_symbols.py index 888019c5..0db07a49 100644 --- a/tests/test_documented_symbols.py +++ b/tests/test_documented_symbols.py @@ -22,9 +22,7 @@ def _write_pkg(root: Path) -> None: ) ) (pkg / "core.py").write_text("class TopClass:\n def go(self): ...\n") - (sub / "__init__.py").write_text( - "from mypkg.sub.things import Widget\n__all__ = ['Widget']\n" - ) + (sub / "__init__.py").write_text("from mypkg.sub.things import Widget\n__all__ = ['Widget']\n") (sub / "things.py").write_text("class Widget:\n def fit(self): ...\n") (root / "great-docs.yml").write_text( textwrap.dedent( @@ -48,6 +46,29 @@ def test_flattens_config_into_dotted_stems(tmp_path: Path): assert names == ["TopClass", "sub.Widget", "sub.Widget.fit"] +def test_does_not_write_build_artifacts(tmp_path: Path): + """`documented_symbol_names` is a read-only query: it must leave no files behind. + + The resolution pipeline (via `_create_api_sections_from_config`) normally + writes `_object_types.json` (and a `_constant_values.json` sidecar). Calling + it as a programmatic query (e.g., from `api-snapshot` / `api-diff`) must not + drop those artifacts into the project tree. + """ + _write_pkg(tmp_path) + gd = GreatDocs(project_path=str(tmp_path)) + + names = gd.documented_symbol_names("mypkg") + assert names # sanity: the config path (which performs the write) ran + + artifacts = list(tmp_path.rglob("_object_types.json")) + list( + tmp_path.rglob("_constant_values.json") + ) + assert artifacts == [], f"query left build artifacts behind: {artifacts}" + + # The flag is reset so a subsequent real build can still write artifacts. + assert gd._suppress_artifact_writes is False + + def test_returns_empty_without_reference_config(tmp_path: Path): """Without a reference config, the resolver delegates to auto-discovery (same as the renderer). From 04972c3065d7413450ce85dff263a621202ede52 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 29 Jun 2026 16:32:41 -0400 Subject: [PATCH 15/16] Add several tests --- tests/test_versioned_build.py | 95 ++++++++++++++++++++++++++++++++--- 1 file changed, 88 insertions(+), 7 deletions(-) diff --git a/tests/test_versioned_build.py b/tests/test_versioned_build.py index 42813277..aa177079 100644 --- a/tests/test_versioned_build.py +++ b/tests/test_versioned_build.py @@ -1623,10 +1623,25 @@ def test_method_stem_valid_when_in_snapshot(self): assert _is_valid_ref_name("MyClass.method", {"MyClass", "MyClass.method"}) is True - def test_method_stem_invalid_when_not_in_snapshot(self): + def test_method_stem_invalid_when_absent_from_deep_snapshot(self): + # A deep snapshot carries dotted keys, so a method stem that is not + # itself present is authoritatively invalid (the method was dropped). from great_docs._versioned_build import _is_valid_ref_name - assert _is_valid_ref_name("MyClass.method", {"MyClass"}) is False + assert _is_valid_ref_name("MyClass.gone", {"MyClass", "MyClass.kept"}) is False + + def test_method_stem_kept_under_shallow_snapshot(self): + # A shallow snapshot has no dotted keys; pruning a `Class.method` page + # under exact membership would reintroduce the #190 regression, so the + # class-prefix safety net keeps it. + from great_docs._versioned_build import _is_valid_ref_name + + assert _is_valid_ref_name("MyClass.method", {"MyClass", "func"}) is True + + def test_method_stem_dropped_when_class_absent_under_shallow_snapshot(self): + from great_docs._versioned_build import _is_valid_ref_name + + assert _is_valid_ref_name("Gone.method", {"MyClass"}) is False def test_unknown_symbol(self): from great_docs._versioned_build import _is_valid_ref_name @@ -2162,6 +2177,7 @@ def test_upcoming_page_via_scoping(self, tmp_path: Path): # Build for 0.4 (the prerelease) dest = tmp_path / "build" pages = preprocess_version(source, dest, versions[0], versions) + assert "new-feature.html" in pages def test_upcoming_page_via_frontmatter_key(self, tmp_path: Path): @@ -2176,9 +2192,11 @@ def test_upcoming_page_via_frontmatter_key(self, tmp_path: Path): ) versions = self._setup_versions() - # Build for 0.3 — future.qmd should be included but flagged as upcoming + + # Build for 0.3: future.qmd should be included but flagged as upcoming dest = tmp_path / "build" pages = preprocess_version(source, dest, versions[1], versions) + assert "future.html" in pages def test_badge_expiry_per_page_override(self, tmp_path: Path): @@ -2197,6 +2215,7 @@ def test_badge_expiry_per_page_override(self, tmp_path: Path): dest = tmp_path / "build" preprocess_version(source, dest, versions[1], versions) content = (dest / "page.qmd").read_text() + # With expiry="1 releases", badge for 0.2 should be expired when building 0.3 # (0.3 is 1 release after 0.2) assert "gd-badge" not in content # badge was suppressed @@ -2230,7 +2249,6 @@ def test_section_level_scoping_excludes_pages(self, tmp_path: Path): class TestRebuildApiFromSnapshotEdge: def test_no_ref_dir_creates_it(self, tmp_path: Path): - from great_docs._versioned_build import _rebuild_api_from_snapshot # Create a minimal snapshot snap_path = tmp_path / "snap.json" @@ -2254,12 +2272,12 @@ def test_no_ref_dir_creates_it(self, tmp_path: Path): entry = _make_entry("0.2") pages = _rebuild_api_from_snapshot(dest_dir, snap_path, entry) + assert "reference/MyFunc.html" in pages assert (dest_dir / "reference" / "MyFunc.qmd").exists() assert (dest_dir / "reference" / "index.qmd").exists() def test_preserves_rich_pages(self, tmp_path: Path): - from great_docs._versioned_build import _rebuild_api_from_snapshot snap_path = tmp_path / "snap.json" snap = ApiSnapshot( @@ -2282,7 +2300,6 @@ def test_preserves_rich_pages(self, tmp_path: Path): assert "Rich content" in (ref_dir / "GT.qmd").read_text() def test_generates_index_with_classes_and_functions(self, tmp_path: Path): - from great_docs._versioned_build import _rebuild_api_from_snapshot snap_path = tmp_path / "snap.json" snap = ApiSnapshot( @@ -2308,7 +2325,6 @@ def test_generates_index_with_classes_and_functions(self, tmp_path: Path): assert "my_func" in index_content def test_prunes_existing_pages_not_in_snapshot(self, tmp_path: Path): - from great_docs._versioned_build import _rebuild_api_from_snapshot snap_path = tmp_path / "snap.json" snap = ApiSnapshot( @@ -2332,6 +2348,71 @@ def test_prunes_existing_pages_not_in_snapshot(self, tmp_path: Path): assert not (ref_dir / "removed.qmd").exists() assert (ref_dir / "index.qmd").exists() + def test_shallow_snapshot_keeps_method_pages(self, tmp_path: Path): + # Regression guard for #190: when the documented set can't be resolved, + # the snapshot is shallow (top-level keys only). Method pages must NOT + # be destructively pruned just because `Class.method` is not a key. + + snap_path = tmp_path / "snap.json" + snap = ApiSnapshot( + version="0.2", + package_name="pkg", + symbols={"Big": SymbolInfo(name="Big", kind="class")}, + ) + snap.save(snap_path) + + dest_dir = tmp_path / "build" + ref_dir = dest_dir / "reference" + ref_dir.mkdir(parents=True) + (ref_dir / "Big.qmd").write_text("---\ntitle: Big\n---\n") + (ref_dir / "Big.m0.qmd").write_text("---\ntitle: Big.m0\n---\n") + (ref_dir / "Big.m1.qmd").write_text("---\ntitle: Big.m1\n---\n") + (ref_dir / "Orphan.method.qmd").write_text("---\ntitle: Orphan.method\n---\n") + (ref_dir / "index.qmd").write_text("---\ntitle: API\n---\n") + + entry = _make_entry("0.2") + _rebuild_api_from_snapshot(dest_dir, snap_path, entry) + + # Methods of a documented class survive under the shallow fallback. + assert (ref_dir / "Big.qmd").exists() + assert (ref_dir / "Big.m0.qmd").exists() + assert (ref_dir / "Big.m1.qmd").exists() + + # A method whose class is absent is still pruned. + assert not (ref_dir / "Orphan.method.qmd").exists() + + def test_deep_snapshot_prunes_dropped_method_pages(self, tmp_path: Path): + # With a deep snapshot (dotted keys present), exact membership governs: + # a method page absent from the snapshot is correctly pruned. + from great_docs._versioned_build import _rebuild_api_from_snapshot + + snap_path = tmp_path / "snap.json" + snap = ApiSnapshot( + version="0.2", + package_name="pkg", + symbols={ + "Big": SymbolInfo(name="Big", kind="class"), + "Big.m0": SymbolInfo(name="Big.m0", kind="function"), + }, + ) + snap.save(snap_path) + + dest_dir = tmp_path / "build" + ref_dir = dest_dir / "reference" + ref_dir.mkdir(parents=True) + (ref_dir / "Big.qmd").write_text("---\ntitle: Big\n---\n") + (ref_dir / "Big.m0.qmd").write_text("---\ntitle: Big.m0\n---\n") + (ref_dir / "Big.m1.qmd").write_text("---\ntitle: Big.m1\n---\n") + (ref_dir / "index.qmd").write_text("---\ntitle: API\n---\n") + + entry = _make_entry("0.2") + _rebuild_api_from_snapshot(dest_dir, snap_path, entry) + + assert (ref_dir / "Big.qmd").exists() + assert (ref_dir / "Big.m0.qmd").exists() + # m1 is no longer documented at this version → pruned. + assert not (ref_dir / "Big.m1.qmd").exists() + def test_rich_index_pruned_not_regenerated(self, tmp_path: Path): from great_docs._versioned_build import _rebuild_api_from_snapshot From 75c239ab8cf8d69567b8de77f9df212f22f4e4e8 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 29 Jun 2026 18:33:55 +0300 Subject: [PATCH 16/16] build(deps): bump griffe to ~=2.1.0 Raise the floor from <2.0.0 to ~=2.1.0 and drop the now-dead version-conditional code that supported older griffe: - Remove `_patch_griffe`; griffe 2.x always exports `CyclicAliasError` and `AliasResolutionError` at the top level, so it was a no-op. - Detect PEP 695 `type X = ...` aliases in `is_typealias` via the dedicated `TypeAlias` kind (resolves the standing TODO); old-style `X: TypeAlias = ...` attributes still use the annotation heuristic. --- great_docs/_renderer/_type_checks.py | 12 ++++++--- great_docs/core.py | 39 ++-------------------------- pyproject.toml | 2 +- tests/test_great_docs.py | 37 -------------------------- 4 files changed, 11 insertions(+), 79 deletions(-) diff --git a/great_docs/_renderer/_type_checks.py b/great_docs/_renderer/_type_checks.py index 186caa15..d5e528c9 100644 --- a/great_docs/_renderer/_type_checks.py +++ b/great_docs/_renderer/_type_checks.py @@ -19,11 +19,15 @@ def is_typealias(obj: gf.Object | gf.Alias) -> bool: """ - Return True if obj is a declaration of a TypeAlias + True when obj is a type alias + + Covers both PEP 695 ``type X = ...`` aliases, which griffe models as a + dedicated `TypeAlias`, and explicit ``X: TypeAlias = ...`` attributes. """ - # TODO: - # Figure out if this handles new-style typealiases introduced - # in python 3.12 to handle + # `isinstance` avoids resolving aliases, which can raise for unresolved + # targets; PEP 695 aliases are a distinct type rather than an Attribute. + if isinstance(obj, gf.TypeAlias): + return True if not (isinstance(obj, gf.Attribute) and obj.annotation): return False elif isinstance(obj.annotation, gf.ExprName): diff --git a/great_docs/core.py b/great_docs/core.py index 0623e011..0cdfb9a7 100644 --- a/great_docs/core.py +++ b/great_docs/core.py @@ -28,28 +28,6 @@ ] -def _patch_griffe(): - """Ensure griffe has CyclicAliasError and AliasResolutionError at top level. - - Older griffe versions don't re-export these from the top-level package. This patches them in so - `griffe.CyclicAliasError` etc. work everywhere. - """ - import griffe - - if hasattr(griffe, "CyclicAliasError") and hasattr(griffe, "AliasResolutionError"): - return - - try: - from griffe.exceptions import AliasResolutionError, CyclicAliasError - except ImportError: - from griffe._internal.exceptions import AliasResolutionError, CyclicAliasError - - if not hasattr(griffe, "CyclicAliasError"): - griffe.CyclicAliasError = CyclicAliasError - if not hasattr(griffe, "AliasResolutionError"): - griffe.AliasResolutionError = AliasResolutionError - - class QuartoNotFoundError(RuntimeError): """Raised when the Quarto CLI is not available on the system.""" @@ -185,8 +163,6 @@ def _get_griffe_package(self, package_name: str): """ import griffe - _patch_griffe() - if self._griffe_pkg_cache is None: self._griffe_pkg_cache = {} @@ -6384,8 +6360,6 @@ def _discover_package_exports(self, package_name: str) -> list | None: try: import griffe - _patch_griffe() - # Normalize package name (replace dashes with underscores) normalized_name = package_name.replace("-", "_") @@ -6626,8 +6600,6 @@ def _detect_docstring_style(self, package_name: str) -> str: try: import griffe - _patch_griffe() - # Normalize package name normalized_name = package_name.replace("-", "_") @@ -6769,8 +6741,6 @@ def _detect_dynamic_mode(self, package_name: str) -> bool: try: import griffe - _patch_griffe() - from great_docs._renderer.introspection import get_object as qd_get_object except ImportError: # pragma: no cover # If renderer isn't available, default to True (will fail at build time anyway) @@ -7043,7 +7013,8 @@ def _sub_classify_attribute(obj) -> str: except Exception: # pragma: no cover labels = set() # pragma: no cover - # griffe >= 0.40 has a dedicated "type alias" kind + # griffe gives PEP 695 type aliases a dedicated "type alias" kind. + # Reading `.kind` can raise for an unresolved alias, hence the guard. try: if obj.kind.value == "type alias": return "type_alias" @@ -7247,8 +7218,6 @@ def _categorize_api_objects(self, package_name: str, exports: list) -> dict: try: import griffe - _patch_griffe() - # Load the package using griffe normalized_name = package_name.replace("-", "_") @@ -8056,8 +8025,6 @@ def _extract_all_directives(self, package_name: str) -> dict: try: import griffe - _patch_griffe() - normalized_name = package_name.replace("-", "_") try: @@ -8238,8 +8205,6 @@ def _categorize_referenced_objects(self, package_name: str, reference_config: li """ import griffe - _patch_griffe() - normalized_name = package_name.replace("-", "_") try: diff --git a/pyproject.toml b/pyproject.toml index 2da05da7..c108d57a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ "jupyter>=1.1.1", "py-yaml12>=0.1.0", "click>=8.0.0", - "griffe>=0.35.0,<2.0.0", + "griffe~=2.1.0", "pygments>=2.0.0", "requests>=2.25.0", "Pillow>=9.0.0", diff --git a/tests/test_great_docs.py b/tests/test_great_docs.py index 6a6fa0fa..fd455ed8 100644 --- a/tests/test_great_docs.py +++ b/tests/test_great_docs.py @@ -21142,43 +21142,6 @@ def test_detect_docstring_style_no_docstrings(): assert result == "numpy" -def test_patch_griffe_adds_missing_exceptions(): - """Test _patch_griffe patches CyclicAliasError and AliasResolutionError onto griffe.""" - import griffe - - from great_docs.core import _patch_griffe - - # Save originals (they may already exist) - had_cyclic = hasattr(griffe, "CyclicAliasError") - had_alias = hasattr(griffe, "AliasResolutionError") - orig_cyclic = getattr(griffe, "CyclicAliasError", None) - orig_alias = getattr(griffe, "AliasResolutionError", None) - - try: - # Remove to simulate older griffe - if hasattr(griffe, "CyclicAliasError"): - delattr(griffe, "CyclicAliasError") - if hasattr(griffe, "AliasResolutionError"): - delattr(griffe, "AliasResolutionError") - - _patch_griffe() - - assert hasattr(griffe, "CyclicAliasError") - assert hasattr(griffe, "AliasResolutionError") - assert issubclass(griffe.CyclicAliasError, Exception) - assert issubclass(griffe.AliasResolutionError, Exception) - finally: - # Restore originals - if had_cyclic: - griffe.CyclicAliasError = orig_cyclic - elif hasattr(griffe, "CyclicAliasError"): - delattr(griffe, "CyclicAliasError") - if had_alias: - griffe.AliasResolutionError = orig_alias - elif hasattr(griffe, "AliasResolutionError"): - delattr(griffe, "AliasResolutionError") - - def test_detect_docstring_style_griffe_unavailable(): """Test _detect_docstring_style defaults to numpy when griffe import fails."""