Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion autoapi/_astroid_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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.

Expand Down
11 changes: 11 additions & 0 deletions autoapi/_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions autoapi/_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions tests/python/pytypeparams/conf.py
Original file line number Diff line number Diff line change
@@ -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",
]
48 changes: 48 additions & 0 deletions tests/python/pytypeparams/example/example.py
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions tests/python/pytypeparams/index.rst
Original file line number Diff line number Diff line change
@@ -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`

20 changes: 20 additions & 0 deletions tests/python/pytypeparams/template_overrides/python/class.rst
Original file line number Diff line number Diff line change
@@ -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 %}


Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.. py:function:: {{ obj.short_name }}[{{obj.type_params}}]({{ obj.args }}) -> {{ obj.return_annotation }}

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.. py:method:: {{ obj.short_name }}[{{obj.type_params}}]({{ obj.args }}) -> {{ obj.return_annotation }}

15 changes: 15 additions & 0 deletions tests/python/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
106 changes: 106 additions & 0 deletions tests/python/test_pyintegration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]¶"