From 4bf5a3dc2acc985c69523b71178af01717e4ff12 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Wed, 21 Oct 2026 23:50:26 +0300 Subject: [PATCH 1/4] refactor: remove the dead constant-value injection Constant reference pages already show `NAME: annotation = value` straight from the renderer. The post-render step that tried to add the same line looked for a bare-name paragraph the renderer no longer produces, so it never changed a page. Remove that step along with the metadata it read: the build no longer extracts constant values or writes `_constant_values.json`, and the post-render script no longer loads `_object_types.json`, which only that step used. The rendered reference pages are unchanged. --- great_docs/assets/post-render.py | 52 ------- great_docs/core.py | 53 -------- tests/test_core.py | 63 +-------- tests/test_documented_symbols.py | 10 +- tests/test_great_docs.py | 225 ------------------------------- 5 files changed, 5 insertions(+), 398 deletions(-) diff --git a/great_docs/assets/post-render.py b/great_docs/assets/post-render.py index f0f47398..ff7bef3e 100644 --- a/great_docs/assets/post-render.py +++ b/great_docs/assets/post-render.py @@ -1,7 +1,6 @@ from __future__ import annotations import glob -import json import os import re import sys @@ -33,28 +32,6 @@ def _configure_stdio_for_unicode() -> None: from pygments.formatters import HtmlFormatter from pygments.lexers import PythonLexer -# Load object type metadata for accurate classification -# Keys are object names (e.g., "parser.ParserError"), values are type strings -# ("class", "namedtuple", "typeddict", "protocol", "abc", "exception", "function", "method", "constant", "enum", "type_alias", "other") -object_types = {} -object_types_path = "_object_types.json" -if os.path.exists(object_types_path): - print(f"Loading object types from {object_types_path}") - with open(object_types_path, "r", encoding="utf-8") as f: - object_types = json.load(f) - print(f"Loaded {len(object_types)} object type entries") -else: - print("No object types file found, falling back to heuristic classification") - -# Load constant value metadata for displaying values on constant reference pages. -# Keys are constant names, values are dicts with optional "value" and "annotation". -constant_values: dict[str, dict[str, str]] = {} -constant_values_path = "_constant_values.json" -if os.path.exists(constant_values_path): - with open(constant_values_path, "r", encoding="utf-8") as f: - constant_values = json.load(f) - print(f"Loaded {len(constant_values)} constant value entries") - # Pygments class to Quarto class mapping # Quarto uses different class names than Pygments default PYGMENTS_TO_QUARTO_CLASS = { @@ -226,9 +203,6 @@ def enhance_sig_name(m): for html_file in html_files: print(f"Processing: {html_file}") - # Extract the item name from the filename (e.g., "GreatDocs.html" -> "GreatDocs") - item_name_from_file = os.path.basename(html_file).replace(".html", "") - with open(html_file, "r", encoding="utf-8") as file: content = file.read() @@ -238,32 +212,6 @@ def enhance_sig_name(m): # Convert back to lines for line-by-line processing content = content.splitlines(keepends=True) - content_str = "".join(content) - - # Inject constant value/annotation into constant reference pages. - # Replaces the bare ``

NAME

`` that the renderer emits with a - # styled display showing the type annotation and assigned value. - obj_type_for_value = object_types.get(item_name_from_file) - if obj_type_for_value == "constant" and item_name_from_file in constant_values: - meta = constant_values[item_name_from_file] - annotation = meta.get("annotation", "") - value = meta.get("value", "") - - # Build the display string: NAME: type = value - parts = [f"{item_name_from_file}"] - if annotation: - parts.append(f': {annotation}') - if value: - parts.append(f' = {value}') - replacement_html = "

" + "".join(parts) + "

" - - # Replace the original bare name paragraph - bare_name_html = f"

{item_name_from_file}

" - if bare_name_html in content_str: - content_str = content_str.replace(bare_name_html, replacement_html, 1) - - content = content_str.splitlines(keepends=True) - # Add separator lines between class details and individual members, # and between individual member sections. # - Thin solid line after the Methods/Attributes summary table (before first member section) diff --git a/great_docs/core.py b/great_docs/core.py index 85262a26..e50968a9 100644 --- a/great_docs/core.py +++ b/great_docs/core.py @@ -8007,42 +8007,6 @@ def _sub_classify_attribute(obj) -> str: # constant without more context. Default to constant. return "constant" - @staticmethod - def _extract_constant_metadata(obj, name: str, categories: dict) -> None: - """ - Extract value and type annotation from a griffe constant/attribute. - - Populates `categories["constant_metadata"][name]` with `"value"` - and `"annotation"` strings when they are available and simple enough - to display. - - Parameters - ---------- - obj - A griffe `Attribute` (or `Alias`) object. - name - The documented name of the constant. - categories - The mutable categories dict being built. - """ - meta: dict[str, str] = {} - try: - if obj.value is not None: - val = str(obj.value) - # Only store "simple" values (literals, short collections). - # Avoid huge reprs that would clutter the page. - if len(val) <= 200: - meta["value"] = val - except Exception: - pass - try: - if obj.annotation is not None: - meta["annotation"] = str(obj.annotation) - except Exception: - pass - if meta: - categories["constant_metadata"][name] = meta - @staticmethod def _empty_categories() -> dict: """Return a fresh, empty categories dict with all keys.""" @@ -8068,7 +8032,6 @@ def _empty_categories() -> dict: "class_methods": {}, "class_method_names": {}, "class_member_types": {}, - "constant_metadata": {}, "cyclic_alias_count": 0, # ── Convenience unions ── "all_classes": [], @@ -8139,17 +8102,6 @@ def _write_object_types_json(self, categories: dict) -> None: print(f"Wrote object type metadata ({len(object_types)} items) to {types_path}") - # Write constant value metadata (sidecar file) when any constants have - # extractable values or annotations. - constant_metadata = categories.get("constant_metadata", {}) - if constant_metadata: - values_path = self.build_dir / "_constant_values.json" - with open(values_path, "w") as f: - json.dump(constant_metadata, f, indent=2, sort_keys=True) - print( - f"Wrote constant value metadata ({len(constant_metadata)} items) to {values_path}" - ) - def _categorize_api_objects(self, package_name: str, exports: list) -> dict: """ Categorize API objects using griffe introspection. @@ -8386,7 +8338,6 @@ def _categorize_api_objects(self, package_name: str, exports: list) -> dict: categories["type_aliases"].append(name) # pragma: no cover else: categories["constants"].append(name) - self._extract_constant_metadata(obj, name, categories) elif obj.kind.value == "module": # Drill into the module to discover its public classes and functions # Use qualified names like "module.ClassName" so the renderer resolves @@ -8597,9 +8548,6 @@ def _categorize_api_objects(self, package_name: str, exports: list) -> dict: ) # pragma: no cover else: categories["constants"].append(qualified) - self._extract_constant_metadata( - member, qualified, categories - ) module_had_members = True except ( # pragma: no cover @@ -9189,7 +9137,6 @@ def _categorize_referenced_objects(self, package_name: str, reference_config: li categories["type_aliases"].append(name) else: categories["constants"].append(name) - self._extract_constant_metadata(obj, name, categories) elif kind == "module": # Expand module members into qualified names for module_members mapping try: diff --git a/tests/test_core.py b/tests/test_core.py index 936e60bd..e53e60d7 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -760,7 +760,6 @@ def test_classifies_attribute_as_constant(self, tmp_path, monkeypatch): pkg.members = {"MY_CONST": obj} monkeypatch.setattr(gd, "_get_griffe_package", lambda name: pkg) monkeypatch.setattr(gd, "_sub_classify_attribute", lambda o: "constant") - monkeypatch.setattr(gd, "_extract_constant_metadata", lambda o, n, c: None) result = gd._categorize_referenced_objects("mypkg", [{"contents": ["MY_CONST"]}]) assert "MY_CONST" in result["constants"] @@ -1724,47 +1723,6 @@ def test_kind_raises_falls_through(self): assert GreatDocs._sub_classify_attribute(obj) == "constant" -# --------------------------------------------------------------------------- -# Extract constant metadata -# --------------------------------------------------------------------------- - - -class TestExtractConstantMetadata: - def test_stores_value_and_annotation(self): - obj = MagicMock() - obj.value = "42" - obj.annotation = "int" - categories = {"constant_metadata": {}} - GreatDocs._extract_constant_metadata(obj, "MY_CONST", categories) - assert categories["constant_metadata"]["MY_CONST"]["value"] == "42" - assert categories["constant_metadata"]["MY_CONST"]["annotation"] == "int" - - def test_skips_long_value(self): - obj = MagicMock() - obj.value = "x" * 201 - obj.annotation = None - categories = {"constant_metadata": {}} - GreatDocs._extract_constant_metadata(obj, "BIG", categories) - assert "BIG" not in categories["constant_metadata"] - - def test_none_value_skipped(self): - obj = MagicMock() - obj.value = None - obj.annotation = "str" - categories = {"constant_metadata": {}} - GreatDocs._extract_constant_metadata(obj, "X", categories) - assert "value" not in categories["constant_metadata"]["X"] - assert categories["constant_metadata"]["X"]["annotation"] == "str" - - def test_value_exception_graceful(self): - obj = MagicMock() - type(obj).value = property(lambda s: (_ for _ in ()).throw(Exception())) - obj.annotation = None - categories = {"constant_metadata": {}} - GreatDocs._extract_constant_metadata(obj, "BAD", categories) - assert "BAD" not in categories["constant_metadata"] - - # --------------------------------------------------------------------------- # Empty categories # --------------------------------------------------------------------------- @@ -1777,7 +1735,6 @@ def test_all_keys_present(self): assert "functions" in cats assert "constants" in cats assert "type_aliases" in cats - assert "constant_metadata" in cats assert cats["cyclic_alias_count"] == 0 def test_returns_fresh_instance(self): @@ -2305,7 +2262,7 @@ def test_nested_contents(self): # --------------------------------------------------------------------------- -# _write_object_types_json – basic, member_types fallback, constant_metadata +# _write_object_types_json – basic, member_types fallback # --------------------------------------------------------------------------- @@ -2346,24 +2303,6 @@ def test_member_types_fallback(self, tmp_path): assert data["Foo.bar"] == "property" assert data["Foo.baz"] == "attribute" - def test_constant_metadata_sidecar(self, tmp_path): - gd = _make_gd(tmp_path) - gd.build_dir.mkdir(parents=True, exist_ok=True) - gd._suppress_artifact_writes = False - categories = { - "constants": ["MY_CONST"], - "class_method_names": {}, - "class_member_types": {}, - "constant_metadata": {"MY_CONST": {"value": "42", "annotation": "int"}}, - } - gd._write_object_types_json(categories) - import json - - values_path = gd.build_dir / "_constant_values.json" - assert values_path.exists() - data = json.loads(values_path.read_text()) - assert data["MY_CONST"]["value"] == "42" - def test_suppressed_writes_nothing(self, tmp_path): gd = _make_gd(tmp_path) gd._suppress_artifact_writes = True diff --git a/tests/test_documented_symbols.py b/tests/test_documented_symbols.py index 77fed0fa..286ab466 100644 --- a/tests/test_documented_symbols.py +++ b/tests/test_documented_symbols.py @@ -70,9 +70,9 @@ 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. + writes `_object_types.json`. Calling it as a programmatic query (e.g., from + `api-snapshot` / `api-diff`) must not drop that artifact into the project + tree. """ _write_pkg(tmp_path) gd = GreatDocs(project_path=str(tmp_path)) @@ -80,9 +80,7 @@ def test_does_not_write_build_artifacts(tmp_path: 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") - ) + artifacts = list(tmp_path.rglob("_object_types.json")) assert artifacts == [], f"query left build artifacts behind: {artifacts}" # The flag is reset so a subsequent real build can still write artifacts. diff --git a/tests/test_great_docs.py b/tests/test_great_docs.py index 7aa05f23..80cd52ee 100644 --- a/tests/test_great_docs.py +++ b/tests/test_great_docs.py @@ -9898,49 +9898,6 @@ class FakeObj: assert GreatDocs._sub_classify_attribute(FakeObj()) == "constant" -def test_extract_constant_metadata_value_and_annotation(): - """_extract_constant_metadata captures both value and annotation.""" - - class FakeObj: - value = "42" - annotation = "int" - - categories = {"constant_metadata": {}} - GreatDocs._extract_constant_metadata(FakeObj(), "MY_CONST", categories) - - assert "MY_CONST" in categories["constant_metadata"] - assert categories["constant_metadata"]["MY_CONST"]["value"] == "42" - assert categories["constant_metadata"]["MY_CONST"]["annotation"] == "int" - - -def test_extract_constant_metadata_no_value_with_annotation(): - """_extract_constant_metadata skips when value is None.""" - - class FakeObj: - value = None - annotation = "str" - - categories = {"constant_metadata": {}} - GreatDocs._extract_constant_metadata(FakeObj(), "X", categories) - - assert "X" in categories["constant_metadata"] - assert "value" not in categories["constant_metadata"]["X"] - assert categories["constant_metadata"]["X"]["annotation"] == "str" - - -def test_extract_constant_metadata_long_value_key_absence(): - """_extract_constant_metadata skips values longer than 200 chars.""" - - class FakeObj: - value = "x" * 201 - annotation = None - - categories = {"constant_metadata": {}} - GreatDocs._extract_constant_metadata(FakeObj(), "BIG", categories) - - assert "BIG" not in categories["constant_metadata"] - - def test_count_cli_sidebar_items_flat_inline(): """_count_cli_sidebar_items counts simple string entries.""" assert GreatDocs._count_cli_sidebar_items(["a.qmd", "b.qmd", "c.qmd"]) == 3 @@ -18342,25 +18299,6 @@ def test_write_object_types_json_basic(): assert data["Widget.update"] == "classmethod" -def test_write_object_types_json_with_constant_metadata(): - """Test _write_object_types_json writes constant_values.json.""" - - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - docs.build_dir.mkdir(parents=True, exist_ok=True) - - categories = GreatDocs._empty_categories() - categories["constants"] = ["VERSION"] - categories["constant_metadata"] = {"VERSION": {"value": "1.0.0", "annotation": "str"}} - - docs._write_object_types_json(categories) - - values_path = docs.build_dir / "_constant_values.json" - assert values_path.exists() - data = json.loads(values_path.read_text()) - assert data["VERSION"]["value"] == "1.0.0" - - def test_categorize_api_objects_fallback_basic(): """Test _categorize_api_objects_fallback with known stdlib module.""" with tempfile.TemporaryDirectory() as tmp_dir: @@ -18945,7 +18883,6 @@ def test_empty_categories_structure(): "class_methods", "class_method_names", "class_member_types", - "constant_metadata", "cyclic_alias_count", "all_classes", "all_functions", @@ -19898,45 +19835,6 @@ def test_sub_classify_attribute_constant(): assert result == "constant" -def test_extract_constant_metadata_value(): - """Test _extract_constant_metadata extracts value.""" - - obj = MagicMock() - obj.value = "42" - obj.annotation = "int" - categories = {"constant_metadata": {}} - GreatDocs._extract_constant_metadata(obj, "MY_CONST", categories) - - assert "MY_CONST" in categories["constant_metadata"] - assert categories["constant_metadata"]["MY_CONST"]["value"] == "42" - assert categories["constant_metadata"]["MY_CONST"]["annotation"] == "int" - - -def test_extract_constant_metadata_long_value(): - """Test _extract_constant_metadata skips very long values.""" - - obj = MagicMock() - obj.value = "x" * 300 # Exceeds 200-char limit - obj.annotation = None - categories = {"constant_metadata": {}} - GreatDocs._extract_constant_metadata(obj, "BIG_CONST", categories) - meta = categories["constant_metadata"].get("BIG_CONST", {}) - - assert "value" not in meta - - -def test_extract_constant_metadata_no_value(): - """Test _extract_constant_metadata handles None value.""" - - obj = MagicMock() - obj.value = None - obj.annotation = None - categories = {"constant_metadata": {}} - GreatDocs._extract_constant_metadata(obj, "NONE_CONST", categories) - - assert "NONE_CONST" not in categories["constant_metadata"] - - def test_get_package_metadata_license_string(): """Test _get_package_metadata reads PEP 639 string license.""" with tempfile.TemporaryDirectory() as tmp_dir: @@ -21925,7 +21823,6 @@ def test_write_object_types_json_creates_file(): "class_methods": {"MyClass": 2}, "class_method_names": {"MyClass": ["run", "stop"]}, "class_member_types": {"MyClass.run": "method", "MyClass.stop": "classmethod"}, - "constant_metadata": {"MY_CONST": {"value": "42", "annotation": "int"}}, } docs._write_object_types_json(categories) @@ -21945,27 +21842,6 @@ def test_write_object_types_json_creates_file(): assert data["MyClass.run"] == "method" assert data["MyClass.stop"] == "classmethod" - # Check constant values sidecar file - values_path = build_dir / "_constant_values.json" - assert values_path.exists() - values = json.loads(values_path.read_text(encoding="utf-8")) - assert values["MY_CONST"]["value"] == "42" - - -def test_write_object_types_json_no_constants(): - """Test _write_object_types_json skips constant values file when empty.""" - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - build_dir = Path(tmp_dir) / "great-docs" - build_dir.mkdir(parents=True, exist_ok=True) - - categories = docs._empty_categories() - - docs._write_object_types_json(categories) - - values_path = build_dir / "_constant_values.json" - assert not values_path.exists() - def test_process_sections_blog_type(): """Test _process_sections handles blog-type sections.""" @@ -22526,7 +22402,6 @@ def test_empty_categories_has_all_keys(): "class_methods", "class_method_names", "class_member_types", - "constant_metadata", "cyclic_alias_count", "all_classes", "all_functions", @@ -22535,55 +22410,6 @@ def test_empty_categories_has_all_keys(): assert key in cats -def test_extract_constant_metadata_simple(): - """Test _extract_constant_metadata extracts value and annotation.""" - - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - - obj = MagicMock() - obj.value = "42" - obj.annotation = "int" - - categories = {"constant_metadata": {}} - docs._extract_constant_metadata(obj, "MY_CONST", categories) - assert "MY_CONST" in categories["constant_metadata"] - assert categories["constant_metadata"]["MY_CONST"]["value"] == "42" - assert categories["constant_metadata"]["MY_CONST"]["annotation"] == "int" - - -def test_extract_constant_metadata_no_annotation(): - """Test _extract_constant_metadata omits annotation when None.""" - - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - - obj = MagicMock() - obj.value = "'hello'" - obj.annotation = None - - categories = {"constant_metadata": {}} - docs._extract_constant_metadata(obj, "GREETING", categories) - assert "GREETING" in categories["constant_metadata"] - assert "annotation" not in categories["constant_metadata"]["GREETING"] - - -def test_extract_constant_metadata_very_long_value(): - """Test _extract_constant_metadata skips very long values.""" - - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - - obj = MagicMock() - obj.value = "x" * 250 # > 200 chars - obj.annotation = None - - categories = {"constant_metadata": {}} - docs._extract_constant_metadata(obj, "BIG", categories) - # Should not store value since it's too long - assert "BIG" not in categories["constant_metadata"] - - def test_sub_classify_class_with_decorators_abc(): """Test _sub_classify_class detects ABC from decorators.""" @@ -36452,56 +36278,6 @@ def test_classify_attribute_kind_exception(self): assert GreatDocs._sub_classify_attribute(obj) == "constant" -class TestExtractConstantMetadata: - """Tests for _extract_constant_metadata.""" - - def test_extracts_value_and_annotation(self): - """Extracts both value and annotation.""" - obj = MagicMock() - obj.value = "42" - obj.annotation = "int" - categories = GreatDocs._empty_categories() - GreatDocs._extract_constant_metadata(obj, "MY_CONST", categories) - assert categories["constant_metadata"]["MY_CONST"]["value"] == "42" - assert categories["constant_metadata"]["MY_CONST"]["annotation"] == "int" - - def test_skips_long_values(self): - """Skips values longer than 200 characters.""" - obj = MagicMock() - obj.value = "x" * 201 - obj.annotation = None - categories = GreatDocs._empty_categories() - GreatDocs._extract_constant_metadata(obj, "BIG_CONST", categories) - assert "BIG_CONST" not in categories["constant_metadata"] - - def test_handles_value_exception(self): - """Handles exception when accessing value.""" - obj = MagicMock() - type(obj).value = PropertyMock(side_effect=RuntimeError) - obj.annotation = "str" - categories = GreatDocs._empty_categories() - GreatDocs._extract_constant_metadata(obj, "ERR_CONST", categories) - assert categories["constant_metadata"]["ERR_CONST"]["annotation"] == "str" - - def test_handles_annotation_exception(self): - """Handles exception when accessing annotation.""" - obj = MagicMock() - obj.value = "hello" - type(obj).annotation = PropertyMock(side_effect=RuntimeError) - categories = GreatDocs._empty_categories() - GreatDocs._extract_constant_metadata(obj, "ERR_CONST", categories) - assert categories["constant_metadata"]["ERR_CONST"]["value"] == "hello" - - def test_no_metadata(self): - """Does not add entry when both value and annotation are None.""" - obj = MagicMock() - obj.value = None - obj.annotation = None - categories = GreatDocs._empty_categories() - GreatDocs._extract_constant_metadata(obj, "EMPTY", categories) - assert "EMPTY" not in categories["constant_metadata"] - - class TestFindPackageInit: """Tests for _find_package_init.""" @@ -37019,7 +36795,6 @@ def test_has_all_keys(self): assert "class_methods" in cats assert "class_method_names" in cats assert "class_member_types" in cats - assert "constant_metadata" in cats assert "other" in cats def test_all_lists_empty(self): From 3344d4b14601645e050b76cb70b1b451befa4bf4 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Thu, 22 Oct 2026 00:10:51 +0300 Subject: [PATCH 2/4] refactor: draw reference member separators from the stylesheet Class and module reference pages separate inline members with a solid rule after the summary table and a dotted rule between members. These rules were inserted into the HTML after rendering; the stylesheet now draws them instead, with the same colours, opacity and spacing, so the pages look the same in light and dark mode. The rules now also appear between the members of a nested class, which the old insertion skipped. A stylesheet rule for member indentation that never matched any rendered element is removed. --- great_docs/assets/great-docs.scss | 25 ++++++++++++++++++------ great_docs/assets/post-render.py | 32 +------------------------------ tests/test_gdg_rendered.py | 21 ++++++++++++++------ 3 files changed, 35 insertions(+), 43 deletions(-) diff --git a/great_docs/assets/great-docs.scss b/great_docs/assets/great-docs.scss index af019b2a..188d5450 100644 --- a/great_docs/assets/great-docs.scss +++ b/great_docs/assets/great-docs.scss @@ -34,6 +34,7 @@ $font-family-monospace: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, " // Selectors for similar groups of elements that are created by the renderer $docstring-sections: ":is(.doc-parameters, .doc-other-parameters, .doc-type-parameters, .doc-returns, .doc-yields, .doc-receives, .doc-raises, .doc-warns, .doc-attributes, .doc-parameter-attributes, .doc-init-parameters, .doc-see-also, .doc-examples)"; $doc-object-names: "span.doc-object-name:is(.doc-class-name, .doc-method-name, .doc-module-name, .doc-function-name, .doc-alias-name, .doc-attribute-name, .doc-type-name, .doc-typevar)"; +$member-groups: "section:is(.doc-attributes, .doc-classes, .doc-type-aliases, .doc-methods, .doc-functions):not(.doc-pages)"; // Reference pages and the index links in their sidebars. Sidebar links include // the path from the site root, so index links end in `/index.html`, and the @@ -470,12 +471,24 @@ section:has(> h2.doc-notes, > h3.doc-notes) { text-decoration: none; } -// docstring spacing and indentation -section.doc-attributes > section.doc-attribute, -section.doc-methods > section.doc-method { - @extend .ps-4; - border-left: 0.025rem solid $gray-500; - backgroud-color: cyan; +// Rules between the members of a group: solid after the summary table, +// dotted between members. Drawn as a block before the member's heading so +// that the margins collapse as they would around an `
`. The responsive +// tables script may wrap the summary table in `.gd-table-responsive`. +#{$member-groups} { + > :is(table, .gd-table-responsive) + section::before, + > section + section::before { + content: ""; + display: block; + margin: 1.5rem 0 1rem 0; + border-top: 2px solid #c5c8cd; + opacity: 0.25; + } + + > section + section::before { + border-top-style: dotted; + border-top-color: #b0b4ba; + } } // docstring section parameters diff --git a/great_docs/assets/post-render.py b/great_docs/assets/post-render.py index ff7bef3e..6c8e48d5 100644 --- a/great_docs/assets/post-render.py +++ b/great_docs/assets/post-render.py @@ -209,38 +209,8 @@ def enhance_sig_name(m): # Re-highlight the signature with Pygments for better syntax coloring content = highlight_signature_with_pygments(content) - # Convert back to lines for line-by-line processing - content = content.splitlines(keepends=True) - - # Add separator lines between class details and individual members, - # and between individual member sections. - # - Thin solid line after the Methods/Attributes summary table (before first member section) - # - Dotted line between each individual member section - for i, line in enumerate(content): - # Individual members render as `level3` sections. - if "
in
) - # or closes another member section - for j in range(i - 1, max(0, i - 5), -1): - prev = content[j].strip() - if not prev: - continue - if "" in prev: - # First member section after a summary table — solid line - content[i] = ( - '
\n' - + content[i] - ) - elif "" in prev: - # Between member sections — dotted line - content[i] = ( - '
\n' - + content[i] - ) - break - with open(html_file, "w", encoding="utf-8") as file: - file.writelines(content) + file.write(content) print("Finished processing all files") diff --git a/tests/test_gdg_rendered.py b/tests/test_gdg_rendered.py index 5b822fd1..bd75bc20 100644 --- a/tests/test_gdg_rendered.py +++ b/tests/test_gdg_rendered.py @@ -1085,9 +1085,11 @@ def test_member_separator_rules_present(pkg_name: str, page_name: str): """ Verify class member separators - Class pages contain one solid rule after the member summary and one dotted - rule between each pair of `level3` members. Derive the expected count from - the rendered members so fixtures can add methods without changing the test. + The stylesheet draws one solid rule after the member summary and one dotted + rule between each pair of `level3` members. Select the members those rules + match, and check that the deployed stylesheet carries them. Derive the + expected count from the rendered members so fixtures can add methods + without changing the test. """ page = _ref_dir(pkg_name) / page_name if not page.exists(): @@ -1100,9 +1102,16 @@ def test_member_separator_rules_present(pkg_name: str, page_name: str): if not members: pytest.skip(f"{page_name}: no member sections to check") - rules = soup.select("hr") - solid_rules = [r for r in rules if "solid" in (r.get("style") or "")] - dotted_rules = [r for r in rules if "dotted" in (r.get("style") or "")] + groups = ( + "section:is(.doc-attributes, .doc-classes, .doc-type-aliases, .doc-methods," + " .doc-functions):not(.doc-pages)" + ) + solid_rules = soup.select(f"{groups} > :is(table, .gd-table-responsive) + section") + dotted_rules = soup.select(f"{groups} > section + section") + + css = re.sub(r"\s+", "", _deployed_css(pkg_name)) + assert ">:is(table,.gd-table-responsive)+section::before" in css + assert ">section+section::before" in css assert len(solid_rules) == 1, ( f"{page_name}: expected exactly one solid rule after the members " From c2dab6816763cf699312f6ec4fcd1090a1f1b770 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Thu, 22 Oct 2026 14:46:02 +0300 Subject: [PATCH 3/4] refactor: remove the source-link and object-type sidecar files The renderer builds source links and inventory roles straight from the package, so two files the build wrote for the old post-render script had no remaining reader: the source-link map, and the object-type map whose only use was the build-time estimate. Stop writing both, and drop the "Generate source links" step from the build log. The build-time estimate now counts the entries in the previous build's `objects.inv` instead. Like before, it reflects the last build, because the estimate is printed before this build resolves the package. --- great_docs/_build_log.py | 7 +- great_docs/core.py | 395 +------------ tests/test_core.py | 257 -------- tests/test_documented_symbols.py | 23 - tests/test_gdg_rendered.py | 34 +- tests/test_great_docs.py | 981 +------------------------------ 6 files changed, 34 insertions(+), 1663 deletions(-) diff --git a/great_docs/_build_log.py b/great_docs/_build_log.py index c07e1c2b..195bd241 100644 --- a/great_docs/_build_log.py +++ b/great_docs/_build_log.py @@ -200,7 +200,7 @@ def estimate_build_time( ========= =========== ============================================ griffe 3.2 s/item Import + introspection (~1.3 s) **plus** source-link resolution (~1.9 s) per - `_object_types.json` entry. + documented object. quarto 0.5 s/page Markdown-only page render average code cell 3.0 s/cell Jupyter kernel startup amortised per cell post-rend 0.3 s/page HTML transforms (BeautifulSoup) @@ -209,8 +209,9 @@ def estimate_build_time( Parameters ---------- n_api_items - Total entries in `_object_types.json` (classes + methods + - functions + attributes — **not** just top-level exports). + Number of documented objects, counted as entries in the previous + build's `objects.inv` (classes, methods, functions and attributes, + **not** just top-level exports). n_total_pages Total `.qmd` / `.md` pages Quarto will render (reference + user guide + sections + changelog + homepage + CLI). diff --git a/great_docs/core.py b/great_docs/core.py index e50968a9..3e3e0c4d 100644 --- a/great_docs/core.py +++ b/great_docs/core.py @@ -257,10 +257,6 @@ def __init__( # 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 @@ -316,40 +312,6 @@ def _get_griffe_package(self, package_name: str): ) return self._griffe_pkg_cache[normalized] - def _get_source_location_from_pkg(self, pkg, item_name: str) -> dict | None: - """Get source location by navigating an already-loaded griffe package tree. - - Parameters - ---------- - pkg - A griffe package object (already loaded). - item_name - Dotted name relative to the package (e.g., "ClassName.method_name"). - - Returns - ------- - dict | None - Dictionary with file, start_line, end_line or None if not found. - """ - parts = item_name.split(".") - obj = pkg - for part in parts: - if part not in obj.members: - return None - obj = obj.members[part] - - if not hasattr(obj, "lineno") or obj.lineno is None: - return None - if not hasattr(obj, "filepath") or not obj.filepath: - return None - - end_lineno = getattr(obj, "endlineno", obj.lineno) - return { - "file": str(obj.filepath), - "start_line": obj.lineno, - "end_line": end_lineno or obj.lineno, - } - def _validate_build_outputs(self) -> None: """Reject version and ownership conflicts before changing project files""" validate_layout_outputs(self.layout) @@ -6787,96 +6749,6 @@ def _create_blended_index(self, user_guide_info: dict, copied_files: list[str]) print(" 📄 Blended homepage: first UG page → index.qmd") - def _get_source_location(self, package_name: str, item_name: str) -> dict | None: - """ - Get source file and line numbers for a class, method, or function. - - Uses griffe for static analysis to avoid import side effects. - Leverages the per-build griffe cache to avoid redundant package loads. - - Parameters - ---------- - package_name - The name of the package containing the item. - item_name - The fully qualified name of the item (e.g., "ClassName" or "ClassName.method_name"). - - Returns - ------- - dict | None - Dictionary with file path and line numbers, or None if not found. - """ - try: - normalized_name = package_name.replace("-", "_") - pkg = self._get_griffe_package(normalized_name) - return self._get_source_location_from_pkg(pkg, item_name) - except Exception: - return None - - def _build_github_source_url( - self, source_location: dict, branch: str | None = None - ) -> str | None: - """ - Build a GitHub URL for viewing source code at specific line numbers. - - Parameters - ---------- - source_location - Dictionary with file path and line numbers. - branch - Git branch/tag to link to. If None, attempts to detect from git - or falls back to 'main'. - - Returns - ------- - str | None - Full GitHub URL with line anchors, or None if repo info not available. - """ - owner, repo, base_url = self._get_github_repo_info() - - if not base_url: - return None - - # Determine the branch/ref to use - if branch is None: - branch = self._detect_git_ref() # pragma: no cover - - # Get the file path relative to the repository root - filepath = source_location.get("file", "") - package_root = self._find_package_root() - - # Handle source path configuration for monorepos - metadata = self._get_package_metadata() - source_path = metadata.get("source_link_path") - - if source_path: - # Custom source path specified (for monorepos) - relative_path = f"{source_path}/{Path(filepath).name}" - else: - # Try to make the path relative to package root - try: - filepath_obj = Path(filepath) - if filepath_obj.is_absolute(): - relative_path = str(filepath_obj.relative_to(package_root)) - else: - relative_path = filepath - except ValueError: - # Path is not relative to package root, use as-is - relative_path = filepath - - # Build the URL with line number anchors - start_line = source_location.get("start_line", 1) - end_line = source_location.get("end_line", start_line) - - url = f"{base_url}/blob/{branch}/{relative_path}" - - if start_line == end_line: - url += f"#L{start_line}" - else: - url += f"#L{start_line}-L{end_line}" - - return url - def _detect_git_ref(self) -> str: """ Detect the current git branch or tag. @@ -6925,150 +6797,6 @@ def _detect_git_ref(self) -> str: # Default fallback return "main" - def _generate_source_links_json(self, package_name: str) -> None: - """ - Generate a JSON file mapping object names to their GitHub source URLs. - - This file is used by the post-render script to inject source links - into the HTML documentation. - - Parameters - ---------- - package_name - The name of the package to generate source links for. - """ - import json - - metadata = self._get_package_metadata() - - # Check if source links are enabled - if not metadata.get("source_link_enabled", True): - print("Source links disabled in configuration") - return - - # Check if we have GitHub repo info - owner, repo, base_url = self._get_github_repo_info() - if not base_url: - print("No GitHub repository URL found, skipping source links") - return - - print(f"Generating source links for {package_name}...") - - source_links: dict[str, dict] = {} - normalized_name = package_name.replace("-", "_") - - # Get all exports - exports = self._get_package_exports(package_name) - if not exports: - return - - # Get branch for source links - branch = self._detect_git_ref() - print(f"Using git ref: {branch}") - - # Pre-warm the griffe cache so _get_source_location doesn't reload per-item - try: - pkg = self._get_griffe_package(normalized_name) - except Exception: - pkg = None - - # Derive class membership directly from the griffe tree — avoids the - # expensive _categorize_api_objects() path which calls get_object() per - # method (dynamic imports, ~100-200ms each). - _INIT_DUNDERS = {"__init__", "__new__", "__init_subclass__"} - class_method_names: dict[str, list[str]] = {} - if pkg is not None: - for item_name in exports: - if item_name not in pkg.members: - continue - obj = pkg.members[item_name] - try: - if obj.kind.value != "class": # pragma: no cover - continue # pragma: no cover - except Exception: # pragma: no cover - continue # pragma: no cover - methods = [] - try: - for member_name, member in obj.members.items(): - if member_name.startswith("__") and member_name.endswith("__"): - if member_name in _INIT_DUNDERS: - continue - elif member_name.startswith("_"): - continue - try: - if member.kind.value in ("function", "method", "attribute"): - methods.append(member_name) - except Exception: # pragma: no cover - continue # pragma: no cover - except Exception: # pragma: no cover - continue # pragma: no cover - if methods: - class_method_names[item_name] = methods - print(f"{item_name}: class with {len(methods)} public methods") - - # Hoist invariants for _build_github_source_url out of the loop - package_root = self._find_package_root() - metadata = self._get_package_metadata() - source_path = metadata.get("source_link_path") - - def _make_github_url(source_loc: dict) -> str | None: - filepath = source_loc.get("file", "") - if source_path: - relative_path = f"{source_path}/{Path(filepath).name}" - else: - try: - filepath_obj = Path(filepath) - if filepath_obj.is_absolute(): - relative_path = str(filepath_obj.relative_to(package_root)) - else: - relative_path = filepath - except ValueError: # pragma: no cover - relative_path = filepath # pragma: no cover - - start_line = source_loc.get("start_line", 1) - end_line = source_loc.get("end_line", start_line) - url = f"{base_url}/blob/{branch}/{relative_path}" - if start_line == end_line: - url += f"#L{start_line}" - else: - url += f"#L{start_line}-L{end_line}" - return url - - # Generate source links for each export (uses cached griffe pkg internally) - for item_name in exports: - source_loc = self._get_source_location(normalized_name, item_name) - if source_loc: - github_url = _make_github_url(source_loc) - if github_url: - source_links[item_name] = { - "url": github_url, - "file": source_loc.get("file", ""), - "start_line": source_loc.get("start_line", 0), - "end_line": source_loc.get("end_line", 0), - } - - # Also get source links for methods of classes - if item_name in class_method_names: - for method_name in class_method_names[item_name]: - full_name = f"{item_name}.{method_name}" - method_loc = self._get_source_location(normalized_name, full_name) - if method_loc: - method_url = _make_github_url(method_loc) - if method_url: - source_links[full_name] = { - "url": method_url, - "file": method_loc.get("file", ""), - "start_line": method_loc.get("start_line", 0), - "end_line": method_loc.get("end_line", 0), - } - - # Write to JSON file in the docs directory - source_links_path = self.build_dir / "_source_links.json" - with open(source_links_path, "w", encoding="utf-8") as f: - json.dump(source_links, f, indent=2) - - print(f"Generated source links for {len(source_links)} items") - def _find_package_init(self, package_name: str) -> Path | None: """ Find the __init__.py file for a package, searching common locations. @@ -8038,70 +7766,6 @@ def _empty_categories() -> dict: "all_functions": [], } - def _write_object_types_json(self, categories: dict) -> None: - """ - Write `_object_types.json` mapping each documented name to its type. - - The post-render script reads this file instead of relying on heuristics - (e.g. first-character case) to classify objects for badges and - parentheses. - - Parameters - ---------- - 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 - _CATEGORY_TYPE_MAP = { - "classes": "class", - "dataclasses": "class", - "enums": "enum", - "exceptions": "exception", - "namedtuples": "namedtuple", - "typeddicts": "typeddict", - "protocols": "protocol", - "abstract_classes": "abc", - "functions": "function", - "async_functions": "function", - "constants": "constant", - "type_aliases": "type_alias", - "other": "other", - } - - object_types: dict[str, str] = {} - - for cat_key, obj_type in _CATEGORY_TYPE_MAP.items(): - for name in categories.get(cat_key, []): - object_types[name] = obj_type - - # Methods: ClassName.method → specific type (classmethod/staticmethod/method) - member_types = categories.get("class_member_types", {}) - for class_name, method_names in categories.get("class_method_names", {}).items(): - for method in method_names: - key = f"{class_name}.{method}" - # Use the specific sub-type if available, otherwise "method" - object_types[key] = member_types.get(key, "method") - - # Properties and other descriptor members not in class_method_names - # (e.g., @property attributes classified as "attribute" by griffe) - for key, member_type in member_types.items(): - if key not in object_types: - object_types[key] = member_type - - types_path = self.build_dir / "_object_types.json" - types_path.parent.mkdir(parents=True, exist_ok=True) - with open(types_path, "w") as f: - json.dump(object_types, f, indent=2, sort_keys=True) - - print(f"Wrote object type metadata ({len(object_types)} items) to {types_path}") - def _categorize_api_objects(self, package_name: str, exports: list) -> dict: """ Categorize API objects using griffe introspection. @@ -8903,9 +8567,6 @@ def _make_class_section(title: str, desc: str, class_names: list) -> list[dict]: } ) - # Write object type metadata for post-render classification - self._write_object_types_json(categories) - return sections if sections else None def _build_sections_from_reference_config( @@ -9012,7 +8673,7 @@ def _categorize_referenced_objects(self, package_name: str, reference_config: li Returns ------- dict - A categories dict compatible with `_write_object_types_json`. + A categories dict in the shape `_categorize_api_objects` returns. """ import griffe @@ -9399,9 +9060,6 @@ def _create_api_sections_from_config(self, package_name: str) -> list | None: if sections: print(f"Generated {len(sections)} section(s) from reference config") - # Write object type metadata for post-render classification - self._write_object_types_json(categories) - return sections if sections else None @contextlib.contextmanager @@ -9409,8 +9067,8 @@ def _resolved_api_reference(self, package_name: str) -> "Iterator[APIReference | """ Yield the API reference for a package, or None when it cannot be resolved - Restores `sys.path`, `sys.modules` and the artefact-write suppression - flag on the way out, so a read-only query leaves no trace. In `dynamic` + Restores `sys.path` and `sys.modules` on the way out, so a read-only + query leaves no trace. In `dynamic` mode resolving the reference performs a real import of the target package. @@ -9441,9 +9099,6 @@ def _resolved_api_reference(self, package_name: str) -> "Iterator[APIReference | try: # Suppress diagnostic prints from the resolution/filtering pipeline — this is a # programmatic query method and its callers own their own output streams. - # `_suppress_artifact_writes` additionally prevents the pipeline from writing - # `_object_types.json` (and its sidecar) into the project root. - self._suppress_artifact_writes = True # `APIReference` loads the package through a plain griffe loader (sys.path-based), # unlike `_create_api_sections_with_config`'s own loader, which is handed explicit # search paths. Temporarily extend sys.path so a package that isn't installed (e.g. @@ -9491,7 +9146,6 @@ def _resolved_api_reference(self, package_name: str) -> "Iterator[APIReference | ) yield ref finally: - self._suppress_artifact_writes = False if modules_evicted: for key in [ k @@ -15910,10 +15564,6 @@ def _prepare_for_freeze(self) -> None: # pragma: no cover self._generate_skill_md() except Exception: pass - try: - self._generate_source_links() - except Exception: - pass try: self._generate_changelog() except Exception: @@ -16153,12 +15803,17 @@ def _quiet_prints(): n_api_items = 0 n_total_pages = 0 n_code_cells = 0 - obj_types_path = self.build_dir / "_object_types.json" - if obj_types_path.exists(): + # The previous build's inventory is the best count available before + # this build resolves the package. + import zlib + + from great_docs._interlinks.sphinx_inventory import INVENTORY_FILENAME, decode + + inventory_path = self.build_dir / INVENTORY_FILENAME + if inventory_path.exists(): try: - with open(obj_types_path) as f: - n_api_items = len(json.load(f)) - except Exception: + n_api_items = len(decode(inventory_path.read_bytes()).entries) + except (OSError, ValueError, zlib.error): pass # Cheap page & code-cell scan for the time estimate @@ -16201,7 +15856,7 @@ def _quiet_prints(): except Exception: pass # never let estimation crash the build - total_steps = 19 + total_steps = 18 est_seconds = estimate_build_time( n_api_items=n_api_items, n_total_pages=n_total_pages, @@ -16406,23 +16061,6 @@ class Result: else: log.step_skip(step, "disabled in config") - # ── Step 5: Generate source links ────────────────────────── - step += 1 - log.step_start(step, "Generate source links") - package_name = self._detect_package_name() - if package_name: - # Source-link generation (and the CLI reference in Step 7, - # which reuses pkg_name) load the importable module, which can - # differ from the PyPI project name. Honor an explicit - # `module:` and build-backend hints via _detect_module_name. - pkg_name = self._resolve_importable_name(package_name) - with _quiet_prints(): - self._generate_source_links_json(pkg_name) - log.step_done(f"Source links generated for {pkg_name}") - else: - pkg_name = None - log.step_skip(step, "no package detected") - # ── Step 6: Generate changelog ───────────────────────────── step += 1 log.step_start(step, "Generate changelog") @@ -16449,9 +16087,12 @@ class Result: step += 1 log.step_start(step, "Generate CLI reference") metadata = self._get_package_metadata() - if metadata.get("cli_enabled", False) and not pkg_name: + if metadata.get("cli_enabled", False) and not package_name: log.step_skip(step, "no package detected") elif metadata.get("cli_enabled", False): + # Discover the CLI in the importable module, which can differ + # from the PyPI project name. + pkg_name = self._resolve_importable_name(package_name) try: with _quiet_prints(): cli_info = self._discover_click_cli(pkg_name, display_name=package_name) diff --git a/tests/test_core.py b/tests/test_core.py index e53e60d7..fb8f78e6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2261,57 +2261,6 @@ def test_nested_contents(self): assert GreatDocs._count_cli_sidebar_items(items) == 2 -# --------------------------------------------------------------------------- -# _write_object_types_json – basic, member_types fallback -# --------------------------------------------------------------------------- - - -class TestWriteObjectTypesJson: - def test_writes_object_types(self, tmp_path): - gd = _make_gd(tmp_path) - gd.build_dir.mkdir(parents=True, exist_ok=True) - gd._suppress_artifact_writes = False - categories = { - "classes": ["MyClass"], - "functions": ["my_func"], - "class_method_names": {"MyClass": ["method_a"]}, - "class_member_types": {}, - } - gd._write_object_types_json(categories) - import json - - types_path = gd.build_dir / "_object_types.json" - assert types_path.exists() - data = json.loads(types_path.read_text()) - assert data["MyClass"] == "class" - assert data["my_func"] == "function" - assert data["MyClass.method_a"] == "method" - - def test_member_types_fallback(self, tmp_path): - gd = _make_gd(tmp_path) - gd.build_dir.mkdir(parents=True, exist_ok=True) - gd._suppress_artifact_writes = False - categories = { - "classes": ["Foo"], - "class_method_names": {"Foo": ["bar"]}, - "class_member_types": {"Foo.bar": "property", "Foo.baz": "attribute"}, - } - gd._write_object_types_json(categories) - import json - - data = json.loads((gd.build_dir / "_object_types.json").read_text()) - assert data["Foo.bar"] == "property" - assert data["Foo.baz"] == "attribute" - - def test_suppressed_writes_nothing(self, tmp_path): - gd = _make_gd(tmp_path) - gd._suppress_artifact_writes = True - gd._write_object_types_json( - {"classes": ["X"], "class_method_names": {}, "class_member_types": {}} - ) - assert not (gd.build_dir / "_object_types.json").exists() - - # --------------------------------------------------------------------------- # _generate_package_info_page – early returns and marker branch # --------------------------------------------------------------------------- @@ -2850,212 +2799,6 @@ def test_js_newline_appended(self, tmp_path, monkeypatch): assert merged_js.read_bytes().endswith(b"\n") -# --------------------------------------------------------------------------- -# _generate_source_links_json – _make_github_url branches, class methods -# --------------------------------------------------------------------------- - - -class TestGenerateSourceLinksJson: - def test_disabled_returns_early(self, tmp_path, monkeypatch): - gd = _make_gd(tmp_path) - monkeypatch.setattr(gd, "_get_package_metadata", lambda: {"source_link_enabled": False}) - gd._generate_source_links_json("mypkg") - assert not (gd.build_dir / "_source_links.json").exists() - - def test_no_github_info_returns_early(self, tmp_path, monkeypatch): - gd = _make_gd(tmp_path) - monkeypatch.setattr(gd, "_get_package_metadata", lambda: {"source_link_enabled": True}) - monkeypatch.setattr(gd, "_get_github_repo_info", lambda: (None, None, None)) - gd._generate_source_links_json("mypkg") - assert not (gd.build_dir / "_source_links.json").exists() - - def test_no_exports_returns_early(self, tmp_path, monkeypatch): - gd = _make_gd(tmp_path) - monkeypatch.setattr(gd, "_get_package_metadata", lambda: {"source_link_enabled": True}) - monkeypatch.setattr( - gd, "_get_github_repo_info", lambda: ("owner", "repo", "https://github.com/o/r") - ) - monkeypatch.setattr(gd, "_get_package_exports", lambda p: None) - gd._generate_source_links_json("mypkg") - assert not (gd.build_dir / "_source_links.json").exists() - - def test_writes_source_links(self, tmp_path, monkeypatch): - gd = _make_gd(tmp_path) - gd.build_dir.mkdir(parents=True, exist_ok=True) - monkeypatch.setattr(gd, "_get_package_metadata", lambda: {"source_link_enabled": True}) - monkeypatch.setattr( - gd, "_get_github_repo_info", lambda: ("owner", "repo", "https://github.com/o/r") - ) - monkeypatch.setattr(gd, "_detect_git_ref", lambda: "main") - monkeypatch.setattr(gd, "_get_package_exports", lambda p: ["MyFunc"]) - monkeypatch.setattr(gd, "_find_package_root", lambda: tmp_path / "src" / "mypkg") - monkeypatch.setattr( - gd, - "_get_source_location", - lambda pkg, name: {"file": "module.py", "start_line": 10, "end_line": 20}, - ) - mock_pkg = MagicMock() - mock_pkg.members = {} - monkeypatch.setattr(gd, "_get_griffe_package", lambda p: mock_pkg) - gd._generate_source_links_json("mypkg") - import json - - data = json.loads((gd.build_dir / "_source_links.json").read_text()) - assert "MyFunc" in data - assert "#L10-L20" in data["MyFunc"]["url"] - - def test_single_line_anchor(self, tmp_path, monkeypatch): - gd = _make_gd(tmp_path) - gd.build_dir.mkdir(parents=True, exist_ok=True) - monkeypatch.setattr(gd, "_get_package_metadata", lambda: {"source_link_enabled": True}) - monkeypatch.setattr( - gd, "_get_github_repo_info", lambda: ("o", "r", "https://github.com/o/r") - ) - monkeypatch.setattr(gd, "_detect_git_ref", lambda: "main") - monkeypatch.setattr(gd, "_get_package_exports", lambda p: ["F"]) - monkeypatch.setattr(gd, "_find_package_root", lambda: tmp_path) - monkeypatch.setattr( - gd, - "_get_source_location", - lambda pkg, name: {"file": "f.py", "start_line": 5, "end_line": 5}, - ) - mock_pkg = MagicMock() - mock_pkg.members = {} - monkeypatch.setattr(gd, "_get_griffe_package", lambda p: mock_pkg) - gd._generate_source_links_json("mypkg") - import json - - data = json.loads((gd.build_dir / "_source_links.json").read_text()) - assert "#L5" in data["F"]["url"] - assert "#L5-L" not in data["F"]["url"] - - def test_source_path_override(self, tmp_path, monkeypatch): - gd = _make_gd(tmp_path) - gd.build_dir.mkdir(parents=True, exist_ok=True) - monkeypatch.setattr( - gd, - "_get_package_metadata", - lambda: {"source_link_enabled": True, "source_link_path": "src/mypkg"}, - ) - monkeypatch.setattr( - gd, "_get_github_repo_info", lambda: ("o", "r", "https://github.com/o/r") - ) - monkeypatch.setattr(gd, "_detect_git_ref", lambda: "main") - monkeypatch.setattr(gd, "_get_package_exports", lambda p: ["F"]) - monkeypatch.setattr(gd, "_find_package_root", lambda: tmp_path) - monkeypatch.setattr( - gd, - "_get_source_location", - lambda pkg, name: {"file": "/abs/path/module.py", "start_line": 1, "end_line": 1}, - ) - mock_pkg = MagicMock() - mock_pkg.members = {} - monkeypatch.setattr(gd, "_get_griffe_package", lambda p: mock_pkg) - gd._generate_source_links_json("mypkg") - import json - - data = json.loads((gd.build_dir / "_source_links.json").read_text()) - assert "src/mypkg/module.py" in data["F"]["url"] - - def test_absolute_filepath_relative_to_root(self, tmp_path, monkeypatch): - gd = _make_gd(tmp_path) - gd.build_dir.mkdir(parents=True, exist_ok=True) - pkg_root = tmp_path / "src" / "mypkg" - pkg_root.mkdir(parents=True) - monkeypatch.setattr(gd, "_get_package_metadata", lambda: {"source_link_enabled": True}) - monkeypatch.setattr( - gd, "_get_github_repo_info", lambda: ("o", "r", "https://github.com/o/r") - ) - monkeypatch.setattr(gd, "_detect_git_ref", lambda: "main") - monkeypatch.setattr(gd, "_get_package_exports", lambda p: ["F"]) - monkeypatch.setattr(gd, "_find_package_root", lambda: pkg_root) - monkeypatch.setattr( - gd, - "_get_source_location", - lambda pkg, name: {"file": str(pkg_root / "core.py"), "start_line": 1, "end_line": 1}, - ) - mock_pkg = MagicMock() - mock_pkg.members = {} - monkeypatch.setattr(gd, "_get_griffe_package", lambda p: mock_pkg) - gd._generate_source_links_json("mypkg") - import json - - data = json.loads((gd.build_dir / "_source_links.json").read_text()) - assert "core.py" in data["F"]["url"] - - def test_griffe_exception_sets_pkg_none(self, tmp_path, monkeypatch): - gd = _make_gd(tmp_path) - gd.build_dir.mkdir(parents=True, exist_ok=True) - monkeypatch.setattr(gd, "_get_package_metadata", lambda: {"source_link_enabled": True}) - monkeypatch.setattr( - gd, "_get_github_repo_info", lambda: ("o", "r", "https://github.com/o/r") - ) - monkeypatch.setattr(gd, "_detect_git_ref", lambda: "main") - monkeypatch.setattr(gd, "_get_package_exports", lambda p: ["F"]) - monkeypatch.setattr(gd, "_find_package_root", lambda: tmp_path) - monkeypatch.setattr( - gd, - "_get_source_location", - lambda pkg, name: {"file": "f.py", "start_line": 1, "end_line": 1}, - ) - - def raise_err(p): - raise RuntimeError("griffe failed") - - monkeypatch.setattr(gd, "_get_griffe_package", raise_err) - gd._generate_source_links_json("mypkg") - import json - - data = json.loads((gd.build_dir / "_source_links.json").read_text()) - assert "F" in data - - def test_class_methods_included(self, tmp_path, monkeypatch): - gd = _make_gd(tmp_path) - gd.build_dir.mkdir(parents=True, exist_ok=True) - monkeypatch.setattr(gd, "_get_package_metadata", lambda: {"source_link_enabled": True}) - monkeypatch.setattr( - gd, "_get_github_repo_info", lambda: ("o", "r", "https://github.com/o/r") - ) - monkeypatch.setattr(gd, "_detect_git_ref", lambda: "main") - monkeypatch.setattr(gd, "_get_package_exports", lambda p: ["MyClass"]) - monkeypatch.setattr(gd, "_find_package_root", lambda: tmp_path) - - cls_obj = MagicMock() - cls_obj.kind.value = "class" - method_member = MagicMock() - method_member.kind.value = "method" - cls_obj.members = { - "__init__": MagicMock(), - "do_stuff": method_member, - "_private": MagicMock(), - } - cls_obj.members["__init__"].kind.value = "function" - cls_obj.members["_private"].kind.value = "function" - - mock_pkg = MagicMock() - mock_pkg.members = {"MyClass": cls_obj} - monkeypatch.setattr(gd, "_get_griffe_package", lambda p: mock_pkg) - - call_count = {"n": 0} - - def fake_source_loc(pkg, name): - call_count["n"] += 1 - return { - "file": "mod.py", - "start_line": call_count["n"], - "end_line": call_count["n"] + 5, - } - - monkeypatch.setattr(gd, "_get_source_location", fake_source_loc) - gd._generate_source_links_json("mypkg") - import json - - data = json.loads((gd.build_dir / "_source_links.json").read_text()) - assert "MyClass" in data - assert "MyClass.do_stuff" in data - assert "MyClass._private" not in data - - # --------------------------------------------------------------------------- # _generate_tags_json – shadow tags, tag-location override, icon resolution # --------------------------------------------------------------------------- diff --git a/tests/test_documented_symbols.py b/tests/test_documented_symbols.py index 286ab466..0d80b40b 100644 --- a/tests/test_documented_symbols.py +++ b/tests/test_documented_symbols.py @@ -66,27 +66,6 @@ def test_documented_objects_returns_the_manifest_items(tmp_path: Path): assert "fit" in by_name["mypkg.sub.Widget.fit"].aliases -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`. Calling it as a programmatic query (e.g., from - `api-snapshot` / `api-diff`) must not drop that artifact 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")) - 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). @@ -230,8 +209,6 @@ def fail_search_paths() -> list[str]: with pytest.raises(RuntimeError, match="search path failure"): gd.documented_symbol_names("mypkg") - assert gd._suppress_artifact_writes is False - def test_restores_cached_package_modules(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: _write_pkg(tmp_path) diff --git a/tests/test_gdg_rendered.py b/tests/test_gdg_rendered.py index bd75bc20..3773e25e 100644 --- a/tests/test_gdg_rendered.py +++ b/tests/test_gdg_rendered.py @@ -3170,19 +3170,6 @@ def test_toc_title_config(): # ═══════════════════════════════════════════════════════════════════════════════ -@pytest.mark.dedicated -def test_source_disabled_no_links_file(): - """When source.enabled is false, _source_links.json should not be generated.""" - pkg = "gdtest_source_disabled" - if not _has_rendered_site(pkg): - pytest.skip(f"{pkg} not rendered") - - source_links = Layout.make(_RENDERED_DIR / pkg).build_dir / "_source_links.json" - assert not source_links.exists(), ( - f"_source_links.json should not exist when source is disabled: {source_links}" - ) - - # ═══════════════════════════════════════════════════════════════════════════════ # R4: Display Configuration — Badges, Authors, Funding # ═══════════════════════════════════════════════════════════════════════════════ @@ -3596,7 +3583,7 @@ def test_config_combo_a_display_name_and_authors(): @pytest.mark.dedicated @requires_bs4 def test_config_combo_b_opt_out_flags(): - """Combo B: sidebar_filter, dark_mode_toggle, and source all disabled.""" + """Combo B: sidebar_filter and dark_mode_toggle disabled.""" pkg = "gdtest_config_combo_b" if not _has_rendered_site(pkg): pytest.skip(f"{pkg} not rendered") @@ -3613,10 +3600,6 @@ def test_config_combo_b_opt_out_flags(): "dark-mode-toggle.js should be absent when dark_mode_toggle is disabled" ) - # source.enabled=false → no _source_links.json - source_links = Layout.make(_RENDERED_DIR / pkg).build_dir / "_source_links.json" - assert not source_links.exists(), "_source_links.json should not exist when source is disabled" - # ═══════════════════════════════════════════════════════════════════════════════ # R4: Config user_guide as explicit list @@ -6018,17 +6001,6 @@ def test_DED_config_extra_keys_builds(): assert (ref / "identity.html").exists(), "identity page missing" -@pytest.mark.dedicated -def test_DED_config_minimal_source_disabled(): - """gdtest_config_minimal: source disabled means no _source_links.json.""" - pkg = "gdtest_config_minimal" - if not _has_rendered_site(pkg): - pytest.skip(f"{pkg} not rendered") - - source_links = Layout.make(_RENDERED_DIR / pkg).build_dir / "_source_links.json" - assert not source_links.exists(), "_source_links.json should not exist when source disabled" - - @pytest.mark.dedicated def test_DED_config_parser_google(): """gdtest_config_parser: ref pages exist with google parser override.""" @@ -6084,13 +6056,11 @@ def test_DED_config_sections_examples_dir(): @pytest.mark.dedicated def test_DED_source_branch_no_links(): - """gdtest_source_branch: _source_links.json not present (no repo URL).""" + """gdtest_source_branch: reference pages render without a repo URL.""" pkg = "gdtest_source_branch" if not _has_rendered_site(pkg): pytest.skip(f"{pkg} not rendered") - source_links = Layout.make(_RENDERED_DIR / pkg).build_dir / "_source_links.json" - # Synthetic packages don't have a real repo, so _source_links.json may not exist ref = _ref_dir(pkg) assert (ref / "read_data.html").exists(), "read_data page missing" assert (ref / "write_data.html").exists(), "write_data page missing" diff --git a/tests/test_great_docs.py b/tests/test_great_docs.py index 80cd52ee..7d2add2b 100644 --- a/tests/test_great_docs.py +++ b/tests/test_great_docs.py @@ -1359,76 +1359,6 @@ def test_get_github_repo_info_yml_non_github(): assert base_url is None -def test_get_source_location(): - """Test source location detection for classes and methods.""" - docs = GreatDocs() - source_loc = docs._get_source_location("great_docs", "GreatDocs") - - assert source_loc is not None - assert "file" in source_loc - assert "start_line" in source_loc - assert "end_line" in source_loc - assert source_loc["start_line"] > 0 - assert source_loc["end_line"] >= source_loc["start_line"] - assert "core.py" in source_loc["file"] - - -def test_get_source_location_method_real_project(): - """Test source location detection for methods.""" - docs = GreatDocs() - source_loc = docs._get_source_location("great_docs", "GreatDocs.install") - - assert source_loc is not None - assert "file" in source_loc - assert "start_line" in source_loc - assert source_loc["start_line"] > 0 - - -def test_get_source_location_not_found_real_project(): - """Test source location returns None for non-existent items.""" - docs = GreatDocs() - source_loc = docs._get_source_location("great_docs", "NonExistentClass") - - assert source_loc is None - - -def test_build_github_source_url(): - """Test GitHub source URL construction.""" - docs = GreatDocs() - - source_loc = { - "file": "/path/to/great_docs/core.py", - "start_line": 42, - "end_line": 58, - } - - url = docs._build_github_source_url(source_loc, branch="main") - - assert url is not None - assert "https://github.com/posit-dev/great-docs" in url - assert "blob/main" in url - assert "#L42-L58" in url - - -def test_build_github_source_url_single_line_hardcoded_path(): - """Test GitHub source URL with single line.""" - docs = GreatDocs() - - source_loc = { - "file": "/path/to/great_docs/core.py", - "start_line": 42, - "end_line": 42, - } - - url = docs._build_github_source_url(source_loc, branch="main") - - assert url is not None - assert "#L42" in url - - # Should not have the range format - assert "#L42-L42" not in url - - def test_source_link_config_defaults(): """Test that source link configuration has proper defaults.""" with tempfile.TemporaryDirectory() as tmp_dir: @@ -6708,139 +6638,6 @@ def test_module_submodule_allowed_in_exports(): sys.path.remove(tmp_dir) -def test_write_object_types_json(): - """Test that _write_object_types_json writes correct type mapping.""" - - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - docs.build_dir.mkdir(parents=True, exist_ok=True) - - categories = docs._empty_categories() - categories["classes"] = ["MyClass", "parser.ParserInfo"] - categories["exceptions"] = ["parser.ParserError"] - categories["functions"] = ["parse", "easter.easter"] - categories["constants"] = ["DEFAULTPARSER"] - categories["enums"] = ["Color"] - categories["type_aliases"] = ["DateType"] - categories["other"] = ["sys"] - categories["class_method_names"] = { - "MyClass": ["fit", "predict"], - "parser.ParserInfo": ["info"], - } - - docs._write_object_types_json(categories) - - types_path = docs.build_dir / "_object_types.json" - assert types_path.exists() - - with open(types_path) as f: - obj_types = json.load(f) - - # Classes (including module-qualified ones) - assert obj_types["MyClass"] == "class" - assert obj_types["parser.ParserInfo"] == "class" - - # Exceptions - assert obj_types["parser.ParserError"] == "exception" - - # Functions - assert obj_types["parse"] == "function" - assert obj_types["easter.easter"] == "function" - - # Constants - assert obj_types["DEFAULTPARSER"] == "constant" - - # Enums - assert obj_types["Color"] == "enum" - - # Type aliases - assert obj_types["DateType"] == "type_alias" - - # Other - assert obj_types["sys"] == "other" - - # Methods - assert obj_types["MyClass.fit"] == "method" - assert obj_types["MyClass.predict"] == "method" - assert obj_types["parser.ParserInfo.info"] == "method" - - -def test_write_object_types_json_empty_categories(): - """Test that _write_object_types_json handles empty categories.""" - - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - docs.build_dir.mkdir(parents=True, exist_ok=True) - categories = docs._empty_categories() - - docs._write_object_types_json(categories) - - types_path = docs.build_dir / "_object_types.json" - - assert types_path.exists() - - with open(types_path) as f: - obj_types = json.load(f) - - assert obj_types == {} - - -def test_object_types_integrated_with_categorization(): - """Test that _categorize_api_objects + _write_object_types_json produce - correct types for a package with mixed exports.""" - - with tempfile.TemporaryDirectory() as tmp_dir: - pkg_dir = Path(tmp_dir) / "typepkg" - pkg_dir.mkdir() - (pkg_dir / "__init__.py").write_text( - '"""Package with diverse types."""\n' - '__all__ = ["MyClass", "my_func", "MyError", "MAX_SIZE"]\n' - "\n" - "class MyClass:\n" - ' """A normal class."""\n' - " def action(self): pass\n" - "\n" - "def my_func():\n" - ' """A function."""\n' - " pass\n" - "\n" - "class MyError(Exception):\n" - ' """An exception."""\n' - " pass\n" - "\n" - "MAX_SIZE = 1024\n" - ) - - sys.path.insert(0, tmp_dir) - - # Create a build directory for the docs output - build_dir = Path(tmp_dir) / "docs" - build_dir.mkdir() - - try: - docs = GreatDocs(project_path=str(build_dir)) - docs.build_dir.mkdir(parents=True, exist_ok=True) - categories = docs._categorize_api_objects( - "typepkg", ["MyClass", "my_func", "MyError", "MAX_SIZE"] - ) - - docs._write_object_types_json(categories) - - types_path = docs.build_dir / "_object_types.json" - assert types_path.exists() - - with open(types_path) as f: - obj_types = json.load(f) - - assert obj_types["MyClass"] == "class" - assert obj_types["my_func"] == "function" - assert obj_types["MyError"] == "exception" - assert obj_types["MAX_SIZE"] == "constant" - assert obj_types["MyClass.action"] == "method" - finally: - sys.path.remove(tmp_dir) - - def test_categorize_fallback_functions_not_other(): """Functions must be categorized as 'functions', not 'other', when griffe cannot load the package and the inspect-based fallback is used.""" @@ -9251,31 +9048,29 @@ def test_detect_module_name_returns_none(): @patch.object(GreatDocs, "_generate_llms_full_txt") @patch.object(GreatDocs, "_generate_llms_txt") @patch.object(GreatDocs, "_prepare_build_directory") -@patch.object(GreatDocs, "_generate_source_links_json") -def test_build_source_links_use_module_name( - mock_src, mock_prep, mock_llms, mock_llms_full, mock_skill, mock_quarto +@patch.object(GreatDocs, "_discover_click_cli") +def test_build_cli_reference_uses_module_name( + mock_discover, mock_prep, mock_llms, mock_llms_full, mock_skill, mock_quarto ): - """Regression: build() generates source links for the importable module name, + """Regression: build() discovers the Click CLI in the importable module, not the PyPI project name, when the two diverge.""" - - class _StopBuild(Exception): - pass - - mock_src.side_effect = _StopBuild + mock_discover.return_value = None with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) (root / "pyproject.toml").write_text('[project]\nname = "my-dist"\n') - (root / "great-docs.yml").write_text("module: actual_module\n") + (root / "great-docs.yml").write_text("module: actual_module\ncli:\n enabled: true\n") docs = GreatDocs(project_path=tmp_dir) docs.build_dir.mkdir(parents=True, exist_ok=True) (docs.build_dir / "_quarto.yml").write_text(QUARTO_YML_HEADER) - with pytest.raises(_StopBuild): + # The CLI step swallows errors, so the build runs on until the API + # reference step fails for want of an `api-reference:` section. + with pytest.raises(SystemExit): docs.build(refresh=False) - mock_src.assert_called_once_with("actual_module") + mock_discover.assert_called_once_with("actual_module", display_name="my-dist") @patch("great_docs._api_diff.snapshot_from_griffe") @@ -11774,55 +11569,6 @@ def test_detect_git_ref_fallback_permissive(): assert isinstance(ref, str) and len(ref) > 0 -def test_build_github_source_url_basic_line_range(): - """_build_github_source_url builds URL with line anchors.""" - with tempfile.TemporaryDirectory() as tmp_dir: - tmp = Path(tmp_dir) - (tmp / "pyproject.toml").write_text( - '[project]\nname = "mypkg"\nversion = "1.0"\n' - '[project.urls]\nSource = "https://github.com/user/repo"\n' - ) - - docs = GreatDocs(project_path=tmp_dir) - source = {"file": "mypkg/core.py", "start_line": 10, "end_line": 20} - url = docs._build_github_source_url(source, branch="main") - - assert url is not None - assert "github.com/user/repo" in url - assert "/blob/main/" in url - assert "#L10-L20" in url - - -def test_build_github_source_url_single_line(): - """_build_github_source_url uses single-line anchor when start==end.""" - with tempfile.TemporaryDirectory() as tmp_dir: - tmp = Path(tmp_dir) - (tmp / "pyproject.toml").write_text( - '[project]\nname = "mypkg"\nversion = "1.0"\n' - '[project.urls]\nSource = "https://github.com/user/repo"\n' - ) - - docs = GreatDocs(project_path=tmp_dir) - source = {"file": "mypkg/core.py", "start_line": 42, "end_line": 42} - url = docs._build_github_source_url(source, branch="v1.0") - - assert "#L42" in url - assert "-L" not in url - - -def test_build_github_source_url_no_repo_empty_url(): - """_build_github_source_url returns None when no GitHub info available.""" - with tempfile.TemporaryDirectory() as tmp_dir: - tmp = Path(tmp_dir) - (tmp / "pyproject.toml").write_text('[project]\nname = "mypkg"\n') - - docs = GreatDocs(project_path=tmp_dir) - source = {"file": "mypkg/core.py", "start_line": 1, "end_line": 10} - url = docs._build_github_source_url(source, branch="main") - - assert url is None - - def test_process_sections_no_config(): """_process_sections returns 0 when no sections configured.""" with tempfile.TemporaryDirectory() as tmp_dir: @@ -13047,49 +12793,6 @@ def test_normalize_package_name_v2(): assert docs._normalize_package_name("multi-hyphen-name") == "multi_hyphen_name" -def test_get_source_location_found(): - """_get_source_location returns file/line info for an exported symbol.""" - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - - # Use great_docs itself as a known package - result = docs._get_source_location("great_docs", "GreatDocs") - - assert result is not None - assert "file" in result - assert "start_line" in result - assert "end_line" in result - assert result["start_line"] > 0 - - -def test_get_source_location_not_found_v2(): - """_get_source_location returns None for non-existent symbol.""" - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - result = docs._get_source_location("great_docs", "NonExistentSymbol") - - assert result is None - - -def test_get_source_location_method(): - """_get_source_location finds a class method by dotted name.""" - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - result = docs._get_source_location("great_docs", "GreatDocs.build") - if result is not None: - assert result["start_line"] > 0 - assert result["end_line"] >= result["start_line"] - - -def test_get_source_location_bad_package(): - """_get_source_location returns None for non-existent packages.""" - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - result = docs._get_source_location("nonexistent_pkg_xyzzy", "SomeClass") - - assert result is None - - def test_count_cli_sidebar_items_nested_v2(): """_count_cli_sidebar_items counts nested dict items recursively.""" items = [ @@ -15294,115 +14997,6 @@ def test_get_quarto_env_venv_alternative_name(): assert env["QUARTO_PYTHON"] == str(venv_python.resolve()) -def test_build_github_source_url_basic(): - """Test _build_github_source_url with single-line source location.""" - - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - - # Set up pyproject.toml so _get_github_repo_info returns valid info - pyproject = Path(tmp_dir) / "pyproject.toml" - pyproject.write_text( - '[project]\nname = "mypkg"\n' - '[project.urls]\nRepository = "https://github.com/owner/repo"\n', - encoding="utf-8", - ) - - result = docs._build_github_source_url( - {"file": "mypkg/module.py", "start_line": 10, "end_line": 10}, - branch="main", - ) - - assert result is not None - assert result.endswith("#L10") - assert "/blob/main/" in result - - -def test_build_github_source_url_line_range(): - """Test _build_github_source_url with multi-line range.""" - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - - pyproject = Path(tmp_dir) / "pyproject.toml" - pyproject.write_text( - '[project]\nname = "mypkg"\n' - '[project.urls]\nRepository = "https://github.com/owner/repo"\n', - encoding="utf-8", - ) - - result = docs._build_github_source_url( - {"file": "mypkg/core.py", "start_line": 5, "end_line": 20}, - branch="v1.0", - ) - - assert result is not None - assert result.endswith("#L5-L20") - assert "/blob/v1.0/" in result - - -def test_build_github_source_url_no_repo_v2(): - """Test _build_github_source_url returns None when no repo info.""" - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - - # No pyproject.toml -> no repo info - result = docs._build_github_source_url( - {"file": "mypkg/module.py", "start_line": 1, "end_line": 1}, - branch="main", - ) - - assert result is None - - -def test_build_github_source_url_source_path_config(): - """Test _build_github_source_url with custom source_link_path for monorepos.""" - - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - - pyproject = Path(tmp_dir) / "pyproject.toml" - pyproject.write_text( - '[project]\nname = "mypkg"\n' - '[project.urls]\nRepository = "https://github.com/owner/repo"\n', - encoding="utf-8", - ) - - gd_yml = Path(tmp_dir) / "great-docs.yml" - gd_yml.write_text("source:\n path: packages/mypkg/src\n", encoding="utf-8") - docs._config = Config(Path(tmp_dir)) - - result = docs._build_github_source_url( - {"file": "/absolute/path/module.py", "start_line": 3, "end_line": 3}, - branch="main", - ) - - assert result is not None - assert "packages/mypkg/src/module.py" in result - - -def test_build_github_source_url_absolute_path(): - """Test _build_github_source_url with absolute filepath converted to relative.""" - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - - pyproject = Path(tmp_dir) / "pyproject.toml" - pyproject.write_text( - '[project]\nname = "mypkg"\n' - '[project.urls]\nRepository = "https://github.com/owner/repo"\n', - encoding="utf-8", - ) - - # Make the filepath absolute and inside the package root - abs_path = str(Path(tmp_dir) / "mypkg" / "core.py") - result = docs._build_github_source_url( - {"file": abs_path, "start_line": 1, "end_line": 5}, - branch="dev", - ) - - assert result is not None - assert "/blob/dev/" in result - - def _render_doc_source_relative_path(obj): """Invoke the renderer's `_source_relative_path` with a stand-in griffe object.""" import great_docs._apiref._render.doc as docmod @@ -15525,49 +15119,6 @@ def test_detect_git_ref_fallback(): assert result == "main" -def test_generate_source_links_json_disabled(): - """Test _generate_source_links_json skips when source links disabled.""" - - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - - pyproject = Path(tmp_dir) / "pyproject.toml" - pyproject.write_text('[project]\nname = "mypkg"\n', encoding="utf-8") - - gd_yml = Path(tmp_dir) / "great-docs.yml" - gd_yml.write_text("source:\n enabled: false\n", encoding="utf-8") - docs._config = Config(Path(tmp_dir)) - - docs._generate_source_links_json("mypkg") - - # Should return early; no JSON file created - assert not (docs.build_dir / "_source_links.json").exists() - - -def test_generate_source_links_json_no_repo(): - """Test _generate_source_links_json skips when no GitHub repo info.""" - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - - # No pyproject.toml = no repo - docs._generate_source_links_json("mypkg") - - assert not (docs.build_dir / "_source_links.json").exists() - - -def test_get_source_location_no_griffe(): - """Test _get_source_location handles missing griffe gracefully.""" - - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - - # Mock griffe import failure - with patch.dict("sys.modules", {"griffe": None}): - result = docs._get_source_location("nonexistent_package", "SomeClass") - - assert result is None - - def test_strip_frontmatter_with_yaml(): """Test _strip_frontmatter removes YAML front matter.""" with tempfile.TemporaryDirectory() as tmp_dir: @@ -18265,40 +17816,6 @@ def test_format_authors_yaml_empty(): assert docs._format_authors_yaml([]) == "" -def test_write_object_types_json_basic(): - """Test _write_object_types_json writes correct type metadata.""" - - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - docs.build_dir.mkdir(parents=True, exist_ok=True) - - categories = GreatDocs._empty_categories() - categories["classes"] = ["Widget", "Panel"] - categories["functions"] = ["create_widget"] - categories["constants"] = ["VERSION"] - categories["exceptions"] = ["WidgetError"] - categories["enums"] = ["Color"] - categories["class_method_names"] = {"Widget": ["render", "update"]} - categories["class_member_types"] = { - "Widget.render": "method", - "Widget.update": "classmethod", - } - - docs._write_object_types_json(categories) - - types_path = docs.build_dir / "_object_types.json" - assert types_path.exists() - data = json.loads(types_path.read_text()) - assert data["Widget"] == "class" - assert data["Panel"] == "class" - assert data["create_widget"] == "function" - assert data["VERSION"] == "constant" - assert data["WidgetError"] == "exception" - assert data["Color"] == "enum" - assert data["Widget.render"] == "method" - assert data["Widget.update"] == "classmethod" - - def test_categorize_api_objects_fallback_basic(): """Test _categorize_api_objects_fallback with known stdlib module.""" with tempfile.TemporaryDirectory() as tmp_dir: @@ -19667,27 +19184,6 @@ def test_parse_package_exports_no_all(): assert result is None -def test_get_source_location_not_found(): - """Test _get_source_location returns None for nonexistent item.""" - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - result = docs._get_source_location("nonexistent_pkg", "SomeClass") - - assert result is None - - -def test_build_github_source_url_no_repo(): - """Test _build_github_source_url returns None when no repo info.""" - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - pyproject = Path(tmp_dir) / "pyproject.toml" - pyproject.write_text('[project]\nname = "mypkg"\n', encoding="utf-8") - source_loc = {"file": "src/module.py", "start_line": 10, "end_line": 20} - result = docs._build_github_source_url(source_loc) - - assert result is None - - def test_generate_landing_page_content_no_name(): """Test _generate_landing_page_content uses 'Package' when no name detected.""" with tempfile.TemporaryDirectory() as tmp_dir: @@ -20126,29 +19622,6 @@ def test_process_user_guide_returns_false_no_guide(): assert result is False -def test_generate_source_links_json_no_metadata(): - """Test _generate_source_links_json skips when source links disabled.""" - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - pyproject = Path(tmp_dir) / "pyproject.toml" - pyproject.write_text('[project]\nname = "mypkg"\n', encoding="utf-8") - gd_yml = Path(tmp_dir) / "great-docs.yml" - gd_yml.write_text("source:\n enabled: false\n", encoding="utf-8") - docs._config = Config(Path(tmp_dir)) - # Should not crash, just return early - docs._generate_source_links_json("mypkg") - - -def test_generate_source_links_json_no_github(): - """Test _generate_source_links_json skips when no GitHub URL.""" - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - pyproject = Path(tmp_dir) / "pyproject.toml" - pyproject.write_text('[project]\nname = "mypkg"\n', encoding="utf-8") - # No repository URL configured - docs._generate_source_links_json("mypkg") - - def test_get_cli_help_text_for_llms_disabled(): """Test _get_cli_help_text_for_llms returns empty when CLI disabled.""" with tempfile.TemporaryDirectory() as tmp_dir: @@ -21071,23 +20544,6 @@ def test_add_section_sidebar(): assert "recipes" in sidebar_ids -def test_build_github_source_url_with_repo(): - """Test _build_github_source_url builds correct URL.""" - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - pyproject = Path(tmp_dir) / "pyproject.toml" - pyproject.write_text( - '[project]\nname = "pkg"\n[project.urls]\n' - 'Repository = "https://github.com/owner/repo"\n', - encoding="utf-8", - ) - source_loc = {"file": "src/module.py", "start_line": 10, "end_line": 20} - result = docs._build_github_source_url(source_loc, branch="main") - assert result is not None - assert "github.com/owner/repo" in result - assert "main" in result - - def test_get_cli_help_text_for_llms_with_cli(): """Test _get_cli_help_text_for_llms generates formatted text.""" @@ -21540,148 +20996,6 @@ def test_get_cli_entry_point_name_no_scripts_b9(): assert result is None -def test_generate_source_links_json_basic(): - """Test _generate_source_links_json writes JSON with source links.""" - - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - pyproject = Path(tmp_dir) / "pyproject.toml" - pyproject.write_text( - '[project]\nname = "mypkg"\n[project.urls]\n' - 'Repository = "https://github.com/owner/repo"\n', - encoding="utf-8", - ) - build_dir = Path(tmp_dir) / "great-docs" - build_dir.mkdir(parents=True, exist_ok=True) - - fake_source = { - "file": "mypkg/core.py", - "start_line": 10, - "end_line": 20, - } - - with ( - patch.object(docs, "_get_package_exports", return_value=["my_func"]), - patch.object(docs, "_get_source_location", return_value=fake_source), - patch.object( - docs, - "_categorize_api_objects", - return_value={"all_classes": [], "class_method_names": {}}, - ), - patch.object(docs, "_detect_git_ref", return_value="main"), - ): - docs._generate_source_links_json("mypkg") - - json_path = build_dir / "_source_links.json" - assert json_path.exists() - - data = json.loads(json_path.read_text(encoding="utf-8")) - assert "my_func" in data - assert "url" in data["my_func"] - - -def test_generate_source_links_json_disabled(): - """Test _generate_source_links_json skips when source links disabled.""" - - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - pyproject = Path(tmp_dir) / "pyproject.toml" - pyproject.write_text('[project]\nname = "mypkg"\n', encoding="utf-8") - - with patch.object( - docs, - "_get_package_metadata", - return_value={"source_link_enabled": False}, - ): - docs._generate_source_links_json("mypkg") - - # Should not create the json file - json_path = Path(tmp_dir) / "great-docs" / "_source_links.json" - assert not json_path.exists() - - -def test_generate_source_links_json_no_repo(): - """Test _generate_source_links_json skips when no GitHub repo found.""" - - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - pyproject = Path(tmp_dir) / "pyproject.toml" - pyproject.write_text('[project]\nname = "mypkg"\n', encoding="utf-8") - - with ( - patch.object(docs, "_get_package_metadata", return_value={"source_link_enabled": True}), - patch.object(docs, "_get_github_repo_info", return_value=(None, None, None)), - ): - docs._generate_source_links_json("mypkg") - - json_path = Path(tmp_dir) / "great-docs" / "_source_links.json" - assert not json_path.exists() - - -def test_generate_source_links_json_no_exports(): - """Test _generate_source_links_json skips when no exports.""" - - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - pyproject = Path(tmp_dir) / "pyproject.toml" - pyproject.write_text( - '[project]\nname = "mypkg"\n[project.urls]\n' - 'Repository = "https://github.com/owner/repo"\n', - encoding="utf-8", - ) - - with patch.object(docs, "_get_package_exports", return_value=None): - docs._generate_source_links_json("mypkg") - - json_path = Path(tmp_dir) / "great-docs" / "_source_links.json" - assert not json_path.exists() - - -def test_generate_source_links_json_with_class_methods(): - """Test _generate_source_links_json includes method source links for classes.""" - - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - pyproject = Path(tmp_dir) / "pyproject.toml" - pyproject.write_text( - '[project]\nname = "mypkg"\n[project.urls]\n' - 'Repository = "https://github.com/owner/repo"\n', - encoding="utf-8", - ) - build_dir = Path(tmp_dir) / "great-docs" - build_dir.mkdir(parents=True, exist_ok=True) - - def fake_source_location(pkg_name, item_name): - return { - "file": f"mypkg/{item_name.replace('.', '/')}.py", - "start_line": 1, - "end_line": 10, - } - - mock_method_do = MagicMock() - mock_method_do.kind.value = "function" - mock_method_run = MagicMock() - mock_method_run.kind.value = "function" - mock_class = MagicMock() - mock_class.kind.value = "class" - mock_class.members = {"do_stuff": mock_method_do, "run": mock_method_run} - mock_pkg = MagicMock() - mock_pkg.members = {"MyClass": mock_class} - - with ( - patch.object(docs, "_get_package_exports", return_value=["MyClass"]), - patch.object(docs, "_get_source_location", side_effect=fake_source_location), - patch.object(docs, "_get_griffe_package", return_value=mock_pkg), - patch.object(docs, "_detect_git_ref", return_value="main"), - ): - docs._generate_source_links_json("mypkg") - - data = json.loads((build_dir / "_source_links.json").read_text(encoding="utf-8")) - assert "MyClass" in data - assert "MyClass.do_stuff" in data - assert "MyClass.run" in data - - def test_detect_docstring_style_google(): """Test _detect_docstring_style detects google style docstrings.""" @@ -21799,50 +21113,6 @@ def fake_get_object(path, dynamic=False): assert result is False -def test_write_object_types_json_creates_file(): - """Test _write_object_types_json writes object types to JSON.""" - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - build_dir = Path(tmp_dir) / "great-docs" - build_dir.mkdir(parents=True, exist_ok=True) - - categories = { - "classes": ["MyClass"], - "functions": ["my_func"], - "async_functions": ["async_func"], - "constants": ["MY_CONST"], - "type_aliases": ["MyAlias"], - "enums": ["Color"], - "exceptions": ["MyError"], - "dataclasses": ["DataCls"], - "namedtuples": [], - "typeddicts": [], - "protocols": ["MyProto"], - "abstract_classes": ["MyABC"], - "other": ["something_else"], - "class_methods": {"MyClass": 2}, - "class_method_names": {"MyClass": ["run", "stop"]}, - "class_member_types": {"MyClass.run": "method", "MyClass.stop": "classmethod"}, - } - - docs._write_object_types_json(categories) - - types_path = build_dir / "_object_types.json" - assert types_path.exists() - data = json.loads(types_path.read_text(encoding="utf-8")) - assert data["MyClass"] == "class" - assert data["my_func"] == "function" - assert data["async_func"] == "function" - assert data["MY_CONST"] == "constant" - assert data["Color"] == "enum" - assert data["MyError"] == "exception" - assert data["MyProto"] == "protocol" - assert data["MyABC"] == "abc" - assert data["DataCls"] == "class" - assert data["MyClass.run"] == "method" - assert data["MyClass.stop"] == "classmethod" - - def test_process_sections_blog_type(): """Test _process_sections handles blog-type sections.""" with tempfile.TemporaryDirectory() as tmp_dir: @@ -23651,48 +22921,6 @@ def test_add_frontmatter_option_string_value(): assert 'format: "html"' in result -def test_build_github_source_url_basic(): - """Test _build_github_source_url creates correct URL.""" - with tempfile.TemporaryDirectory() as tmp_dir: - pyproject = Path(tmp_dir) / "pyproject.toml" - pyproject.write_text( - '[project]\nname = "pkg"\n[project.urls]\n' - 'Repository = "https://github.com/testowner/testrepo"\n', - encoding="utf-8", - ) - docs = GreatDocs(project_path=tmp_dir) - source_loc = {"file": "pkg/core.py", "start_line": 10, "end_line": 20} - url = docs._build_github_source_url(source_loc, branch="main") - assert url is not None - assert "github.com/testowner/testrepo" in url - assert "#L10-L20" in url - - -def test_build_github_source_url_single_line(): - """Test _build_github_source_url with single line.""" - with tempfile.TemporaryDirectory() as tmp_dir: - pyproject = Path(tmp_dir) / "pyproject.toml" - pyproject.write_text( - '[project]\nname = "pkg"\n[project.urls]\n' - 'Repository = "https://github.com/owner/repo"\n', - encoding="utf-8", - ) - docs = GreatDocs(project_path=tmp_dir) - source_loc = {"file": "pkg/a.py", "start_line": 5, "end_line": 5} - url = docs._build_github_source_url(source_loc, branch="v1.0") - assert "#L5" in url - assert "#L5-L5" not in url - - -def test_build_github_source_url_no_repo(): - """Test _build_github_source_url returns None when no repo info.""" - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - source_loc = {"file": "pkg/core.py", "start_line": 1, "end_line": 1} - url = docs._build_github_source_url(source_loc) - assert url is None - - def test_get_github_repo_info_from_config(): """Test _get_github_repo_info reads from great-docs.yml repo setting.""" with tempfile.TemporaryDirectory() as tmp_dir: @@ -23963,14 +23191,6 @@ def test_add_changelog_to_navbar_idempotent(): assert changelog_count == 1 -def test_get_source_location_nonexistent_package(): - """Test _get_source_location returns None for nonexistent package.""" - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=tmp_dir) - result = docs._get_source_location("nonexistent_pkg_xyz", "some_func") - assert result is None - - def test_extract_click_command_basic(): """Test _extract_click_command with a simple Click command.""" @@ -24304,7 +23524,6 @@ def test_build_prepare_and_render_flow(): patch.object(docs, "_generate_llms_txt"), patch.object(docs, "_generate_llms_full_txt"), patch.object(docs, "_detect_package_name", return_value="mypkg"), - patch.object(docs, "_generate_source_links_json"), patch.object(docs, "_get_package_metadata", return_value={}), patch.object(docs, "_process_user_guide"), patch.object(docs, "_copy_assets", return_value=False), @@ -24358,7 +23577,6 @@ def test_build_no_api_reference(): patch.object(docs, "_generate_llms_txt"), patch.object(docs, "_generate_llms_full_txt"), patch.object(docs, "_detect_package_name", return_value=None), - patch.object(docs, "_generate_source_links_json"), patch.object(docs, "_get_package_metadata", return_value={}), patch.object(docs, "_process_user_guide"), patch.object(docs, "_copy_assets", return_value=False), @@ -24413,7 +23631,6 @@ def build_side_effect(): patch.object(docs, "_generate_llms_txt"), patch.object(docs, "_generate_llms_full_txt"), patch.object(docs, "_detect_package_name", return_value="mypkg"), - patch.object(docs, "_generate_source_links_json"), patch.object(docs, "_get_package_metadata", return_value={}), patch.object(docs, "_process_user_guide"), patch.object(docs, "_copy_assets", return_value=False), @@ -24479,7 +23696,6 @@ def build_side_effect(): patch.object(docs, "_generate_llms_txt"), patch.object(docs, "_generate_llms_full_txt"), patch.object(docs, "_detect_package_name", return_value="mypkg"), - patch.object(docs, "_generate_source_links_json"), patch.object(docs, "_get_package_metadata", return_value={}), patch.object(docs, "_process_user_guide"), patch.object(docs, "_copy_assets", return_value=False), @@ -24538,7 +23754,6 @@ def test_build_static_mode_failure_exits(): patch.object(docs, "_generate_llms_txt"), patch.object(docs, "_generate_llms_full_txt"), patch.object(docs, "_detect_package_name", return_value="mypkg"), - patch.object(docs, "_generate_source_links_json"), patch.object(docs, "_get_package_metadata", return_value={}), patch.object(docs, "_process_user_guide"), patch.object(docs, "_copy_assets", return_value=False), @@ -24586,7 +23801,6 @@ def test_build_non_dynamic_failure_exits(): patch.object(docs, "_generate_llms_txt"), patch.object(docs, "_generate_llms_full_txt"), patch.object(docs, "_detect_package_name", return_value="mypkg"), - patch.object(docs, "_generate_source_links_json"), patch.object(docs, "_get_package_metadata", return_value={}), patch.object(docs, "_process_user_guide"), patch.object(docs, "_copy_assets", return_value=False), @@ -24627,7 +23841,6 @@ def test_build_with_changelog(): patch.object(docs, "_generate_llms_txt"), patch.object(docs, "_generate_llms_full_txt"), patch.object(docs, "_detect_package_name", return_value="mypkg"), - patch.object(docs, "_generate_source_links_json"), patch.object(docs, "_get_package_metadata", return_value={"cli_enabled": False}), patch.object( docs, @@ -24678,7 +23891,6 @@ def test_build_changelog_error_handled(): patch.object(docs, "_generate_llms_txt"), patch.object(docs, "_generate_llms_full_txt"), patch.object(docs, "_detect_package_name", return_value="mypkg"), - patch.object(docs, "_generate_source_links_json"), patch.object(docs, "_get_package_metadata", return_value={}), patch.object( docs, @@ -24729,7 +23941,6 @@ def test_build_with_cli_documentation(): patch.object(docs, "_generate_llms_txt"), patch.object(docs, "_generate_llms_full_txt"), patch.object(docs, "_detect_package_name", return_value="mypkg"), - patch.object(docs, "_generate_source_links_json"), patch.object( docs, "_get_package_metadata", @@ -24782,7 +23993,6 @@ def test_build_with_sections(): patch.object(docs, "_generate_llms_txt"), patch.object(docs, "_generate_llms_full_txt"), patch.object(docs, "_detect_package_name", return_value=None), - patch.object(docs, "_generate_source_links_json"), patch.object(docs, "_get_package_metadata", return_value={}), patch.object(docs, "_process_user_guide"), patch.object(docs, "_process_sections", return_value=2), @@ -24826,7 +24036,6 @@ def test_build_with_assets_triggers_config_update(): patch.object(docs, "_generate_llms_txt"), patch.object(docs, "_generate_llms_full_txt"), patch.object(docs, "_detect_package_name", return_value=None), - patch.object(docs, "_generate_source_links_json"), patch.object(docs, "_get_package_metadata", return_value={}), patch.object(docs, "_process_user_guide"), patch.object(docs, "_copy_assets", return_value=True), @@ -24870,7 +24079,6 @@ def test_build_watch_mode(): patch.object(docs, "_generate_llms_txt"), patch.object(docs, "_generate_llms_full_txt"), patch.object(docs, "_detect_package_name", return_value=None), - patch.object(docs, "_generate_source_links_json"), patch.object(docs, "_get_package_metadata", return_value={}), patch.object(docs, "_process_user_guide"), patch.object(docs, "_copy_assets", return_value=False), @@ -24911,7 +24119,6 @@ def test_build_quarto_render_failure(): patch.object(docs, "_generate_llms_txt"), patch.object(docs, "_generate_llms_full_txt"), patch.object(docs, "_detect_package_name", return_value=None), - patch.object(docs, "_generate_source_links_json"), patch.object(docs, "_get_package_metadata", return_value={}), patch.object(docs, "_process_user_guide"), patch.object(docs, "_copy_assets", return_value=False), @@ -24953,7 +24160,6 @@ def test_build_cli_error_handled(): patch.object(docs, "_generate_llms_txt"), patch.object(docs, "_generate_llms_full_txt"), patch.object(docs, "_detect_package_name", return_value="mypkg"), - patch.object(docs, "_generate_source_links_json"), patch.object( docs, "_get_package_metadata", @@ -26099,7 +25305,6 @@ def test_create_api_sections_from_config_basic(): "_get_package_exports", return_value=["ClassA", "func_b"], ), - patch.object(docs, "_write_object_types_json"), ): result = docs._create_api_sections_from_config("secpkg") @@ -26141,7 +25346,6 @@ def test_create_api_sections_from_config_large_class(): with ( patch.object(docs, "_get_package_exports", return_value=["BigClass"]), - patch.object(docs, "_write_object_types_json"), ): result = docs._create_api_sections_from_config("lgpkg") @@ -26186,7 +25390,6 @@ def test_create_api_sections_from_config_dict_items(): with ( patch.object(docs, "_get_package_exports", return_value=["MyClass"]), - patch.object(docs, "_write_object_types_json"), ): result = docs._create_api_sections_from_config("dictpkg") @@ -26239,7 +25442,6 @@ def test_create_api_sections_from_config_explicit_members_passthrough(): with ( patch.object(docs, "_get_package_exports", return_value=["Child"]), - patch.object(docs, "_write_object_types_json"), ): result = docs._create_api_sections_from_config("inhpkg") @@ -26292,7 +25494,6 @@ def test_create_api_sections_from_config_include_inherited(): with ( patch.object(docs, "_get_package_exports", return_value=["Derived"]), - patch.object(docs, "_write_object_types_json"), ): result = docs._create_api_sections_from_config("inhpkg2") @@ -27760,7 +26961,6 @@ def test_build_section_error_handled(): patch.object(docs, "_generate_llms_txt"), patch.object(docs, "_generate_llms_full_txt"), patch.object(docs, "_detect_package_name", return_value=None), - patch.object(docs, "_generate_source_links_json"), patch.object(docs, "_get_package_metadata", return_value={}), patch.object(docs, "_process_user_guide"), patch.object(docs, "_process_sections", side_effect=RuntimeError("section problem")), @@ -27803,7 +27003,6 @@ def test_build_user_guide_error_handled(): patch.object(docs, "_generate_llms_txt"), patch.object(docs, "_generate_llms_full_txt"), patch.object(docs, "_detect_package_name", return_value=None), - patch.object(docs, "_generate_source_links_json"), patch.object(docs, "_get_package_metadata", return_value={}), patch.object(docs, "_process_user_guide", side_effect=RuntimeError("UG error")), patch.object(docs, "_copy_assets", return_value=False), @@ -27845,7 +27044,6 @@ def test_build_copy_assets_error_handled(): patch.object(docs, "_generate_llms_txt"), patch.object(docs, "_generate_llms_full_txt"), patch.object(docs, "_detect_package_name", return_value=None), - patch.object(docs, "_generate_source_links_json"), patch.object(docs, "_get_package_metadata", return_value={}), patch.object(docs, "_process_user_guide"), patch.object(docs, "_copy_assets", side_effect=RuntimeError("copy failed")), @@ -27887,7 +27085,6 @@ def test_build_changelog_no_releases(): patch.object(docs, "_generate_llms_txt"), patch.object(docs, "_generate_llms_full_txt"), patch.object(docs, "_detect_package_name", return_value="mypkg"), - patch.object(docs, "_generate_source_links_json"), patch.object(docs, "_get_package_metadata", return_value={}), patch.object( docs, @@ -27934,7 +27131,6 @@ def test_build_changelog_no_repo(): patch.object(docs, "_generate_llms_txt"), patch.object(docs, "_generate_llms_full_txt"), patch.object(docs, "_detect_package_name", return_value="mypkg"), - patch.object(docs, "_generate_source_links_json"), patch.object(docs, "_get_package_metadata", return_value={}), patch.object(docs, "_get_github_repo_info", return_value=(None, None, None)), patch.object(docs, "_process_user_guide"), @@ -34965,30 +34161,6 @@ def test_rewrite_href_recursive_nested_list(): assert found4 is False -def test_build_github_source_url_with_source_path(): - """_build_github_source_url uses custom source_link_path.""" - with tempfile.TemporaryDirectory() as tmp_dir: - tmp = Path(tmp_dir) - (tmp / "pyproject.toml").write_text( - '[project]\nname = "mypkg"\nversion = "1.0"\n' - '[project.urls]\nSource = "https://github.com/user/repo"\n' - ) - (tmp / "great-docs.yml").write_text("source:\n path: packages/mypkg\n") - - docs = GreatDocs(project_path=tmp_dir) - - source_loc = { - "file": "/abs/path/to/mypkg/module.py", - "start_line": 10, - "end_line": 20, - } - - url = docs._build_github_source_url(source_loc, branch="main") - # Should include the custom path - assert url is not None - assert "main" in url - - def test_type_sections_empty_lists(): """TypeSections with no items produces empty Blocks output.""" @@ -36555,85 +35727,6 @@ def test_replaces_existing_ug_sidebar(self): assert "Old" not in str(ug_sidebars[0]) -class TestGetSourceLocation: - """Tests for _get_source_location.""" - - def test_finds_function_location(self): - """Finds source location for a function.""" - with tempfile.TemporaryDirectory() as tmp_dir: - tmp = Path(tmp_dir) - (tmp / "pyproject.toml").write_text('[project]\nname = "mypkg"\n') - pkg = tmp / "mypkg" - pkg.mkdir() - (pkg / "__init__.py").write_text('def my_func():\n """A function."""\n pass\n') - docs = GreatDocs(project_path=tmp_dir) - old_path = sys.path[:] - sys.path.insert(0, tmp_dir) - try: - result = docs._get_source_location("mypkg", "my_func") - finally: - sys.path[:] = old_path - assert result is not None - assert result["start_line"] == 1 - - def test_finds_method_location(self): - """Finds source location for a dotted method.""" - with tempfile.TemporaryDirectory() as tmp_dir: - tmp = Path(tmp_dir) - (tmp / "pyproject.toml").write_text('[project]\nname = "mypkg"\n') - pkg = tmp / "mypkg" - pkg.mkdir() - (pkg / "__init__.py").write_text( - "class MyClass:\n def my_method(self):\n pass\n" - ) - docs = GreatDocs(project_path=tmp_dir) - old_path = sys.path[:] - sys.path.insert(0, tmp_dir) - try: - result = docs._get_source_location("mypkg", "MyClass.my_method") - finally: - sys.path[:] = old_path - assert result is not None - assert "file" in result - - def test_returns_none_for_missing_item(self): - """Returns None for an item that doesn't exist.""" - with tempfile.TemporaryDirectory() as tmp_dir: - tmp = Path(tmp_dir) - (tmp / "pyproject.toml").write_text('[project]\nname = "mypkg"\n') - pkg = tmp / "mypkg" - pkg.mkdir() - (pkg / "__init__.py").write_text("") - docs = GreatDocs(project_path=tmp_dir) - result = docs._get_source_location("mypkg", "nonexistent") - assert result is None - - -class TestBuildGithubSourceUrl: - """Tests for _build_github_source_url.""" - - def test_builds_url_with_line_anchors(self): - """Builds URL with line anchors.""" - with tempfile.TemporaryDirectory() as tmp_dir: - tmp = Path(tmp_dir) - (tmp / "pyproject.toml").write_text( - '[project]\nname = "mypkg"\nversion = "1.0"\n' - '[project.urls]\nRepository = "https://github.com/user/repo"\n' - ) - pkg = tmp / "mypkg" - pkg.mkdir() - (pkg / "__init__.py").write_text('__version__ = "1.0"\n') - (pkg / "module.py").write_text("def func(): pass\n") - docs = GreatDocs(project_path=tmp_dir) - result = docs._build_github_source_url( - {"file": str(pkg / "module.py"), "start_line": 10, "end_line": 20}, - branch="main", - ) - assert result is not None - assert "github.com/user/repo" in result - assert "#L10-L20" in result - - class TestDetectDocstringStyleSphinx: """Tests for Sphinx docstring style detection.""" @@ -42451,7 +41544,6 @@ def test_create_api_sections_from_config_inline_methods_true_never_splits(): with ( patch.object(docs, "_get_package_exports", return_value=["BigClass"]), - patch.object(docs, "_write_object_types_json"), ): result = docs._create_api_sections_from_config("inlinepkg") @@ -42518,7 +41610,6 @@ def method_b(self) -> str: with ( patch.object(docs, "_get_package_exports", return_value=["SmallClass"]), - patch.object(docs, "_write_object_types_json"), ): result = docs._create_api_sections_from_config("splitforcepkg") @@ -42559,7 +41650,6 @@ def test_create_api_sections_from_config_inline_methods_custom_threshold(): with ( patch.object(docs, "_get_package_exports", return_value=["MediumClass"]), - patch.object(docs, "_write_object_types_json"), ): result = docs._create_api_sections_from_config("threshpkg") @@ -43227,57 +42317,6 @@ def test_get_package_metadata_invalid_dep_falls_back(): assert any(d["specifier"] == "" for d in meta["dependencies"]) -# --------------------------------------------------------------------------- -# _get_source_location_from_pkg — missing lineno / filepath branches -# --------------------------------------------------------------------------- - - -def test_get_source_location_no_lineno(): - """_get_source_location_from_pkg returns None when obj has no lineno.""" - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=str(tmp_dir)) - - class FakeObj: - lineno = None - filepath = "/some/file.py" - members = {} - - class FakePkg: - members = {"MyClass": FakeObj()} - - result = docs._get_source_location_from_pkg(FakePkg(), "MyClass") - assert result is None - - -def test_get_source_location_no_filepath(): - """_get_source_location_from_pkg returns None when obj has no filepath.""" - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=str(tmp_dir)) - - class FakeObj: - lineno = 10 - filepath = None - members = {} - - class FakePkg: - members = {"MyClass": FakeObj()} - - result = docs._get_source_location_from_pkg(FakePkg(), "MyClass") - assert result is None - - -def test_get_source_location_member_missing(): - """_get_source_location_from_pkg returns None when dotted path member absent.""" - with tempfile.TemporaryDirectory() as tmp_dir: - docs = GreatDocs(project_path=str(tmp_dir)) - - class FakePkg: - members = {} - - result = docs._get_source_location_from_pkg(FakePkg(), "Missing.method") - assert result is None - - # --------------------------------------------------------------------------- # _copy_blog_files — co-located asset and subdirectory copying # --------------------------------------------------------------------------- From 9c1099c96f10cf518b450e5d01489c6b026161f4 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Thu, 22 Oct 2026 14:46:16 +0300 Subject: [PATCH 4/4] fix: withhold the repository URL when source links are disabled API reference pages showed "Source" links to GitHub whenever the project had a repository URL, even with `source.enabled: false`. The renderer links to source only when it knows the repository URL, so the build now withholds that URL when source links are disabled. --- great_docs/core.py | 6 ++++-- tests/test_great_docs.py | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/great_docs/core.py b/great_docs/core.py index 3e3e0c4d..e9056f26 100644 --- a/great_docs/core.py +++ b/great_docs/core.py @@ -268,9 +268,11 @@ def __init__( self._config, self.layout.source_dir, self._get_canonical_base_url() ) - # Set environment variables needed by the qrenderer + # Set environment variables needed by the qrenderer. The renderer links + # to source only when it has a repository URL, so `source.enabled: false` + # withholds it. _, _, url = self._get_github_repo_info() - if url: + if url and self._config.source_enabled: os.environ["GITHUB_REPO_URL"] = str(url) else: os.environ.pop("GITHUB_REPO_URL", None) diff --git a/tests/test_great_docs.py b/tests/test_great_docs.py index 7d2add2b..11a25ed5 100644 --- a/tests/test_great_docs.py +++ b/tests/test_great_docs.py @@ -15090,6 +15090,28 @@ def test_init_exports_source_path_env(monkeypatch): assert os.environ.get("SOURCE_PATH") == "packages/mypkg/src" +@pytest.mark.parametrize( + ("source_yml", "exported"), [("", True), ("source:\n enabled: false\n", False)] +) +def test_init_exports_repo_url_only_when_source_enabled(monkeypatch, source_yml, exported): + """The renderer links to source only when source.enabled leaves it on.""" + with tempfile.TemporaryDirectory() as tmp_dir: + pyproject = Path(tmp_dir) / "pyproject.toml" + pyproject.write_text( + '[project]\nname = "mypkg"\n\n' + '[project.urls]\nRepository = "https://github.com/owner/mypkg"\n', + encoding="utf-8", + ) + (Path(tmp_dir) / "great-docs.yml").write_text(source_yml, encoding="utf-8") + # Register so monkeypatch restores it after __init__ writes os.environ. + monkeypatch.setenv("GITHUB_REPO_URL", "sentinel") + + GreatDocs(project_path=tmp_dir) + + expected = "https://github.com/owner/mypkg" if exported else None + assert os.environ.get("GITHUB_REPO_URL") == expected + + def test_detect_git_ref_configured_branch(): """Test _detect_git_ref uses configured branch from metadata."""