Skip to content

Commit

Permalink
NamedTuples are always documented as a class
Browse files Browse the repository at this point in the history
Closes #485
  • Loading branch information
AWhetter committed Nov 30, 2024
1 parent 6f91543 commit b3f768e
Show file tree
Hide file tree
Showing 6 changed files with 145 additions and 15 deletions.
78 changes: 64 additions & 14 deletions autoapi/_astroid_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,18 @@ def get_full_basenames(node: astroid.nodes.ClassDef) -> Iterable[str]:
yield _resolve_annotation(base)


def _get_const_value(node: astroid.nodes.NodeNG) -> str | None:
def get_const_value(node: astroid.nodes.NodeNG) -> str | None:
"""Get the string representation of the value represented by a node.
The value must be a constant or container of constants.
Args:
node: The node to get the representation of.
Returns:
The string representation of the value represented by the node
(if it can be converted).
"""
if isinstance(node, astroid.nodes.Const):
if isinstance(node.value, str) and "\n" in node.value:
return f'"""{node.value}"""'
Expand Down Expand Up @@ -168,19 +179,18 @@ def _inner(node: astroid.nodes.NodeNG) -> Any:
return repr(result)


def get_assign_value(
def _get_assign_target_node(
node: astroid.nodes.Assign | astroid.nodes.AnnAssign,
) -> tuple[str, str | None] | None:
"""Get the name and value of the assignment of the given node.
) -> astroid.nodes.NodeNG | None:
"""Get the target of the given assignment node.
Assignments to multiple names are ignored, as per PEP 257.
Assignments to multiple names are ignored, as per :pep:`257`.
Args:
node: The node to get the assignment value from.
Returns:
The name that is assigned to, and the string representation of
the value assigned to the name (if it can be converted).
The node representing the name that is assigned to.
"""
try:
targets = node.targets
Expand All @@ -189,17 +199,42 @@ def get_assign_value(

if len(targets) == 1:
target = targets[0]
if isinstance(target, astroid.nodes.AssignName):
name = target.name
elif isinstance(target, astroid.nodes.AssignAttr):
name = target.attrname
else:
return None
return (name, _get_const_value(node.value))
if isinstance(target, (astroid.nodes.AssignName, astroid.nodes.AssignAttr)):
return target

return None


def get_assign_value(
node: astroid.nodes.Assign | astroid.nodes.AnnAssign,
) -> tuple[str, str | None] | None:
"""Get the name and value of the assignment of the given node.
Assignments to multiple names are ignored, as per :pep:`257`.
Args:
node: The node to get the assignment value from.
Returns:
The name that is assigned to, and the string representation of
the value assigned to the name (if it can be converted).
"""
target = _get_assign_target_node(node)

if isinstance(target, astroid.nodes.AssignName):
name = target.name
elif isinstance(target, astroid.nodes.AssignAttr):
name = target.attrname
else:
return None

value = next(target.infer())
if value is astroid.util.Uninferable:
value = None

return (name, value)


def get_assign_annotation(
node: astroid.nodes.Assign | astroid.nodes.AnnAssign,
) -> str | None:
Expand Down Expand Up @@ -680,3 +715,18 @@ def is_abstract_class(node: astroid.nodes.ClassDef) -> bool:
return True

return False


def is_functional_namedtuple(node: astroid.nodes.NodeNG) -> bool:
if not isinstance(node, astroid.nodes.Call):
return False

func = node.func
if isinstance(func, astroid.nodes.Attribute):
name = func.attrname
elif isinstance(func, astroid.nodes.Name):
name = func.name
else:
return False

return name in ("namedtuple", "NamedTuple")
46 changes: 45 additions & 1 deletion autoapi/_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,17 @@ def _parse_assign(self, node):
return []

target = assign_value[0]
value = assign_value[1]
value_node = assign_value[1]

annotation = _astroid_utils.get_assign_annotation(node)
if annotation in ("TypeAlias", "typing.TypeAlias"):
value = node.value.as_string()
elif isinstance(
value_node, astroid.nodes.ClassDef
) and _astroid_utils.is_functional_namedtuple(node.value):
return self._parse_namedtuple(value_node)
else:
value = _astroid_utils.get_const_value(value_node)

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

return [data]

def _parse_namedtuple(self, node):
qual_name = self._get_qual_name(node.name)
full_name = self._get_full_name(node.name)
self._qual_name_stack.append(node.name)
self._full_name_stack.append(node.name)

data = {
"type": "class",
"name": node.name,
"qual_name": qual_name,
"full_name": full_name,
"bases": list(_astroid_utils.get_full_basenames(node)),
"doc": _prepare_docstring(node.doc_node.value if node.doc_node else ""),
"from_line_no": node.fromlineno,
"to_line_no": node.tolineno,
"children": [],
"is_abstract": _astroid_utils.is_abstract_class(node),
}

for child in node.instance_attrs:
child_data = {
"type": "attribute",
"name": child,
"qual_name": self._get_qual_name(child),
"full_name": self._get_full_name(child),
"doc": _prepare_docstring(""),
"value": None,
"from_line_no": node.fromlineno,
"to_line_no": node.tolineno,
"annotation": None,
}
data["children"].append(child_data)

self._qual_name_stack.pop()
self._full_name_stack.pop()

return [data]

def _parse_classdef(self, node, use_name_stacks):
if use_name_stacks:
qual_name = self._get_qual_name(node.name)
Expand Down
1 change: 1 addition & 0 deletions docs/changes/485.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
NamedTuples that have been created with functional syntax are documented as a class
10 changes: 10 additions & 0 deletions tests/python/pyexample/example/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
This is a description
"""

import collections
from dataclasses import dataclass
from functools import cached_property
import typing

A_TUPLE = ("a", "b")
"""A tuple to be rendered as a tuple."""
Expand Down Expand Up @@ -199,3 +201,11 @@ def __init__(self, one: int = 1) -> None:
def typed_method(self, two: int) -> int:
"""This is TypedClassInit.typed_method."""
return self._one + two


UniqueValue = collections.namedtuple("UniqueValue", ("value", "count"))


TypedUniqueValue = typing.NamedTuple(
"TypedUniqueValue", [("value", str), ("count", int)]
)
23 changes: 23 additions & 0 deletions tests/python/test_pyintegration.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,29 @@ def test_property(self, parse):
property_simple_docstring = property_simple.parent.find("dd").text.strip()
assert property_simple_docstring == "This property should parse okay."

def test_namedtuple(self, parse):
example_file = parse("_build/html/manualapi.html")

uv_sig = example_file.find(id="example.UniqueValue")
assert uv_sig
assert uv_sig.find(class_="pre").text.strip() == "class"

value_sig = example_file.find(id="example.UniqueValue.value")
assert value_sig

count_sig = example_file.find(id="example.UniqueValue.count")
assert count_sig

tuv_sig = example_file.find(id="example.TypedUniqueValue")
assert tuv_sig
assert tuv_sig.find(class_="pre").text.strip() == "class"

value_sig = example_file.find(id="example.TypedUniqueValue.value")
assert value_sig

count_sig = example_file.find(id="example.TypedUniqueValue.count")
assert count_sig


class TestMovedConfPy(TestSimpleModule):
@pytest.fixture(autouse=True, scope="class")
Expand Down
2 changes: 2 additions & 0 deletions tests/test_astroid_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ class ThisClass({}): #@
def test_can_get_assign_values(self, source, expected):
node = astroid.extract_node(source)
value = _astroid_utils.get_assign_value(node)
if value:
value = (value[0], _astroid_utils.get_const_value(value[1]))
assert value == expected

@pytest.mark.parametrize(
Expand Down

0 comments on commit b3f768e

Please sign in to comment.