Coverage for src/griffe2md/rendering.py: 52.53%

212 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-03-20 11:44 +0100

1"""This module implements rendering utilities.""" 

2 

3from __future__ import annotations 

4 

5import enum 

6import logging 

7import random 

8import re 

9import string 

10import sys 

11import warnings 

12from functools import lru_cache, partial 

13from typing import TYPE_CHECKING, Any, Callable, Match, Pattern, Sequence 

14 

15from griffe.docstrings.dataclasses import ( 

16 DocstringAttribute, 

17 DocstringClass, 

18 DocstringFunction, 

19 DocstringModule, 

20 DocstringSectionAttributes, 

21 DocstringSectionClasses, 

22 DocstringSectionFunctions, 

23 DocstringSectionModules, 

24) 

25from griffe.exceptions import AliasResolutionError, CyclicAliasError 

26from jinja2 import pass_context 

27from markupsafe import Markup 

28 

29if TYPE_CHECKING: 

30 from griffe.dataclasses import Alias, Attribute, Class, Function, Module, Object 

31 from jinja2.runtime import Context 

32 

33 

34logger = logging.getLogger(__name__) 

35 

36 

37class Order(enum.Enum): 

38 """Enumeration for the possible members ordering.""" 

39 

40 alphabetical = "alphabetical" 

41 """Alphabetical order.""" 

42 source = "source" 

43 """Source code order.""" 

44 

45 

46default_config: dict = { 

47 "docstring_style": "google", 

48 "docstring_options": {"ignore_init_summary": True}, 

49 "show_root_heading": True, 

50 "show_root_full_path": True, 

51 "show_root_members_full_path": True, 

52 "show_object_full_path": True, 

53 "show_category_heading": False, 

54 "show_if_no_docstring": True, 

55 "show_signature": True, 

56 "show_signature_annotations": False, 

57 "signature_crossrefs": False, 

58 "separate_signature": True, 

59 "line_length": 80, 

60 "merge_init_into_class": True, 

61 "show_docstring_attributes": True, 

62 "show_docstring_description": True, 

63 "show_docstring_examples": True, 

64 "show_docstring_other_parameters": True, 

65 "show_docstring_parameters": True, 

66 "show_docstring_raises": True, 

67 "show_docstring_receives": True, 

68 "show_docstring_returns": True, 

69 "show_docstring_warns": True, 

70 "show_docstring_yields": True, 

71 "show_bases": True, 

72 "show_submodules": True, 

73 "group_by_category": False, 

74 "heading_level": 2, 

75 "members_order": Order.alphabetical.value, 

76 "docstring_section_style": "list", 

77 "members": None, 

78 "inherited_members": True, 

79 "filters": ["!^_"], 

80 "annotations_path": "brief", 

81 "preload_modules": None, 

82 "load_external_modules": False, 

83 "allow_inspection": True, 

84 "summary": True, 

85} 

86 

87 

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

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

90 

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

92 

93 Arguments: 

94 seq: An iterable object. 

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

96 

97 Returns: 

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

99 """ 

100 if attribute is None: 

101 return any(seq) 

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

103 

104 

105def _sort_key_alphabetical(item: Object | Alias) -> Any: 

106 # chr(sys.maxunicode) is a string that contains the final unicode 

107 # character, so if 'name' isn't found on the object, the item will go to 

108 # the end of the list. 

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

110 

111 

112def _sort_key_source(item: Object | Alias) -> Any: 

113 # if 'lineno' is none, the item will go to the start of the list. 

114 return item.lineno if item.lineno is not None else -1 

115 

116 

117order_map = { 

118 Order.alphabetical: _sort_key_alphabetical, 

119 Order.source: _sort_key_source, 

120} 

121 

122 

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

124 """Format code using Black. 

125 

126 Parameters: 

127 code: The code to format. 

128 line_length: The line length to give to Black. 

129 

130 Returns: 

131 The same code, formatted. 

132 """ 

133 code = code.strip() 

134 if len(code) < line_length: 

135 return code 

136 formatter = _get_black_formatter() 

137 return formatter(code, line_length) 

138 

139 

140_stash_key_alphabet = string.ascii_letters + string.digits 

141 

142 

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

144 return "_" + "".join(random.choice(_stash_key_alphabet) for _ in range(max(1, length - 1))) # noqa: S311 

145 

146 

147def _gen_stash_key(stash: dict[str, str], length: int) -> str: 

148 key = _gen_key(length) 

149 while key in stash: 

150 key = _gen_key(length) 

151 return key 

152 

153 

154def _stash_crossref(stash: dict[str, str], crossref: str, *, length: int) -> str: 

155 key = _gen_stash_key(stash, length) 

156 stash[key] = crossref 

157 return key 

158 

159 

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

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

162 signature = signature.strip() 

163 if len(name + signature) < line_length: 

164 return name + signature 

165 

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

167 # the whole name with a string of equal length 

168 name_length = len(name) 

169 formatter = _get_black_formatter() 

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

171 formatted = formatter(formatable, line_length) 

172 

173 # We put back the original name 

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

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

176 

177 

178@pass_context 

179def do_format_signature( 

180 context: Context, 

181 callable_path: Markup, 

182 function: Function, 

183 line_length: int, 

184 *, 

185 annotations: bool | None = None, 

186 crossrefs: bool = False, 

187) -> str: 

188 """Format a signature using Black. 

189 

190 Parameters: 

191 context: Jinja context, passed automatically. 

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

193 function: The function we render the signature of. 

194 line_length: The line length to give to Black. 

195 annotations: Whether to show type annotations. 

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

197 

198 Returns: 

199 The same code, formatted. 

200 """ 

201 env = context.environment 

202 template = env.get_template("signature.md.jinja") 

203 config_annotations = context.parent["config"]["show_signature_annotations"] 

204 old_stash_ref_filter = env.filters["stash_crossref"] 

205 

206 stash: dict[str, str] = {} 

207 if (annotations or config_annotations) and crossrefs: 207 ↛ 208line 207 didn't jump to line 208, because the condition on line 207 was never true

208 env.filters["stash_crossref"] = partial(_stash_crossref, stash) 

209 

210 if annotations is None: 210 ↛ 213line 210 didn't jump to line 213, because the condition on line 210 was never false

211 new_context = context.parent 

212 else: 

213 new_context = dict(context.parent) 

214 new_context["config"] = dict(new_context["config"]) 

215 new_context["config"]["show_signature_annotations"] = annotations 

216 try: 

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

218 finally: 

219 env.filters["stash_crossref"] = old_stash_ref_filter 

220 

221 signature = _format_signature(callable_path, signature, line_length) 

222 

223 if stash: 223 ↛ 224line 223 didn't jump to line 224, because the condition on line 223 was never true

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

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

226 

227 return signature 

228 

229 

230@pass_context 

231def do_format_attribute( 

232 context: Context, 

233 attribute_path: Markup, 

234 attribute: Attribute, 

235 line_length: int, 

236 *, 

237 crossrefs: bool = False, 

238) -> str: 

239 """Format an attribute using Black. 

240 

241 Parameters: 

242 context: Jinja context, passed automatically. 

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

244 attribute: The attribute we render the signature of. 

245 line_length: The line length to give to Black. 

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

247 

248 Returns: 

249 The same code, formatted. 

250 """ 

251 env = context.environment 

252 template = env.get_template("expression.md.jinja") 

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

254 separate_signature = context.parent["config"]["separate_signature"] 

255 old_stash_ref_filter = env.filters["stash_crossref"] 

256 

257 stash: dict[str, str] = {} 

258 if separate_signature and crossrefs: 

259 env.filters["stash_crossref"] = partial(_stash_crossref, stash) 

260 

261 try: 

262 signature = str(attribute_path).strip() 

263 if annotations and attribute.annotation: 

264 annotation = template.render(context.parent, expression=attribute.annotation, signature=True) 

265 signature += f": {annotation}" 

266 if attribute.value: 

267 value = template.render(context.parent, expression=attribute.value, signature=True) 

268 signature += f" = {value}" 

269 finally: 

270 env.filters["stash_crossref"] = old_stash_ref_filter 

271 

272 signature = do_format_code(signature, line_length) 

273 

274 if stash: 

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

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

277 

278 return signature 

279 

280 

281def do_order_members( 

282 members: Sequence[Object | Alias], 

283 order: Order, 

284 members_list: bool | list[str] | None, 

285) -> Sequence[Object | Alias]: 

286 """Order members given an ordering method. 

287 

288 Parameters: 

289 members: The members to order. 

290 order: The ordering method. 

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

292 

293 Returns: 

294 The same members, ordered. 

295 """ 

296 if isinstance(members_list, list) and members_list: 296 ↛ 297line 296 didn't jump to line 297, because the condition on line 296 was never true

297 sorted_members = [] 

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

299 for name in members_list: 

300 if name in members_dict: 

301 sorted_members.append(members_dict[name]) 

302 return sorted_members 

303 return sorted(members, key=order_map[order]) 

304 

305 

306@lru_cache 

307def _warn_crossref() -> None: 

308 warnings.warn( 

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

310 DeprecationWarning, 

311 stacklevel=1, 

312 ) 

313 

314 

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

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

317 

318 Parameters: 

319 path: The path to link to. 

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

321 

322 Returns: 

323 Markup text. 

324 """ 

325 _warn_crossref() 

326 full_path = path 

327 if brief: 

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

329 return Markup("<span data-autorefs-optional-hover={full_path}>{path}</span>").format(full_path=full_path, path=path) 

330 

331 

332@lru_cache 

333def _warn_multi_crossref() -> None: 

334 warnings.warn( 

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

336 DeprecationWarning, 

337 stacklevel=1, 

338 ) 

339 

340 

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"<span data-autorefs-optional-hover={{{path_var}}}>{{{path_var}}}</span>" 

362 

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

364 if code: 

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

366 return Markup(text).format(**variables) 

367 

368 

369def do_heading(content: str, heading_level: int) -> str: 

370 """Render a Markdown heading.""" 

371 return f"\n{'#' * heading_level} {content}\n\n" 

372 

373 

374def do_split_path(path: str, full_path: str) -> list[tuple[str, str]]: 

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

376 

377 Parameters: 

378 path: The path to split. 

379 

380 Returns: 

381 A list of pairs (title, full path). 

382 """ 

383 if "." not in path: 383 ↛ 385line 383 didn't jump to line 385, because the condition on line 383 was never false

384 return [(path, full_path)] 

385 pairs = [] 

386 full_path = "" 

387 for part in path.split("."): 

388 if full_path: 

389 full_path += f".{part}" 

390 else: 

391 full_path = part 

392 pairs.append((part, full_path)) 

393 return pairs 

394 

395 

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

397 keep = None 

398 rules = set() 

399 for regex, exclude in filters: 

400 rules.add(exclude) 

401 if regex.search(name): 

402 keep = not exclude 

403 if keep is None: 

404 if rules == {False}: 404 ↛ 406line 404 didn't jump to line 406, because the condition on line 404 was never true

405 # only included stuff, no match = reject 

406 return False 

407 # only excluded stuff, or included and excluded stuff, no match = keep 

408 return True 

409 return keep 

410 

411 

412def do_filter_objects( 

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

414 *, 

415 filters: Sequence[tuple[Pattern, bool]] | None = None, 

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

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

418 keep_no_docstrings: bool = True, 

419) -> list[Object | Alias]: 

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

421 

422 Parameters: 

423 objects_dictionary: The dictionary of objects. 

424 filters: Filters to apply, based on members' names. 

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

426 to reject the object if the pattern matches. 

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

428 When given and empty, return an empty list. 

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

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

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

432 

433 Returns: 

434 A list of objects. 

435 """ 

436 inherited_members_specified = False 

437 if inherited_members is True: 437 ↛ 440line 437 didn't jump to line 440, because the condition on line 437 was never false

438 # Include all inherited members. 

439 objects = list(objects_dictionary.values()) 

440 elif inherited_members is False: 

441 # Include no inherited members. 

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

443 else: 

444 # Include specific inherited members. 

445 inherited_members_specified = True 

446 objects = [ 

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

448 ] 

449 

450 if members_list is True: 450 ↛ 452line 450 didn't jump to line 452, because the condition on line 450 was never true

451 # Return all pre-selected members. 

452 return objects 

453 

454 if members_list is False or members_list == []: 454 ↛ 456line 454 didn't jump to line 456, because the condition on line 454 was never true

455 # Return selected inherited members, if any. 

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

457 

458 if members_list is not None: 458 ↛ 460line 458 didn't jump to line 460, because the condition on line 458 was never true

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

460 return [ 

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

462 ] 

463 

464 # Use filters and docstrings. 

465 if filters: 465 ↛ 469line 465 didn't jump to line 469, because the condition on line 465 was never false

466 objects = [ 

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

468 ] 

469 if keep_no_docstrings: 469 ↛ 472line 469 didn't jump to line 472, because the condition on line 469 was never false

470 return objects 

471 

472 return [obj for obj in objects if obj.has_docstrings or (inherited_members_specified and obj.inherited)] 

473 

474 

475@lru_cache(maxsize=1) 

476def _get_black_formatter() -> Callable[[str, int], str]: 

477 try: 

478 from black import InvalidInput, Mode, format_str 

479 except ModuleNotFoundError: 

480 logger.info("Formatting signatures requires Black to be installed.") 

481 return lambda text, _: text 

482 

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

484 mode = Mode(line_length=line_length) 

485 try: 

486 return format_str(code, mode=mode) 

487 except InvalidInput: 

488 return code 

489 

490 return formatter 

491 

492 

493def from_private_package(obj: Object | Alias) -> bool: 

494 """Tell if an alias points to an object coming from a corresponding private package. 

495 

496 For example, return true for an alias in package `ast` pointing at an object in package `_ast`. 

497 

498 Parameters: 

499 obj: The object (alias) to check. 

500 

501 Returns: 

502 True or false. 

503 """ 

504 if not obj.is_alias: 504 ↛ 505line 504 didn't jump to line 505, because the condition on line 504 was never true

505 return False 

506 try: 

507 return obj.target.package.name == f"_{obj.parent.package.name}" # type: ignore[union-attr] 

508 except (AliasResolutionError, CyclicAliasError): 

509 return False 

510 

511 

512def do_as_attributes_section( 

513 attributes: Sequence[Attribute], 

514 *, 

515 check_public: bool = True, 

516) -> DocstringSectionAttributes: 

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

518 

519 Parameters: 

520 attributes: The attributes to build the section from. 

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

522 

523 Returns: 

524 An attributes docstring section. 

525 """ 

526 return DocstringSectionAttributes( 

527 [ 

528 DocstringAttribute( 

529 name=attribute.name, 

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

531 annotation=attribute.annotation, 

532 value=attribute.value, # type: ignore[arg-type] 

533 ) 

534 for attribute in attributes 

535 if not check_public or attribute.is_public(check_name=False) or from_private_package(attribute) 

536 ], 

537 ) 

538 

539 

540def do_as_functions_section(functions: Sequence[Function], *, check_public: bool = True) -> DocstringSectionFunctions: 

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

542 

543 Parameters: 

544 functions: The functions to build the section from. 

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

546 

547 Returns: 

548 A functions docstring section. 

549 """ 

550 return DocstringSectionFunctions( 

551 [ 

552 DocstringFunction( 

553 name=function.name, 

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

555 ) 

556 for function in functions 

557 if not check_public or function.is_public(check_name=False) or from_private_package(function) 

558 ], 

559 ) 

560 

561 

562def do_as_classes_section(classes: Sequence[Class], *, check_public: bool = True) -> DocstringSectionClasses: 

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

564 

565 Parameters: 

566 classes: The classes to build the section from. 

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

568 

569 Returns: 

570 A classes docstring section. 

571 """ 

572 return DocstringSectionClasses( 

573 [ 

574 DocstringClass( 

575 name=cls.name, 

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

577 ) 

578 for cls in classes 

579 if not check_public or cls.is_public(check_name=False) or from_private_package(cls) 

580 ], 

581 ) 

582 

583 

584def do_as_modules_section(modules: Sequence[Module], *, check_public: bool = True) -> DocstringSectionModules: 

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

586 

587 Parameters: 

588 modules: The modules to build the section from. 

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

590 

591 Returns: 

592 A modules docstring section. 

593 """ 

594 return DocstringSectionModules( 

595 [ 

596 DocstringModule( 

597 name=module.name, 

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

599 ) 

600 for module in modules 

601 if not check_public or module.is_public(check_name=False) or from_private_package(module) 

602 ], 

603 )