Coverage for python / lsst / sphinxutils / ext / packagetoctree.py: 23%
102 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-30 08:37 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-30 08:37 +0000
1"""Create toctrees for modules and packages."""
3__all__ = ["ModuleTocTree", "PackageTocTree", "setup"]
5from collections.abc import Generator
6from typing import Any
8from docutils import nodes
9from docutils.parsers.rst import Directive, directives
10from sphinx.addnodes import toctree
11from sphinx.application import Sphinx
12from sphinx.util.logging import getLogger
13from sphinx.util.nodes import set_source_info
14from sphinx.util.typing import ExtensionMetadata
16from ..version import __version__
19class ModuleTocTree(Directive):
20 """Toctree that automatically displays a list of modules in the Stack
21 documentation.
23 Notes
24 -----
25 Modules are detected as document paths. All modules have index pages
26 with paths ``modules/{{name}}/index`` by virtue of the linking during the
27 build process. Thus this directive does not directly interact with eups.
29 **Options**
31 ``skip``
32 Module (or modules) to skip (optional). For multiple modules, provide a
33 comma-delimited list. If possible, use the ``-s`` option from the
34 ``stack-docs`` command-line app instead to prevent orphan document
35 issues.
36 """
38 has_content = False
40 option_spec = {"skip": directives.unchanged}
42 def run(self) -> list[nodes.Node]:
43 """Run the directive.
45 Returns
46 -------
47 `list`
48 Nodes to add to the doctree.
49 """
50 logger = getLogger(__name__)
52 env = self.state.document.settings.env
53 new_nodes: list[nodes.Node] = []
55 # Get skip list
56 skipped_modules = self._parse_skip_option()
58 # List of homepage documents for each module
59 module_index_files = []
61 # Collect paths with the form `modules/<module-name>/index`
62 for docname in _filter_index_pages(env.found_docs, "modules"):
63 logger.debug("module-toctree found %s", docname)
64 if self._parse_module_name(docname) in skipped_modules:
65 logger.debug("module-toctree skipped %s", docname)
66 continue
67 module_index_files.append(docname)
68 module_index_files.sort()
69 entries = [(None, docname) for docname in module_index_files]
70 logger.debug("module-toctree found %d modules", len(module_index_files))
72 # Add the toctree's node itself
73 subnode = _build_toctree_node(
74 parent=env.docname,
75 entries=entries,
76 includefiles=module_index_files,
77 caption=None,
78 )
79 set_source_info(self, subnode) # Sphinx TocTree does this.
81 wrappernode = nodes.compound(classes=["toctree-wrapper", "module-toctree"])
82 wrappernode.append(subnode)
83 self.add_name(wrappernode)
84 new_nodes.append(wrappernode)
86 return new_nodes
88 def _parse_skip_option(self) -> list[str]:
89 """Parse the ``skip`` option of skipped module names."""
90 try:
91 skip_text = self.options["skip"]
92 except KeyError:
93 return []
95 modules = [module.strip() for module in skip_text.split(",")]
96 return modules
98 def _parse_module_name(self, docname: str) -> str:
99 """Parse the module name given a docname with the form
100 ``modules/<module>/index``.
101 """
102 return docname.split("/")[1]
105class PackageTocTree(Directive):
106 """Toctree that automatically lists packages in the Stack documentation.
108 Notes
109 -----
110 Packages are detected as document paths. All packages have index pages
111 with paths ``packages/{{name}}/index`` by virtue of the linking during the
112 build process. Thus this directive does not directly interact with eups.
114 **Options**
116 ``skip``
117 Package (or packages) to skip (optional). For multiple packages, provide
118 a comma-delimited list. If possible, use the ``-s`` option from the
119 ``stack-docs`` command-line app instead to prevent orphan document
120 issues.
122 """
124 has_content = False
126 option_spec = {"skip": directives.unchanged}
128 def run(self) -> list[nodes.Node]:
129 """Run the directive.
131 Returns
132 -------
133 `list`
134 Nodes to add to the doctree.
135 """
136 logger = getLogger(__name__)
138 env = self.state.document.settings.env
139 new_nodes: list[nodes.Node] = []
141 # Get skip list
142 skipped_packages = self._parse_skip_option()
144 # List of homepage documents for each package
145 package_index_files = []
147 # Collect paths with the form `modules/<module-name>/index`
148 for docname in _filter_index_pages(env.found_docs, "packages"):
149 logger.debug("package-toctree found %s", docname)
150 if self._parse_package_name(docname) in skipped_packages:
151 logger.debug("package-toctree skipped %s", docname)
152 continue
153 package_index_files.append(docname)
154 package_index_files.sort()
155 entries = [(None, docname) for docname in package_index_files]
156 logger.debug("package-toctree found %d packages", len(package_index_files))
158 # Add the toctree's node itself
159 subnode = _build_toctree_node(
160 parent=env.docname,
161 entries=entries,
162 includefiles=package_index_files,
163 caption=None,
164 )
166 set_source_info(self, subnode) # Sphinx TocTree does this.
168 wrappernode = nodes.compound(classes=["toctree-wrapper", "package-toctree"])
169 wrappernode.append(subnode)
170 self.add_name(wrappernode)
171 new_nodes.append(wrappernode)
173 return new_nodes
175 def _parse_skip_option(self) -> list[str]:
176 """Parse the ``skip`` option of skipped package names."""
177 try:
178 skip_text = self.options["skip"]
179 except KeyError:
180 return []
182 packages = [package.strip() for package in skip_text.split(",")]
183 return packages
185 def _parse_package_name(self, docname: str) -> str:
186 """Parse the package name given a docname with the form
187 ``packages/<package>/index``.
188 """
189 return docname.split("/")[1]
192def _filter_index_pages(docnames: list[str], base_dir: str) -> Generator[str, None, None]:
193 """Filter docnames to only yield paths of the form
194 ``<base_dir>/<name>/index``.
196 Parameters
197 ----------
198 docnames
199 List of document names (``env.found_docs``).
200 base_dir
201 Base directory of all sub-directories containing index pages.
203 Yields
204 ------
205 `str`
206 Document name that meets the pattern.
207 """
208 for docname in docnames:
209 parts = docname.split("/")
210 if len(parts) == 3 and parts[0] == base_dir and parts[2] == "index":
211 yield docname
214def _build_toctree_node(
215 parent: nodes.Node | None = None,
216 entries: list[tuple[Any, str]] | None = None,
217 includefiles: list[str] | None = None,
218 caption: str | None = None,
219) -> toctree:
220 """Create a toctree node."""
221 # Add the toctree's node itself
222 subnode = toctree()
223 subnode["parent"] = parent
224 subnode["entries"] = entries
225 subnode["includefiles"] = includefiles
226 subnode["caption"] = caption
227 # These values are needed for toctree node types. We don't need/want
228 # these to be configurable for module-toctree.
229 subnode["maxdepth"] = 1
230 subnode["hidden"] = False
231 subnode["glob"] = None
232 subnode["hidden"] = False
233 subnode["includehidden"] = False
234 subnode["numbered"] = 0
235 subnode["titlesonly"] = False
236 return subnode
239def setup(app: Sphinx) -> ExtensionMetadata:
240 """Set up ``module-toctree`` and ``package-toctree`` directives."""
241 app.add_directive("module-toctree", ModuleTocTree)
242 app.add_directive("package-toctree", PackageTocTree)
244 return {
245 "version": __version__,
246 "parallel_read_safe": True,
247 "parallel_write_safe": True,
248 }