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

1"""Attribute docstrings parsing.

2

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

4"""

5

6 import ast

7 import inspect

8 import textwrap

9 from types import ModuleType

10 from typing import Any, Optional, Union, overload

11

12 from typing_extensions import TypeGuard, override

13

14 from .common import Docstring, DocstringParam

15

16 ast_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

25 def 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

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

45 ...

46

47

48@overload

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

50 ...

51

52

53 def 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

72 def 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

92 def 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

119 class 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

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