diff --git a/docs/type_param_demo.py b/docs/type_param_demo.py index fa31b6004..d3129b888 100644 --- a/docs/type_param_demo.py +++ b/docs/type_param_demo.py @@ -6,6 +6,7 @@ Iterator, KeysView, Optional, + TypeAlias, TypeVar, Union, ValuesView, @@ -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", +] diff --git a/pyproject.toml b/pyproject.toml index d6031c644..2e5297cb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -229,3 +229,6 @@ conflicts = [ [tool.ruff] lint.extend-select = ["I"] + +[too.ruff.per-file-target-version] +"tests/python_apigen_test_modules/pep695.py" = "py312" diff --git a/sphinx_immaterial/apidoc/object_description_options.py b/sphinx_immaterial/apidoc/object_description_options.py index 83ba0871a..c386c5b4d 100644 --- a/sphinx_immaterial/apidoc/object_description_options.py +++ b/sphinx_immaterial/apidoc/object_description_options.py @@ -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", { diff --git a/sphinx_immaterial/apidoc/python/apigen.py b/sphinx_immaterial/apidoc/python/apigen.py index dcd54edad..743999448 100644 --- a/sphinx_immaterial/apidoc/python/apigen.py +++ b/sphinx_immaterial/apidoc/python/apigen.py @@ -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: @@ -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() @@ -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") diff --git a/sphinx_immaterial/apidoc/python/autodoc_type_alias_support.py b/sphinx_immaterial/apidoc/python/autodoc_type_alias_support.py new file mode 100644 index 000000000..5da6f63e4 --- /dev/null +++ b/sphinx_immaterial/apidoc/python/autodoc_type_alias_support.py @@ -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, + } diff --git a/sphinx_immaterial/apidoc/python/default.py b/sphinx_immaterial/apidoc/python/default.py index 6c24dccb7..029794fad 100644 --- a/sphinx_immaterial/apidoc/python/default.py +++ b/sphinx_immaterial/apidoc/python/default.py @@ -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, diff --git a/sphinx_immaterial/apidoc/python/type_param_utils.py b/sphinx_immaterial/apidoc/python/type_param_utils.py index aafbfdfb8..3942dc873 100644 --- a/sphinx_immaterial/apidoc/python/type_param_utils.py +++ b/sphinx_immaterial/apidoc/python/type_param_utils.py @@ -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. @@ -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 {} diff --git a/tests/python_apigen_test.py b/tests/python_apigen_test.py index 52099ae37..97065dc60 100644 --- a/tests/python_apigen_test.py +++ b/tests/python_apigen_test.py @@ -1,5 +1,6 @@ import json import pathlib +import sys import pytest import sphinx @@ -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" @@ -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( diff --git a/tests/python_apigen_test_modules/pep613.py b/tests/python_apigen_test_modules/pep613.py new file mode 100644 index 000000000..722336a07 --- /dev/null +++ b/tests/python_apigen_test_modules/pep613.py @@ -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.""" diff --git a/tests/python_apigen_test_modules/pep695.py b/tests/python_apigen_test_modules/pep695.py new file mode 100644 index 000000000..e106cafba --- /dev/null +++ b/tests/python_apigen_test_modules/pep695.py @@ -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.""" diff --git a/tests/snapshots/python_apigen_test/test_type_aliases/pep613/entities.json b/tests/snapshots/python_apigen_test/test_type_aliases/pep613/entities.json new file mode 100644 index 000000000..ab0c52057 --- /dev/null +++ b/tests/snapshots/python_apigen_test/test_type_aliases/pep613/entities.json @@ -0,0 +1,46 @@ +[ + { + "canonical_full_name": "python_apigen_test_modules.pep613.MyAlias", + "directive": "py:type", + "options": { + "canonical": "int | float", + "module": "python_apigen_test_modules.pep613" + }, + "type_params": "()" + }, + { + "canonical_full_name": "python_apigen_test_modules.pep613.MyGenericAlias", + "directive": "py:type", + "options": { + "canonical": "list[__SPHINX_IMMATERIAL_TYPE_VAR__V_T] | tuple[__SPHINX_IMMATERIAL_TYPE_VAR__V_T, ...]", + "module": "python_apigen_test_modules.pep613" + }, + "type_params": "(~T,)" + }, + { + "canonical_full_name": "python_apigen_test_modules.pep613.Foo", + "directive": "py:class", + "options": { + "module": "python_apigen_test_modules.pep613" + }, + "type_params": "()" + }, + { + "canonical_full_name": "python_apigen_test_modules.pep613.Foo.MyMemberAlias", + "directive": "py:type", + "options": { + "canonical": "int | float", + "module": "python_apigen_test_modules.pep613" + }, + "type_params": "()" + }, + { + "canonical_full_name": "python_apigen_test_modules.pep613.Foo.MyGenericMemberAlias", + "directive": "py:type", + "options": { + "canonical": "list[__SPHINX_IMMATERIAL_TYPE_VAR__V_T] | tuple[__SPHINX_IMMATERIAL_TYPE_VAR__V_U, ...]", + "module": "python_apigen_test_modules.pep613" + }, + "type_params": "(~T, ~U)" + } +] \ No newline at end of file diff --git a/tests/snapshots/python_apigen_test/test_type_aliases/pep695/entities.json b/tests/snapshots/python_apigen_test/test_type_aliases/pep695/entities.json new file mode 100644 index 000000000..c94e7d5cc --- /dev/null +++ b/tests/snapshots/python_apigen_test/test_type_aliases/pep695/entities.json @@ -0,0 +1,54 @@ +[ + { + "canonical_full_name": "python_apigen_test_modules.pep695.MyAliasType", + "directive": "py:type", + "options": { + "canonical": "int | float", + "module": "python_apigen_test_modules.pep695" + }, + "type_params": "()" + }, + { + "canonical_full_name": "python_apigen_test_modules.pep695.MyGenericAliasType", + "directive": "py:type", + "options": { + "canonical": "list[__SPHINX_IMMATERIAL_TYPE_VAR__V_T] | tuple[__SPHINX_IMMATERIAL_TYPE_VAR__V_T, ...]", + "module": "python_apigen_test_modules.pep695" + }, + "type_params": "(T,)" + }, + { + "canonical_full_name": "python_apigen_test_modules.pep695.Bar", + "directive": "py:class", + "options": { + "module": "python_apigen_test_modules.pep695" + }, + "type_params": "(T,)" + }, + { + "canonical_full_name": "python_apigen_test_modules.pep695.Foo", + "directive": "py:class", + "options": { + "module": "python_apigen_test_modules.pep695" + }, + "type_params": "()" + }, + { + "canonical_full_name": "python_apigen_test_modules.pep695.Foo.MyMemberAlias", + "directive": "py:type", + "options": { + "canonical": "int | float", + "module": "python_apigen_test_modules.pep695" + }, + "type_params": "()" + }, + { + "canonical_full_name": "python_apigen_test_modules.pep695.Foo.MyGenericMemberAlias", + "directive": "py:type", + "options": { + "canonical": "list[__SPHINX_IMMATERIAL_TYPE_VAR__V_T] | tuple[__SPHINX_IMMATERIAL_TYPE_VAR__V_U, ...]", + "module": "python_apigen_test_modules.pep695" + }, + "type_params": "(T, U)" + } +] \ No newline at end of file