Coverage for src/mkdocstrings/plugin.py: 58.89%

142 statements  

« prev     ^ index     » next       coverage.py v7.6.2, created at 2024-10-12 18:59 +0200

1"""This module contains the "mkdocstrings" plugin for MkDocs. 

2 

3The plugin instantiates a Markdown extension ([`MkdocstringsExtension`][mkdocstrings.extension.MkdocstringsExtension]), 

4and adds it to the list of Markdown extensions used by `mkdocs` 

5during the [`on_config` event hook](https://www.mkdocs.org/user-guide/plugins/#on_config). 

6 

7Once the documentation is built, the [`on_post_build` event hook](https://www.mkdocs.org/user-guide/plugins/#on_post_build) 

8is triggered and calls the [`handlers.teardown()` method][mkdocstrings.handlers.base.Handlers.teardown]. This method is 

9used to teardown the handlers that were instantiated during documentation buildup. 

10 

11Finally, when serving the documentation, it can add directories to watch 

12during the [`on_serve` event hook](https://www.mkdocs.org/user-guide/plugins/#on_serve). 

13""" 

14 

15from __future__ import annotations 

16 

17import datetime 

18import functools 

19import os 

20import sys 

21from collections.abc import Iterable, Mapping 

22from concurrent import futures 

23from io import BytesIO 

24from typing import TYPE_CHECKING, Any, Callable, TypeVar 

25 

26from mkdocs.config import Config 

27from mkdocs.config import config_options as opt 

28from mkdocs.plugins import BasePlugin 

29from mkdocs.utils import write_file 

30from mkdocs_autorefs.plugin import AutorefsConfig, AutorefsPlugin 

31 

32from mkdocstrings._cache import download_and_cache_url, download_url_with_gz 

33from mkdocstrings.extension import MkdocstringsExtension 

34from mkdocstrings.handlers.base import BaseHandler, Handlers 

35from mkdocstrings.loggers import get_logger 

36 

37if TYPE_CHECKING: 

38 from jinja2.environment import Environment 

39 from mkdocs.config.defaults import MkDocsConfig 

40 

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

42 from typing_extensions import ParamSpec 

43else: 

44 from typing import ParamSpec 

45 

46log = get_logger(__name__) 

47 

48InventoryImportType = list[tuple[str, Mapping[str, Any]]] 

49InventoryLoaderType = Callable[..., Iterable[tuple[str, str]]] 

50 

51P = ParamSpec("P") 

52R = TypeVar("R") 

53 

54 

55def list_to_tuple(function: Callable[P, R]) -> Callable[P, R]: 

56 """Decorater to convert lists to tuples in the arguments.""" 

57 

58 def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: 

59 safe_args = [tuple(item) if isinstance(item, list) else item for item in args] 

60 if kwargs: 

61 kwargs = {key: tuple(value) if isinstance(value, list) else value for key, value in kwargs.items()} # type: ignore[assignment] 

62 return function(*safe_args, **kwargs) # type: ignore[arg-type] 

63 

64 return wrapper 

65 

66 

67class PluginConfig(Config): 

68 """The configuration options of `mkdocstrings`, written in `mkdocs.yml`.""" 

69 

70 handlers = opt.Type(dict, default={}) 

71 """ 

72 Global configuration of handlers. 

73 

74 You can set global configuration per handler, applied everywhere, 

75 but overridable in each "autodoc" instruction. Example: 

76 

77 ```yaml 

78 plugins: 

79 - mkdocstrings: 

80 handlers: 

81 python: 

82 options: 

83 option1: true 

84 option2: "value" 

85 rust: 

86 options: 

87 option9: 2 

88 ``` 

89 """ 

90 

91 default_handler = opt.Type(str, default="python") 

92 """The default handler to use. The value is the name of the handler module. Default is "python".""" 

93 custom_templates = opt.Optional(opt.Dir(exists=True)) 

94 """Location of custom templates to use when rendering API objects. 

95 

96 Value should be the path of a directory relative to the MkDocs configuration file. 

97 """ 

98 enable_inventory = opt.Optional(opt.Type(bool)) 

99 """Whether to enable object inventory creation.""" 

100 enabled = opt.Type(bool, default=True) 

101 """Whether to enable the plugin. Default is true. If false, *mkdocstrings* will not collect or render anything.""" 

102 

103 

104class MkdocstringsPlugin(BasePlugin[PluginConfig]): 

105 """An `mkdocs` plugin. 

106 

107 This plugin defines the following event hooks: 

108 

109 - `on_config` 

110 - `on_env` 

111 - `on_post_build` 

112 

113 Check the [Developing Plugins](https://www.mkdocs.org/user-guide/plugins/#developing-plugins) page of `mkdocs` 

114 for more information about its plugin system. 

115 """ 

116 

117 css_filename = "assets/_mkdocstrings.css" 

118 

119 def __init__(self) -> None: 

120 """Initialize the object.""" 

121 super().__init__() 

122 self._handlers: Handlers | None = None 

123 

124 @property 

125 def handlers(self) -> Handlers: 

126 """Get the instance of [mkdocstrings.handlers.base.Handlers][] for this plugin/build. 

127 

128 Raises: 

129 RuntimeError: If the plugin hasn't been initialized with a config. 

130 

131 Returns: 

132 An instance of [mkdocstrings.handlers.base.Handlers][] (the same throughout the build). 

133 """ 

134 if not self._handlers: 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true

135 raise RuntimeError("The plugin hasn't been initialized with a config yet") 

136 return self._handlers 

137 

138 def on_config(self, config: MkDocsConfig) -> MkDocsConfig | None: 

139 """Instantiate our Markdown extension. 

140 

141 Hook for the [`on_config` event](https://www.mkdocs.org/user-guide/plugins/#on_config). 

142 In this hook, we instantiate our [`MkdocstringsExtension`][mkdocstrings.extension.MkdocstringsExtension] 

143 and add it to the list of Markdown extensions used by `mkdocs`. 

144 

145 We pass this plugin's configuration dictionary to the extension when instantiating it (it will need it 

146 later when processing markdown to get handlers and their global configurations). 

147 

148 Arguments: 

149 config: The MkDocs config object. 

150 

151 Returns: 

152 The modified config. 

153 """ 

154 if not self.plugin_enabled: 

155 log.debug("Plugin is not enabled. Skipping.") 

156 return config 

157 log.debug("Adding extension to the list") 

158 

159 theme_name = config.theme.name or os.path.dirname(config.theme.dirs[0]) 

160 

161 to_import: InventoryImportType = [] 

162 for handler_name, conf in self.config.handlers.items(): 162 ↛ 163line 162 didn't jump to line 163 because the loop on line 162 never started

163 for import_item in conf.pop("import", ()): 

164 if isinstance(import_item, str): 

165 import_item = {"url": import_item} # noqa: PLW2901 

166 to_import.append((handler_name, import_item)) 

167 

168 extension_config = { 

169 "theme_name": theme_name, 

170 "mdx": config.markdown_extensions, 

171 "mdx_configs": config.mdx_configs, 

172 "mkdocstrings": self.config, 

173 "mkdocs": config, 

174 } 

175 self._handlers = Handlers(extension_config) 

176 

177 autorefs: AutorefsPlugin 

178 try: 

179 # If autorefs plugin is explicitly enabled, just use it. 

180 autorefs = config.plugins["autorefs"] # type: ignore[assignment] 

181 log.debug(f"Picked up existing autorefs instance {autorefs!r}") 

182 except KeyError: 

183 # Otherwise, add a limited instance of it that acts only on what's added through `register_anchor`. 

184 autorefs = AutorefsPlugin() 

185 autorefs.config = AutorefsConfig() 

186 autorefs.scan_toc = False 

187 config.plugins["autorefs"] = autorefs 

188 log.debug(f"Added a subdued autorefs instance {autorefs!r}") 

189 # Add collector-based fallback in either case. 

190 autorefs.get_fallback_anchor = self.handlers.get_anchors 

191 

192 mkdocstrings_extension = MkdocstringsExtension(extension_config, self.handlers, autorefs) 

193 config.markdown_extensions.append(mkdocstrings_extension) # type: ignore[arg-type] 

194 

195 config.extra_css.insert(0, self.css_filename) # So that it has lower priority than user files. 

196 

197 self._inv_futures = {} 

198 if to_import: 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true

199 inv_loader = futures.ThreadPoolExecutor(4) 

200 for handler_name, import_item in to_import: 

201 loader = self.get_handler(handler_name).load_inventory 

202 future = inv_loader.submit( 

203 self._load_inventory, # type: ignore[misc] 

204 loader, 

205 **import_item, 

206 ) 

207 self._inv_futures[future] = (loader, import_item) 

208 inv_loader.shutdown(wait=False) 

209 

210 return config 

211 

212 @property 

213 def inventory_enabled(self) -> bool: 

214 """Tell if the inventory is enabled or not. 

215 

216 Returns: 

217 Whether the inventory is enabled. 

218 """ 

219 inventory_enabled = self.config.enable_inventory 

220 if inventory_enabled is None: 

221 inventory_enabled = any(handler.enable_inventory for handler in self.handlers.seen_handlers) 

222 return inventory_enabled 

223 

224 @property 

225 def plugin_enabled(self) -> bool: 

226 """Tell if the plugin is enabled or not. 

227 

228 Returns: 

229 Whether the plugin is enabled. 

230 """ 

231 return self.config.enabled 

232 

233 def on_env(self, env: Environment, config: MkDocsConfig, *args: Any, **kwargs: Any) -> None: # noqa: ARG002 

234 """Extra actions that need to happen after all Markdown rendering and before HTML rendering. 

235 

236 Hook for the [`on_env` event](https://www.mkdocs.org/user-guide/plugins/#on_env). 

237 

238 - Write mkdocstrings' extra files into the site dir. 

239 - Gather results from background inventory download tasks. 

240 """ 

241 if not self.plugin_enabled: 241 ↛ 243line 241 didn't jump to line 243 because the condition on line 241 was always true

242 return 

243 if self._handlers: 

244 css_content = "\n".join(handler.extra_css for handler in self.handlers.seen_handlers) 

245 write_file(css_content.encode("utf-8"), os.path.join(config.site_dir, self.css_filename)) 

246 

247 if self.inventory_enabled: 

248 log.debug("Creating inventory file objects.inv") 

249 inv_contents = self.handlers.inventory.format_sphinx() 

250 write_file(inv_contents, os.path.join(config.site_dir, "objects.inv")) 

251 

252 if self._inv_futures: 

253 log.debug(f"Waiting for {len(self._inv_futures)} inventory download(s)") 

254 futures.wait(self._inv_futures, timeout=30) 

255 results = {} 

256 # Reversed order so that pages from first futures take precedence: 

257 for fut in reversed(list(self._inv_futures)): 

258 try: 

259 results.update(fut.result()) 

260 except Exception as error: # noqa: BLE001 

261 loader, import_item = self._inv_futures[fut] 

262 loader_name = loader.__func__.__qualname__ 

263 log.error(f"Couldn't load inventory {import_item} through {loader_name}: {error}") # noqa: TRY400 

264 for page, identifier in results.items(): 

265 config.plugins["autorefs"].register_url(page, identifier) # type: ignore[attr-defined] 

266 self._inv_futures = {} 

267 

268 def on_post_build( 

269 self, 

270 config: MkDocsConfig, # noqa: ARG002 

271 **kwargs: Any, # noqa: ARG002 

272 ) -> None: 

273 """Teardown the handlers. 

274 

275 Hook for the [`on_post_build` event](https://www.mkdocs.org/user-guide/plugins/#on_post_build). 

276 This hook is used to teardown all the handlers that were instantiated and cached during documentation buildup. 

277 

278 For example, a handler could open a subprocess in the background and keep it open 

279 to feed it "autodoc" instructions and get back JSON data. If so, it should then close the subprocess at some point: 

280 the proper place to do this is in the handler's `teardown` method, which is indirectly called by this hook. 

281 

282 Arguments: 

283 config: The MkDocs config object. 

284 **kwargs: Additional arguments passed by MkDocs. 

285 """ 

286 if not self.plugin_enabled: 

287 return 

288 

289 for future in self._inv_futures: 289 ↛ 290line 289 didn't jump to line 290 because the loop on line 289 never started

290 future.cancel() 

291 

292 if self._handlers: 292 ↛ exitline 292 didn't return from function 'on_post_build' because the condition on line 292 was always true

293 log.debug("Tearing handlers down") 

294 self.handlers.teardown() 

295 

296 def get_handler(self, handler_name: str) -> BaseHandler: 

297 """Get a handler by its name. See [mkdocstrings.handlers.base.Handlers.get_handler][]. 

298 

299 Arguments: 

300 handler_name: The name of the handler. 

301 

302 Returns: 

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

304 """ 

305 return self.handlers.get_handler(handler_name) 

306 

307 @classmethod 

308 # lru_cache does not allow mutable arguments such lists, but that is what we load from YAML config. 

309 @list_to_tuple 

310 @functools.cache 

311 def _load_inventory(cls, loader: InventoryLoaderType, url: str, **kwargs: Any) -> Mapping[str, str]: 

312 """Download and process inventory files using a handler. 

313 

314 Arguments: 

315 loader: A function returning a sequence of pairs (identifier, url). 

316 url: The URL to download and process. 

317 **kwargs: Extra arguments to pass to the loader. 

318 

319 Returns: 

320 A mapping from identifier to absolute URL. 

321 """ 

322 log.debug(f"Downloading inventory from {url!r}") 

323 content = download_and_cache_url(url, download_url_with_gz, datetime.timedelta(days=1)) 

324 result = dict(loader(BytesIO(content), url=url, **kwargs)) 

325 log.debug(f"Loaded inventory from {url!r}: {len(result)} items") 

326 return result