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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 48 additions & 5 deletions great_docs/_renderer/_render/doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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>/<filename>`.
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):
"""
Expand Down
4 changes: 3 additions & 1 deletion great_docs/_renderer/_type_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions great_docs/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
95 changes: 95 additions & 0 deletions tests/test_great_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading