Skip to content
Open
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
67 changes: 46 additions & 21 deletions src/docc/plugins/listing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]
Comment on lines +215 to +222
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In add_source, if we're still adding the index page in two places, why do we need to change this?



class ListingContext(Provider[Listing]):
Expand Down Expand Up @@ -397,7 +421,8 @@ def render_html(
):
# Regular source, or an index source (like `__init__.py`) appearing
# in its own listing. Show as `<name>`.
path = relative.name if node.leaf else str(relative)
display = _display_path(source)
path = display.name if node.leaf else str(display)
Comment on lines +424 to +425
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
display = _display_path(source)
path = display.name if node.leaf else str(display)
path = _display_path(source).name if node.leaf else str(display)

Tiny micro optimization :P

else:
# Synthetic listing, or a file-based index (like `__init__.py`)
# appearing in its parent directory's listing. Show as `<dir>/`.
Expand Down
34 changes: 28 additions & 6 deletions src/docc/plugins/python/cst.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,12 @@ def _python_source(
root_path: PurePath,
relative_path: PurePath,
absolute_path: PurePath,
*,
strip_root: bool = False,
Comment on lines +95 to +96
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you mind also bumping the major version in src/docc/__init__.py? I believe this is a breaking change.

) -> "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
Expand Down Expand Up @@ -129,14 +133,23 @@ 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)

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,
)


Expand All @@ -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"
Expand All @@ -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
Comment on lines +197 to +202
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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
if self._strip_root:
base = self.absolute_path.relative_to(self.root_path)
else:
base = self._relative_path
return base.with_name("index") if self._is_init() else base


def open(self) -> TextIO:
"""
Expand Down
127 changes: 127 additions & 0 deletions tests/test_listing.py
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

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"),
}
Loading