Coverage for src/pytkdocs/parsers/attributes.py: 95.78%
112 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-09 18:24 +0100
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-09 18:24 +0100
1"""Module containing functions to parse attributes in the source code."""
3import ast
4import inspect
5from collections.abc import Iterator
6from functools import lru_cache
7from textwrap import dedent
8from types import ModuleType
9from typing import Any, Callable, get_type_hints
11try:
12 from ast import unparse # type: ignore[attr-defined]
13except ImportError:
14 from astunparse import unparse # type: ignore[no-redef]
16RECURSIVE_NODES = (ast.If, ast.IfExp, ast.Try, ast.With)
19def get_nodes(obj: Any) -> list[ast.stmt]: # noqa: D103
20 try:
21 source = inspect.getsource(obj)
22 except (OSError, TypeError):
23 source = ""
24 return ast.parse(dedent(source)).body
27def recurse_on_node(node: ast.AST) -> Iterator[tuple]: # noqa: D103
28 if isinstance(node, ast.Try):
29 yield from get_pairs(node.body)
30 for handler in node.handlers:
31 yield from get_pairs(handler.body)
32 yield from get_pairs(node.orelse)
33 yield from get_pairs(node.finalbody)
34 elif isinstance(node, ast.If): 34 ↛ 38line 34 didn't jump to line 38 because the condition on line 34 was always true
35 yield from get_pairs(node.body)
36 yield from get_pairs(node.orelse)
37 else:
38 yield from get_pairs(node.body) # type: ignore[attr-defined]
41def get_pairs(nodes: list) -> Iterator[tuple]: # noqa: D103
42 if len(nodes) < 2: # noqa: PLR2004
43 return
45 index = 0
46 while index < len(nodes):
47 node1 = nodes[index]
48 node2 = nodes[index + 1] if index < len(nodes) - 1 else None
49 if isinstance(node1, (ast.Assign, ast.AnnAssign)):
50 if isinstance(node2, ast.Expr) and isinstance(node2.value, ast.Constant):
51 yield node1, node2.value
52 index += 2
53 else:
54 yield node1, None
55 index += 1
56 else:
57 index += 1
58 if isinstance(node1, RECURSIVE_NODES):
59 yield from recurse_on_node(node1)
60 if isinstance(node2, RECURSIVE_NODES):
61 yield from recurse_on_node(node2)
62 index += 1
63 elif not isinstance(node2, (ast.Assign, ast.AnnAssign)):
64 index += 1
67def get_module_or_class_attributes(nodes: list) -> dict: # noqa: D103
68 result = {}
69 for assignment, string_node in get_pairs(nodes):
70 string = inspect.cleandoc(string_node.s) if string_node else None
71 if isinstance(assignment, ast.Assign):
72 names = []
73 for target in assignment.targets:
74 if isinstance(target, ast.Name):
75 names.append(target.id)
76 elif isinstance(target, ast.Tuple):
77 names.extend([name.id for name in target.elts]) # type: ignore[attr-defined]
78 else:
79 names = [assignment.target.id]
80 for name in names:
81 result[name] = string
82 return result
85def combine(docstrings: dict, type_hints: dict) -> dict: # noqa: D103
86 return {
87 name: {"annotation": type_hints.get(name, inspect.Signature.empty), "docstring": docstrings.get(name)}
88 for name in set(docstrings.keys()) | set(type_hints.keys())
89 }
92def merge(base: dict, extra: dict) -> None: # noqa: D103
93 for attr_name, data in extra.items():
94 if attr_name in base:
95 if data["annotation"] is not inspect.Signature.empty: 95 ↛ 97line 95 didn't jump to line 97 because the condition on line 95 was always true
96 base[attr_name]["annotation"] = data["annotation"]
97 if data["docstring"] is not None: 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true
98 base[attr_name]["docstring"] = data["docstring"]
99 else:
100 base[attr_name] = data
103@lru_cache
104def get_module_attributes(module: ModuleType) -> dict: # noqa: D103
105 return combine(get_module_or_class_attributes(get_nodes(module)), get_type_hints(module))
108@lru_cache
109def get_class_attributes(cls: type) -> dict: # noqa: D103
110 nodes = get_nodes(cls)
111 if not nodes:
112 return {}
113 try:
114 type_hints = get_type_hints(cls)
115 except NameError:
116 # The __config__ attribute (a class) of Pydantic models trigger this error:
117 # NameError: name 'SchemaExtraCallable' is not defined
118 type_hints = {}
119 return combine(get_module_or_class_attributes(nodes[0].body), type_hints) # type: ignore[attr-defined]
122def pick_target(target: ast.AST) -> bool: # noqa: D103
123 return isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name) and target.value.id == "self"
126def unparse_annotation(node: ast.AST) -> str: # noqa: D103
127 code = unparse(node).rstrip("\n")
128 return code.replace("(", "").replace(")", "")
131@lru_cache
132def get_instance_attributes(func: Callable) -> dict: # noqa: D103
133 nodes = get_nodes(func)
134 if not nodes:
135 return {}
137 result = {}
139 for assignment, string in get_pairs(nodes[0].body): # type: ignore[attr-defined]
140 annotation = names = None
141 if isinstance(assignment, ast.AnnAssign):
142 if pick_target(assignment.target):
143 names = [assignment.target.attr] # type: ignore[union-attr]
144 annotation = unparse_annotation(assignment.annotation)
145 else:
146 names = [target.attr for target in assignment.targets if pick_target(target)]
148 if not names or (string is None and annotation is None):
149 continue
151 docstring = inspect.cleandoc(string.s) if string else None
152 for name in names:
153 result[name] = {"annotation": annotation, "docstring": docstring}
155 return result