Coverage for src/mkdocstrings_handlers/python/_internal/rendering.py: 61.83%

351 statements  

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

1# This module implements rendering utilities. 

2 

3from __future__ import annotations 

4 

5import random 

6import re 

7import string 

8import subprocess 

9import sys 

10import warnings 

11from collections import defaultdict 

12from contextlib import suppress 

13from dataclasses import replace 

14from functools import lru_cache 

15from pathlib import Path 

16from re import Match, Pattern 

17from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal, TypeVar 

18 

19from griffe import ( 

20 Alias, 

21 AliasResolutionError, 

22 CyclicAliasError, 

23 DocstringAttribute, 

24 DocstringClass, 

25 DocstringFunction, 

26 DocstringModule, 

27 DocstringSectionAttributes, 

28 DocstringSectionClasses, 

29 DocstringSectionFunctions, 

30 DocstringSectionModules, 

31 Object, 

32) 

33from jinja2 import TemplateNotFound, pass_context, pass_environment 

34from markupsafe import Markup 

35from mkdocs_autorefs import AutorefsHookInterface, Backlink, BacklinkCrumb 

36from mkdocstrings import get_logger 

37 

38if TYPE_CHECKING: 

39 from collections.abc import Iterable, Iterator, Sequence 

40 

41 from griffe import Attribute, Class, Function, Module 

42 from jinja2 import Environment 

43 from jinja2.runtime import Context 

44 from mkdocstrings import CollectorItem 

45 

46_logger = get_logger(__name__) 

47 

48 

49def _sort_key_alphabetical(item: CollectorItem) -> str: 

50 # `chr(sys.maxunicode)` is a string that contains the final unicode character, 

51 # so if `name` isn't found on the object, the item will go to the end of the list. 

52 return item.name or chr(sys.maxunicode) 

53 

54 

55def _sort_key_source(item: CollectorItem) -> float: 

56 # If `lineno` is none, the item will go to the end of the list. 

57 if item.is_alias: 

58 return item.alias_lineno if item.alias_lineno is not None else float("inf") 

59 return item.lineno if item.lineno is not None else float("inf") 

60 

61 

62def _sort__all__(item: CollectorItem) -> float: 

63 if item.parent.exports is not None: 

64 try: 

65 return item.parent.exports.index(item.name) 

66 except ValueError: 

67 # If the item is not in `__all__`, it will go to the end of the list. 

68 return float("inf") 

69 # No exports declared, refuse to sort (try other methods or return members as they are). 

70 raise ValueError(f"Parent object {item.parent.path} doesn't declare exports") 

71 

72 

73Order = Literal["__all__", "alphabetical", "source"] 

74"""Ordering methods. 

75 

76- `__all__`: order members according to `__all__` module attributes, if declared; 

77- `alphabetical`: order members alphabetically; 

78- `source`: order members as they appear in the source file. 

79""" 

80 

81_order_map: dict[str, Callable[[Object | Alias], str | float]] = { 

82 "alphabetical": _sort_key_alphabetical, 

83 "source": _sort_key_source, 

84 "__all__": _sort__all__, 

85} 

86 

87 

88def do_format_code(code: str, line_length: int) -> str: 

89 """Format code. 

90 

91 Parameters: 

92 code: The code to format. 

93 line_length: The line length. 

94 

95 Returns: 

96 The same code, formatted. 

97 """ 

98 code = code.strip() 

99 if len(code) < line_length: 

100 return code 

101 formatter = _get_formatter() 

102 return formatter(code, line_length) 

103 

104 

105class _StashCrossRefFilter: 

106 stash: ClassVar[dict[str, str]] = {} 

107 

108 @staticmethod 

109 def _gen_key(length: int) -> str: 

110 return "_" + "".join(random.choice(string.ascii_letters + string.digits) for _ in range(max(1, length - 1))) # noqa: S311 

111 

112 def _gen_stash_key(self, length: int) -> str: 

113 key = self._gen_key(length) 

114 while key in self.stash: 114 ↛ 115line 114 didn't jump to line 115 because the condition on line 114 was never true

115 key = self._gen_key(length) 

116 return key 

117 

118 def __call__(self, crossref: str, *, length: int) -> str: 

119 key = self._gen_stash_key(length) 

120 self.stash[key] = crossref 

121 return key 

122 

123 

124do_stash_crossref = _StashCrossRefFilter() 

125"""Filter to stash cross-references (and restore them after formatting and highlighting).""" 

126 

127 

128def _format_signature(name: Markup, signature: str, line_length: int) -> str: 

129 name = str(name).strip() # type: ignore[assignment] 

130 signature = signature.strip() 

131 if len(name + signature) < line_length: 

132 return name + signature 

133 

134 # Black cannot format names with dots, so we replace 

135 # the whole name with a string of equal length 

136 name_length = len(name) 

137 formatter = _get_formatter() 

138 formatable = f"def {'x' * name_length}{signature}: pass" 

139 formatted = formatter(formatable, line_length) 

140 

141 # We put back the original name 

142 # and remove starting `def ` and trailing `: pass` 

143 return name + formatted[4:-5].strip()[name_length:-1] 

144 

145 

146@pass_context 

147def do_format_signature( 

148 context: Context, 

149 callable_path: Markup, 

150 function: Function, 

151 line_length: int, 

152 *, 

153 annotations: bool | None = None, 

154 crossrefs: bool = False, # noqa: ARG001 

155) -> str: 

156 """Format a signature. 

157 

158 Parameters: 

159 context: Jinja context, passed automatically. 

160 callable_path: The path of the callable we render the signature of. 

161 function: The function we render the signature of. 

162 line_length: The line length. 

163 annotations: Whether to show type annotations. 

164 crossrefs: Whether to cross-reference types in the signature. 

165 

166 Returns: 

167 The same code, formatted. 

168 """ 

169 env = context.environment 

170 # YORE: Bump 2: Replace `do_get_template(env, "signature")` with `"signature.html.jinja"` within line. 

171 template = env.get_template(do_get_template(env, "signature")) 

172 

173 if annotations is None: 

174 new_context = context.parent 

175 else: 

176 new_context = dict(context.parent) 

177 new_context["config"] = replace(new_context["config"], show_signature_annotations=annotations) 

178 

179 signature = template.render(new_context, function=function, signature=True) 

180 signature = _format_signature(callable_path, signature, line_length) 

181 signature = str( 

182 env.filters["highlight"]( 

183 Markup.escape(signature), 

184 language="python", 

185 inline=False, 

186 classes=["doc-signature"], 

187 linenums=False, 

188 ), 

189 ) 

190 

191 # Since we highlight the signature without `def`, 

192 # Pygments sees it as a function call and not a function definition. 

193 # The result is that the function name is not parsed as such, 

194 # but instead as a regular name: `n` CSS class instead of `nf`. 

195 # When the function name is a known special name like `__exit__`, 

196 # Pygments will set an `fm` (function -> magic) CSS class. 

197 # To fix this, we replace the CSS class in the first span with `nf`, 

198 # unless we already found an `nf` span. 

199 if not re.search(r'<span class="nf">', signature): 199 ↛ 202line 199 didn't jump to line 202 because the condition on line 199 was always true

200 signature = re.sub(r'<span class="[a-z]{1,2}">', '<span class="nf">', signature, count=1) 

201 

202 if stash := env.filters["stash_crossref"].stash: 

203 for key, value in stash.items(): 

204 signature = re.sub(rf"\b{key}\b", value, signature) 

205 stash.clear() 

206 

207 return signature 

208 

209 

210@pass_context 

211def do_format_attribute( 

212 context: Context, 

213 attribute_path: Markup, 

214 attribute: Attribute, 

215 line_length: int, 

216 *, 

217 crossrefs: bool = False, # noqa: ARG001 

218 show_value: bool = True, 

219) -> str: 

220 """Format an attribute. 

221 

222 Parameters: 

223 context: Jinja context, passed automatically. 

224 attribute_path: The path of the callable we render the signature of. 

225 attribute: The attribute we render the signature of. 

226 line_length: The line length. 

227 crossrefs: Whether to cross-reference types in the signature. 

228 

229 Returns: 

230 The same code, formatted. 

231 """ 

232 env = context.environment 

233 # YORE: Bump 2: Replace `do_get_template(env, "expression")` with `"expression.html.jinja"` within line. 

234 template = env.get_template(do_get_template(env, "expression")) 

235 annotations = context.parent["config"].show_signature_annotations 

236 

237 signature = str(attribute_path).strip() 

238 if annotations and attribute.annotation: 

239 annotation = template.render( 

240 context.parent, 

241 expression=attribute.annotation, 

242 signature=True, 

243 backlink_type="returned-by", 

244 ) 

245 signature += f": {annotation}" 

246 if show_value and attribute.value: 

247 value = template.render(context.parent, expression=attribute.value, signature=True, backlink_type="used-by") 

248 signature += f" = {value}" 

249 

250 signature = do_format_code(signature, line_length) 

251 signature = str( 

252 env.filters["highlight"]( 

253 Markup.escape(signature), 

254 language="python", 

255 inline=False, 

256 classes=["doc-signature"], 

257 linenums=False, 

258 ), 

259 ) 

260 

261 if stash := env.filters["stash_crossref"].stash: 

262 for key, value in stash.items(): 

263 signature = re.sub(rf"\b{key}\b", value, signature) 

264 stash.clear() 

265 

266 return signature 

267 

268 

269def do_order_members( 

270 members: Sequence[Object | Alias], 

271 order: Order | list[Order], 

272 members_list: bool | list[str] | None, # noqa: FBT001 

273) -> Sequence[Object | Alias]: 

274 """Order members given an ordering method. 

275 

276 Parameters: 

277 members: The members to order. 

278 order: The ordering method. 

279 members_list: An optional member list (manual ordering). 

280 

281 Returns: 

282 The same members, ordered. 

283 """ 

284 if isinstance(members_list, list) and members_list: 

285 sorted_members = [] 

286 members_dict = {member.name: member for member in members} 

287 for name in members_list: 

288 if name in members_dict: 288 ↛ 287line 288 didn't jump to line 287 because the condition on line 288 was always true

289 sorted_members.append(members_dict[name]) 

290 return sorted_members 

291 if isinstance(order, str): 291 ↛ 293line 291 didn't jump to line 293 because the condition on line 291 was always true

292 order = [order] 

293 for method in order: 293 ↛ 296line 293 didn't jump to line 296 because the loop on line 293 didn't complete

294 with suppress(ValueError): 

295 return sorted(members, key=_order_map[method]) 

296 return members 

297 

298 

299# YORE: Bump 2: Remove block. 

300@lru_cache 

301def _warn_crossref() -> None: 

302 warnings.warn( 

303 "The `crossref` filter is deprecated and will be removed in a future version", 

304 DeprecationWarning, 

305 stacklevel=1, 

306 ) 

307 

308 

309# YORE: Bump 2: Remove block. 

310def do_crossref(path: str, *, brief: bool = True) -> Markup: 

311 """Deprecated. Filter to create cross-references. 

312 

313 Parameters: 

314 path: The path to link to. 

315 brief: Show only the last part of the path, add full path as hover. 

316 

317 Returns: 

318 Markup text. 

319 """ 

320 _warn_crossref() 

321 full_path = path 

322 if brief: 

323 path = full_path.split(".")[-1] 

324 return Markup("<autoref identifier={full_path} optional hover>{path}</autoref>").format( 

325 full_path=full_path, 

326 path=path, 

327 ) 

328 

329 

330# YORE: Bump 2: Remove block. 

331@lru_cache 

332def _warn_multi_crossref() -> None: 

333 warnings.warn( 

334 "The `multi_crossref` filter is deprecated and will be removed in a future version", 

335 DeprecationWarning, 

336 stacklevel=1, 

337 ) 

338 

339 

340# YORE: Bump 2: Remove block. 

341def do_multi_crossref(text: str, *, code: bool = True) -> Markup: 

342 """Deprecated. Filter to create cross-references. 

343 

344 Parameters: 

345 text: The text to scan. 

346 code: Whether to wrap the result in a code tag. 

347 

348 Returns: 

349 Markup text. 

350 """ 

351 _warn_multi_crossref() 

352 group_number = 0 

353 variables = {} 

354 

355 def repl(match: Match) -> str: 

356 nonlocal group_number 

357 group_number += 1 

358 path = match.group() 

359 path_var = f"path{group_number}" 

360 variables[path_var] = path 

361 return f"<autoref identifier={{{path_var}}} optional hover>{{{path_var}}}</autoref>" 

362 

363 text = re.sub(r"([\w.]+)", repl, text) 

364 if code: 

365 text = f"<code>{text}</code>" 

366 return Markup(text).format(**variables) # noqa: S704 

367 

368 

369_split_path_re = re.compile(r"([.(]?)([\w]+)(\))?") 

370_splitable_re = re.compile(r"[().]") 

371 

372 

373def do_split_path(path: str, full_path: str) -> Iterator[tuple[str, str, str, str]]: 

374 """Split object paths for building cross-references. 

375 

376 Parameters: 

377 path: The path to split. 

378 full_path: The full path, used to compute correct paths for each part of the path. 

379 

380 Yields: 

381 4-tuples: prefix, word, full path, suffix. 

382 """ 

383 # Path is a single word, yield full path directly. 

384 if not _splitable_re.search(path): 384 ↛ 388line 384 didn't jump to line 388 because the condition on line 384 was always true

385 yield ("", path, full_path, "") 

386 return 

387 

388 current_path = "" 

389 if path == full_path: 

390 # Split full path and yield directly without storing data in a dict. 

391 for match in _split_path_re.finditer(full_path): 

392 prefix, word, suffix = match.groups() 

393 current_path = f"{current_path}{prefix}{word}{suffix or ''}" if current_path else word 

394 yield prefix or "", word, current_path, suffix or "" 

395 return 

396 

397 # Split full path first to store tuples in a dict. 

398 elements = {} 

399 for match in _split_path_re.finditer(full_path): 

400 prefix, word, suffix = match.groups() 

401 current_path = f"{current_path}{prefix}{word}{suffix or ''}" if current_path else word 

402 elements[word] = (prefix or "", word, current_path, suffix or "") 

403 

404 # Then split path and pick tuples from the dict. 

405 first = True 

406 for match in _split_path_re.finditer(path): 

407 prefix, word, current_path, suffix = elements[match.group(2)] 

408 yield "" if first else prefix, word, current_path, suffix 

409 first = False 

410 

411 

412def _keep_object(name: str, filters: Sequence[tuple[Pattern, bool]]) -> bool: 

413 keep = None 

414 rules = set() 

415 for regex, exclude in filters: 

416 rules.add(exclude) 

417 if regex.search(name): 

418 keep = not exclude 

419 if keep is None: 

420 # When we only include stuff, no match = reject. 

421 # When we only exclude stuff, or include and exclude stuff, no match = keep. 

422 return rules != {False} 

423 return keep 

424 

425 

426def _parents(obj: Alias) -> set[str]: 

427 parent: Object | Alias = obj.parent # type: ignore[assignment] 

428 parents = {obj.path, parent.path} 

429 if parent.is_alias: 

430 parents.add(parent.final_target.path) # type: ignore[union-attr] 

431 while parent.parent: 

432 parent = parent.parent 

433 parents.add(parent.path) 

434 if parent.is_alias: 434 ↛ 435line 434 didn't jump to line 435 because the condition on line 434 was never true

435 parents.add(parent.final_target.path) # type: ignore[union-attr] 

436 return parents 

437 

438 

439def _remove_cycles(objects: list[Object | Alias]) -> Iterator[Object | Alias]: 

440 suppress_errors = suppress(AliasResolutionError, CyclicAliasError) 

441 for obj in objects: 

442 if obj.is_alias: 

443 with suppress_errors: 

444 if obj.final_target.path in _parents(obj): # type: ignore[arg-type,union-attr] 

445 continue 

446 yield obj 

447 

448 

449def do_filter_objects( 

450 objects_dictionary: dict[str, Object | Alias], 

451 *, 

452 filters: Sequence[tuple[Pattern, bool]] | Literal["public"] | None = None, 

453 members_list: bool | list[str] | None = None, 

454 inherited_members: bool | list[str] = False, 

455 keep_no_docstrings: bool = True, 

456) -> list[Object | Alias]: 

457 """Filter a dictionary of objects based on their docstrings. 

458 

459 Parameters: 

460 objects_dictionary: The dictionary of objects. 

461 filters: Filters to apply, based on members' names, or `"public"`. 

462 Each element is a tuple: a pattern, and a boolean indicating whether 

463 to reject the object if the pattern matches. 

464 members_list: An optional, explicit list of members to keep. 

465 When given and empty, return an empty list. 

466 When given and not empty, ignore filters and docstrings presence/absence. 

467 inherited_members: Whether to keep inherited members or exclude them. 

468 keep_no_docstrings: Whether to keep objects with no/empty docstrings (recursive check). 

469 

470 Returns: 

471 A list of objects. 

472 """ 

473 inherited_members_specified = False 

474 if inherited_members is True: 

475 # Include all inherited members. 

476 objects = list(objects_dictionary.values()) 

477 elif inherited_members is False: 

478 # Include no inherited members. 

479 objects = [obj for obj in objects_dictionary.values() if not obj.inherited] 

480 else: 

481 # Include specific inherited members. 

482 inherited_members_specified = True 

483 objects = [ 

484 obj for obj in objects_dictionary.values() if not obj.inherited or obj.name in set(inherited_members) 

485 ] 

486 

487 if members_list is True: 

488 # Return all pre-selected members. 

489 return objects 

490 

491 if members_list is False or members_list == []: 

492 # Return selected inherited members, if any. 

493 return [obj for obj in objects if obj.inherited] 

494 

495 if members_list is not None: 

496 # Return selected members (keeping any pre-selected inherited members). 

497 return [ 

498 obj for obj in objects if obj.name in set(members_list) or (inherited_members_specified and obj.inherited) 

499 ] 

500 

501 # Use filters and docstrings. 

502 if filters == "public": 

503 objects = [obj for obj in objects if obj.is_public] 

504 elif filters: 

505 objects = [ 

506 obj for obj in objects if _keep_object(obj.name, filters) or (inherited_members_specified and obj.inherited) 

507 ] 

508 if not keep_no_docstrings: 

509 objects = [obj for obj in objects if obj.has_docstrings or (inherited_members_specified and obj.inherited)] 

510 

511 # Prevent infinite recursion. 

512 if objects: 

513 objects = list(_remove_cycles(objects)) 

514 

515 return objects 

516 

517 

518@lru_cache(maxsize=1) 

519def _get_formatter() -> Callable[[str, int], str]: 

520 for formatter_function in [ 520 ↛ 527line 520 didn't jump to line 527 because the loop on line 520 didn't complete

521 _get_black_formatter, 

522 _get_ruff_formatter, 

523 ]: 

524 if (formatter := formatter_function()) is not None: 524 ↛ 520line 524 didn't jump to line 520 because the condition on line 524 was always true

525 return formatter 

526 

527 _logger.info("Formatting signatures requires either Black or Ruff to be installed.") 

528 return lambda text, _: text 

529 

530 

531def _get_ruff_formatter() -> Callable[[str, int], str] | None: 

532 try: 

533 from ruff.__main__ import find_ruff_bin # noqa: PLC0415 

534 except ImportError: 

535 return None 

536 

537 try: 

538 ruff_bin = find_ruff_bin() 

539 except FileNotFoundError: 

540 ruff_bin = "ruff" 

541 

542 def formatter(code: str, line_length: int) -> str: 

543 try: 

544 completed_process = subprocess.run( # noqa: S603 

545 [ 

546 ruff_bin, 

547 "format", 

548 "--config", 

549 f"line-length={line_length}", 

550 "--stdin-filename", 

551 "file.py", 

552 "-", 

553 ], 

554 check=True, 

555 capture_output=True, 

556 text=True, 

557 input=code, 

558 ) 

559 except subprocess.CalledProcessError: 

560 return code 

561 else: 

562 return completed_process.stdout 

563 

564 return formatter 

565 

566 

567def _get_black_formatter() -> Callable[[str, int], str] | None: 

568 try: 

569 from black import InvalidInput, Mode, format_str # noqa: PLC0415 

570 except ModuleNotFoundError: 

571 return None 

572 

573 def formatter(code: str, line_length: int) -> str: 

574 mode = Mode(line_length=line_length) 

575 try: 

576 return format_str(code, mode=mode) 

577 except InvalidInput: 

578 return code 

579 

580 return formatter 

581 

582 

583# YORE: Bump 2: Remove line. 

584@pass_environment 

585# YORE: Bump 2: Replace `env: Environment, ` with `` within line. 

586# YORE: Bump 2: Replace `str | ` with `` within line. 

587def do_get_template(env: Environment, obj: str | Object) -> str: 

588 """Get the template name used to render an object. 

589 

590 Parameters: 

591 env: The Jinja environment, passed automatically. 

592 obj: A Griffe object, or a template name. 

593 

594 Returns: 

595 A template name. 

596 """ 

597 name = obj 

598 if isinstance(obj, (Alias, Object)): 

599 extra_data = getattr(obj, "extra", {}).get("mkdocstrings", {}) 

600 if name := extra_data.get("template", ""): 600 ↛ 601line 600 didn't jump to line 601 because the condition on line 600 was never true

601 return name 

602 name = obj.kind.value 

603 # YORE: Bump 2: Replace block with `return f"{name}.html.jinja"`. 

604 try: 

605 template = env.get_template(f"{name}.html") 

606 except TemplateNotFound: 

607 return f"{name}.html.jinja" 

608 our_template = Path(template.filename).is_relative_to(Path(__file__).parent.parent) # type: ignore[arg-type] 

609 if our_template: 609 ↛ 611line 609 didn't jump to line 611 because the condition on line 609 was always true

610 return f"{name}.html.jinja" 

611 _logger.warning( 

612 f"DeprecationWarning: Overriding '{name}.html' is deprecated, override '{name}.html.jinja' instead. ", 

613 once=True, 

614 ) 

615 return f"{name}.html" 

616 

617 

618@pass_context 

619def do_as_attributes_section( 

620 context: Context, # noqa: ARG001 

621 attributes: Sequence[Attribute], 

622 *, 

623 check_public: bool = True, 

624) -> DocstringSectionAttributes: 

625 """Build an attributes section from a list of attributes. 

626 

627 Parameters: 

628 attributes: The attributes to build the section from. 

629 check_public: Whether to check if the attribute is public. 

630 

631 Returns: 

632 An attributes docstring section. 

633 """ 

634 

635 def _parse_docstring_summary(attribute: Attribute) -> str: 

636 if attribute.docstring is None: 

637 return "" 

638 line = attribute.docstring.value.split("\n", 1)[0] 

639 if ":" in line and attribute.docstring.parser_options.get("returns_type_in_property_summary", False): 

640 _, line = line.split(":", 1) 

641 return line 

642 

643 return DocstringSectionAttributes( 

644 [ 

645 DocstringAttribute( 

646 name=attribute.name, 

647 description=_parse_docstring_summary(attribute), 

648 annotation=attribute.annotation, 

649 value=attribute.value, 

650 ) 

651 for attribute in attributes 

652 if not check_public or attribute.is_public 

653 ], 

654 ) 

655 

656 

657@pass_context 

658def do_as_functions_section( 

659 context: Context, 

660 functions: Sequence[Function], 

661 *, 

662 check_public: bool = True, 

663) -> DocstringSectionFunctions: 

664 """Build a functions section from a list of functions. 

665 

666 Parameters: 

667 functions: The functions to build the section from. 

668 check_public: Whether to check if the function is public. 

669 

670 Returns: 

671 A functions docstring section. 

672 """ 

673 keep_init_method = not context.parent["config"].merge_init_into_class 

674 return DocstringSectionFunctions( 

675 [ 

676 DocstringFunction( 

677 name=function.name, 

678 description=function.docstring.value.split("\n", 1)[0] if function.docstring else "", 

679 ) 

680 for function in functions 

681 if (not check_public or function.is_public) and (function.name != "__init__" or keep_init_method) 

682 ], 

683 ) 

684 

685 

686@pass_context 

687def do_as_classes_section( 

688 context: Context, # noqa: ARG001 

689 classes: Sequence[Class], 

690 *, 

691 check_public: bool = True, 

692) -> DocstringSectionClasses: 

693 """Build a classes section from a list of classes. 

694 

695 Parameters: 

696 classes: The classes to build the section from. 

697 check_public: Whether to check if the class is public. 

698 

699 Returns: 

700 A classes docstring section. 

701 """ 

702 return DocstringSectionClasses( 

703 [ 

704 DocstringClass( 

705 name=cls.name, 

706 description=cls.docstring.value.split("\n", 1)[0] if cls.docstring else "", 

707 ) 

708 for cls in classes 

709 if not check_public or cls.is_public 

710 ], 

711 ) 

712 

713 

714@pass_context 

715def do_as_modules_section( 

716 context: Context, # noqa: ARG001 

717 modules: Sequence[Module], 

718 *, 

719 check_public: bool = True, 

720) -> DocstringSectionModules: 

721 """Build a modules section from a list of modules. 

722 

723 Parameters: 

724 modules: The modules to build the section from. 

725 check_public: Whether to check if the module is public. 

726 

727 Returns: 

728 A modules docstring section. 

729 """ 

730 return DocstringSectionModules( 

731 [ 

732 DocstringModule( 

733 name=module.name, 

734 description=module.docstring.value.split("\n", 1)[0] if module.docstring else "", 

735 ) 

736 for module in modules 

737 if not check_public or module.is_public 

738 ], 

739 ) 

740 

741 

742class AutorefsHook(AutorefsHookInterface): 

743 """Autorefs hook. 

744 

745 With this hook, we're able to add context to autorefs (cross-references), 

746 such as originating file path and line number, to improve error reporting. 

747 """ 

748 

749 def __init__(self, current_object: Object | Alias, config: dict[str, Any]) -> None: 

750 """Initialize the hook. 

751 

752 Parameters: 

753 current_object: The object being rendered. 

754 config: The configuration dictionary. 

755 """ 

756 self.current_object = current_object 

757 """The current object being rendered.""" 

758 self.config = config 

759 """The configuration options.""" 

760 

761 def expand_identifier(self, identifier: str) -> str: 

762 """Expand an identifier. 

763 

764 Parameters: 

765 identifier: The identifier to expand. 

766 

767 Returns: 

768 The expanded identifier. 

769 """ 

770 # Handle leading dots in the identifier: 

771 # - `.name` is a reference to the current object's `name` member. 

772 # - `..name` is a reference to the parent object's `name` member. 

773 # - etc. 

774 if self.config.relative_crossrefs and identifier.startswith("."): # type: ignore[attr-defined] 774 ↛ 775line 774 didn't jump to line 775 because the condition on line 774 was never true

775 identifier = identifier[1:] 

776 obj = self.current_object 

777 while identifier and identifier[0] == ".": 

778 identifier = identifier[1:] 

779 obj = obj.parent # type: ignore[assignment] 

780 identifier = f"{obj.path}.{identifier}" if identifier else obj.path 

781 

782 # We resolve the identifier to its full path. 

783 # For this we take out the first name, resolve it, and then append the rest. 

784 if self.config.scoped_crossrefs: # type: ignore[attr-defined] 784 ↛ 785line 784 didn't jump to line 785 because the condition on line 784 was never true

785 if "." in identifier: 

786 identifier, remaining = identifier.split(".", 1) 

787 else: 

788 remaining = "" 

789 with suppress(Exception): 

790 identifier = self.current_object.resolve(identifier) 

791 if remaining: 

792 identifier = f"{identifier}.{remaining}" 

793 

794 return identifier 

795 

796 def get_context(self) -> AutorefsHookInterface.Context: 

797 """Get the context for the current object. 

798 

799 Returns: 

800 The context. 

801 """ 

802 role = { 

803 "attribute": "data" if self.current_object.parent and self.current_object.parent.is_module else "attr", 

804 "class": "class", 

805 "function": "meth" if self.current_object.parent and self.current_object.parent.is_class else "func", 

806 "module": "mod", 

807 }.get(self.current_object.kind.value.lower(), "obj") 

808 origin = self.current_object.path 

809 try: 

810 filepath = self.current_object.docstring.parent.filepath # type: ignore[union-attr] 

811 lineno = self.current_object.docstring.lineno or 0 # type: ignore[union-attr] 

812 except AttributeError: 

813 filepath = self.current_object.filepath 

814 lineno = 0 

815 

816 return AutorefsHookInterface.Context( 

817 domain="py", 

818 role=role, 

819 origin=origin, 

820 filepath=str(filepath), 

821 lineno=lineno, 

822 ) 

823 

824 

825_T = TypeVar("_T") 

826_Tree = dict[_T, "_Tree"] 

827_rtree = lambda: defaultdict(_rtree) # type: ignore[has-type,var-annotated] # noqa: E731 

828 

829Tree = dict[tuple[_T, ...], "Tree"] 

830"""A tree type. Each node holds a tuple of items.""" 

831 

832 

833def _tree(data: Iterable[tuple[_T, ...]]) -> _Tree: 

834 new_tree = _rtree() 

835 for nav in data: 

836 *path, leaf = nav 

837 node = new_tree 

838 for key in path: 

839 node = node[key] 

840 node[leaf] = _rtree() 

841 return new_tree 

842 

843 

844def _compact_tree(tree: _Tree) -> Tree: 

845 new_tree = _rtree() 

846 for key, value in tree.items(): 

847 child = _compact_tree(value) 

848 if len(child) == 1: 

849 child_key, child_value = next(iter(child.items())) 

850 new_key = (key, *child_key) 

851 new_tree[new_key] = child_value 

852 else: 

853 new_tree[(key,)] = child 

854 return new_tree 

855 

856 

857def do_backlink_tree(backlinks: list[Backlink]) -> Tree[BacklinkCrumb]: 

858 """Build a tree of backlinks. 

859 

860 Parameters: 

861 backlinks: The list of backlinks. 

862 

863 Returns: 

864 A tree of backlinks. 

865 """ 

866 return _compact_tree(_tree(backlink.crumbs for backlink in backlinks))