Skip to content

Commit b3f768e

Browse files
committed
NamedTuples are always documented as a class
Closes #485
1 parent 6f91543 commit b3f768e

File tree

6 files changed

+145
-15
lines changed

6 files changed

+145
-15
lines changed

autoapi/_astroid_utils.py

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,18 @@ def get_full_basenames(node: astroid.nodes.ClassDef) -> Iterable[str]:
135135
yield _resolve_annotation(base)
136136

137137

138-
def _get_const_value(node: astroid.nodes.NodeNG) -> str | None:
138+
def get_const_value(node: astroid.nodes.NodeNG) -> str | None:
139+
"""Get the string representation of the value represented by a node.
140+
141+
The value must be a constant or container of constants.
142+
143+
Args:
144+
node: The node to get the representation of.
145+
146+
Returns:
147+
The string representation of the value represented by the node
148+
(if it can be converted).
149+
"""
139150
if isinstance(node, astroid.nodes.Const):
140151
if isinstance(node.value, str) and "\n" in node.value:
141152
return f'"""{node.value}"""'
@@ -168,19 +179,18 @@ def _inner(node: astroid.nodes.NodeNG) -> Any:
168179
return repr(result)
169180

170181

171-
def get_assign_value(
182+
def _get_assign_target_node(
172183
node: astroid.nodes.Assign | astroid.nodes.AnnAssign,
173-
) -> tuple[str, str | None] | None:
174-
"""Get the name and value of the assignment of the given node.
184+
) -> astroid.nodes.NodeNG | None:
185+
"""Get the target of the given assignment node.
175186
176-
Assignments to multiple names are ignored, as per PEP 257.
187+
Assignments to multiple names are ignored, as per :pep:`257`.
177188
178189
Args:
179190
node: The node to get the assignment value from.
180191
181192
Returns:
182-
The name that is assigned to, and the string representation of
183-
the value assigned to the name (if it can be converted).
193+
The node representing the name that is assigned to.
184194
"""
185195
try:
186196
targets = node.targets
@@ -189,17 +199,42 @@ def get_assign_value(
189199

190200
if len(targets) == 1:
191201
target = targets[0]
192-
if isinstance(target, astroid.nodes.AssignName):
193-
name = target.name
194-
elif isinstance(target, astroid.nodes.AssignAttr):
195-
name = target.attrname
196-
else:
197-
return None
198-
return (name, _get_const_value(node.value))
202+
if isinstance(target, (astroid.nodes.AssignName, astroid.nodes.AssignAttr)):
203+
return target
199204

200205
return None
201206

202207

208+
def get_assign_value(
209+
node: astroid.nodes.Assign | astroid.nodes.AnnAssign,
210+
) -> tuple[str, str | None] | None:
211+
"""Get the name and value of the assignment of the given node.
212+
213+
Assignments to multiple names are ignored, as per :pep:`257`.
214+
215+
Args:
216+
node: The node to get the assignment value from.
217+
218+
Returns:
219+
The name that is assigned to, and the string representation of
220+
the value assigned to the name (if it can be converted).
221+
"""
222+
target = _get_assign_target_node(node)
223+
224+
if isinstance(target, astroid.nodes.AssignName):
225+
name = target.name
226+
elif isinstance(target, astroid.nodes.AssignAttr):
227+
name = target.attrname
228+
else:
229+
return None
230+
231+
value = next(target.infer())
232+
if value is astroid.util.Uninferable:
233+
value = None
234+
235+
return (name, value)
236+
237+
203238
def get_assign_annotation(
204239
node: astroid.nodes.Assign | astroid.nodes.AnnAssign,
205240
) -> str | None:
@@ -680,3 +715,18 @@ def is_abstract_class(node: astroid.nodes.ClassDef) -> bool:
680715
return True
681716

682717
return False
718+
719+
720+
def is_functional_namedtuple(node: astroid.nodes.NodeNG) -> bool:
721+
if not isinstance(node, astroid.nodes.Call):
722+
return False
723+
724+
func = node.func
725+
if isinstance(func, astroid.nodes.Attribute):
726+
name = func.attrname
727+
elif isinstance(func, astroid.nodes.Name):
728+
name = func.name
729+
else:
730+
return False
731+
732+
return name in ("namedtuple", "NamedTuple")

autoapi/_parser.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,17 @@ def _parse_assign(self, node):
8787
return []
8888

8989
target = assign_value[0]
90-
value = assign_value[1]
90+
value_node = assign_value[1]
9191

9292
annotation = _astroid_utils.get_assign_annotation(node)
9393
if annotation in ("TypeAlias", "typing.TypeAlias"):
9494
value = node.value.as_string()
95+
elif isinstance(
96+
value_node, astroid.nodes.ClassDef
97+
) and _astroid_utils.is_functional_namedtuple(node.value):
98+
return self._parse_namedtuple(value_node)
99+
else:
100+
value = _astroid_utils.get_const_value(value_node)
95101

96102
data = {
97103
"type": type_,
@@ -107,6 +113,44 @@ def _parse_assign(self, node):
107113

108114
return [data]
109115

116+
def _parse_namedtuple(self, node):
117+
qual_name = self._get_qual_name(node.name)
118+
full_name = self._get_full_name(node.name)
119+
self._qual_name_stack.append(node.name)
120+
self._full_name_stack.append(node.name)
121+
122+
data = {
123+
"type": "class",
124+
"name": node.name,
125+
"qual_name": qual_name,
126+
"full_name": full_name,
127+
"bases": list(_astroid_utils.get_full_basenames(node)),
128+
"doc": _prepare_docstring(node.doc_node.value if node.doc_node else ""),
129+
"from_line_no": node.fromlineno,
130+
"to_line_no": node.tolineno,
131+
"children": [],
132+
"is_abstract": _astroid_utils.is_abstract_class(node),
133+
}
134+
135+
for child in node.instance_attrs:
136+
child_data = {
137+
"type": "attribute",
138+
"name": child,
139+
"qual_name": self._get_qual_name(child),
140+
"full_name": self._get_full_name(child),
141+
"doc": _prepare_docstring(""),
142+
"value": None,
143+
"from_line_no": node.fromlineno,
144+
"to_line_no": node.tolineno,
145+
"annotation": None,
146+
}
147+
data["children"].append(child_data)
148+
149+
self._qual_name_stack.pop()
150+
self._full_name_stack.pop()
151+
152+
return [data]
153+
110154
def _parse_classdef(self, node, use_name_stacks):
111155
if use_name_stacks:
112156
qual_name = self._get_qual_name(node.name)

docs/changes/485.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
NamedTuples that have been created with functional syntax are documented as a class

tests/python/pyexample/example/example.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
This is a description
44
"""
55

6+
import collections
67
from dataclasses import dataclass
78
from functools import cached_property
9+
import typing
810

911
A_TUPLE = ("a", "b")
1012
"""A tuple to be rendered as a tuple."""
@@ -199,3 +201,11 @@ def __init__(self, one: int = 1) -> None:
199201
def typed_method(self, two: int) -> int:
200202
"""This is TypedClassInit.typed_method."""
201203
return self._one + two
204+
205+
206+
UniqueValue = collections.namedtuple("UniqueValue", ("value", "count"))
207+
208+
209+
TypedUniqueValue = typing.NamedTuple(
210+
"TypedUniqueValue", [("value", str), ("count", int)]
211+
)

tests/python/test_pyintegration.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,29 @@ def test_property(self, parse):
246246
property_simple_docstring = property_simple.parent.find("dd").text.strip()
247247
assert property_simple_docstring == "This property should parse okay."
248248

249+
def test_namedtuple(self, parse):
250+
example_file = parse("_build/html/manualapi.html")
251+
252+
uv_sig = example_file.find(id="example.UniqueValue")
253+
assert uv_sig
254+
assert uv_sig.find(class_="pre").text.strip() == "class"
255+
256+
value_sig = example_file.find(id="example.UniqueValue.value")
257+
assert value_sig
258+
259+
count_sig = example_file.find(id="example.UniqueValue.count")
260+
assert count_sig
261+
262+
tuv_sig = example_file.find(id="example.TypedUniqueValue")
263+
assert tuv_sig
264+
assert tuv_sig.find(class_="pre").text.strip() == "class"
265+
266+
value_sig = example_file.find(id="example.TypedUniqueValue.value")
267+
assert value_sig
268+
269+
count_sig = example_file.find(id="example.TypedUniqueValue.count")
270+
assert count_sig
271+
249272

250273
class TestMovedConfPy(TestSimpleModule):
251274
@pytest.fixture(autouse=True, scope="class")

tests/test_astroid_utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ class ThisClass({}): #@
104104
def test_can_get_assign_values(self, source, expected):
105105
node = astroid.extract_node(source)
106106
value = _astroid_utils.get_assign_value(node)
107+
if value:
108+
value = (value[0], _astroid_utils.get_const_value(value[1]))
107109
assert value == expected
108110

109111
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)