Coverage for python / lsst / sphinxutils / ext / packagetoctree.py: 23%

102 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-17 08:41 +0000

1"""Create toctrees for modules and packages.""" 

2 

3__all__ = ["ModuleTocTree", "PackageTocTree", "setup"] 

4 

5from collections.abc import Generator 

6from typing import Any 

7 

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 

15 

16from ..version import __version__ 

17 

18 

19class ModuleTocTree(Directive): 

20 """Toctree that automatically displays a list of modules in the Stack 

21 documentation. 

22 

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. 

28 

29 **Options** 

30 

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 """ 

37 

38 has_content = False 

39 

40 option_spec = {"skip": directives.unchanged} 

41 

42 def run(self) -> list[nodes.Node]: 

43 """Run the directive. 

44 

45 Returns 

46 ------- 

47 `list` 

48 Nodes to add to the doctree. 

49 """ 

50 logger = getLogger(__name__) 

51 

52 env = self.state.document.settings.env 

53 new_nodes: list[nodes.Node] = [] 

54 

55 # Get skip list 

56 skipped_modules = self._parse_skip_option() 

57 

58 # List of homepage documents for each module 

59 module_index_files = [] 

60 

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

71 

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. 

80 

81 wrappernode = nodes.compound(classes=["toctree-wrapper", "module-toctree"]) 

82 wrappernode.append(subnode) 

83 self.add_name(wrappernode) 

84 new_nodes.append(wrappernode) 

85 

86 return new_nodes 

87 

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 [] 

94 

95 modules = [module.strip() for module in skip_text.split(",")] 

96 return modules 

97 

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] 

103 

104 

105class PackageTocTree(Directive): 

106 """Toctree that automatically lists packages in the Stack documentation. 

107 

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. 

113 

114 **Options** 

115 

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. 

121 

122 """ 

123 

124 has_content = False 

125 

126 option_spec = {"skip": directives.unchanged} 

127 

128 def run(self) -> list[nodes.Node]: 

129 """Run the directive. 

130 

131 Returns 

132 ------- 

133 `list` 

134 Nodes to add to the doctree. 

135 """ 

136 logger = getLogger(__name__) 

137 

138 env = self.state.document.settings.env 

139 new_nodes: list[nodes.Node] = [] 

140 

141 # Get skip list 

142 skipped_packages = self._parse_skip_option() 

143 

144 # List of homepage documents for each package 

145 package_index_files = [] 

146 

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

157 

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 ) 

165 

166 set_source_info(self, subnode) # Sphinx TocTree does this. 

167 

168 wrappernode = nodes.compound(classes=["toctree-wrapper", "package-toctree"]) 

169 wrappernode.append(subnode) 

170 self.add_name(wrappernode) 

171 new_nodes.append(wrappernode) 

172 

173 return new_nodes 

174 

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 [] 

181 

182 packages = [package.strip() for package in skip_text.split(",")] 

183 return packages 

184 

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] 

190 

191 

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``. 

195 

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. 

202 

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 

212 

213 

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 

237 

238 

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) 

243 

244 return { 

245 "version": __version__, 

246 "parallel_read_safe": True, 

247 "parallel_write_safe": True, 

248 }