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
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-31 15:13 +0100
1"""Attribute docstrings parsing.
3.. seealso:: https://peps.python.org/pep-0257/#what-is-a-docstring
4"""
6import ast
7import inspect
8import textwrap
9from types import ModuleType
10from typing import Any, Optional, Union, overload
12from typing_extensions import TypeGuard, override
14from .common import Docstring, DocstringParam
16ast_constant_attr = {
17 ast.Constant: "value",
18 # python <= 3.7:
19 ast.NameConstant: "value",
20 ast.Num: "n",
21 ast.Str: "s",
22}
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.
30 Parameters
31 ----------
32 node : Union[ast.Str, ast.Num, ast.NameConstant, ast.Constant]
33 Node to get the constant value from.
35 Returns
36 -------
37 Any
38 Actual value contained by the node.
39 """
40 return getattr(node, ast_constant_attr[node.__class__])
43@overload
44def ast_unparse(node: None) -> None:
45 ...
48@overload
49def ast_unparse(node: ast.AST) -> str:
50 ...
53def ast_unparse(node: Optional[ast.AST]) -> Optional[str]:
54 """Convert the AST node to source code as a string.
56 Parameters
57 ----------
58 node : Optional[ast.AST]
59 Node to unparse.
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)
72def ast_is_literal_str(node: ast.AST) -> TypeGuard[ast.Expr]:
73 """Return True if the given node is a literal string.
75 Parameters
76 ----------
77 node : ast.AST
78 Node to check.
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 )
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.
97 Parameters
98 ----------
99 node : ast.AST
100 Node to get the attribute from
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
119class AttributeDocstrings(ast.NodeVisitor):
120 """An ast.NodeVisitor that collects attribute docstrings."""
122 attr_docs: Optional[dict[str, tuple[str, Optional[str], Optional[str]]]] = None
123 prev_attr = None
125 @override
126 def visit(self, node: ast.AST) -> None:
127 """Visit a node and collect its attribute docstrings.
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)
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.
154 Parameters
155 ----------
156 component : Any
157 component to process (class or module)
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
179def add_attribute_docstrings(
180 obj: Union[type, ModuleType], docstring: Docstring
181) -> None:
182 """Add attribute docstrings found in the object's source code.
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)