From 246bbd2705d42e02e1b8729e5991e0e219fa1bdc Mon Sep 17 00:00:00 2001 From: danceratopz Date: Thu, 30 Apr 2026 21:27:38 +0200 Subject: [PATCH 1/3] Strip wrapper directory from PythonSource output paths Auto-detect whether each discovery root is itself a Python package: if the root has no __init__.py it's treated as a wrapper (e.g. ``src/``) and dropped from output_path so generated URLs start at the top-level package, not the source-tree directory. Roots that *are* packages keep their name. The listing plugin's hierarchy lookups now key off output_path (via a small _hierarchy_path helper) so navigation matches the URL tree once the wrapper is stripped, and listing entries use a separate _display_path so a file-backed index like ``__init__.py`` still shows under its directory rather than the source-tree name. --- src/docc/plugins/listing/__init__.py | 67 ++++++++---- src/docc/plugins/python/cst.py | 48 ++++++++- tests/test_listing.py | 124 ++++++++++++++++++++++ tests/test_python_discover.py | 147 +++++++++++++++++++++++++++ whitelist.txt | 2 + 5 files changed, 363 insertions(+), 25 deletions(-) create mode 100644 tests/test_listing.py create mode 100644 tests/test_python_discover.py diff --git a/src/docc/plugins/listing/__init__.py b/src/docc/plugins/listing/__init__.py index b9f732c..19b0087 100644 --- a/src/docc/plugins/listing/__init__.py +++ b/src/docc/plugins/listing/__init__.py @@ -50,6 +50,36 @@ from docc.transform import Transform +def _hierarchy_path(source: Source) -> PurePath: + """ + Return the position of `source` in the navigation hierarchy. + + Index sources (synthetic listings, ``__init__.py``) occupy the + directory they index; everything else occupies its `output_path`. + """ + index_dir = Listable._index_dir(source) + if index_dir is not None: + return index_dir + return source.output_path + + +def _display_path(source: Source) -> PurePath: + """ + Return the path used to display `source` in a listing as a file entry. + + For a file-backed index source (e.g. ``__init__.py``), this is the + directory it indexes joined with the file's original name, so that + URL-relative listings show the source filename without any wrapper + prefix that may have been stripped from `output_path`. For other + sources it is just the `output_path`. + """ + relative = source.relative_path + index_dir = Listable._index_dir(source) + if index_dir is not None and relative is not None: + return index_dir / relative.name + return source.output_path + + class Listable: """ Mixin to change visibility of a Source in a directory listing. @@ -62,8 +92,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 +140,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 +172,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 +203,23 @@ 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. """ - source_path = source.relative_path or source.output_path - return self.sources[source_path.parent] + 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..3ce6ae4 100644 --- a/src/docc/plugins/python/cst.py +++ b/src/docc/plugins/python/cst.py @@ -85,6 +85,15 @@ class PythonDiscover(Discover): excludes all children. """ + _strip_roots: Final[Dict[str, bool]] + """ + Whether each discovery root's name should be stripped from output paths. + + A root is stripped when it is just a wrapper directory (e.g. ``src/``) + rather than a package itself: its name belongs to the source tree, not + the package being documented. + """ + settings: PluginSettings def _python_source( @@ -93,7 +102,12 @@ def _python_source( relative_path: PurePath, absolute_path: PurePath, ) -> "PythonSource": - return PythonSource(root_path, relative_path, absolute_path) + return PythonSource( + root_path, + relative_path, + absolute_path, + strip_root=self._strip_roots[str(root_path)], + ) def __init__(self, config: PluginSettings) -> None: self.settings = config @@ -119,6 +133,21 @@ def __init__(self, config: PluginSettings) -> None: self.excluded_paths = [PurePath(p) for p in excluded_paths] + self._strip_roots = { + root: not self._root_is_package(root) for root in self.paths + } + + @staticmethod + def _root_is_package(root_text: str) -> bool: + """ + Return whether `root_text` is a regular package directory. + + A discovery root that is itself a package (contains ``__init__.py``) + keeps its name in the documentation URL hierarchy. A wrapper + directory like ``src/`` does not, and gets stripped. + """ + return os.path.isfile(os.path.join(root_text, "__init__.py")) + def discover(self, known: FrozenSet[T]) -> Iterator[Source]: """ Find sources. @@ -148,16 +177,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 +206,18 @@ def relative_path(self) -> Optional[PurePath]: def output_path(self) -> PurePath: """ Where to put the output derived from this source. + + When the discovery root is a wrapper directory (e.g. ``src/``), it + is stripped so that URLs start at the top-level package. """ - if self._is_init(): - return self._relative_path.with_name("index") + if self._strip_root: + base = self.absolute_path.relative_to(self.root_path) + else: + base = self._relative_path - return self._relative_path + if self._is_init(): + return base.with_name("index") + return base def open(self) -> TextIO: """ diff --git a/tests/test_listing.py b/tests/test_listing.py new file mode 100644 index 0000000..97ceb3e --- /dev/null +++ b/tests/test_listing.py @@ -0,0 +1,124 @@ +# 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} + + +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 From 7fdc2d45363989cbaf2166f2887f08ca72875e52 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Fri, 1 May 2026 01:15:13 +0200 Subject: [PATCH 2/3] Fix `__init__.py` siblings to match its package contents An `__init__.py` source's hierarchy position is the directory it indexes, so `_hierarchy_path(...).parent` pointed one level too high and `siblings()` returned the parent listing instead of the package's own members. Treat an index source as a member of the directory it indexes when computing siblings, matching what regular files in the same package see. --- src/docc/plugins/listing/__init__.py | 6 ++++++ tests/test_listing.py | 3 +++ 2 files changed, 9 insertions(+) diff --git a/src/docc/plugins/listing/__init__.py b/src/docc/plugins/listing/__init__.py index 19b0087..ab219a2 100644 --- a/src/docc/plugins/listing/__init__.py +++ b/src/docc/plugins/listing/__init__.py @@ -218,7 +218,13 @@ def descendants(self, source: Source) -> Iterable[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 the other entries in + that directory rather than entries one level higher in the tree. """ + if Listable._index_dir(source) is not None: + return self.sources[_hierarchy_path(source)] return self.sources[_hierarchy_path(source).parent] diff --git a/tests/test_listing.py b/tests/test_listing.py index 97ceb3e..7c7e54c 100644 --- a/tests/test_listing.py +++ b/tests/test_listing.py @@ -92,6 +92,9 @@ def test_listing_descendants_and_siblings() -> None: 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: From 5807c47d1eead1c629c46e24f68388a255863fc9 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Fri, 1 May 2026 01:22:40 +0200 Subject: [PATCH 3/3] Tighten strip-root and listing helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the per-discover strip cache: detect each root's wrapper status inline in `discover()` and pass `strip_root` directly to the source factory. Collapse `output_path`, `_hierarchy_path`, `_display_path`, and `Listing.siblings` to their conditional one-liners and reuse `descendants()` for index-source siblings. Pure simplification — no behavior change. --- src/docc/plugins/listing/__init__.py | 30 ++++++--------- src/docc/plugins/python/cst.py | 56 ++++++++++------------------ 2 files changed, 31 insertions(+), 55 deletions(-) diff --git a/src/docc/plugins/listing/__init__.py b/src/docc/plugins/listing/__init__.py index ab219a2..ef9c94f 100644 --- a/src/docc/plugins/listing/__init__.py +++ b/src/docc/plugins/listing/__init__.py @@ -52,31 +52,25 @@ def _hierarchy_path(source: Source) -> PurePath: """ - Return the position of `source` in the navigation hierarchy. + Return the navigation-tree position of `source`. Index sources (synthetic listings, ``__init__.py``) occupy the directory they index; everything else occupies its `output_path`. """ - index_dir = Listable._index_dir(source) - if index_dir is not None: - return index_dir - return source.output_path + return Listable._index_dir(source) or source.output_path def _display_path(source: Source) -> PurePath: """ - Return the path used to display `source` in a listing as a file entry. + Return the path used to display `source` as a file entry. - For a file-backed index source (e.g. ``__init__.py``), this is the - directory it indexes joined with the file's original name, so that - URL-relative listings show the source filename without any wrapper - prefix that may have been stripped from `output_path`. For other - sources it is just the `output_path`. + 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. """ - relative = source.relative_path index_dir = Listable._index_dir(source) - if index_dir is not None and relative is not None: - return index_dir / relative.name + if index_dir and source.relative_path: + return index_dir / source.relative_path.name return source.output_path @@ -219,12 +213,12 @@ 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 the other entries in - that directory rather than entries one level higher in the tree. + 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. """ if Listable._index_dir(source) is not None: - return self.sources[_hierarchy_path(source)] + return self.descendants(source) return self.sources[_hierarchy_path(source).parent] diff --git a/src/docc/plugins/python/cst.py b/src/docc/plugins/python/cst.py index 3ce6ae4..c6e91c0 100644 --- a/src/docc/plugins/python/cst.py +++ b/src/docc/plugins/python/cst.py @@ -85,15 +85,6 @@ class PythonDiscover(Discover): excludes all children. """ - _strip_roots: Final[Dict[str, bool]] - """ - Whether each discovery root's name should be stripped from output paths. - - A root is stripped when it is just a wrapper directory (e.g. ``src/``) - rather than a package itself: its name belongs to the source tree, not - the package being documented. - """ - settings: PluginSettings def _python_source( @@ -101,12 +92,11 @@ 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, - strip_root=self._strip_roots[str(root_path)], + root_path, relative_path, absolute_path, strip_root=strip_root ) def __init__(self, config: PluginSettings) -> None: @@ -133,21 +123,6 @@ def __init__(self, config: PluginSettings) -> None: self.excluded_paths = [PurePath(p) for p in excluded_paths] - self._strip_roots = { - root: not self._root_is_package(root) for root in self.paths - } - - @staticmethod - def _root_is_package(root_text: str) -> bool: - """ - Return whether `root_text` is a regular package directory. - - A discovery root that is itself a package (contains ``__init__.py``) - keeps its name in the documentation URL hierarchy. A wrapper - directory like ``src/`` does not, and gets stripped. - """ - return os.path.isfile(os.path.join(root_text, "__init__.py")) - def discover(self, known: FrozenSet[T]) -> Iterator[Source]: """ Find sources. @@ -158,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) @@ -165,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, ) @@ -210,14 +194,12 @@ def output_path(self) -> PurePath: When the discovery root is a wrapper directory (e.g. ``src/``), it is stripped so that URLs start at the top-level package. """ - if self._strip_root: - base = self.absolute_path.relative_to(self.root_path) - else: - base = self._relative_path - - if self._is_init(): - return base.with_name("index") - return base + 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: """