From 1d552cd086cd561faac6cd438bac607cc0d9a78d Mon Sep 17 00:00:00 2001 From: John Grey Date: Mon, 4 Aug 2025 22:49:27 +0100 Subject: [PATCH 1/3] [add]: type param handling for classes, methods, and functions --- autoapi/_astroid_utils.py | 37 ++++++- autoapi/_objects.py | 7 ++ autoapi/_parser.py | 2 + tests/python/pytypeparams/conf.py | 27 +++++ tests/python/pytypeparams/example/example.py | 42 ++++++++ tests/python/pytypeparams/index.rst | 25 +++++ .../template_overrides/python/class.rst | 20 ++++ .../template_overrides/python/function.rst | 2 + .../template_overrides/python/method.rst | 2 + tests/python/test_parser.py | 15 +++ tests/python/test_pyintegration.py | 101 ++++++++++++++++++ 11 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 tests/python/pytypeparams/conf.py create mode 100644 tests/python/pytypeparams/example/example.py create mode 100644 tests/python/pytypeparams/index.rst create mode 100644 tests/python/pytypeparams/template_overrides/python/class.rst create mode 100644 tests/python/pytypeparams/template_overrides/python/function.rst create mode 100644 tests/python/pytypeparams/template_overrides/python/method.rst diff --git a/autoapi/_astroid_utils.py b/autoapi/_astroid_utils.py index ea353d20..946f66f2 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) @@ -661,6 +669,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: + match x: + case 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)) + case astroid.nodes.TypeVarTuple(): + result.append(ArgInfo("*", x.name.name, None, None)) + case 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..2f78d067 100644 --- a/autoapi/_objects.py +++ b/autoapi/_objects.py @@ -264,6 +264,11 @@ 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 +434,8 @@ 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..fa7fb06c --- /dev/null +++ b/tests/python/pytypeparams/example/example.py @@ -0,0 +1,42 @@ +#!/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..a6b38e5e 100644 --- a/tests/python/test_pyintegration.py +++ b/tests/python/test_pyintegration.py @@ -1394,3 +1394,104 @@ 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]ΒΆ" From 5e024b08501472de8969cbffe9a6101a6aff2cc0 Mon Sep 17 00:00:00 2001 From: John Grey Date: Mon, 4 Aug 2025 23:09:41 +0100 Subject: [PATCH 2/3] [chore]: --- autoapi/_astroid_utils.py | 10 +-- autoapi/_objects.py | 12 ++- tests/python/pytypeparams/example/example.py | 16 ++-- tests/python/test_pyintegration.py | 83 +++++++++++--------- 4 files changed, 68 insertions(+), 53 deletions(-) diff --git a/autoapi/_astroid_utils.py b/autoapi/_astroid_utils.py index 946f66f2..18dbb7cb 100644 --- a/autoapi/_astroid_utils.py +++ b/autoapi/_astroid_utils.py @@ -670,11 +670,11 @@ 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, + 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 diff --git a/autoapi/_objects.py b/autoapi/_objects.py index 2f78d067..ac8bc070 100644 --- a/autoapi/_objects.py +++ b/autoapi/_objects.py @@ -265,10 +265,13 @@ def __init__(self, *args, **kwargs): 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 "" + 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.""" @@ -434,8 +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.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/tests/python/pytypeparams/example/example.py b/tests/python/pytypeparams/example/example.py index fa7fb06c..3ce185f9 100644 --- a/tests/python/pytypeparams/example/example.py +++ b/tests/python/pytypeparams/example/example.py @@ -1,42 +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]: + +def generic_with_bound[T: str](val: T) -> list[T]: pass -def multiple_var_function[T, X](val:T) -> X: + +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: +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/test_pyintegration.py b/tests/python/test_pyintegration.py index a6b38e5e..da4bd020 100644 --- a/tests/python/test_pyintegration.py +++ b/tests/python/test_pyintegration.py @@ -1395,6 +1395,7 @@ def test_integration(self, parse): meth = myast_file.find(id="myast.MyVisitor.visit") assert not meth + class TestTypeParams: """Check that type params are documented""" @@ -1410,86 +1411,90 @@ 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) + 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]") + 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) + 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]") + 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) + 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") + 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) + 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]") + 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) + 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]") + 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) + 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) + 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) + 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") + assert ret_type.text == "T" def test_generic_attr(self, parse): example_file = parse("_build/html/autoapi/example/index.html") From fc217c9426bbae378c7301350168944a9bc650b2 Mon Sep 17 00:00:00 2001 From: John Grey Date: Thu, 7 Aug 2025 14:23:46 +0100 Subject: [PATCH 3/3] [refactor]: match statement to ifs --- autoapi/_astroid_utils.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/autoapi/_astroid_utils.py b/autoapi/_astroid_utils.py index 18dbb7cb..e1e6de05 100644 --- a/autoapi/_astroid_utils.py +++ b/autoapi/_astroid_utils.py @@ -669,6 +669,7 @@ 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 @@ -682,17 +683,16 @@ def get_type_params_info( return [] result: list[ArgInfo] = [] for x in params: - match x: - case 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)) - case astroid.nodes.TypeVarTuple(): - result.append(ArgInfo("*", x.name.name, None, None)) - case astroid.nodes.ParamSpec(): - result.append(ArgInfo("**", x.name.name, None, None)) + 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