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