Coverage for src/mkdocstrings_handlers/python/_internal/handler.py: 69.62%

200 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-08-14 23:22 +0200

1# This module implements a handler for the Python language. 

2 

3from __future__ import annotations 

4 

5import glob 

6import os 

7import posixpath 

8import sys 

9from contextlib import suppress 

10from dataclasses import asdict 

11from pathlib import Path 

12from typing import TYPE_CHECKING, Any, BinaryIO, ClassVar 

13from warnings import warn 

14 

15from griffe import ( 

16 AliasResolutionError, 

17 GriffeLoader, 

18 LinesCollection, 

19 ModulesCollection, 

20 Parser, 

21 load_extensions, 

22 patch_loggers, 

23) 

24from mkdocs.exceptions import PluginError 

25from mkdocs_autorefs import BacklinkCrumb 

26from mkdocstrings import BaseHandler, CollectionError, CollectorItem, HandlerOptions, Inventory, get_logger 

27 

28from mkdocstrings_handlers.python._internal import rendering 

29from mkdocstrings_handlers.python._internal.config import PythonConfig, PythonOptions 

30 

31if TYPE_CHECKING: 

32 from collections.abc import Iterable, Iterator, Mapping, MutableMapping, Sequence 

33 

34 from mkdocs.config.defaults import MkDocsConfig 

35 from mkdocs_autorefs import Backlink 

36 

37 

38# YORE: EOL 3.10: Replace block with line 2. 

39if sys.version_info >= (3, 11): 

40 from contextlib import chdir 

41else: 

42 from contextlib import contextmanager 

43 

44 @contextmanager 

45 def chdir(path: str) -> Iterator[None]: 

46 old_wd = os.getcwd() 

47 os.chdir(path) 

48 try: 

49 yield 

50 finally: 

51 os.chdir(old_wd) 

52 

53 

54_logger = get_logger(__name__) 

55 

56patch_loggers(get_logger) 

57 

58 

59# YORE: Bump 2: Remove block. 

60def _warn_extra_options(names: Sequence[str]) -> None: 

61 warn( 

62 "Passing extra options directly under `options` is deprecated. " 

63 "Instead, pass them under `options.extra`, and update your templates. " 

64 f"Current extra (unrecognized) options: {', '.join(sorted(names))}", 

65 DeprecationWarning, 

66 stacklevel=3, 

67 ) 

68 

69 

70class PythonHandler(BaseHandler): 

71 """The Python handler class.""" 

72 

73 name: ClassVar[str] = "python" 

74 """The handler's name.""" 

75 

76 domain: ClassVar[str] = "py" 

77 """The cross-documentation domain/language for this handler.""" 

78 

79 enable_inventory: ClassVar[bool] = True 

80 """Whether this handler is interested in enabling the creation of the `objects.inv` Sphinx inventory file.""" 

81 

82 fallback_theme: ClassVar[str] = "material" 

83 """The fallback theme.""" 

84 

85 def __init__(self, config: PythonConfig, base_dir: Path, **kwargs: Any) -> None: 

86 """Initialize the handler. 

87 

88 Parameters: 

89 config: The handler configuration. 

90 base_dir: The base directory of the project. 

91 **kwargs: Arguments passed to the parent constructor. 

92 """ 

93 super().__init__(**kwargs) 

94 

95 self.config = config 

96 """The handler configuration.""" 

97 self.base_dir = base_dir 

98 """The base directory of the project.""" 

99 

100 # YORE: Bump 2: Remove block. 

101 global_extra, global_options = PythonOptions._extract_extra(config.options) 

102 if global_extra: 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true

103 _warn_extra_options(global_extra.keys()) # type: ignore[arg-type] 

104 self._global_extra = global_extra 

105 self.global_options = global_options 

106 """The global configuration options (in `mkdocs.yml`).""" 

107 

108 # YORE: Bump 2: Replace `# ` with `` within block. 

109 # self.global_options = config.options 

110 # """The global configuration options (in `mkdocs.yml`).""" 

111 

112 # Warn if user overrides base templates. 

113 if self.custom_templates: 113 ↛ 114line 113 didn't jump to line 114 because the condition on line 113 was never true

114 for theme_dir in base_dir.joinpath(self.custom_templates, "python").iterdir(): 

115 if theme_dir.joinpath("_base").is_dir(): 

116 _logger.warning( 

117 f"Overriding base template '{theme_dir.name}/_base/<template>.html.jinja' is not supported, " 

118 f"override '{theme_dir.name}/<template>.html.jinja' instead", 

119 ) 

120 

121 paths = config.paths or [] 

122 

123 # Expand paths with glob patterns. 

124 with chdir(str(base_dir)): 

125 resolved_globs = [glob.glob(path) for path in paths] 

126 paths = [path for glob_list in resolved_globs for path in glob_list] 

127 

128 # By default, add the base directory to the search paths. 

129 if not paths: 129 ↛ 130line 129 didn't jump to line 130 because the condition on line 129 was never true

130 paths.append(str(base_dir)) 

131 

132 # Initialize search paths from `sys.path`, eliminating empty paths. 

133 search_paths = [path for path in sys.path if path] 

134 

135 for path in reversed(paths): 

136 # If it's not absolute, make path relative to the config file path, then make it absolute. 

137 if not os.path.isabs(path): 

138 path = os.path.abspath(base_dir / path) # noqa: PLW2901 

139 # Remove pre-listed paths. 

140 if path in search_paths: 

141 search_paths.remove(path) 

142 # Give precedence to user-provided paths. 

143 search_paths.insert(0, path) 

144 

145 self._paths = search_paths 

146 self._modules_collection: ModulesCollection = ModulesCollection() 

147 self._lines_collection: LinesCollection = LinesCollection() 

148 

149 def get_inventory_urls(self) -> list[tuple[str, dict[str, Any]]]: 

150 """Return the URLs of the inventory files to download.""" 

151 return [(inv.url, inv._config) for inv in self.config.inventories] 

152 

153 @staticmethod 

154 def load_inventory( 

155 in_file: BinaryIO, 

156 url: str, 

157 base_url: str | None = None, 

158 domains: list[str] | None = None, 

159 **kwargs: Any, # noqa: ARG004 

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

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

162 

163 This implements mkdocstrings' `load_inventory` "protocol" (see [`mkdocstrings.plugin`][]). 

164 

165 Arguments: 

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

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

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

169 domains: A list of domain strings to filter the inventory by, when not passed, "py" will be used. 

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

171 

172 Yields: 

173 Tuples of (item identifier, item URL). 

174 """ 

175 domains = domains or ["py"] 

176 if base_url is None: 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true

177 base_url = posixpath.dirname(url) 

178 

179 for item in Inventory.parse_sphinx(in_file, domain_filter=domains).values(): 179 ↛ exitline 179 didn't return from function 'load_inventory' because the loop on line 179 didn't complete

180 yield item.name, posixpath.join(base_url, item.uri) 

181 

182 def get_options(self, local_options: Mapping[str, Any]) -> HandlerOptions: 

183 """Get combined default, global and local options. 

184 

185 Arguments: 

186 local_options: The local options. 

187 

188 Returns: 

189 The combined options. 

190 """ 

191 # YORE: Bump 2: Remove block. 

192 local_extra, local_options = PythonOptions._extract_extra(local_options) # type: ignore[arg-type] 

193 if local_extra: 193 ↛ 194line 193 didn't jump to line 194 because the condition on line 193 was never true

194 _warn_extra_options(local_extra.keys()) # type: ignore[arg-type] 

195 unknown_extra = self._global_extra | local_extra 

196 

197 extra = {**self.global_options.get("extra", {}), **local_options.get("extra", {})} 

198 options = {**self.global_options, **local_options, "extra": extra} 

199 try: 

200 # YORE: Bump 2: Replace `opts =` with `return` within line. 

201 opts = PythonOptions.from_data(**options) 

202 except Exception as error: 

203 raise PluginError(f"Invalid options: {error}") from error 

204 

205 # YORE: Bump 2: Remove block. 

206 for key, value in unknown_extra.items(): 206 ↛ 207line 206 didn't jump to line 207 because the loop on line 206 never started

207 object.__setattr__(opts, key, value) 

208 return opts 

209 

210 def collect(self, identifier: str, options: PythonOptions) -> CollectorItem: 

211 """Collect the documentation for the given identifier. 

212 

213 Parameters: 

214 identifier: The identifier of the object to collect. 

215 options: The options to use for the collection. 

216 

217 Returns: 

218 The collected item. 

219 """ 

220 module_name = identifier.split(".", 1)[0] 

221 unknown_module = module_name not in self._modules_collection 

222 reapply = True 

223 if options == {}: 223 ↛ 224line 223 didn't jump to line 224 because the condition on line 223 was never true

224 if unknown_module: 

225 raise CollectionError("Not loading additional modules during fallback") 

226 options = self.get_options({}) 

227 reapply = False 

228 

229 parser_name = options.docstring_style 

230 parser = parser_name and Parser(parser_name) 

231 parser_options = options.docstring_options and asdict(options.docstring_options) 

232 

233 if unknown_module: 233 ↛ 268line 233 didn't jump to line 268 because the condition on line 233 was always true

234 extensions = self.normalize_extension_paths(options.extensions) 

235 loader = GriffeLoader( 

236 extensions=load_extensions(*extensions), 

237 search_paths=self._paths, 

238 docstring_parser=parser, 

239 docstring_options=parser_options, # type: ignore[arg-type] 

240 modules_collection=self._modules_collection, 

241 lines_collection=self._lines_collection, 

242 allow_inspection=options.allow_inspection, 

243 force_inspection=options.force_inspection, 

244 ) 

245 try: 

246 for pre_loaded_module in options.preload_modules: 246 ↛ 247line 246 didn't jump to line 247 because the loop on line 246 never started

247 if pre_loaded_module not in self._modules_collection: 

248 loader.load( 

249 pre_loaded_module, 

250 try_relative_path=False, 

251 find_stubs_package=options.find_stubs_package, 

252 ) 

253 loader.load( 

254 module_name, 

255 try_relative_path=False, 

256 find_stubs_package=options.find_stubs_package, 

257 ) 

258 except ImportError as error: 

259 raise CollectionError(str(error)) from error 

260 unresolved, iterations = loader.resolve_aliases( 

261 implicit=False, 

262 external=self.config.load_external_modules, 

263 ) 

264 if unresolved: 264 ↛ 265line 264 didn't jump to line 265 because the condition on line 264 was never true

265 _logger.debug(f"{len(unresolved)} aliases were still unresolved after {iterations} iterations") 

266 _logger.debug(f"Unresolved aliases: {', '.join(sorted(unresolved))}") 

267 

268 try: 

269 doc_object = self._modules_collection[identifier] 

270 except KeyError as error: 

271 raise CollectionError(f"{identifier} could not be found") from error 

272 except AliasResolutionError as error: 

273 raise CollectionError(str(error)) from error 

274 

275 if not unknown_module and reapply: 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true

276 with suppress(AliasResolutionError): 

277 if doc_object.docstring is not None: 

278 doc_object.docstring.parser = parser 

279 doc_object.docstring.parser_options = parser_options or {} 

280 

281 return doc_object 

282 

283 def render(self, data: CollectorItem, options: PythonOptions, locale: str | None = None) -> str: 

284 """Render the collected data. 

285 

286 Parameters: 

287 data: The collected data. 

288 options: The options to use for rendering. 

289 locale: The locale to use for rendering (default is "en"). 

290 

291 Returns: 

292 The rendered data (HTML). 

293 """ 

294 template_name = rendering.do_get_template(self.env, data) 

295 template = self.env.get_template(template_name) 

296 

297 return template.render( 

298 **{ 

299 "config": options, 

300 data.kind.value: data, 

301 # Heading level is a "state" variable, that will change at each step 

302 # of the rendering recursion. Therefore, it's easier to use it as a plain value 

303 # than as an item in a dictionary. 

304 "heading_level": options.heading_level, 

305 "root": True, 

306 # YORE: Bump 2: Regex-replace ` or .+` with ` or "en",` within line. 

307 "locale": locale or self.config.locale, 

308 }, 

309 ) 

310 

311 def render_backlinks(self, backlinks: Mapping[str, Iterable[Backlink]]) -> str: 

312 """Render the backlinks. 

313 

314 Parameters: 

315 backlinks: The backlinks to render. 

316 

317 Returns: 

318 The rendered backlinks (HTML). 

319 """ 

320 template = self.env.get_template("backlinks.html.jinja") 

321 verbose_type = {key: key.capitalize().replace("-by", " by") for key in backlinks.keys()} # noqa: SIM118 

322 return template.render( 

323 backlinks=backlinks, 

324 config=self.get_options({}), 

325 verbose_type=verbose_type, 

326 default_crumb=BacklinkCrumb(title="", url=""), 

327 ) 

328 

329 def update_env(self, config: Any) -> None: # noqa: ARG002 

330 """Update the Jinja environment with custom filters and tests. 

331 

332 Parameters: 

333 config: The SSG configuration. 

334 """ 

335 self.env.trim_blocks = True 

336 self.env.lstrip_blocks = True 

337 self.env.keep_trailing_newline = False 

338 self.env.filters["split_path"] = rendering.do_split_path 

339 self.env.filters["crossref"] = rendering.do_crossref 

340 self.env.filters["multi_crossref"] = rendering.do_multi_crossref 

341 self.env.filters["order_members"] = rendering.do_order_members 

342 self.env.filters["format_code"] = rendering.do_format_code 

343 self.env.filters["format_signature"] = rendering.do_format_signature 

344 self.env.filters["format_attribute"] = rendering.do_format_attribute 

345 self.env.filters["filter_objects"] = rendering.do_filter_objects 

346 self.env.filters["stash_crossref"] = rendering.do_stash_crossref 

347 self.env.filters["get_template"] = rendering.do_get_template 

348 self.env.filters["as_attributes_section"] = rendering.do_as_attributes_section 

349 self.env.filters["as_functions_section"] = rendering.do_as_functions_section 

350 self.env.filters["as_classes_section"] = rendering.do_as_classes_section 

351 self.env.filters["as_modules_section"] = rendering.do_as_modules_section 

352 self.env.filters["backlink_tree"] = rendering.do_backlink_tree 

353 self.env.globals["AutorefsHook"] = rendering.AutorefsHook 

354 self.env.tests["existing_template"] = lambda template_name: template_name in self.env.list_templates() 

355 

356 def get_aliases(self, identifier: str) -> tuple[str, ...]: 

357 """Get the aliases for the given identifier. 

358 

359 Parameters: 

360 identifier: The identifier to get the aliases for. 

361 

362 Returns: 

363 The aliases. 

364 """ 

365 if "(" in identifier: 

366 identifier, parameter = identifier.split("(", 1) 

367 parameter.removesuffix(")") 

368 else: 

369 parameter = "" 

370 try: 

371 data = self._modules_collection[identifier] 

372 except (KeyError, AliasResolutionError): 

373 return () 

374 aliases = [data.path] 

375 try: 

376 for alias in [data.canonical_path, *data.aliases]: 

377 if alias not in aliases: 

378 aliases.append(alias) 

379 except AliasResolutionError: 

380 pass 

381 if parameter: 

382 return tuple(f"{alias}({parameter})" for alias in aliases) 

383 return tuple(aliases) 

384 

385 def normalize_extension_paths(self, extensions: Sequence) -> list[str | dict[str, Any]]: 

386 """Resolve extension paths relative to config file. 

387 

388 Parameters: 

389 extensions: The extensions (configuration) to normalize. 

390 

391 Returns: 

392 The normalized extensions. 

393 """ 

394 normalized: list[str | dict[str, Any]] = [] 

395 

396 for ext in extensions: 

397 if isinstance(ext, dict): 

398 pth, options = next(iter(ext.items())) 

399 pth = str(pth) 

400 else: 

401 pth = str(ext) 

402 options = None 

403 

404 if pth.endswith(".py") or ".py:" in pth or "/" in pth or "\\" in pth: 

405 # This is a system path. Normalize it, make it absolute relative to config file path. 

406 pth = os.path.abspath(self.base_dir / pth) 

407 

408 if options is not None: 

409 normalized.append({pth: options}) 

410 else: 

411 normalized.append(pth) 

412 

413 return normalized 

414 

415 

416def get_handler( 

417 handler_config: MutableMapping[str, Any], 

418 tool_config: MkDocsConfig, 

419 **kwargs: Any, 

420) -> PythonHandler: 

421 """Return an instance of `PythonHandler`. 

422 

423 Parameters: 

424 handler_config: The handler configuration. 

425 tool_config: The tool (SSG) configuration. 

426 **kwargs: Additional arguments to pass to the handler. 

427 

428 Returns: 

429 An instance of `PythonHandler`. 

430 """ 

431 base_dir = Path(tool_config.config_file_path or "./mkdocs.yml").parent 

432 if "inventories" not in handler_config and "import" in handler_config: 432 ↛ 433line 432 didn't jump to line 433 because the condition on line 432 was never true

433 warn("The 'import' key is renamed 'inventories' for the Python handler", FutureWarning, stacklevel=1) 

434 handler_config["inventories"] = handler_config.pop("import", []) 

435 return PythonHandler( 

436 config=PythonConfig.from_data(**handler_config), 

437 base_dir=base_dir, 

438 **kwargs, 

439 )