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
40 changes: 39 additions & 1 deletion docs/type_param_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
Iterator,
KeysView,
Optional,
TypeAlias,
TypeVar,
Union,
ValuesView,
Expand Down Expand Up @@ -123,4 +124,41 @@ class Derived(Map[int, U], Generic[U]):
pass


__all__ = ["Map", "Derived"]
MyAlias: TypeAlias = int | float
"""PEP 613 type alias that can be an int or float.

Group:
type-param
"""


MyGenericAlias: TypeAlias = list[T] | tuple[T, ...]
"""PEP 613 generic alias that can be a list or tuple with the given element type.

Group:
type-param
"""


type MyAliasType = int | float
"""PEP 695 type that can be an int or float.

Group:
type-param
"""

type MyGenericAliasType[T, U] = list[T] | tuple[U, ...]
"""PEP 695 generic alias that can be a list or tuple with the given element types.

Group:
type-param
"""

__all__ = [
"Map",
"Derived",
"MyAlias",
"MyGenericAlias",
"MyAliasType",
"MyGenericAliasType",
]
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,6 @@ conflicts = [

[tool.ruff]
lint.extend-select = ["I"]

[too.ruff.per-file-target-version]
Copy link
Collaborator

@2bndy5 2bndy5 Oct 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
[too.ruff.per-file-target-version]
[tool.ruff.per-file-target-version]

Should help ruff better understand how to format/lint that 1 file.

"tests/python_apigen_test_modules/pep695.py" = "py312"
1 change: 1 addition & 0 deletions sphinx_immaterial/apidoc/object_description_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def format_object_description_tooltip(
("py:property", {"toc_icon_class": "alias", "toc_icon_text": "P"}),
("py:attribute", {"toc_icon_class": "alias", "toc_icon_text": "A"}),
("py:data", {"toc_icon_class": "alias", "toc_icon_text": "V"}),
("py:type", {"toc_icon_class": "alias", "toc_icon_text": "T"}),
(
"py:parameter",
{
Expand Down
21 changes: 16 additions & 5 deletions sphinx_immaterial/apidoc/python/apigen.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@
from . import type_param_utils
from .parameter_objects import TYPE_PARAM_SYMBOL_PREFIX_ATTR_KEY

if sphinx.version_info >= (7, 4):
from .autodoc_type_alias_support import TypeAliasDocumenter
else:
TypeAliasDocumenter = ()

if sphinx.version_info >= (6, 1):
stringify_annotation = sphinx.util.typing.stringify_annotation
else:
Expand Down Expand Up @@ -730,11 +735,12 @@ def object_description_transform(
if summary:
content = _summarize_rst_content(content)
options["noindex"] = ""
# Avoid "canonical" option because it results in duplicate object warnings
# when combined with multiple signatures that produce different object ids.
#
# Instead, the canonical aliases are handled separately below.
options.pop("canonical", None)
if entity.objtype != "type":
# Avoid "canonical" option because it results in duplicate object warnings
# when combined with multiple signatures that produce different object ids.
#
# Instead, the canonical aliases are handled separately below.
options.pop("canonical", None)
try:
with apigen_utils.save_rst_defaults(env):
rst_input = docutils.statemachine.StringList()
Expand Down Expand Up @@ -1760,6 +1766,11 @@ def document_members(*args, **kwargs):
and typing.get_origin(base) is not typing.Generic
)
]
elif isinstance(entry.documenter, TypeAliasDocumenter):
type_params = type_param_utils.get_type_alias_params(
entry.documenter.object
)
signatures = [type_param_utils.stringify_type_params(type_params)]
else:
if primary_entity is None:
signatures = entry.documenter.format_signature().split("\n")
Expand Down
113 changes: 113 additions & 0 deletions sphinx_immaterial/apidoc/python/autodoc_type_alias_support.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Adds TypeAlias and TypeAliasType support to autodoc."""

import ast
import sys
import typing
from typing import Any, TypeAlias, ClassVar

import sphinx

if sphinx.version_info < (7, 4):
raise ValueError("Sphinx >= 7.4 required for type alias support")

import sphinx.application
from sphinx.ext.autodoc import (
Documenter,
DataDocumenter,
AttributeDocumenter,
ModuleDocumenter,
)
import sphinx.util.typing
import sphinx.util.inspect
import sphinx.domains.python
from sphinx.pycode.parser import VariableCommentPicker
import typing_extensions

if sys.version_info >= (3, 12):
TYPE_ALIAS_TYPES = (typing.TypeAliasType, typing_extensions.TypeAliasType)
TYPE_ALIAS_AST_NODES = (ast.TypeAlias,)
else:
TYPE_ALIAS_TYPES = typing_extensions.TypeAliasType
TYPE_ALIAS_AST_NODES = ()


class TypeAliasDocumenter(DataDocumenter):
priority = max(DataDocumenter.priority, AttributeDocumenter.priority) + 1
objtype = "type"
option_spec: ClassVar[sphinx.util.typing.OptionSpec] = dict(Documenter.option_spec)
option_spec["no-value"] = DataDocumenter.option_spec["no-value"]

@classmethod
def can_document_member(
cls: type[Documenter], member: Any, membername: str, isattr: bool, parent: Any
) -> bool:
if isinstance(member, TYPE_ALIAS_TYPES):
return True

if not isattr:
return False

type_hints = sphinx.util.typing.get_type_hints(
parent.object, include_extras=True
)
return type_hints.get(membername) is TypeAlias

def add_directive_header(self, sig: str) -> None:
Documenter.add_directive_header(self, sig)
sourcename = self.get_sourcename()
try:
if self.options.no_value or self.should_suppress_value_header():
pass
else:
value = self.object
if isinstance(value, TYPE_ALIAS_TYPES):
value = value.__value__
objrepr = sphinx.util.typing.stringify_annotation(value)
self.add_line(" :canonical: " + objrepr, sourcename)
except ValueError:
pass


def _monkey_patch_sphinx_analyzer_to_support_type_aliases():
orig_visit = VariableCommentPicker.visit

def visit(self, node: ast.AST) -> None:
if isinstance(node, TYPE_ALIAS_AST_NODES):
new_node = ast.Assign(targets=[node.name], value=node.value)
ast.copy_location(new_node, node)
ast.fix_missing_locations(new_node)
node = new_node
orig_visit(self, node)

VariableCommentPicker.visit = visit


def _monkey_patch_sphinx_pr_13926():
# https://github.com/sphinx-doc/sphinx/pull/13926

PyTypeAlias = sphinx.domains.python.PyTypeAlias

orig_add_target_and_index = PyTypeAlias.add_target_and_index

def add_target_and_index(self, name_cls, sig, signode) -> None:
saved_canonical = self.options.get("canonical", False)
try:
self.options.pop("canonical", None)
orig_add_target_and_index(self, name_cls, sig, signode)
finally:
if saved_canonical is not False:
self.options["canonical"] = saved_canonical

PyTypeAlias.add_target_and_index = add_target_and_index


_monkey_patch_sphinx_analyzer_to_support_type_aliases()
_monkey_patch_sphinx_pr_13926()


def setup(app: sphinx.application.Sphinx):
app.add_autodocumenter(TypeAliasDocumenter)
return {
"parallel_read_safe": True,
"parallel_write_safe": True,
}
5 changes: 5 additions & 0 deletions sphinx_immaterial/apidoc/python/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,17 @@
type_annotation_transforms,
)

if sphinx.version_info >= (7, 4):
from . import autodoc_type_alias_support


def setup(app: sphinx.application.Sphinx):
app.setup_extension(parameter_objects.__name__)
app.setup_extension(strip_property_prefix.__name__)
app.setup_extension(type_annotation_transforms.__name__)
app.setup_extension(strip_self_and_return_type_annotations.__name__)
if sphinx.version_info >= (7, 4):
app.setup_extension(autodoc_type_alias_support.__name__)

return {
"parallel_read_safe": True,
Expand Down
17 changes: 17 additions & 0 deletions sphinx_immaterial/apidoc/python/type_param_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,20 @@ def get_class_type_params(cls: type) -> tuple[TypeParam, ...]:
return tuple(params)


def get_type_alias_params(obj: typing.Any) -> tuple[TypeParam, ...]:
"""Returns the ordered list of type parameters of a type alias."""

type_params = safe_getattr(obj, "__type_params__", ())
if type_params:
return type_params

return tuple(
get_type_params_from_signature(
sphinx.util.typing.stringify_annotation(obj)
).values()
)


def stringify_type_params(type_params: typing.Iterable[TypeParam]) -> str:
"""Convert a type parameter list to its string representation.

Expand Down Expand Up @@ -256,6 +270,9 @@ def stringify_annotation(
def get_class_type_params(cls: type) -> tuple[TypeParam, ...]:
return ()

def get_type_alias_params(obj: typing.Any) -> tuple[TypeParam, ...]:
return ()

def get_type_params_from_signature(signature: str) -> dict[str, TypeParam]:
return {}

Expand Down
47 changes: 47 additions & 0 deletions tests/python_apigen_test.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import pathlib
import sys

import pytest
import sphinx
Expand All @@ -19,6 +20,7 @@ def apigen_make_app(tmp_path: pathlib.Path, make_app):
conf = """
extensions = [
"sphinx_immaterial",
"sphinx.ext.napoleon",
"sphinx_immaterial.apidoc.python.apigen",
]
html_theme = "sphinx_immaterial"
Expand Down Expand Up @@ -188,6 +190,51 @@ def test_type_params(apigen_make_app):
assert not app._warning.getvalue()


@pytest.mark.skipif(
sphinx.version_info < (7, 4),
reason=f"Type aliases are not supported by Sphinx {sphinx.version_info}",
)
@pytest.mark.parametrize(
"modname",
["pep613"] + (["pep695"] if sys.version_info >= (3, 12) else []),
)
def test_type_aliases(apigen_make_app, modname: str, snapshot):
"""Tests that type aliases work."""
testmod = f"python_apigen_test_modules.{modname}"
app = apigen_make_app(
confoverrides=dict(
python_apigen_modules={
testmod: "api/",
},
nitpicky=True,
),
)

data = _get_api_data(app.env)

print(app._status.getvalue())
print(app._warning.getvalue())
assert not app._warning.getvalue()

print(data.entities)

snapshot.assert_match(
json.dumps(
[
{
"canonical_full_name": entity.canonical_full_name,
"directive": entity.directive,
"options": entity.options,
"type_params": str(entity.type_params),
}
for entity in data.entities.values()
],
indent=2,
),
"entities.json",
)


def test_pybind11_overloaded_function(apigen_make_app, snapshot):
testmod = "sphinx_immaterial_pybind11_issue_134"
app = apigen_make_app(
Expand Down
30 changes: 30 additions & 0 deletions tests/python_apigen_test_modules/pep613.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import typing


MyAlias: typing.TypeAlias = int | float
"""PEP 613 type alias that can be int or float.

Group:
alias-group
"""


T = typing.TypeVar("T")
U = typing.TypeVar("U")

MyGenericAlias: typing.TypeAlias = list[T] | tuple[T, ...]
"""My generic alias that can be a list or tuple with the given element type.

Group:
alias-group
"""


class Foo:
"""Foo class."""

MyMemberAlias: typing.TypeAlias = int | float
"""PEP 613 alias within a class."""

MyGenericMemberAlias: typing.TypeAlias = list[T] | tuple[U, ...]
"""PEP 613 alias within a class."""
28 changes: 28 additions & 0 deletions tests/python_apigen_test_modules/pep695.py
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like mypy needs to be told to skip this file.

Using Python v3.12+

tests/python_apigen_test_modules/pep695.py:1: error: PEP 695 type aliases are
not yet supported  [valid-type]

Using Python <= v3.11

tests/python_apigen_test_modules/pep695.py:1: error: invalid syntax  [syntax]

Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
type MyAliasType = int | float
"""PEP 695 type alias that can be int or float.

Group:
alias-group
"""


type MyGenericAliasType[T] = list[T] | tuple[T, ...]
"""PEP 695 type alias that can be a list or tuple with the given element type.

Group:
alias-group
"""


class Bar[T]:
"""Class with PEP 695 type params."""


class Foo:
"""Foo class."""

type MyMemberAlias = int | float
"""PEP 613 alias within a class."""

type MyGenericMemberAlias[T, U] = list[T] | tuple[U, ...]
"""PEP 613 alias within a class."""
Loading
Loading