Coverage for pymend\docstring_parser\attrdoc.py: 95%

59 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-10-31 15:13 +0100

1"""Attribute docstrings parsing. 

2 

3.. seealso:: https://peps.python.org/pep-0257/#what-is-a-docstring 

4""" 

5 

6import ast 

7import inspect 

8import textwrap 

9from types import ModuleType 

10from typing import Any, Optional, Union, overload 

11 

12from typing_extensions import TypeGuard, override 

13 

14from .common import Docstring, DocstringParam 

15 

16ast_constant_attr = { 

17 ast.Constant: "value", 

18 # python <= 3.7: 

19 ast.NameConstant: "value", 

20 ast.Num: "n", 

21 ast.Str: "s", 

22} 

23 

24 

25def ast_get_constant_value( 

26 node: Union[ast.Str, ast.Num, ast.NameConstant, ast.Constant] 

27) -> Any: # noqa: ANN401 

28 """Return the constant's value if the given node is a constant. 

29 

30 Parameters 

31 ---------- 

32 node : Union[ast.Str, ast.Num, ast.NameConstant, ast.Constant] 

33 Node to get the constant value from. 

34 

35 Returns 

36 ------- 

37 Any 

38 Actual value contained by the node. 

39 """ 

40 return getattr(node, ast_constant_attr[node.__class__]) 

41 

42 

43@overload 

44def ast_unparse(node: None) -> None: 

45 ... 

46 

47 

48@overload 

49def ast_unparse(node: ast.AST) -> str: 

50 ... 

51 

52 

53def ast_unparse(node: Optional[ast.AST]) -> Optional[str]: 

54 """Convert the AST node to source code as a string. 

55 

56 Parameters 

57 ---------- 

58 node : Optional[ast.AST] 

59 Node to unparse. 

60 

61 Returns 

62 ------- 

63 Optional[str] 

64 `None` if `node` was `None`. 

65 Otherwise the unparsed node. 

66 """ 

67 if node is None: 

68 return None 

69 return ast.unparse(node) 

70 

71 

72def ast_is_literal_str(node: ast.AST) -> TypeGuard[ast.Expr]: 

73 """Return True if the given node is a literal string. 

74 

75 Parameters 

76 ---------- 

77 node : ast.AST 

78 Node to check. 

79 

80 Returns 

81 ------- 

82 TypeGuard[ast.Expr] 

83 True if the node represents a string literal. 

84 """ 

85 return ( 

86 isinstance(node, ast.Expr) 

87 and isinstance(node.value, (ast.Constant, ast.Str)) 

88 and isinstance(ast_get_constant_value(node.value), str) 

89 ) 

90 

91 

92def ast_get_attribute( 

93 node: ast.AST, 

94) -> Optional[tuple[str, Optional[str], Optional[str]]]: 

95 """Return name, type and default if the given node is an attribute. 

96 

97 Parameters 

98 ---------- 

99 node : ast.AST 

100 Node to get the attribute from 

101 

102 Returns 

103 ------- 

104 Optional[tuple[str, Optional[str], Optional[str]]] 

105 None if the node does not represent an assignment. 

106 Otherwise a tuple of name, type and default value. 

107 """ 

108 if isinstance(node, (ast.Assign, ast.AnnAssign)): 

109 target = node.targets[0] if isinstance(node, ast.Assign) else node.target 

110 if isinstance(target, ast.Name): 110 ↛ 116line 110 didn't jump to line 116, because the condition on line 110 was never false

111 type_str = None 

112 if isinstance(node, ast.AnnAssign): 

113 type_str = ast_unparse(node.annotation) 

114 default = ast_unparse(node.value) if node.value else None 

115 return target.id, type_str, default 

116 return None 

117 

118 

119class AttributeDocstrings(ast.NodeVisitor): 

120 """An ast.NodeVisitor that collects attribute docstrings.""" 

121 

122 attr_docs: Optional[dict[str, tuple[str, Optional[str], Optional[str]]]] = None 

123 prev_attr = None 

124 

125 @override 

126 def visit(self, node: ast.AST) -> None: 

127 """Visit a node and collect its attribute docstrings. 

128 

129 Parameters 

130 ---------- 

131 node : ast.AST 

132 Node to visit. 

133 """ 

134 if self.prev_attr and self.attr_docs is not None and ast_is_literal_str(node): 

135 attr_name, attr_type, attr_default = self.prev_attr 

136 # This is save because `ast_is_literal_str` 

137 # ensure that node.value is of type (ast.Constant, ast.Str) 

138 self.attr_docs[attr_name] = ( 

139 ast_get_constant_value( 

140 node.value # pyright: ignore[reportGeneralTypeIssues] 

141 ), 

142 attr_type, 

143 attr_default, 

144 ) 

145 self.prev_attr = ast_get_attribute(node) 

146 if isinstance(node, (ast.ClassDef, ast.Module)): 

147 self.generic_visit(node) 

148 

149 def get_attr_docs( 

150 self, component: Any # noqa: ANN401 

151 ) -> dict[str, tuple[str, Optional[str], Optional[str]]]: 

152 """Get attribute docstrings from the given component. 

153 

154 Parameters 

155 ---------- 

156 component : Any 

157 component to process (class or module) 

158 

159 Returns 

160 ------- 

161 dict[str, tuple[str, Optional[str], Optional[str]]] 

162 for each attribute docstring, a tuple with 

163 (description, type, default) 

164 """ 

165 self.attr_docs = {} 

166 self.prev_attr = None 

167 try: 

168 source = textwrap.dedent(inspect.getsource(component)) 

169 except OSError: 

170 pass 

171 else: 

172 # This change might cause issues with older python versions 

173 # Not sure yet. 

174 tree = ast.parse(source) 

175 self.visit(tree) 

176 return self.attr_docs 

177 

178 

179def add_attribute_docstrings( 

180 obj: Union[type, ModuleType], docstring: Docstring 

181) -> None: 

182 """Add attribute docstrings found in the object's source code. 

183 

184 Parameters 

185 ---------- 

186 obj : Union[type, ModuleType] 

187 object from which to parse attribute docstrings 

188 docstring : Docstring 

189 Docstring object where found attributes are added 

190 """ 

191 params = {p.arg_name for p in docstring.params} 

192 for arg_name, (description, type_name, default) in ( 

193 AttributeDocstrings().get_attr_docs(obj).items() 

194 ): 

195 if arg_name not in params: 195 ↛ 192line 195 didn't jump to line 192

196 param = DocstringParam( 

197 args=["attribute", arg_name], 

198 description=description, 

199 arg_name=arg_name, 

200 type_name=type_name, 

201 is_optional=default is not None, 

202 default=default, 

203 ) 

204 docstring.meta.append(param)