Coverage for src/mkdocstrings/handlers/base.py: 85.90%

173 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-11-14 19:41 +0100

1"""Base module for handlers. 

2 

3This module contains the base classes for implementing handlers. 

4""" 

5 

6from __future__ import annotations 

7 

8import importlib 

9import sys 

10from pathlib import Path 

11from typing import Any, BinaryIO, ClassVar, Iterable, Iterator, Mapping, MutableMapping, Sequence, cast 

12from xml.etree.ElementTree import Element, tostring 

13 

14from jinja2 import Environment, FileSystemLoader 

15from markdown import Markdown 

16from markdown.extensions.toc import TocTreeprocessor 

17from markupsafe import Markup 

18 

19from mkdocstrings.handlers.rendering import ( 

20 HeadingShiftingTreeprocessor, 

21 Highlighter, 

22 IdPrependingTreeprocessor, 

23 MkdocstringsInnerExtension, 

24 ParagraphStrippingTreeprocessor, 

25) 

26from mkdocstrings.inventory import Inventory 

27from mkdocstrings.loggers import get_template_logger 

28 

29# TODO: remove once support for Python 3.9 is dropped 

30if sys.version_info < (3, 10): 

31 from importlib_metadata import entry_points 

32else: 

33 from importlib.metadata import entry_points 

34 

35CollectorItem = Any 

36 

37 

38class CollectionError(Exception): 

39 """An exception raised when some collection of data failed.""" 

40 

41 

42class ThemeNotSupported(Exception): # noqa: N818 

43 """An exception raised to tell a theme is not supported.""" 

44 

45 

46def do_any(seq: Sequence, attribute: str | None = None) -> bool: 

47 """Check if at least one of the item in the sequence evaluates to true. 

48 

49 The `any` builtin as a filter for Jinja templates. 

50 

51 Arguments: 

52 seq: An iterable object. 

53 attribute: The attribute name to use on each object of the iterable. 

54 

55 Returns: 

56 A boolean telling if any object of the iterable evaluated to True. 

57 """ 

58 if attribute is None: 

59 return any(seq) 

60 return any(_[attribute] for _ in seq) 

61 

62 

63class BaseHandler: 

64 """The base handler class. 

65 

66 Inherit from this class to implement a handler. 

67 

68 You will have to implement the `collect` and `render` methods. 

69 You can also implement the `teardown` method, 

70 and override the `update_env` method, to add more filters to the Jinja environment, 

71 making them available in your Jinja templates. 

72 

73 To define a fallback theme, add a `fallback_theme` class-variable. 

74 To add custom CSS, add an `extra_css` variable or create an 'style.css' file beside the templates. 

75 """ 

76 

77 name: str = "" 

78 """The handler's name, for example "python".""" 

79 domain: str = "default" 

80 """The handler's domain, used to register objects in the inventory, for example "py".""" 

81 enable_inventory: bool = False 

82 """Whether the inventory creation is enabled.""" 

83 fallback_config: ClassVar[dict] = {} 

84 """Fallback configuration when searching anchors for identifiers.""" 

85 fallback_theme: str = "" 

86 """Fallback theme to use when a template isn't found in the configured theme.""" 

87 extra_css = "" 

88 """Extra CSS.""" 

89 

90 def __init__(self, handler: str, theme: str, custom_templates: str | None = None) -> None: 

91 """Initialize the object. 

92 

93 If the given theme is not supported (it does not exist), it will look for a `fallback_theme` attribute 

94 in `self` to use as a fallback theme. 

95 

96 Arguments: 

97 handler: The name of the handler. 

98 theme: The name of theme to use. 

99 custom_templates: Directory containing custom templates. 

100 """ 

101 paths = [] 

102 

103 # add selected theme templates 

104 themes_dir = self.get_templates_dir(handler) 

105 paths.append(themes_dir / theme) 

106 

107 # add extended theme templates 

108 extended_templates_dirs = self.get_extended_templates_dirs(handler) 

109 for templates_dir in extended_templates_dirs: 109 ↛ 110line 109 didn't jump to line 110, because the loop on line 109 never started

110 paths.append(templates_dir / theme) 

111 

112 # add fallback theme templates 

113 if self.fallback_theme and self.fallback_theme != theme: 

114 paths.append(themes_dir / self.fallback_theme) 

115 

116 # add fallback theme of extended templates 

117 for templates_dir in extended_templates_dirs: 117 ↛ 118line 117 didn't jump to line 118, because the loop on line 117 never started

118 paths.append(templates_dir / self.fallback_theme) 

119 

120 for path in paths: 120 ↛ 126line 120 didn't jump to line 126, because the loop on line 120 didn't complete

121 css_path = path / "style.css" 

122 if css_path.is_file(): 

123 self.extra_css += "\n" + css_path.read_text(encoding="utf-8") 

124 break 

125 

126 if custom_templates is not None: 126 ↛ 127line 126 didn't jump to line 127, because the condition on line 126 was never true

127 paths.insert(0, Path(custom_templates) / handler / theme) 

128 

129 self.env = Environment( 

130 autoescape=True, 

131 loader=FileSystemLoader(paths), 

132 auto_reload=False, # Editing a template in the middle of a build is not useful. 

133 ) 

134 self.env.filters["any"] = do_any 

135 self.env.globals["log"] = get_template_logger() 

136 

137 self._headings: list[Element] = [] 

138 self._md: Markdown = None # type: ignore[assignment] # To be populated in `update_env`. 

139 

140 @classmethod 

141 def load_inventory( 

142 cls, 

143 in_file: BinaryIO, # noqa: ARG003 

144 url: str, # noqa: ARG003 

145 base_url: str | None = None, # noqa: ARG003 

146 **kwargs: Any, # noqa: ARG003 

147 ) -> Iterator[tuple[str, str]]: 

148 """Yield items and their URLs from an inventory file streamed from `in_file`. 

149 

150 Arguments: 

151 in_file: The binary file-like object to read the inventory from. 

152 url: The URL that this file is being streamed from (used to guess `base_url`). 

153 base_url: The URL that this inventory's sub-paths are relative to. 

154 **kwargs: Ignore additional arguments passed from the config. 

155 

156 Yields: 

157 Tuples of (item identifier, item URL). 

158 """ 

159 yield from () 

160 

161 def collect(self, identifier: str, config: MutableMapping[str, Any]) -> CollectorItem: 

162 """Collect data given an identifier and user configuration. 

163 

164 In the implementation, you typically call a subprocess that returns JSON, and load that JSON again into 

165 a Python dictionary for example, though the implementation is completely free. 

166 

167 Arguments: 

168 identifier: An identifier for which to collect data. For example, in Python, 

169 it would be 'mkdocstrings.handlers' to collect documentation about the handlers module. 

170 It can be anything that you can feed to the tool of your choice. 

171 config: The handler's configuration options. 

172 

173 Returns: 

174 Anything you want, as long as you can feed it to the handler's `render` method. 

175 """ 

176 raise NotImplementedError 

177 

178 def render(self, data: CollectorItem, config: Mapping[str, Any]) -> str: 

179 """Render a template using provided data and configuration options. 

180 

181 Arguments: 

182 data: The collected data to render. 

183 config: The handler's configuration options. 

184 

185 Returns: 

186 The rendered template as HTML. 

187 """ 

188 raise NotImplementedError 

189 

190 def teardown(self) -> None: 

191 """Teardown the handler. 

192 

193 This method should be implemented to, for example, terminate a subprocess 

194 that was started when creating the handler instance. 

195 """ 

196 

197 def get_templates_dir(self, handler: str | None = None) -> Path: 

198 """Return the path to the handler's templates directory. 

199 

200 Override to customize how the templates directory is found. 

201 

202 Arguments: 

203 handler: The name of the handler to get the templates directory of. 

204 

205 Raises: 

206 ModuleNotFoundError: When no such handler is installed. 

207 FileNotFoundError: When the templates directory cannot be found. 

208 

209 Returns: 

210 The templates directory path. 

211 """ 

212 handler = handler or self.name 

213 try: 

214 import mkdocstrings_handlers 

215 except ModuleNotFoundError as error: 

216 raise ModuleNotFoundError(f"Handler '{handler}' not found, is it installed?") from error 

217 

218 for path in mkdocstrings_handlers.__path__: 218 ↛ 223line 218 didn't jump to line 223, because the loop on line 218 didn't complete

219 theme_path = Path(path, handler, "templates") 

220 if theme_path.exists(): 220 ↛ 218line 220 didn't jump to line 218, because the condition on line 220 was never false

221 return theme_path 

222 

223 raise FileNotFoundError(f"Can't find 'templates' folder for handler '{handler}'") 

224 

225 def get_extended_templates_dirs(self, handler: str) -> list[Path]: 

226 """Load template extensions for the given handler, return their templates directories. 

227 

228 Arguments: 

229 handler: The name of the handler to get the extended templates directory of. 

230 

231 Returns: 

232 The extensions templates directories. 

233 """ 

234 discovered_extensions = entry_points(group=f"mkdocstrings.{handler}.templates") 

235 return [extension.load()() for extension in discovered_extensions] 

236 

237 def get_anchors(self, data: CollectorItem) -> tuple[str, ...]: 

238 """Return the possible identifiers (HTML anchors) for a collected item. 

239 

240 Arguments: 

241 data: The collected data. 

242 

243 Returns: 

244 The HTML anchors (without '#'), or an empty tuple if this item doesn't have an anchor. 

245 """ 

246 # TODO: remove this when https://github.com/mkdocstrings/crystal/pull/6 is merged and released 

247 try: 

248 return (self.get_anchor(data),) # type: ignore[attr-defined] 

249 except AttributeError: 

250 return () 

251 

252 def do_convert_markdown( 

253 self, 

254 text: str, 

255 heading_level: int, 

256 html_id: str = "", 

257 *, 

258 strip_paragraph: bool = False, 

259 ) -> Markup: 

260 """Render Markdown text; for use inside templates. 

261 

262 Arguments: 

263 text: The text to convert. 

264 heading_level: The base heading level to start all Markdown headings from. 

265 html_id: The HTML id of the element that's considered the parent of this element. 

266 strip_paragraph: Whether to exclude the <p> tag from around the whole output. 

267 

268 Returns: 

269 An HTML string. 

270 """ 

271 treeprocessors = self._md.treeprocessors 

272 treeprocessors[HeadingShiftingTreeprocessor.name].shift_by = heading_level # type: ignore[attr-defined] 

273 treeprocessors[IdPrependingTreeprocessor.name].id_prefix = html_id and html_id + "--" # type: ignore[attr-defined] 

274 treeprocessors[ParagraphStrippingTreeprocessor.name].strip = strip_paragraph # type: ignore[attr-defined] 

275 try: 

276 return Markup(self._md.convert(text)) 

277 finally: 

278 treeprocessors[HeadingShiftingTreeprocessor.name].shift_by = 0 # type: ignore[attr-defined] 

279 treeprocessors[IdPrependingTreeprocessor.name].id_prefix = "" # type: ignore[attr-defined] 

280 treeprocessors[ParagraphStrippingTreeprocessor.name].strip = False # type: ignore[attr-defined] 

281 self._md.reset() 

282 

283 def do_heading( 

284 self, 

285 content: Markup, 

286 heading_level: int, 

287 *, 

288 role: str | None = None, 

289 hidden: bool = False, 

290 toc_label: str | None = None, 

291 **attributes: str, 

292 ) -> Markup: 

293 """Render an HTML heading and register it for the table of contents. For use inside templates. 

294 

295 Arguments: 

296 content: The HTML within the heading. 

297 heading_level: The level of heading (e.g. 3 -> `h3`). 

298 role: An optional role for the object bound to this heading. 

299 hidden: If True, only register it for the table of contents, don't render anything. 

300 toc_label: The title to use in the table of contents ('data-toc-label' attribute). 

301 **attributes: Any extra HTML attributes of the heading. 

302 

303 Returns: 

304 An HTML string. 

305 """ 

306 # First, produce the "fake" heading, for ToC only. 

307 el = Element(f"h{heading_level}", attributes) 

308 if toc_label is None: 308 ↛ 309line 308 didn't jump to line 309, because the condition on line 308 was never true

309 toc_label = content.unescape() if isinstance(content, Markup) else content 

310 el.set("data-toc-label", toc_label) 

311 if role: 311 ↛ 313line 311 didn't jump to line 313, because the condition on line 311 was never false

312 el.set("data-role", role) 

313 self._headings.append(el) 

314 

315 if hidden: 

316 return Markup('<a id="{0}"></a>').format(attributes["id"]) 

317 

318 # Now produce the actual HTML to be rendered. The goal is to wrap the HTML content into a heading. 

319 # Start with a heading that has just attributes (no text), and add a placeholder into it. 

320 el = Element(f"h{heading_level}", attributes) 

321 el.append(Element("mkdocstrings-placeholder")) 

322 # Tell the 'toc' extension to make its additions if configured so. 

323 toc = cast(TocTreeprocessor, self._md.treeprocessors["toc"]) 

324 if toc.use_anchors: 324 ↛ 325line 324 didn't jump to line 325, because the condition on line 324 was never true

325 toc.add_anchor(el, attributes["id"]) 

326 if toc.use_permalinks: 

327 toc.add_permalink(el, attributes["id"]) 

328 

329 # The content we received is HTML, so it can't just be inserted into the tree. We had marked the middle 

330 # of the heading with a placeholder that can never occur (text can't directly contain angle brackets). 

331 # Now this HTML wrapper can be "filled" by replacing the placeholder. 

332 html_with_placeholder = tostring(el, encoding="unicode") 

333 assert ( # noqa: S101 

334 html_with_placeholder.count("<mkdocstrings-placeholder />") == 1 

335 ), f"Bug in mkdocstrings: failed to replace in {html_with_placeholder!r}" 

336 html = html_with_placeholder.replace("<mkdocstrings-placeholder />", content) 

337 return Markup(html) 

338 

339 def get_headings(self) -> Sequence[Element]: 

340 """Return and clear the headings gathered so far. 

341 

342 Returns: 

343 A list of HTML elements. 

344 """ 

345 result = list(self._headings) 

346 self._headings.clear() 

347 return result 

348 

349 def update_env(self, md: Markdown, config: dict) -> None: # noqa: ARG002 

350 """Update the Jinja environment. 

351 

352 Arguments: 

353 md: The Markdown instance. Useful to add functions able to convert Markdown into the environment filters. 

354 config: Configuration options for `mkdocs` and `mkdocstrings`, read from `mkdocs.yml`. See the source code 

355 of [mkdocstrings.plugin.MkdocstringsPlugin.on_config][] to see what's in this dictionary. 

356 """ 

357 self._md = md 

358 self.env.filters["highlight"] = Highlighter(md).highlight 

359 self.env.filters["convert_markdown"] = self.do_convert_markdown 

360 self.env.filters["heading"] = self.do_heading 

361 

362 def _update_env(self, md: Markdown, config: dict) -> None: 

363 """Update our handler to point to our configured Markdown instance, grabbing some of the config from `md`.""" 

364 extensions = config["mdx"] + [MkdocstringsInnerExtension(self._headings)] 

365 

366 new_md = Markdown(extensions=extensions, extension_configs=config["mdx_configs"]) 

367 # MkDocs adds its own (required) extension that's not part of the config. Propagate it. 

368 if "relpath" in md.treeprocessors: 

369 new_md.treeprocessors.register(md.treeprocessors["relpath"], "relpath", priority=0) 

370 

371 self.update_env(new_md, config) 

372 

373 

374class Handlers: 

375 """A collection of handlers. 

376 

377 Do not instantiate this directly. [The plugin][mkdocstrings.plugin.MkdocstringsPlugin] will keep one instance of 

378 this for the purpose of caching. Use [mkdocstrings.plugin.MkdocstringsPlugin.get_handler][] for convenient access. 

379 """ 

380 

381 def __init__(self, config: dict) -> None: 

382 """Initialize the object. 

383 

384 Arguments: 

385 config: Configuration options for `mkdocs` and `mkdocstrings`, read from `mkdocs.yml`. See the source code 

386 of [mkdocstrings.plugin.MkdocstringsPlugin.on_config][] to see what's in this dictionary. 

387 """ 

388 self._config = config 

389 self._handlers: dict[str, BaseHandler] = {} 

390 self.inventory: Inventory = Inventory(project=self._config["mkdocs"]["site_name"]) 

391 

392 def get_anchors(self, identifier: str) -> tuple[str, ...]: 

393 """Return the canonical HTML anchor for the identifier, if any of the seen handlers can collect it. 

394 

395 Arguments: 

396 identifier: The identifier (one that [collect][mkdocstrings.handlers.base.BaseHandler.collect] can accept). 

397 

398 Returns: 

399 A tuple of strings - anchors without '#', or an empty tuple if there isn't any identifier familiar with it. 

400 """ 

401 for handler in self._handlers.values(): 

402 fallback_config = getattr(handler, "fallback_config", {}) 

403 try: 

404 anchors = handler.get_anchors(handler.collect(identifier, fallback_config)) 

405 except CollectionError: 

406 continue 

407 if anchors: 407 ↛ 401line 407 didn't jump to line 401, because the condition on line 407 was never false

408 return anchors 

409 return () 

410 

411 def get_handler_name(self, config: dict) -> str: 

412 """Return the handler name defined in an "autodoc" instruction YAML configuration, or the global default handler. 

413 

414 Arguments: 

415 config: A configuration dictionary, obtained from YAML below the "autodoc" instruction. 

416 

417 Returns: 

418 The name of the handler to use. 

419 """ 

420 global_config = self._config["mkdocstrings"] 

421 if "handler" in config: 

422 return config["handler"] 

423 return global_config["default_handler"] 

424 

425 def get_handler_config(self, name: str) -> dict: 

426 """Return the global configuration of the given handler. 

427 

428 Arguments: 

429 name: The name of the handler to get the global configuration of. 

430 

431 Returns: 

432 The global configuration of the given handler. It can be an empty dictionary. 

433 """ 

434 handlers = self._config["mkdocstrings"].get("handlers", {}) 

435 if handlers: 

436 return handlers.get(name, {}) 

437 return {} 

438 

439 def get_handler(self, name: str, handler_config: dict | None = None) -> BaseHandler: 

440 """Get a handler thanks to its name. 

441 

442 This function dynamically imports a module named "mkdocstrings.handlers.NAME", calls its 

443 `get_handler` method to get an instance of a handler, and caches it in dictionary. 

444 It means that during one run (for each reload when serving, or once when building), 

445 a handler is instantiated only once, and reused for each "autodoc" instruction asking for it. 

446 

447 Arguments: 

448 name: The name of the handler. Really, it's the name of the Python module holding it. 

449 handler_config: Configuration passed to the handler. 

450 

451 Returns: 

452 An instance of a subclass of [`BaseHandler`][mkdocstrings.handlers.base.BaseHandler], 

453 as instantiated by the `get_handler` method of the handler's module. 

454 """ 

455 if name not in self._handlers: 

456 if handler_config is None: 

457 handler_config = self.get_handler_config(name) 

458 handler_config.update(self._config) 

459 module = importlib.import_module(f"mkdocstrings_handlers.{name}") 

460 self._handlers[name] = module.get_handler( 

461 theme=self._config["theme_name"], 

462 custom_templates=self._config["mkdocstrings"]["custom_templates"], 

463 config_file_path=self._config["mkdocs"]["config_file_path"], 

464 **handler_config, 

465 ) 

466 return self._handlers[name] 

467 

468 @property 

469 def seen_handlers(self) -> Iterable[BaseHandler]: 

470 """Get the handlers that were encountered so far throughout the build. 

471 

472 Returns: 

473 An iterable of instances of [`BaseHandler`][mkdocstrings.handlers.base.BaseHandler] 

474 (usable only to loop through it). 

475 """ 

476 return self._handlers.values() 

477 

478 def teardown(self) -> None: 

479 """Teardown all cached handlers and clear the cache.""" 

480 for handler in self.seen_handlers: 

481 handler.teardown() 

482 self._handlers.clear()