diff --git a/great_docs/_renderer/_render/doc.py b/great_docs/_renderer/_render/doc.py index ad24801e..31b0b52b 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,65 @@ 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. + """ + # 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") + 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): """ 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 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. 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."""