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

1"""Module containing functions to parse attributes in the source code.""" 

2 

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 

10 

11try: 

12 from ast import unparse # type: ignore[attr-defined] 

13except ImportError: 

14 from astunparse import unparse # type: ignore[no-redef] 

15 

16RECURSIVE_NODES = (ast.If, ast.IfExp, ast.Try, ast.With) 

17 

18 

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 

25 

26 

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] 

39 

40 

41def get_pairs(nodes: list) -> Iterator[tuple]: # noqa: D103 

42 if len(nodes) < 2: # noqa: PLR2004 

43 return 

44 

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 

65 

66 

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 

83 

84 

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 } 

90 

91 

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 

101 

102 

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)) 

106 

107 

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] 

120 

121 

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" 

124 

125 

126def unparse_annotation(node: ast.AST) -> str: # noqa: D103 

127 code = unparse(node).rstrip("\n") 

128 return code.replace("(", "").replace(")", "") 

129 

130 

131@lru_cache 

132def get_instance_attributes(func: Callable) -> dict: # noqa: D103 

133 nodes = get_nodes(func) 

134 if not nodes: 

135 return {} 

136 

137 result = {} 

138 

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)] 

147 

148 if not names or (string is None and annotation is None): 

149 continue 

150 

151 docstring = inspect.cleandoc(string.s) if string else None 

152 for name in names: 

153 result[name] = {"annotation": annotation, "docstring": docstring} 

154 

155 return result