-
Notifications
You must be signed in to change notification settings - Fork 10
Strip wrapper directory from generated doc URLs #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 `<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
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
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>/`. | ||||||||
|
|
||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would you mind also bumping the major version in |
||||||||||||||||||||||||
| ) -> "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,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, | ||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||||||||
|
Comment on lines
+197
to
+202
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| def open(self) -> TextIO: | ||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| 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"), | ||
| } |
There was a problem hiding this comment.
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?