diff --git a/autoapi/_astroid_utils.py b/autoapi/_astroid_utils.py index ea353d20..e1e6de05 100644 --- a/autoapi/_astroid_utils.py +++ b/autoapi/_astroid_utils.py @@ -93,6 +93,11 @@ def resolve_qualname(node: astroid.nodes.NodeNG, basename: str) -> str: else: lookup_node = node.scope() + if hasattr(lookup_node, "type_params"): + type_params = [x.name for x in lookup_node.type_params] + else: + type_params = [] + assigns = lookup_node.lookup(top_level_name)[1] for assignment in assigns: @@ -108,7 +113,10 @@ def resolve_qualname(node: astroid.nodes.NodeNG, basename: str) -> str: full_basename = assignment.qname() break if isinstance(assignment, astroid.nodes.AssignName): - full_basename = f"{assignment.scope().qname()}.{assignment.name}" + if assignment in type_params: + full_basename = assignment.name + else: + full_basename = f"{assignment.scope().qname()}.{assignment.name}" if isinstance(node, astroid.nodes.Call): full_basename = re.sub(r"\(.*\)", "()", full_basename) @@ -662,6 +670,33 @@ def get_args_info(args_node: astroid.nodes.Arguments) -> list[ArgInfo]: return result +def get_type_params_info( + params: list[ + astroid.nodes.TypeVar | astroid.nodes.ParamSpec | astroid.nodes.TypeVarTuple + ], +) -> list[ArgInfo]: + """Extract PEP 695 style type params, + eg: def func[T]() -> T: ... + """ + bound: str | None + if not bool(params): + return [] + result: list[ArgInfo] = [] + for x in params: + if isinstance(x, astroid.nodes.TypeVar): + if x.bound is not None: + bound = _resolve_annotation(x.bound) + else: + bound = None + result.append(ArgInfo(None, x.name.name, bound, None)) + elif isinstance(x, astroid.nodes.TypeVarTuple): + result.append(ArgInfo("*", x.name.name, None, None)) + elif isinstance(x, astroid.nodes.ParamSpec): + result.append(ArgInfo("**", x.name.name, None, None)) + + return result + + def get_return_annotation(node: astroid.nodes.FunctionDef) -> str | None: """Get the return annotation of a node. diff --git a/autoapi/_objects.py b/autoapi/_objects.py index 99e0b12e..ac8bc070 100644 --- a/autoapi/_objects.py +++ b/autoapi/_objects.py @@ -264,6 +264,14 @@ def __init__(self, *args, **kwargs): show_annotations = autodoc_typehints != "none" and not ( autodoc_typehints == "description" and not self.obj["overloads"] ) + + self.type_params: str = ( + _format_args(self.obj["type_params"], show_annotations) + if "type_params" in self.obj + else "" + ) + """The type params of this object, formatted as a string""" + self.args: str = _format_args(self.obj["args"], show_annotations) """The arguments to this object, formatted as a string.""" @@ -429,6 +437,9 @@ def __init__(self, *args, **kwargs): self.bases: list[str] = self.obj["bases"] """The fully qualified names of all base classes.""" + self.type_params: str = ( + _format_args(self.obj["type_params"]) if "type_params" in self.obj else "" + ) self._docstring_resolved: bool = False @property diff --git a/autoapi/_parser.py b/autoapi/_parser.py index 0460e92f..4fbd3cc6 100644 --- a/autoapi/_parser.py +++ b/autoapi/_parser.py @@ -171,6 +171,7 @@ def _parse_classdef(self, node, use_name_stacks): "name": node.name, "qual_name": qual_name, "full_name": full_name, + "type_params": _astroid_utils.get_type_params_info(node.type_params), "bases": list(_astroid_utils.get_full_basenames(node)), "doc": _prepare_docstring(_astroid_utils.get_class_docstring(node)), "from_line_no": node.fromlineno, @@ -297,6 +298,7 @@ def parse_functiondef(self, node): "qual_name": self._get_qual_name(node.name), "full_name": self._get_full_name(node.name), "args": _astroid_utils.get_args_info(node.args), + "type_params": _astroid_utils.get_type_params_info(node.type_params), "doc": _prepare_docstring(node.doc_node.value if node.doc_node else ""), "from_line_no": node.fromlineno, "to_line_no": node.tolineno, diff --git a/tests/python/pytypeparams/conf.py b/tests/python/pytypeparams/conf.py new file mode 100644 index 00000000..7d5def46 --- /dev/null +++ b/tests/python/pytypeparams/conf.py @@ -0,0 +1,27 @@ +templates_path = ["_templates"] +source_suffix = ".rst" +master_doc = "index" +project = "pytypeparams" +copyright = "2025, sphinx-autoapi" +author = "sphinx-autoapi" +version = "0.1" +release = "0.1" +language = "en" +exclude_patterns = ["_build"] +pygments_style = "sphinx" +todo_include_todos = False +html_theme = "alabaster" +htmlhelp_basename = "pytypeparams" +extensions = ["sphinx.ext.autodoc", "autoapi.extension"] +autoapi_dirs = ["example"] +autoapi_python_class_content = "both" +autoapi_template_dir = "template_overrides" +autoapi_options = [ + "members", + "undoc-members", # this is temporary until we add docstrings across the codebase + "show-inheritance", + "show-module-summary", + "special-members", + "imported-members", + "inherited-members", +] diff --git a/tests/python/pytypeparams/example/example.py b/tests/python/pytypeparams/example/example.py new file mode 100644 index 00000000..3ce185f9 --- /dev/null +++ b/tests/python/pytypeparams/example/example.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +""" """ + +from __future__ import annotations +from typing import Callable + + +def generic_function[T](val: T) -> list[T]: + pass + + +def generic_with_bound[T: str](val: T) -> list[T]: + pass + + +def multiple_var_function[T, X](val: T) -> X: + pass + + +def variadic_generic[*T](val: tuple[*T]) -> list[*T]: + pass + + +def paramspec_function[**I](val: Callable[I, int]) -> Callable[I, int]: + pass + + +class SimpleGenericClass[T]: + pass + + +class GenericWithBases[T](Protocol): + pass + + +class ClassWithGenericMethod: + def simple_generic_method[T](self, val: T) -> T: + return val + + +class ClassWithGenericAttr[T]: + values: list[T] + + +class ClassWithGenericProperty: + @property + def simple[T](self) -> list[T]: + pass diff --git a/tests/python/pytypeparams/index.rst b/tests/python/pytypeparams/index.rst new file mode 100644 index 00000000..5d8a591e --- /dev/null +++ b/tests/python/pytypeparams/index.rst @@ -0,0 +1,25 @@ +.. pyexample documentation master file, created by + sphinx-quickstart on Fri May 29 13:34:37 2015. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to pyexample's documentation! +===================================== + +.. toctree:: + + autoapi/index + +Contents: + +.. toctree:: + :maxdepth: 2 + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/tests/python/pytypeparams/template_overrides/python/class.rst b/tests/python/pytypeparams/template_overrides/python/class.rst new file mode 100644 index 00000000..d88c9919 --- /dev/null +++ b/tests/python/pytypeparams/template_overrides/python/class.rst @@ -0,0 +1,20 @@ +.. py:class:: {{ obj.short_name }}[{{obj.type_params}}] + + {% if obj.bases %} + Bases: {% for base in obj.bases %}{{ base|link_objs }}{% if not loop.last %}, {% endif %}{% endfor %} + {% endif %} + +{% set visible_attrs = obj.children|selectattr("display")|selectattr("type", "equalto", "attribute")|list %} + {% for obj_item in visible_attrs %} + + {{ obj_item.render()|indent(3) }} + {% endfor %} + +{% set visible_methods = obj.children|selectattr("display")|selectattr("type", "equalto", "method")|list %} + + {% for obj_item in visible_methods %} + + {{ obj_item.render()|indent(3) }} + {% endfor %} + + diff --git a/tests/python/pytypeparams/template_overrides/python/function.rst b/tests/python/pytypeparams/template_overrides/python/function.rst new file mode 100644 index 00000000..468976ec --- /dev/null +++ b/tests/python/pytypeparams/template_overrides/python/function.rst @@ -0,0 +1,2 @@ +.. py:function:: {{ obj.short_name }}[{{obj.type_params}}]({{ obj.args }}) -> {{ obj.return_annotation }} + diff --git a/tests/python/pytypeparams/template_overrides/python/method.rst b/tests/python/pytypeparams/template_overrides/python/method.rst new file mode 100644 index 00000000..dabceea9 --- /dev/null +++ b/tests/python/pytypeparams/template_overrides/python/method.rst @@ -0,0 +1,2 @@ +.. py:method:: {{ obj.short_name }}[{{obj.type_params}}]({{ obj.args }}) -> {{ obj.return_annotation }} + diff --git a/tests/python/test_parser.py b/tests/python/test_parser.py index 288ad9c2..8fbee654 100644 --- a/tests/python/test_parser.py +++ b/tests/python/test_parser.py @@ -124,3 +124,18 @@ def test_list_index_assignment(self): """ data = self.parse(source)[0] assert data["name"] == "COLOUR" + + def test_parses_typeparams(self): + """Check PEP 695 style type params are parsed""" + source = """ + def generic_fn[T](val:T) -> T: + pass + """ + data = self.parse(source)[0] + assert data["name"] == "generic_fn" + assert data["type"] == "function" + assert "type_params" in data + assert len(data["type_params"]) == 1 + param = data["type_params"][0] + assert param.name == "T" + assert param.annotation is None diff --git a/tests/python/test_pyintegration.py b/tests/python/test_pyintegration.py index b45b2ae5..da4bd020 100644 --- a/tests/python/test_pyintegration.py +++ b/tests/python/test_pyintegration.py @@ -1394,3 +1394,109 @@ def test_integration(self, parse): # Find members that are inherited from standard library classes. meth = myast_file.find(id="myast.MyVisitor.visit") assert not meth + + +class TestTypeParams: + """Check that type params are documented""" + + @pytest.fixture(autouse=True, scope="class") + def built(self, builder): + builder( + "pytypeparams", + warningiserror=True, + confoverrides={"exclude_patterns": ["manualapi.rst"]}, + ) + + def test_generic_function(self, parse): + example_file = parse("_build/html/autoapi/example/index.html") + + target = example_file.find(id="example.generic_function") + for param, expect in zip( + target.find_all(class_="sig-param"), ["T", "val: T"], strict=True + ): + assert param.text == expect + + ret_type = target.find(class_="sig-return-typehint") + assert ret_type.text == "list[T]" + + def test_generic_with_bound(self, parse): + example_file = parse("_build/html/autoapi/example/index.html") + + target = example_file.find(id="example.generic_with_bound") + for param, expect in zip( + target.find_all(class_="sig-param"), ["T: str", "val: T"], strict=True + ): + assert param.text == expect + + ret_type = target.find(class_="sig-return-typehint") + assert ret_type.text == "list[T]" + + def test_multiple_var_function(self, parse): + example_file = parse("_build/html/autoapi/example/index.html") + + target = example_file.find(id="example.multiple_var_function") + for param, expect in zip( + target.find_all(class_="sig-param"), ["T", "X", "val: T"], strict=True + ): + assert param.text == expect + + ret_type = target.find(class_="sig-return-typehint") + assert ret_type.text == "X" + + def test_variadic_function(self, parse): + example_file = parse("_build/html/autoapi/example/index.html") + target = example_file.find(id="example.variadic_generic") + for param, expect in zip( + target.find_all(class_="sig-param"), ["*T", "val: tuple[*T]"], strict=True + ): + assert param.text == expect + + ret_type = target.find(class_="sig-return-typehint") + assert ret_type.text == "list[*T]" + + def test_paramspec_function(self, parse): + example_file = parse("_build/html/autoapi/example/index.html") + target = example_file.find(id="example.paramspec_function") + for param, expect in zip( + target.find_all(class_="sig-param"), + ["**I", "val: Callable[I, int]"], + strict=True, + ): + assert param.text == expect + + ret_type = target.find(class_="sig-return-typehint") + assert ret_type.text == "Callable[I, int]" + + def test_simple_generic_class(self, parse): + example_file = parse("_build/html/autoapi/example/index.html") + target = example_file.find(id="example.SimpleGenericClass") + for param, expect in zip( + target.find_all(class_="sig-param"), ["T"], strict=True + ): + assert param.text == expect + + def test_generic_with_bases(self, parse): + example_file = parse("_build/html/autoapi/example/index.html") + target = example_file.find(id="example.GenericWithBases") + for param, expect in zip( + target.find_all(class_="sig-param"), ["T"], strict=True + ): + assert param.text == expect + + def test_generic_method(self, parse): + example_file = parse("_build/html/autoapi/example/index.html") + target = example_file.find( + id="example.ClassWithGenericMethod.simple_generic_method" + ) + for param, expect in zip( + target.find_all(class_="sig-param"), ["T", "val: T"], strict=True + ): + assert param.text == expect + + ret_type = target.find(class_="sig-return-typehint") + assert ret_type.text == "T" + + def test_generic_attr(self, parse): + example_file = parse("_build/html/autoapi/example/index.html") + target = example_file.find(id="example.ClassWithGenericAttr.values") + assert target.text.strip() == "values: list[T]ΒΆ"