Coverage for src/pytkdocs/loader.py: 91.49%

408 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-09 18:24 +0100

1"""This module is responsible for loading the documentation from Python objects. 

2 

3It uses [`inspect`](https://docs.python.org/3/library/inspect.html) for introspecting objects, 

4iterating over their members, etc. 

5""" 

6 

7import importlib 

8import inspect 

9import pkgutil 

10import re 

11from collections.abc import Mapping, Sequence 

12from contextlib import suppress 

13from functools import cache 

14from itertools import chain 

15from operator import attrgetter 

16from pathlib import Path 

17from typing import Any, Callable, Optional, Union 

18 

19from pytkdocs.objects import Attribute, Class, Function, Method, Module, Object, Source 

20from pytkdocs.parsers.attributes import get_class_attributes, get_instance_attributes, get_module_attributes, merge 

21from pytkdocs.parsers.docstrings import PARSERS 

22from pytkdocs.properties import RE_SPECIAL 

23 

24try: 

25 from functools import cached_property 

26except ImportError: 

27 from cached_property import cached_property # type: ignore[no-redef] 

28 

29 

30class ObjectNode: 

31 """Helper class to represent an object tree. 

32 

33 It's not really a tree but more a backward-linked list: 

34 each node has a reference to its parent, but not to its child (for simplicity purposes and to avoid bugs). 

35 

36 Each node stores an object, its name, and a reference to its parent node. 

37 """ 

38 

39 def __init__(self, obj: Any, name: str, parent: Optional["ObjectNode"] = None) -> None: 

40 """Initialize the object. 

41 

42 Arguments: 

43 obj: A Python object. 

44 name: The object's name. 

45 parent: The object's parent node. 

46 """ 

47 try: # noqa: SIM105 

48 obj = inspect.unwrap(obj) 

49 except Exception: # noqa: S110 , BLE001 (we purposely catch every possible exception) 

50 # inspect.unwrap at some point runs hasattr(obj, "__wrapped__"), 

51 # which triggers the __getattr__ method of the object, which in 

52 # turn can raise various exceptions. Probably not just __getattr__. 

53 # See https://github.com/pawamoy/pytkdocs/issues/45 

54 pass 

55 

56 self.obj: Any = obj 

57 """The actual Python object.""" 

58 

59 self.name: str = name 

60 """The Python object's name.""" 

61 

62 self.parent: Optional[ObjectNode] = parent 

63 """The parent node.""" 

64 

65 @property 

66 def dotted_path(self) -> str: 

67 """Return the Python dotted path to the object. 

68 

69 Returns: 

70 The Python dotted path to the object. 

71 """ 

72 parts = [self.name] 

73 current = self.parent 

74 while current: 

75 parts.append(current.name) 

76 current = current.parent 

77 return ".".join(reversed(parts)) 

78 

79 @property 

80 def file_path(self) -> str: 

81 """Return the object's module file path. 

82 

83 Returns: 

84 The object's module file path. 

85 """ 

86 return inspect.getabsfile(self.root.obj) 

87 

88 @property 

89 def root(self) -> "ObjectNode": 

90 """Return the root of the tree. 

91 

92 Returns: 

93 The root of the tree. 

94 """ 

95 if self.parent is not None: 

96 return self.parent.root 

97 return self 

98 

99 def is_module(self) -> bool: 

100 """Tell if this node's object is a module. 

101 

102 Returns: 

103 The root of the tree. 

104 """ 

105 return inspect.ismodule(self.obj) 

106 

107 def is_class(self) -> bool: 

108 """Tell if this node's object is a class. 

109 

110 Returns: 

111 If this node's object is a class. 

112 """ 

113 return inspect.isclass(self.obj) 

114 

115 def is_function(self) -> bool: 

116 """Tell if this node's object is a function. 

117 

118 Returns: 

119 If this node's object is a function. 

120 """ 

121 return inspect.isfunction(self.obj) 

122 

123 def is_coroutine_function(self) -> bool: 

124 """Tell if this node's object is a coroutine. 

125 

126 Returns: 

127 If this node's object is a coroutine. 

128 """ 

129 return inspect.iscoroutinefunction(self.obj) 

130 

131 def is_property(self) -> bool: 

132 """Tell if this node's object is a property. 

133 

134 Returns: 

135 If this node's object is a property. 

136 """ 

137 return isinstance(self.obj, property) or self.is_cached_property() 

138 

139 def is_cached_property(self) -> bool: 

140 """Tell if this node's object is a cached property. 

141 

142 Returns: 

143 If this node's object is a cached property. 

144 """ 

145 return isinstance(self.obj, cached_property) 

146 

147 def parent_is_class(self) -> bool: 

148 """Tell if the object of this node's parent is a class. 

149 

150 Returns: 

151 If the object of this node's parent is a class. 

152 """ 

153 return bool(self.parent and self.parent.is_class()) 

154 

155 def is_method(self) -> bool: 

156 """Tell if this node's object is a method. 

157 

158 Returns: 

159 If this node's object is a method. 

160 """ 

161 function_type = type(lambda: None) 

162 return self.parent_is_class() and isinstance(self.obj, function_type) 

163 

164 def is_method_descriptor(self) -> bool: 

165 """Tell if this node's object is a method descriptor. 

166 

167 Built-in methods (e.g. those implemented in C/Rust) are often 

168 method descriptors, rather than normal methods. 

169 

170 Returns: 

171 If this node's object is a method descriptor. 

172 """ 

173 return inspect.ismethoddescriptor(self.obj) 

174 

175 def is_staticmethod(self) -> bool: 

176 """Tell if this node's object is a staticmethod. 

177 

178 Returns: 

179 If this node's object is a staticmethod. 

180 """ 

181 if not self.parent: 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true

182 return False 

183 self_from_parent = self.parent.obj.__dict__.get(self.name, None) 

184 return self.parent_is_class() and isinstance(self_from_parent, staticmethod) 

185 

186 def is_classmethod(self) -> bool: 

187 """Tell if this node's object is a classmethod. 

188 

189 Returns: 

190 If this node's object is a classmethod. 

191 """ 

192 if not self.parent: 192 ↛ 193line 192 didn't jump to line 193 because the condition on line 192 was never true

193 return False 

194 self_from_parent = self.parent.obj.__dict__.get(self.name, None) 

195 return self.parent_is_class() and isinstance(self_from_parent, classmethod) 

196 

197 

198# New path syntax: the new path syntax uses a colon to separate the 

199# modules (to import) from the objects (to get with getattr). 

200# It's easier to deal with, and it naturally improves error handling. 

201# At first, we default to the old syntax, then at some point we will 

202# default to the new syntax, and later again we will drop the old syntax. 

203def get_object_tree(path: str, new_path_syntax: bool = False) -> ObjectNode: # noqa: FBT001, FBT002 

204 """Transform a path into an actual Python object. 

205 

206 The path can be arbitrary long. You can pass the path to a package, 

207 a module, a class, a function or a global variable, as deep as you 

208 want, as long as the deepest module is importable through 

209 `importlib.import_module` and each object is obtainable through 

210 the `getattr` method. It is not possible to load local objects. 

211 

212 Args: 

213 path: The dot/colon-separated path of the object. 

214 new_path_syntax: Whether to use the "colon" syntax for the path. 

215 

216 Raises: 

217 ValueError: When the path is not valid (evaluates to `False`). 

218 ImportError: When the object or its parent module could not be imported. 

219 

220 Returns: 

221 The leaf node representing the object and its parents. 

222 """ 

223 if not path: 

224 raise ValueError(f"path must be a valid Python path, not {path}") 

225 

226 objects: list[str] = [] 

227 

228 if ":" in path or new_path_syntax: 

229 try: 

230 module_path, object_path = path.split(":") 

231 except ValueError: # no colon 

232 module_path, objects = path, [] 

233 else: 

234 objects = object_path.split(".") 

235 

236 # let the ImportError bubble up 

237 parent_module = importlib.import_module(module_path) 

238 

239 else: 

240 # We will try to import the longest dotted-path first. 

241 # If it fails, we remove the right-most part and put it in a list of "objects", used later. 

242 # We loop until we find the deepest importable submodule. 

243 obj_parent_modules = path.split(".") 

244 

245 while True: 

246 parent_module_path = ".".join(obj_parent_modules) 

247 try: 

248 parent_module = importlib.import_module(parent_module_path) 

249 except ImportError as error: 

250 if len(obj_parent_modules) == 1: 

251 raise ImportError( 

252 f"Importing '{path}' failed, possible causes are:\n" 

253 f"- an exception happened while importing\n" 

254 f"- an element in the path does not exist", 

255 ) from error 

256 objects.insert(0, obj_parent_modules.pop(-1)) 

257 else: 

258 break 

259 

260 # We now have the module containing the desired object. 

261 # We will build the object tree by iterating over the previously stored objects names 

262 # and trying to get them as attributes. 

263 current_node = ObjectNode(parent_module, parent_module.__name__) 

264 for obj_name in objects: 

265 obj = getattr(current_node.obj, obj_name) 

266 child = ObjectNode(obj, obj_name, parent=current_node) 

267 current_node = child 

268 

269 leaf = current_node 

270 

271 # We now try to get the "real" parent module, not the one the object was imported into. 

272 # This is important if we want to be able to retrieve the docstring of an attribute for example. 

273 # Once we find an object for which we could get the module, we stop trying to get the module. 

274 # Once we reach the node before the root, we apply the module if found, and break. 

275 real_module = None 

276 while current_node.parent is not None: 

277 if real_module is None: 

278 real_module = inspect.getmodule(current_node.obj) 

279 if inspect.ismodule(current_node.parent.obj): 

280 if real_module is not None and real_module is not current_node.parent.obj: 

281 current_node.parent = ObjectNode(real_module, real_module.__name__) 

282 break 

283 current_node = current_node.parent 

284 

285 return leaf 

286 

287 

288class Loader: 

289 """This class contains the object documentation loading mechanisms. 

290 

291 Any error that occurred during collection of the objects and their documentation is stored in the `errors` list. 

292 """ 

293 

294 def __init__( 

295 self, 

296 filters: Optional[list[str]] = None, 

297 docstring_style: str = "google", 

298 docstring_options: Optional[dict] = None, 

299 inherited_members: bool = False, # noqa: FBT001, FBT002 

300 new_path_syntax: bool = False, # noqa: FBT001, FBT002 

301 ) -> None: 

302 """Initialize the object. 

303 

304 Arguments: 

305 filters: A list of regular expressions to fine-grain select members. It is applied recursively. 

306 docstring_style: The style to use when parsing docstrings. 

307 docstring_options: The options to pass to the docstrings parser. 

308 inherited_members: Whether to select inherited members for classes. 

309 new_path_syntax: Whether to use the "colon" syntax for the path. 

310 """ 

311 if not filters: 

312 filters = [] 

313 

314 self.filters = [(filtr, re.compile(filtr.lstrip("!"))) for filtr in filters] 

315 self.docstring_parser = PARSERS[docstring_style](**(docstring_options or {})) 

316 self.errors: list[str] = [] 

317 self.select_inherited_members = inherited_members 

318 self.new_path_syntax = new_path_syntax 

319 

320 def get_object_documentation(self, dotted_path: str, members: Optional[Union[set[str], bool]] = None) -> Object: 

321 """Get the documentation for an object and its children. 

322 

323 Arguments: 

324 dotted_path: The Python dotted path to the desired object. 

325 members: `True` to select members and filter them, `False` to select no members, 

326 or a list of names to explicitly select the members with these names. 

327 It is applied only on the root object. 

328 

329 Returns: 

330 The documented object. 

331 """ 

332 if members is True: 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true

333 members = set() 

334 

335 root_object: Object 

336 leaf = get_object_tree(dotted_path, self.new_path_syntax) 

337 

338 if leaf.is_module(): 

339 root_object = self.get_module_documentation(leaf, members) 

340 elif leaf.is_class(): 

341 root_object = self.get_class_documentation(leaf, members) 

342 elif leaf.is_staticmethod(): 

343 root_object = self.get_staticmethod_documentation(leaf) 

344 elif leaf.is_classmethod(): 

345 root_object = self.get_classmethod_documentation(leaf) 

346 elif leaf.is_method_descriptor() or leaf.is_method(): 

347 root_object = self.get_regular_method_documentation(leaf) 

348 elif leaf.is_function(): 

349 root_object = self.get_function_documentation(leaf) 

350 elif leaf.is_property(): 

351 root_object = self.get_property_documentation(leaf) 

352 else: 

353 root_object = self.get_attribute_documentation(leaf) 

354 

355 root_object.parse_all_docstrings(self.docstring_parser) 

356 

357 return root_object 

358 

359 def get_module_documentation( 

360 self, 

361 node: ObjectNode, 

362 select_members: Optional[Union[set[str], bool]] = None, 

363 ) -> Module: 

364 """Get the documentation for a module and its children. 

365 

366 Arguments: 

367 node: The node representing the module and its parents. 

368 select_members: Explicit members to select. 

369 

370 Returns: 

371 The documented module object. 

372 """ 

373 module = node.obj 

374 path = node.dotted_path 

375 name = path.split(".")[-1] 

376 source: Optional[Source] 

377 

378 try: 

379 source = Source(inspect.getsource(module), 1) 

380 except OSError: 

381 try: 

382 code = Path(node.file_path).read_text() 

383 except (OSError, UnicodeDecodeError): 

384 source = None 

385 else: 

386 source = Source(code, 1) if code else None 

387 

388 root_object = Module( 

389 name=name, 

390 path=path, 

391 file_path=node.file_path, 

392 docstring=inspect.getdoc(module), 

393 source=source, 

394 ) 

395 

396 if select_members is False: 

397 return root_object 

398 

399 select_members = select_members or set() 

400 

401 attributes_data = get_module_attributes(module) 

402 root_object.parse_docstring(self.docstring_parser, attributes=attributes_data) 

403 

404 for member_name, member in inspect.getmembers(module): 

405 if self.select(member_name, select_members): # type: ignore[arg-type] 

406 child_node = ObjectNode(member, member_name, parent=node) 

407 if child_node.is_class() and node.root.obj is inspect.getmodule(child_node.obj): 

408 root_object.add_child(self.get_class_documentation(child_node)) 

409 elif child_node.is_function() and node.root.obj is inspect.getmodule(child_node.obj): 

410 root_object.add_child(self.get_function_documentation(child_node)) 

411 elif member_name in attributes_data: 

412 root_object.add_child(self.get_attribute_documentation(child_node, attributes_data[member_name])) 

413 

414 if hasattr(module, "__path__"): 

415 for _, modname, _ in pkgutil.iter_modules(module.__path__): 

416 if self.select(modname, select_members): # type: ignore[arg-type] 416 ↛ 415line 416 didn't jump to line 415 because the condition on line 416 was always true

417 leaf = get_object_tree(f"{path}.{modname}") 

418 root_object.add_child(self.get_module_documentation(leaf)) 

419 

420 return root_object 

421 

422 @staticmethod 

423 def _class_path(cls: type) -> str: # noqa: PLW0211 

424 mod = cls.__module__ 

425 qname = cls.__qualname__ 

426 if mod == "builtins": 

427 return qname 

428 return f"{mod}.{qname}" 

429 

430 def get_class_documentation( 

431 self, 

432 node: ObjectNode, 

433 select_members: Optional[Union[set[str], bool]] = None, 

434 ) -> Class: 

435 """Get the documentation for a class and its children. 

436 

437 Arguments: 

438 node: The node representing the class and its parents. 

439 select_members: Explicit members to select. 

440 

441 Returns: 

442 The documented class object. 

443 """ 

444 class_ = node.obj 

445 docstring = inspect.cleandoc(class_.__doc__ or "") 

446 bases = [self._class_path(b) for b in class_.__bases__] 

447 

448 source: Optional[Source] 

449 

450 try: 

451 source = Source(*inspect.getsourcelines(node.obj)) 

452 except (OSError, TypeError): 

453 source = None 

454 

455 root_object = Class( 

456 name=node.name, 

457 path=node.dotted_path, 

458 file_path=node.file_path, 

459 docstring=docstring, 

460 bases=bases, 

461 source=source, 

462 ) 

463 

464 # Even if we don't select members, we want to correctly parse the docstring 

465 attributes_data: dict[str, dict[str, Any]] = {} 

466 for parent_class in reversed(class_.__mro__[:-1]): 

467 merge(attributes_data, get_class_attributes(parent_class)) 

468 context: dict[str, Any] = {"attributes": attributes_data} 

469 if "__init__" in class_.__dict__: 

470 try: 

471 attributes_data.update(get_instance_attributes(class_.__init__)) 

472 context["signature"] = inspect.signature(class_.__init__) 

473 except (TypeError, ValueError): 

474 pass 

475 root_object.parse_docstring(self.docstring_parser, **context) 

476 

477 if select_members is False: 477 ↛ 478line 477 didn't jump to line 478 because the condition on line 477 was never true

478 return root_object 

479 

480 select_members = select_members or set() 

481 

482 # Build the list of members 

483 members = {} 

484 inherited = set() 

485 direct_members = class_.__dict__ 

486 all_members = dict(inspect.getmembers(class_)) 

487 for member_name, member in all_members.items(): 

488 if member is class_: 488 ↛ 489line 488 didn't jump to line 489 because the condition on line 488 was never true

489 continue 

490 if not (member is type or member is object) and self.select(member_name, select_members): # type: ignore[arg-type] 

491 if member_name not in direct_members: 

492 if self.select_inherited_members: 

493 members[member_name] = member 

494 inherited.add(member_name) 

495 else: 

496 members[member_name] = member 

497 

498 # Iterate on the selected members 

499 child: Object 

500 for member_name, member in members.items(): 

501 child_node = ObjectNode(member, member_name, parent=node) 

502 if child_node.is_class(): 

503 child = self.get_class_documentation(child_node) 

504 elif child_node.is_classmethod(): 

505 child = self.get_classmethod_documentation(child_node) 

506 elif child_node.is_staticmethod(): 

507 child = self.get_staticmethod_documentation(child_node) 

508 elif child_node.is_method(): 

509 child = self.get_regular_method_documentation(child_node) 

510 elif child_node.is_property(): 

511 child = self.get_property_documentation(child_node) 

512 elif member_name in attributes_data: 

513 child = self.get_attribute_documentation(child_node, attributes_data[member_name]) 

514 else: 

515 continue 

516 if member_name in inherited: 

517 child.properties.append("inherited") 

518 root_object.add_child(child) 

519 

520 for attr_name, properties, add_method in ( 

521 ("__fields__", ["pydantic-model"], self.get_pydantic_field_documentation), 

522 ("_declared_fields", ["marshmallow-model"], self.get_marshmallow_field_documentation), 

523 ("_meta.get_fields", ["django-model"], self.get_django_field_documentation), 

524 ("__dataclass_fields__", ["dataclass"], self.get_annotated_dataclass_field), 

525 ): 

526 if self.detect_field_model(attr_name, direct_members, all_members): 

527 root_object.properties.extend(properties) 

528 self.add_fields( 

529 node, 

530 root_object, 

531 attr_name, 

532 all_members, 

533 select_members, 

534 class_, 

535 add_method, 

536 ) 

537 break 

538 

539 return root_object 

540 

541 def detect_field_model(self, attr_name: str, direct_members: Sequence[str], all_members: dict) -> bool: 

542 """Detect if an attribute is present in members. 

543 

544 Arguments: 

545 attr_name: The name of the attribute to detect, can contain dots. 

546 direct_members: The direct members of the class. 

547 all_members: All members of the class. 

548 

549 Returns: 

550 Whether the attribute is present. 

551 """ 

552 first_order_attr_name, remainder = split_attr_name(attr_name) 

553 if not ( 

554 first_order_attr_name in direct_members 

555 or (self.select_inherited_members and first_order_attr_name in all_members) 

556 ): 

557 return False 

558 

559 if remainder: 

560 with suppress(AttributeError): 

561 return bool(attrgetter(remainder)(all_members[first_order_attr_name])) 

562 return True 

563 

564 def add_fields( 

565 self, 

566 node: ObjectNode, 

567 root_object: Object, 

568 attr_name: str, 

569 members: Mapping[str, Any], 

570 select_members: Optional[Union[set[str], bool]], 

571 base_class: type, 

572 add_method: Callable, 

573 ) -> None: 

574 """Add detected fields to the current object. 

575 

576 Arguments: 

577 node: The current object node. 

578 root_object: The current object. 

579 attr_name: The fields attribute name. 

580 members: The members to pick the fields attribute in. 

581 select_members: The members to select. 

582 base_class: The class declaring the fields. 

583 add_method: The method to add the children object. 

584 """ 

585 fields = get_fields(attr_name, members=members) 

586 

587 for field_name, field in fields.items(): 

588 select_field = self.select(field_name, select_members) # type: ignore[arg-type] 

589 is_inherited = field_is_inherited(field_name, attr_name, base_class) 

590 

591 if select_field and (self.select_inherited_members or not is_inherited): 591 ↛ 587line 591 didn't jump to line 587 because the condition on line 591 was always true

592 child_node = ObjectNode(obj=field, name=field_name, parent=node) 

593 root_object.add_child(add_method(child_node)) 

594 

595 def get_function_documentation(self, node: ObjectNode) -> Function: 

596 """Get the documentation for a function. 

597 

598 Arguments: 

599 node: The node representing the function and its parents. 

600 

601 Returns: 

602 The documented function object. 

603 """ 

604 function = node.obj 

605 source: Optional[Source] 

606 signature: Optional[inspect.Signature] 

607 

608 try: 

609 signature = inspect.signature(function) 

610 except TypeError: 

611 signature = None 

612 

613 try: 

614 source = Source(*inspect.getsourcelines(function)) 

615 except OSError: 

616 source = None 

617 

618 properties: list[str] = [] 

619 if node.is_coroutine_function(): 

620 properties.append("async") 

621 

622 return Function( 

623 name=node.name, 

624 path=node.dotted_path, 

625 file_path=node.file_path, 

626 docstring=inspect.getdoc(function), 

627 signature=signature, 

628 source=source, 

629 properties=properties, 

630 ) 

631 

632 def get_property_documentation(self, node: ObjectNode) -> Attribute: 

633 """Get the documentation for a property. 

634 

635 Arguments: 

636 node: The node representing the property and its parents. 

637 

638 Returns: 

639 The documented attribute object (properties are considered attributes for now). 

640 """ 

641 prop = node.obj 

642 path = node.dotted_path 

643 properties = ["property"] 

644 if node.is_cached_property(): 

645 # cached_property is always writable, see the docs 

646 properties.extend(["writable", "cached"]) 

647 sig_source_func = prop.func 

648 else: 

649 properties.append("readonly" if prop.fset is None else "writable") 

650 sig_source_func = prop.fget 

651 

652 source: Optional[Source] 

653 

654 try: 

655 signature = inspect.signature(sig_source_func) 

656 except (TypeError, ValueError): 

657 attr_type = None 

658 else: 

659 attr_type = signature.return_annotation 

660 

661 try: 

662 source = Source(*inspect.getsourcelines(sig_source_func)) 

663 except (OSError, TypeError): 

664 source = None 

665 

666 return Attribute( 

667 name=node.name, 

668 path=path, 

669 file_path=node.file_path, 

670 docstring=inspect.getdoc(prop), 

671 attr_type=attr_type, 

672 properties=properties, 

673 source=source, 

674 ) 

675 

676 @staticmethod 

677 def get_pydantic_field_documentation(node: ObjectNode) -> Attribute: 

678 """Get the documentation for a Pydantic Field. 

679 

680 Arguments: 

681 node: The node representing the Field and its parents. 

682 

683 Returns: 

684 The documented attribute object. 

685 """ 

686 prop = node.obj 

687 path = node.dotted_path 

688 properties = ["pydantic-field"] 

689 if prop.required: 

690 properties.append("required") 

691 

692 return Attribute( 

693 name=node.name, 

694 path=path, 

695 file_path=node.file_path, 

696 docstring=prop.field_info.description, 

697 attr_type=prop.outer_type_, 

698 properties=properties, 

699 ) 

700 

701 @staticmethod 

702 def get_django_field_documentation(node: ObjectNode) -> Attribute: 

703 """Get the documentation for a Django Field. 

704 

705 Arguments: 

706 node: The node representing the Field and its parents. 

707 

708 Returns: 

709 The documented attribute object. 

710 """ 

711 prop = node.obj 

712 path = node.dotted_path 

713 properties = ["django-field"] 

714 

715 if prop.null: 715 ↛ 716line 715 didn't jump to line 716 because the condition on line 715 was never true

716 properties.append("nullable") 

717 if prop.blank: 717 ↛ 718line 717 didn't jump to line 718 because the condition on line 717 was never true

718 properties.append("blank") 

719 

720 # set correct docstring based on verbose_name and help_text 

721 # both should be converted to str type in case lazy translation 

722 # is being used, which is common scenario in django 

723 docstring = f"{prop.verbose_name}: {prop.help_text}" if prop.help_text else str(prop.verbose_name) 

724 

725 return Attribute( 

726 name=node.name, 

727 path=path, 

728 file_path=node.file_path, 

729 docstring=docstring, 

730 attr_type=prop.__class__, 

731 properties=properties, 

732 ) 

733 

734 @staticmethod 

735 def get_marshmallow_field_documentation(node: ObjectNode) -> Attribute: 

736 """Get the documentation for a Marshmallow Field. 

737 

738 Arguments: 

739 node: The node representing the Field and its parents. 

740 

741 Returns: 

742 The documented attribute object. 

743 """ 

744 prop = node.obj 

745 path = node.dotted_path 

746 properties = ["marshmallow-field"] 

747 if prop.required: 

748 properties.append("required") 

749 

750 return Attribute( 

751 name=node.name, 

752 path=path, 

753 file_path=node.file_path, 

754 docstring=prop.metadata.get("description"), 

755 attr_type=type(prop), 

756 properties=properties, 

757 ) 

758 

759 @staticmethod 

760 def get_annotated_dataclass_field(node: ObjectNode, attribute_data: Optional[dict] = None) -> Attribute: 

761 """Get the documentation for a dataclass field. 

762 

763 Arguments: 

764 node: The node representing the annotation and its parents. 

765 attribute_data: Docstring and annotation for this attribute. 

766 

767 Returns: 

768 The documented attribute object. 

769 """ 

770 if attribute_data is None: 770 ↛ 776line 770 didn't jump to line 776 because the condition on line 770 was always true

771 if node.parent_is_class(): 771 ↛ 774line 771 didn't jump to line 774 because the condition on line 771 was always true

772 attribute_data = get_class_attributes(node.parent.obj).get(node.name, {}) # type: ignore[union-attr] 

773 else: 

774 attribute_data = get_module_attributes(node.root.obj).get(node.name, {}) 

775 

776 return Attribute( 

777 name=node.name, 

778 path=node.dotted_path, 

779 file_path=node.file_path, 

780 docstring=attribute_data["docstring"], 

781 attr_type=attribute_data["annotation"], 

782 properties=["dataclass-field"], 

783 ) 

784 

785 def get_classmethod_documentation(self, node: ObjectNode) -> Method: 

786 """Get the documentation for a class-method. 

787 

788 Arguments: 

789 node: The node representing the class-method and its parents. 

790 

791 Returns: 

792 The documented method object. 

793 """ 

794 return self.get_method_documentation(node, ["classmethod"]) 

795 

796 def get_staticmethod_documentation(self, node: ObjectNode) -> Method: 

797 """Get the documentation for a static-method. 

798 

799 Arguments: 

800 node: The node representing the static-method and its parents. 

801 

802 Returns: 

803 The documented method object. 

804 """ 

805 return self.get_method_documentation(node, ["staticmethod"]) 

806 

807 def get_regular_method_documentation(self, node: ObjectNode) -> Method: 

808 """Get the documentation for a regular method (not class- nor static-method). 

809 

810 We do extra processing in this method to discard docstrings of `__init__` methods 

811 that were inherited from parent classes. 

812 

813 Arguments: 

814 node: The node representing the method and its parents. 

815 

816 Returns: 

817 The documented method object. 

818 """ 

819 method = self.get_method_documentation(node) 

820 if node.parent: 820 ↛ 834line 820 didn't jump to line 834 because the condition on line 820 was always true

821 class_ = node.parent.obj 

822 if RE_SPECIAL.match(node.name): 

823 docstring = method.docstring 

824 parent_classes = class_.__mro__[1:] 

825 for parent_class in parent_classes: 

826 try: 

827 parent_method = getattr(parent_class, node.name) 

828 except AttributeError: 

829 continue 

830 else: 

831 if docstring == inspect.getdoc(parent_method): 

832 method.docstring = "" 

833 break 

834 return method 

835 

836 def get_method_documentation(self, node: ObjectNode, properties: Optional[list[str]] = None) -> Method: 

837 """Get the documentation for a method or method descriptor. 

838 

839 Arguments: 

840 node: The node representing the method and its parents. 

841 properties: A list of properties to apply to the method. 

842 

843 Returns: 

844 The documented method object. 

845 """ 

846 method = node.obj 

847 path = node.dotted_path 

848 signature: Optional[inspect.Signature] 

849 source: Optional[Source] 

850 

851 try: 

852 source = Source(*inspect.getsourcelines(method)) 

853 except OSError: 

854 source = None 

855 except TypeError: 

856 source = None 

857 

858 if node.is_coroutine_function(): 

859 if properties is None: 859 ↛ 862line 859 didn't jump to line 862 because the condition on line 859 was always true

860 properties = ["async"] 

861 else: 

862 properties.append("async") 

863 

864 try: 

865 # for "built-in" functions, e.g. those implemented in C, 

866 # inspect.signature() uses the __text_signature__ attribute, which 

867 # provides a limited but still useful amount of signature information. 

868 # "built-in" functions with no __text_signature__ will 

869 # raise a ValueError(). 

870 signature = inspect.signature(method) 

871 except ValueError: 

872 signature = None 

873 

874 return Method( 

875 name=node.name, 

876 path=path, 

877 file_path=node.file_path, 

878 docstring=inspect.getdoc(method), 

879 signature=signature, 

880 properties=properties or [], 

881 source=source, 

882 ) 

883 

884 @staticmethod 

885 def get_attribute_documentation(node: ObjectNode, attribute_data: Optional[dict] = None) -> Attribute: 

886 """Get the documentation for an attribute. 

887 

888 Arguments: 

889 node: The node representing the method and its parents. 

890 attribute_data: Docstring and annotation for this attribute. 

891 

892 Returns: 

893 The documented attribute object. 

894 """ 

895 if attribute_data is None: 

896 if node.parent_is_class(): 

897 attribute_data = get_class_attributes(node.parent.obj).get(node.name, {}) # type: ignore[union-attr] 

898 else: 

899 attribute_data = get_module_attributes(node.root.obj).get(node.name, {}) # type: ignore[union-attr] 

900 return Attribute( 

901 name=node.name, 

902 path=node.dotted_path, 

903 file_path=node.file_path, 

904 docstring=attribute_data.get("docstring", ""), 

905 attr_type=attribute_data.get("annotation", None), 

906 ) 

907 

908 def select(self, name: str, names: set[str]) -> bool: 

909 """Tells whether we should select an object or not, given its name. 

910 

911 If the set of names is not empty, we check against it, otherwise we check against filters. 

912 

913 Arguments: 

914 name: The name of the object to select or not. 

915 names: An explicit list of names to select. 

916 

917 Returns: 

918 Yes or no. 

919 """ 

920 if names: 

921 return name in names 

922 return not self.filter_name_out(name) 

923 

924 @cache # noqa: B019 

925 def filter_name_out(self, name: str) -> bool: 

926 """Filter a name based on the loader's filters. 

927 

928 Arguments: 

929 name: The name to filter. 

930 

931 Returns: 

932 True if the name was filtered out, False otherwise. 

933 """ 

934 if not self.filters: 

935 return False 

936 keep = True 

937 for fltr, regex in self.filters: 

938 is_matching = bool(regex.search(name)) 

939 if is_matching: 

940 if str(fltr).startswith("!"): 

941 is_matching = not is_matching 

942 keep = is_matching 

943 return not keep 

944 

945 

946def field_is_inherited(field_name: str, fields_name: str, base_class: type) -> bool: 

947 """Check if a field with a certain name was inherited from parent classes. 

948 

949 Arguments: 

950 field_name: The name of the field to check. 

951 fields_name: The name of the attribute in which the fields are stored. 

952 base_class: The base class in which the field appears. 

953 

954 Returns: 

955 Whether the field was inherited. 

956 """ 

957 # To tell if a field was inherited, we check if it exists in parent classes __fields__ attributes. 

958 # We don't check the current class, nor the top one (object), hence __mro__[1:-1] 

959 return field_name in set( 

960 chain( 

961 *(getattr(parent_class, fields_name, {}).keys() for parent_class in base_class.__mro__[1:-1]), 

962 ), 

963 ) 

964 

965 

966def split_attr_name(attr_name: str) -> tuple[str, Optional[str]]: 

967 """Split an attribute name into a first-order attribute name and remainder. 

968 

969 Args: 

970 attr_name: Attribute name (a) 

971 

972 Returns: 

973 Tuple containing: 

974 first_order_attr_name: Name of the first order attribute (a) 

975 remainder: The remainder (b.c) 

976 

977 """ 

978 first_order_attr_name, *remaining = attr_name.split(".", maxsplit=1) 

979 remainder = remaining[0] if remaining else None 

980 return first_order_attr_name, remainder 

981 

982 

983def get_fields( # noqa: D103 

984 attr_name: str, 

985 *, 

986 members: Optional[Mapping[str, Any]] = None, 

987 class_obj: Optional[type] = None, 

988) -> dict[str, Any]: 

989 if not (bool(members) ^ bool(class_obj)): 989 ↛ 990line 989 didn't jump to line 990 because the condition on line 989 was never true

990 raise ValueError("Either members or class_obj is required.") 

991 first_order_attr_name, remainder = split_attr_name(attr_name) 

992 fields = members[first_order_attr_name] if members else dict(vars(class_obj)).get(first_order_attr_name, {}) 

993 if remainder: 

994 fields = attrgetter(remainder)(fields) 

995 

996 if callable(fields): 

997 fields = fields() 

998 

999 if not isinstance(fields, dict): 

1000 # Support Django models 

1001 try: 

1002 fields = {getattr(f, "name", str(f)): f for f in fields if not getattr(f, "auto_created", False)} 

1003 except TypeError: 

1004 fields = {} 

1005 

1006 return fields