diff --git a/src/docc/plugins/listing/__init__.py b/src/docc/plugins/listing/__init__.py index b9f732c..ef9c94f 100644 --- a/src/docc/plugins/listing/__init__.py +++ b/src/docc/plugins/listing/__init__.py @@ -50,6 +50,30 @@ from docc.transform import Transform +def _hierarchy_path(source: Source) -> PurePath: + """ + Return the navigation-tree position of `source`. + + Index sources (synthetic listings, ``__init__.py``) occupy the + directory they index; everything else occupies its `output_path`. + """ + return Listable._index_dir(source) or source.output_path + + +def _display_path(source: Source) -> PurePath: + """ + Return the path used to display `source` as a file entry. + + For a file-backed index like ``__init__.py``, this rejoins the + original filename to its URL-relative directory so any wrapper + prefix stripped from `output_path` is not shown. + """ + index_dir = Listable._index_dir(source) + if index_dir and source.relative_path: + return index_dir / source.relative_path.name + return source.output_path + + class Listable: """ Mixin to change visibility of a Source in a directory listing. @@ -62,8 +86,11 @@ def _sorting_key( if isinstance(thing, Listable): return thing.listing_order_key() elif isinstance(thing, Source): - path = thing.relative_path or thing.output_path - return (Listable._index_dir(thing) is None, path, None) + return ( + Listable._index_dir(thing) is None, + _hierarchy_path(thing), + None, + ) return (True, None, thing) @staticmethod @@ -107,8 +134,7 @@ def listing_order_key( Key to use when sorting instances while rendering. """ if isinstance(self, Source): - path = self.relative_path or self.output_path - return (self.index_dir is None, path, None) + return (self.index_dir is None, _hierarchy_path(self), None) return (True, None, self) @@ -140,11 +166,7 @@ def discover(self, known: FrozenSet[T]) -> Iterator["ListingSource"]: if not Listable._show_source(source): continue - path = source.relative_path - if not path: - path = source.output_path - - for parent in path.parents: + for parent in _hierarchy_path(source).parents: try: listing = listings[parent] except KeyError: @@ -175,27 +197,29 @@ def add_source(self, source: Source) -> None: """ Register a source. """ - index_dir = Listable._index_dir(source) - if index_dir is None: - path = source.relative_path or source.output_path - self.sources[path.parent].add(source) - else: - self.sources[index_dir].add(source) - self.sources[index_dir.parent].add(source) + hierarchy = _hierarchy_path(source) + self.sources[hierarchy.parent].add(source) + if Listable._index_dir(source) is not None: + # Index sources also appear as the index of their own directory. + self.sources[hierarchy].add(source) def descendants(self, source: Source) -> Iterable[Source]: """ All children of the given source. """ - source_path = source.relative_path or source.output_path - return self.sources[source_path] + return self.sources[_hierarchy_path(source)] def siblings(self, source: Source) -> Iterable[Source]: """ All sources with the same parent as the given source. + + An index source like ``__init__.py`` is treated as a member of + the directory it indexes, so its siblings are that directory's + entries rather than entries one level higher in the tree. """ - source_path = source.relative_path or source.output_path - return self.sources[source_path.parent] + if Listable._index_dir(source) is not None: + return self.descendants(source) + return self.sources[_hierarchy_path(source).parent] class ListingContext(Provider[Listing]): @@ -397,7 +421,8 @@ def render_html( ): # Regular source, or an index source (like `__init__.py`) appearing # in its own listing. Show as ``. - path = relative.name if node.leaf else str(relative) + display = _display_path(source) + path = display.name if node.leaf else str(display) else: # Synthetic listing, or a file-based index (like `__init__.py`) # appearing in its parent directory's listing. Show as `/`. diff --git a/src/docc/plugins/python/cst.py b/src/docc/plugins/python/cst.py index e70c076..c6e91c0 100644 --- a/src/docc/plugins/python/cst.py +++ b/src/docc/plugins/python/cst.py @@ -92,8 +92,12 @@ def _python_source( root_path: PurePath, relative_path: PurePath, absolute_path: PurePath, + *, + strip_root: bool = False, ) -> "PythonSource": - return PythonSource(root_path, relative_path, absolute_path) + return PythonSource( + root_path, relative_path, absolute_path, strip_root=strip_root + ) def __init__(self, config: PluginSettings) -> None: self.settings = config @@ -129,6 +133,12 @@ def discover(self, known: FrozenSet[T]) -> Iterator[Source]: for root_text, absolute_texts in globbed: root_path = PurePath(root_text) + # Strip the root from output paths when it's a wrapper (e.g. + # ``src/``) rather than a package: its name belongs to the + # source tree, not the package being documented. + strip_root = not os.path.isfile( + os.path.join(root_text, "__init__.py") + ) for absolute_text in absolute_texts: absolute_path = PurePath(absolute_text) relative_path = self.settings.unresolve_path(absolute_path) @@ -136,7 +146,10 @@ def discover(self, known: FrozenSet[T]) -> Iterator[Source]: parents = relative_path.parents if not any(p in parents for p in self.excluded_paths): yield self._python_source( - root_path, relative_path, absolute_path + root_path, + relative_path, + absolute_path, + strip_root=strip_root, ) @@ -148,16 +161,20 @@ class PythonSource(TextSource, Listable): root_path: Final[PurePath] absolute_path: Final[PurePath] _relative_path: Final[PurePath] + _strip_root: Final[bool] def __init__( self, root_path: PurePath, relative_path: PurePath, absolute_path: PurePath, + *, + strip_root: bool = False, ) -> None: self.root_path = root_path self._relative_path = relative_path self.absolute_path = absolute_path + self._strip_root = strip_root def _is_init(self) -> bool: return self._relative_path.name == "__init__.py" @@ -173,11 +190,16 @@ def relative_path(self) -> Optional[PurePath]: def output_path(self) -> PurePath: """ Where to put the output derived from this source. - """ - if self._is_init(): - return self._relative_path.with_name("index") - return self._relative_path + When the discovery root is a wrapper directory (e.g. ``src/``), it + is stripped so that URLs start at the top-level package. + """ + base = ( + self.absolute_path.relative_to(self.root_path) + if self._strip_root + else self._relative_path + ) + return base.with_name("index") if self._is_init() else base def open(self) -> TextIO: """ diff --git a/tests/test_listing.py b/tests/test_listing.py new file mode 100644 index 0000000..7c7e54c --- /dev/null +++ b/tests/test_listing.py @@ -0,0 +1,127 @@ +# Copyright (C) 2026 Ethereum Foundation +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from pathlib import PurePath + +from docc.plugins.listing import ( + Listing, + ListingDiscover, + ListingSource, + _display_path, + _hierarchy_path, +) +from docc.plugins.python.cst import PythonSource +from docc.settings import PluginSettings, Settings + + +def _python_source( + rel: str, *, strip_root: bool = True, root: str = "src" +) -> PythonSource: + relative = PurePath(rel) + absolute = PurePath("/abs") / relative + return PythonSource( + root_path=PurePath("/abs") / root, + relative_path=relative, + absolute_path=absolute, + strip_root=strip_root, + ) + + +def test_hierarchy_path_uses_index_dir_for_init() -> None: + source = _python_source("src/pkg/__init__.py") + assert _hierarchy_path(source) == PurePath("pkg") + + +def test_hierarchy_path_uses_output_for_module() -> None: + source = _python_source("src/pkg/mod.py") + assert _hierarchy_path(source) == PurePath("pkg/mod.py") + + +def test_hierarchy_path_uses_index_dir_for_listing_source() -> None: + listing = ListingSource(PurePath("pkg"), PurePath("pkg/index")) + assert _hierarchy_path(listing) == PurePath("pkg") + + +def test_display_path_keeps_init_filename() -> None: + source = _python_source("src/pkg/__init__.py") + assert _display_path(source) == PurePath("pkg/__init__.py") + + +def test_display_path_for_module_is_output_path() -> None: + source = _python_source("src/pkg/mod.py") + assert _display_path(source) == PurePath("pkg/mod.py") + + +def test_listing_groups_init_and_siblings_under_same_dir() -> None: + listing = Listing() + init_src = _python_source("src/pkg/__init__.py") + mod_src = _python_source("src/pkg/mod.py") + + listing.add_source(init_src) + listing.add_source(mod_src) + + # `mod.py` lives in the `pkg/` listing. + assert listing.sources[PurePath("pkg")] == {init_src, mod_src} + # `__init__.py` also appears in its parent's listing as the directory. + assert listing.sources[PurePath(".")] == {init_src} + + +def test_listing_descendants_and_siblings() -> None: + listing = Listing() + init_src = _python_source("src/pkg/__init__.py") + mod_src = _python_source("src/pkg/mod.py") + top = ListingSource(PurePath("."), PurePath("index")) + + listing.add_source(init_src) + listing.add_source(mod_src) + listing.add_source(top) + + # Descendants of the package index are the package's contents. + assert set(listing.descendants(init_src)) == {init_src, mod_src} + # Siblings of `mod.py` include the `__init__.py` and itself. + assert set(listing.siblings(mod_src)) == {init_src, mod_src} + # An `__init__.py` is treated as a member of its own package, so its + # siblings are the package's contents (matching what `mod.py` sees). + assert set(listing.siblings(init_src)) == {init_src, mod_src} + + +def _empty_discover() -> ListingDiscover: + settings = PluginSettings( + Settings(PurePath("."), {}), # type: ignore[arg-type] + {}, + ) + return ListingDiscover(settings) + + +def test_listing_discover_creates_root_listing_only() -> None: + init_src = _python_source("src/pkg/__init__.py") + mod_src = _python_source("src/pkg/mod.py") + + listings = list(_empty_discover().discover(frozenset({init_src, mod_src}))) + + # __init__.py supplies the `pkg/` listing, so only the root is new. + assert {ls.output_path for ls in listings} == {PurePath("index")} + + +def test_listing_discover_creates_intermediate_listings() -> None: + # No __init__.py at `pkg/`, so a synthetic listing is created for it. + mod_src = _python_source("src/pkg/mod.py") + + listings = list(_empty_discover().discover(frozenset({mod_src}))) + + assert {ls.output_path for ls in listings} == { + PurePath("pkg/index"), + PurePath("index"), + } diff --git a/tests/test_python_discover.py b/tests/test_python_discover.py new file mode 100644 index 0000000..43d9f30 --- /dev/null +++ b/tests/test_python_discover.py @@ -0,0 +1,147 @@ +# Copyright (C) 2026 Ethereum Foundation +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from pathlib import Path, PurePath +from typing import Tuple + +import pytest + +from docc.plugins.python.cst import PythonDiscover, PythonSource +from docc.settings import PluginSettings, Settings + + +def _settings(root: Path, paths: Tuple[str, ...]) -> PluginSettings: + return PluginSettings( + Settings( + root, + {"tool": {"docc": {"plugins": {"docc.python.discover": {}}}}}, + ), + {"paths": list(paths)}, + ) + + +def _write_tree(root: Path, files: Tuple[str, ...]) -> None: + for file in files: + path = root / file + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("") + + +def test_wrapper_root_is_stripped(tmp_path: Path) -> None: + _write_tree(tmp_path, ("src/pkg/__init__.py", "src/pkg/mod.py")) + + discover = PythonDiscover(_settings(tmp_path, ("src",))) + sources = sorted( + discover.discover(frozenset()), key=lambda s: s.absolute_path + ) + + assert [s.output_path for s in sources] == [ + PurePath("pkg/index"), + PurePath("pkg/mod.py"), + ] + # relative_path is the on-disk location and is unaffected. + assert [s.relative_path for s in sources] == [ + PurePath("src/pkg/__init__.py"), + PurePath("src/pkg/mod.py"), + ] + + +def test_package_root_is_kept(tmp_path: Path) -> None: + _write_tree(tmp_path, ("pkg/__init__.py", "pkg/mod.py")) + + discover = PythonDiscover(_settings(tmp_path, ("pkg",))) + sources = sorted( + discover.discover(frozenset()), key=lambda s: s.absolute_path + ) + + assert [s.output_path for s in sources] == [ + PurePath("pkg/index"), + PurePath("pkg/mod.py"), + ] + + +def test_namespace_wrapper_is_stripped(tmp_path: Path) -> None: + # No __init__.py at the wrapper root: it's still treated as a wrapper. + _write_tree(tmp_path, ("src/pkg/__init__.py", "src/loose.py")) + + discover = PythonDiscover(_settings(tmp_path, ("src",))) + sources = sorted( + discover.discover(frozenset()), key=lambda s: s.absolute_path + ) + + assert [s.output_path for s in sources] == [ + PurePath("loose.py"), + PurePath("pkg/index"), + ] + + +def test_index_dir_follows_stripped_output(tmp_path: Path) -> None: + _write_tree(tmp_path, ("src/pkg/__init__.py",)) + + discover = PythonDiscover(_settings(tmp_path, ("src",))) + (source,) = list(discover.discover(frozenset())) + + assert isinstance(source, PythonSource) + assert source.index_dir == PurePath("pkg") + + +def test_mixed_roots(tmp_path: Path) -> None: + _write_tree( + tmp_path, + ( + "src/pkg/__init__.py", + "lib/__init__.py", + "lib/util.py", + ), + ) + + discover = PythonDiscover(_settings(tmp_path, ("src", "lib"))) + sources = {s.output_path for s in discover.discover(frozenset())} + + assert sources == { + PurePath("pkg/index"), # src/ stripped + PurePath("lib/index"), # lib kept (lib has __init__.py) + PurePath("lib/util.py"), + } + + +@pytest.mark.parametrize( + "filename,expected", + [ + ("__init__.py", PurePath("pkg/index")), + ("mod.py", PurePath("pkg/mod.py")), + ], +) +def test_python_source_strip_root_param( + tmp_path: Path, filename: str, expected: PurePath +) -> None: + abs_path = tmp_path / "src" / "pkg" / filename + source = PythonSource( + root_path=PurePath(tmp_path / "src"), + relative_path=PurePath("src/pkg") / filename, + absolute_path=PurePath(abs_path), + strip_root=True, + ) + assert source.output_path == expected + + +def test_python_source_default_keeps_relative(tmp_path: Path) -> None: + abs_path = tmp_path / "src" / "pkg" / "mod.py" + source = PythonSource( + root_path=PurePath(tmp_path / "src"), + relative_path=PurePath("src/pkg/mod.py"), + absolute_path=PurePath(abs_path), + ) + assert source.output_path == PurePath("src/pkg/mod.py") diff --git a/whitelist.txt b/whitelist.txt index eb291a8..9e1d197 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -73,3 +73,5 @@ typeddict typeshed unresolve urlunsplit +isfile +tmp