From b3f768e974559f43b324529ec61694ad94666248 Mon Sep 17 00:00:00 2001 From: Ashley Whetter Date: Fri, 29 Nov 2024 17:05:11 -0800 Subject: [PATCH] NamedTuples are always documented as a class Closes #485 --- autoapi/_astroid_utils.py | 78 +++++++++++++++++++---- autoapi/_parser.py | 46 ++++++++++++- docs/changes/485.feature.rst | 1 + tests/python/pyexample/example/example.py | 10 +++ tests/python/test_pyintegration.py | 23 +++++++ tests/test_astroid_utils.py | 2 + 6 files changed, 145 insertions(+), 15 deletions(-) create mode 100644 docs/changes/485.feature.rst diff --git a/autoapi/_astroid_utils.py b/autoapi/_astroid_utils.py index 5510dbe5..1d543808 100644 --- a/autoapi/_astroid_utils.py +++ b/autoapi/_astroid_utils.py @@ -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}"""' @@ -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 @@ -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: @@ -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") diff --git a/autoapi/_parser.py b/autoapi/_parser.py index 4efd548d..615fea80 100644 --- a/autoapi/_parser.py +++ b/autoapi/_parser.py @@ -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_, @@ -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) diff --git a/docs/changes/485.feature.rst b/docs/changes/485.feature.rst new file mode 100644 index 00000000..2f15b137 --- /dev/null +++ b/docs/changes/485.feature.rst @@ -0,0 +1 @@ +NamedTuples that have been created with functional syntax are documented as a class diff --git a/tests/python/pyexample/example/example.py b/tests/python/pyexample/example/example.py index b915cf6e..10a46a63 100644 --- a/tests/python/pyexample/example/example.py +++ b/tests/python/pyexample/example/example.py @@ -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.""" @@ -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)] +) diff --git a/tests/python/test_pyintegration.py b/tests/python/test_pyintegration.py index b589d6a8..a9002752 100644 --- a/tests/python/test_pyintegration.py +++ b/tests/python/test_pyintegration.py @@ -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") diff --git a/tests/test_astroid_utils.py b/tests/test_astroid_utils.py index ace4f68a..9b44d644 100644 --- a/tests/test_astroid_utils.py +++ b/tests/test_astroid_utils.py @@ -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(