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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 74 additions & 4 deletions great_docs/_api_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -526,25 +526,78 @@ 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.
"""
# 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 (AliasResolutionError, CyclicAliasError):
return None
if part not in members:
return None
obj = members[part]

try:
kind = obj.kind.value
except (AliasResolutionError, 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
----------
package_name
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.

Returns
-------
ApiSnapshot
Snapshot keyed by symbol name — dotted names when `documented_names` is given,
bare top-level names otherwise.
"""
import griffe

Expand All @@ -556,6 +609,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:
Expand Down Expand Up @@ -858,6 +919,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.
Expand All @@ -870,18 +932,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)
Expand Down
52 changes: 32 additions & 20 deletions great_docs/_versioned_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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)
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -742,20 +741,31 @@ 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."""
def _is_valid_ref_name(name: str, valid_symbols: set[str]) -> bool:
"""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
# Method page: `ClassName.method` (check the class prefix)
if "." in name:
class_name = name.split(".")[0]
return class_name in valid_classes
# 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], 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:
Expand All @@ -778,7 +788,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)
Expand All @@ -800,7 +810,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]
Expand Down Expand Up @@ -862,9 +872,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
Expand Down Expand Up @@ -914,7 +922,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
Expand Down Expand Up @@ -1043,7 +1051,11 @@ 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 []

Expand Down
24 changes: 20 additions & 4 deletions great_docs/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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)
Expand Down
Loading