From a57474d1c8da13e7197e7b75a2aaedf95e7831c0 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 30 Jun 2026 17:05:06 -0400 Subject: [PATCH 1/5] Create _source_relative_path() function --- great_docs/_renderer/_render/doc.py | 47 ++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/great_docs/_renderer/_render/doc.py b/great_docs/_renderer/_render/doc.py index ad24801e..bb797d55 100644 --- a/great_docs/_renderer/_render/doc.py +++ b/great_docs/_renderer/_render/doc.py @@ -4,6 +4,7 @@ from copy import copy from dataclasses import dataclass from functools import cached_property, singledispatchmethod +from pathlib import Path from typing import TYPE_CHECKING, cast import griffe as gf @@ -600,23 +601,59 @@ def source_link(self) -> Link | None: if not base_url or base_url == "None": return None branch = package_info("GIT_REF") - try: - relative_path = self.obj.relative_package_filepath - except (ValueError, AttributeError): + rel_str = self._source_relative_path() + if rel_str is None: return None # Suppress source links for compiled extensions (PyO3, Cython, # pybind11, C extensions). The file path points at a binary # artifact and `lineno` is None, producing broken `#LNone` anchors. - rel_str = str(relative_path) if rel_str.endswith((".so", ".pyd", ".dylib", ".abi3.so")): return None start, end = self.obj.lineno, self.obj.endlineno if start is None: return None anchor = f"#L{start}-L{end}" if end is not None and start != end else f"#L{start}" - url = f"{base_url}/blob/{branch}/{relative_path}{anchor}" + url = f"{base_url}/blob/{branch}/{rel_str}{anchor}" return Link("Source", url, attr=Attr(attributes={"target": "_blank", "rel": "noopener"})) + def _source_relative_path(self) -> str | None: + """ + Path of the object's source file relative to the repository root. + + griffe's ``relative_package_filepath`` is relative to the package's + *parent* directory (a griffe search path), so for ``src/`` layouts and + other non-flat layouts it drops the leading directory (e.g. ``src/``), + producing a GitHub blob URL that 404s. To match the repository layout we + instead: + + 1. honour an explicit ``source.path`` override for monorepos, then + 2. compute the path relative to the repository root from the object's + absolute filepath (preserving ``src/`` etc.), and only + 3. fall back to griffe's package-parent-relative path when neither the + override nor the repository root is available. + """ + filepath = getattr(self.obj, "filepath", None) + + # 1. Explicit override for monorepos: `/`. + source_path = package_info("SOURCE_PATH") + if source_path and filepath is not None: + return f"{source_path}/{Path(filepath).name}" + + # 2. Path relative to the repository root, preserving the real layout. + package_root = package_info("PACKAGE_ROOT") + if package_root and filepath is not None: + try: + rel = Path(filepath).resolve().relative_to(Path(package_root).resolve()) + return rel.as_posix() + except (ValueError, OSError): + pass + + # 3. Legacy fallback: griffe's package-parent-relative path. + try: + return Path(self.obj.relative_package_filepath).as_posix() + except (ValueError, AttributeError): + return None + class RenderDoc(__RenderDoc): """ From 5aefd20db34475a42608b00df5b6dadac997d4e5 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 30 Jun 2026 17:05:13 -0400 Subject: [PATCH 2/5] Update _type_checks.py --- great_docs/_renderer/_type_checks.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/great_docs/_renderer/_type_checks.py b/great_docs/_renderer/_type_checks.py index d5e528c9..fdc0c67d 100644 --- a/great_docs/_renderer/_type_checks.py +++ b/great_docs/_renderer/_type_checks.py @@ -129,7 +129,9 @@ def is_field_init_false(el: gf.Parameter) -> bool: @lru_cache(4) -def package_info(key: Literal["GITHUB_REPO_URL", "PACKAGE_ROOT"]) -> str | None: +def package_info( + key: Literal["GITHUB_REPO_URL", "GIT_REF", "PACKAGE_ROOT", "SOURCE_PATH"], +) -> str | None: """ Return some bit of package information From 36ceb1d687b3d1be8c4d7e0c170355824f46f04f Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 30 Jun 2026 17:05:23 -0400 Subject: [PATCH 3/5] Update core.py --- great_docs/core.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/great_docs/core.py b/great_docs/core.py index a8faeeaf..d76a8eb4 100644 --- a/great_docs/core.py +++ b/great_docs/core.py @@ -147,6 +147,14 @@ def __init__(self, project_path: str | None = None): if url: os.environ["GITHUB_REPO_URL"] = str(url) os.environ["GIT_REF"] = self._detect_git_ref() + # The repository root, so the renderer can build source-link paths + # relative to it (preserving `src/` and other non-flat layouts) + # instead of griffe's package-parent-relative path, which 404s. + os.environ["PACKAGE_ROOT"] = str(self._find_package_root()) + # Optional explicit source-path override for monorepos (source.path). + source_path = self._config.source_path + if source_path: + os.environ["SOURCE_PATH"] = str(source_path) def _get_griffe_package(self, package_name: str): """Load a griffe package, using a per-build cache to avoid redundant loads. From 53b3c70861417bf21fdfa5226bb645b8b4e3d2ad Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 30 Jun 2026 17:05:38 -0400 Subject: [PATCH 4/5] Update test_great_docs.py --- tests/test_great_docs.py | 95 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/tests/test_great_docs.py b/tests/test_great_docs.py index 78c0b300..b7020f0b 100644 --- a/tests/test_great_docs.py +++ b/tests/test_great_docs.py @@ -14955,6 +14955,101 @@ def test_build_github_source_url_absolute_path(): 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._renderer._render.doc as docmod + from great_docs._renderer import _type_checks + + _type_checks.package_info.cache_clear() + cls = vars(docmod)["__RenderDoc"] + fake_self = types.SimpleNamespace(obj=obj) + return cls.__dict__["_source_relative_path"](fake_self) + + +def test_render_source_relative_path_preserves_src_layout(monkeypatch): + """Renderer source links must keep the `src/` prefix for src-layout packages. + + Regression test: griffe's `relative_package_filepath` is relative to the + package's parent (a search path such as `src/`), so it drops the leading + `src/` and yields a GitHub blob URL that 404s. The renderer should instead + compute the path relative to the repository root. + """ + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + src_file = root / "src" / "raghilda" / "embedding.py" + src_file.parent.mkdir(parents=True) + src_file.write_text("x = 1", encoding="utf-8") + + monkeypatch.delenv("SOURCE_PATH", raising=False) + monkeypatch.setenv("PACKAGE_ROOT", str(root)) + + # griffe would report the path relative to the `src/` search path. + obj = types.SimpleNamespace( + filepath=src_file, + relative_package_filepath=Path("raghilda/embedding.py"), + ) + + assert _render_doc_source_relative_path(obj) == "src/raghilda/embedding.py" + + +def test_render_source_relative_path_source_path_override(monkeypatch): + """An explicit `source.path` override is used verbatim for monorepos.""" + obj = types.SimpleNamespace( + filepath=Path("/anywhere/embedding.py"), + relative_package_filepath=Path("raghilda/embedding.py"), + ) + monkeypatch.setenv("SOURCE_PATH", "packages/raghilda/src") + monkeypatch.setenv("PACKAGE_ROOT", "/some/root") + + assert ( + _render_doc_source_relative_path(obj) == "packages/raghilda/src/embedding.py" + ) + + +def test_render_source_relative_path_falls_back_to_griffe(monkeypatch): + """Without repo root or override, fall back to griffe's package-relative path.""" + obj = types.SimpleNamespace( + filepath=Path("/anywhere/raghilda/embedding.py"), + relative_package_filepath=Path("raghilda/embedding.py"), + ) + monkeypatch.delenv("SOURCE_PATH", raising=False) + monkeypatch.delenv("PACKAGE_ROOT", raising=False) + + assert _render_doc_source_relative_path(obj) == "raghilda/embedding.py" + + +def test_init_exports_package_root_env(monkeypatch): + """GreatDocs.__init__ exports PACKAGE_ROOT so the renderer can resolve paths.""" + with tempfile.TemporaryDirectory() as tmp_dir: + pyproject = Path(tmp_dir) / "pyproject.toml" + pyproject.write_text('[project]\nname = "mypkg"\n', encoding="utf-8") + # Register the vars so monkeypatch restores them even though __init__ + # writes os.environ directly. + monkeypatch.setenv("PACKAGE_ROOT", "sentinel") + monkeypatch.delenv("SOURCE_PATH", raising=False) + + docs = GreatDocs(project_path=tmp_dir) + + assert os.environ.get("PACKAGE_ROOT") == str(docs._find_package_root()) + # No source.path configured -> SOURCE_PATH stays unset. + assert "SOURCE_PATH" not in os.environ + + +def test_init_exports_source_path_env(monkeypatch): + """A configured source.path is exported as SOURCE_PATH for the renderer.""" + with tempfile.TemporaryDirectory() as 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 path: packages/mypkg/src\n", encoding="utf-8") + # Register so monkeypatch restores it after __init__ writes os.environ. + monkeypatch.setenv("SOURCE_PATH", "sentinel") + + GreatDocs(project_path=tmp_dir) + + assert os.environ.get("SOURCE_PATH") == "packages/mypkg/src" + + def test_detect_git_ref_configured_branch(): """Test _detect_git_ref uses configured branch from metadata.""" From 485b6a4caef0d9ebbab6b16113a42434a277e8d6 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 30 Jun 2026 17:24:32 -0400 Subject: [PATCH 5/5] Guard source-link filepath access against parentless objects griffe's `Object.filepath` is a property that raises ValueError when the object has no parent module (e.g. synthetic objects in tests). A bare `getattr(..., None)` only suppresses AttributeError, so the ValueError propagated and crashed rendering. Catch both explicitly and fall through to the existing None handling. Co-Authored-By: Claude Opus 4.8 --- great_docs/_renderer/_render/doc.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/great_docs/_renderer/_render/doc.py b/great_docs/_renderer/_render/doc.py index bb797d55..31b0b52b 100644 --- a/great_docs/_renderer/_render/doc.py +++ b/great_docs/_renderer/_render/doc.py @@ -632,7 +632,13 @@ def _source_relative_path(self) -> str | None: 3. fall back to griffe's package-parent-relative path when neither the override nor the repository root is available. """ - filepath = getattr(self.obj, "filepath", None) + # griffe's `filepath` is a property that raises ValueError for objects + # without a parent module (e.g. synthetic objects), so a bare getattr + # default won't shield us — catch it explicitly. + try: + filepath = self.obj.filepath + except (ValueError, AttributeError): + filepath = None # 1. Explicit override for monorepos: `/`. source_path = package_info("SOURCE_PATH")