diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..0b84a243
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,13 @@
+# Keep GitHub Actions up to date with GitHub's Dependabot...
+# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot
+# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem
+version: 2
+updates:
+ - package-ecosystem: github-actions
+ directory: /
+ groups:
+ github-actions:
+ patterns:
+ - "*" # Group all Actions updates into a single larger pull request
+ schedule:
+ interval: monthly
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index ead7d7ae..9d28e2d2 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -5,6 +5,21 @@ Versions follow `Semantic Versioning `_ (``..
.. towncrier release notes start
+v3.4.0 (2024-11-29)
+-------------------
+
+Features
+^^^^^^^^
+
+- NamedTuples that have been created with functional syntax are documented as a class (#485)
+
+
+Misc
+^^^^
+
+- #484, #490, #498
+
+
v3.3.3 (2024-10-25)
-------------------
diff --git a/README.rst b/README.rst
index 96049cec..044469fa 100644
--- a/README.rst
+++ b/README.rst
@@ -17,9 +17,9 @@ Sphinx AutoAPI
:target: https://pypi.org/project/sphinx-autoapi/
:alt: Supported Python Versions
-.. image:: https://img.shields.io/badge/code%20style-black-000000.svg
- :target: https://github.com/python/black
- :alt: Formatted with Black
+.. image:: https://img.shields.io/badge/code%20style-ruff-000000.svg
+ :target: https://docs.astral.sh/ruff/
+ :alt: Formatted with Ruff
Sphinx AutoAPI is a Sphinx extension for generating complete API documentation
without needing to load, run, or import the project being documented.
@@ -77,27 +77,19 @@ Tests are executed through `tox `_.
Code Style
~~~~~~~~~~
-Code is formatted using `black `_.
+Code is formatted using `ruff `_.
-You can check your formatting using black's check mode:
+You can check your formatting using ruff format's check mode:
.. code-block:: bash
tox -e format
-You can also get black to format your changes for you:
+You can also get ruff to format your changes for you:
.. code-block:: bash
- black autoapi/ tests/
-
-You can even get black to format changes automatically when you commit using `pre-commit `_:
-
-
-.. code-block:: bash
-
- pip install pre-commit
- pre-commit install
+ ruff format
Release Notes
~~~~~~~~~~~~~
diff --git a/autoapi/__init__.py b/autoapi/__init__.py
index 8fe54912..f94695e3 100644
--- a/autoapi/__init__.py
+++ b/autoapi/__init__.py
@@ -3,5 +3,5 @@
from .extension import setup
__all__ = ("setup",)
-__version__ = "3.3.3"
-__version_info__ = (3, 3, 3)
+__version__ = "3.4.0"
+__version_info__ = (3, 4, 0)
diff --git a/autoapi/_astroid_utils.py b/autoapi/_astroid_utils.py
index e9a85f64..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}"""'
@@ -145,9 +156,7 @@ class NotConstException(Exception):
def _inner(node: astroid.nodes.NodeNG) -> Any:
if isinstance(node, (astroid.nodes.List, astroid.nodes.Tuple)):
- new_value = []
- for element in node.elts:
- new_value.append(_inner(element))
+ new_value = [_inner(element) for element in node.elts]
if isinstance(node, astroid.nodes.Tuple):
return tuple(new_value)
@@ -170,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
@@ -191,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:
@@ -682,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/_objects.py b/autoapi/_objects.py
index 0340baaf..9f40b942 100644
--- a/autoapi/_objects.py
+++ b/autoapi/_objects.py
@@ -248,7 +248,7 @@ def _ask_ignore(self, skip: bool) -> bool:
return ask_result if ask_result is not None else skip
def _children_of_type(self, type_: str) -> list[PythonObject]:
- return list(child for child in self.children if child.type == type_)
+ return [child for child in self.children if child.type == type_]
class PythonFunction(PythonObject):
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/conf.py b/docs/conf.py
index 294c7ddc..e7ba3efc 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -13,9 +13,9 @@
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
-project = 'Sphinx AutoAPI'
-copyright = '2023, Read the Docs'
-author = 'Read the Docs'
+project = "Sphinx AutoAPI"
+copyright = "2023, Read the Docs"
+author = "Read the Docs"
version = ".".join(str(x) for x in autoapi.__version_info__[:2])
release = autoapi.__version__
@@ -23,38 +23,39 @@
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = [
- 'autoapi.extension',
- 'sphinx.ext.intersphinx',
- 'sphinx.ext.napoleon',
- 'sphinx_design',
+ "autoapi.extension",
+ "sphinx.ext.intersphinx",
+ "sphinx.ext.napoleon",
+ "sphinx_design",
]
-templates_path = ['_templates']
-exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', 'changes/*.rst']
+templates_path = ["_templates"]
+exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "changes/*.rst"]
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
-html_theme = 'furo'
-html_static_path = ['_static']
-html_css_files = ['overrides.css']
+html_theme = "furo"
+html_static_path = ["_static"]
+html_css_files = ["overrides.css"]
# -- Options for AutoAPI extension -------------------------------------------
-autoapi_dirs = ['../autoapi']
+autoapi_dirs = ["../autoapi"]
autoapi_generate_api_docs = False
# -- Options for intersphinx extension ---------------------------------------
intersphinx_mapping = {
- 'jinja': ('https://jinja.palletsprojects.com/en/3.0.x/', None),
- 'sphinx': ('https://www.sphinx-doc.org/en/master/', None),
- 'python': ('https://docs.python.org/3/', None),
+ "jinja": ("https://jinja.palletsprojects.com/en/3.0.x/", None),
+ "sphinx": ("https://www.sphinx-doc.org/en/master/", None),
+ "python": ("https://docs.python.org/3/", None),
}
# -- Enable confval and event roles ------------------------------------------
-event_sig_re = re.compile(r'([a-zA-Z-]+)\s*\((.*)\)')
+event_sig_re = re.compile(r"([a-zA-Z-]+)\s*\((.*)\)")
+
def parse_event(env, sig, signode):
m = event_sig_re.match(sig)
@@ -64,7 +65,7 @@ def parse_event(env, sig, signode):
name, args = m.groups()
signode += addnodes.desc_name(name, name)
plist = addnodes.desc_parameterlist()
- for arg in args.split(','):
+ for arg in args.split(","):
arg = arg.strip()
plist += addnodes.desc_parameter(arg, arg)
signode += plist
@@ -72,10 +73,19 @@ def parse_event(env, sig, signode):
def setup(app):
- app.add_object_type('confval', 'confval',
- objname='configuration value',
- indextemplate='pair: %s; configuration value')
- fdesc = TypedField('parameter', label='Parameters',
- names=('param',), typenames=('type',), can_collapse=True)
- app.add_object_type('event', 'event', 'pair: %s; event', parse_event,
- doc_field_types=[fdesc])
+ app.add_object_type(
+ "confval",
+ "confval",
+ objname="configuration value",
+ indextemplate="pair: %s; configuration value",
+ )
+ fdesc = TypedField(
+ "parameter",
+ label="Parameters",
+ names=("param",),
+ typenames=("type",),
+ can_collapse=True,
+ )
+ app.add_object_type(
+ "event", "event", "pair: %s; event", parse_event, doc_field_types=[fdesc]
+ )
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 0d2b15bf..9b44d644 100644
--- a/tests/test_astroid_utils.py
+++ b/tests/test_astroid_utils.py
@@ -67,9 +67,7 @@ def test_can_get_full_imported_basename(self, import_, basename, expected):
{}
class ThisClass({}): #@
pass
- """.format(
- import_, basename
- )
+ """.format(import_, basename)
node = astroid.extract_node(source)
basenames = _astroid_utils.resolve_qualname(node.bases[0], node.basenames[0])
assert basenames == expected
@@ -82,9 +80,7 @@ def test_can_get_full_function_basename(self, import_, basename, expected):
{}
class ThisClass({}): #@
pass
- """.format(
- import_, basename
- )
+ """.format(import_, basename)
node = astroid.extract_node(source)
basenames = _astroid_utils.resolve_qualname(node.bases[0], node.basenames[0])
assert basenames == expected
@@ -108,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(
@@ -163,9 +161,7 @@ def test_parse_annotations(self, signature, expected):
"""
def func({}) -> str: #@
pass
- """.format(
- signature
- )
+ """.format(signature)
)
annotations = _astroid_utils.get_args_info(node.args)
@@ -227,9 +223,7 @@ def test_format_args(self, signature, expected):
"""
def func({}) -> str: #@
pass
- """.format(
- signature
- )
+ """.format(signature)
)
args_info = _astroid_utils.get_args_info(node.args)
diff --git a/tox.ini b/tox.ini
index e7372568..22517bb7 100644
--- a/tox.ini
+++ b/tox.ini
@@ -29,9 +29,9 @@ commands =
[testenv:format]
skip_install = true
deps =
- black
+ ruff
commands =
- black --check --diff autoapi tests
+ ruff format --check --diff
[testenv:lint]
skip_install = true