From 0912a5eb9dbac3ae3d2718d2a86599d3e1da1245 Mon Sep 17 00:00:00 2001 From: Martin Matous Date: Sat, 26 Apr 2025 01:05:25 +0200 Subject: [PATCH 01/17] Initial support for PEP 695 type aliases Signed-off-by: Martin Matous --- .ruff.toml | 3 + AUTHORS.rst | 1 + CHANGES.rst | 2 + sphinx/ext/autodoc/__init__.py | 26 ++++- sphinx/pycode/parser.py | 105 +++++++++++------- sphinx/util/typing.py | 11 +- tests/roots/test-ext-autodoc/target/pep695.py | 27 +++++ tests/test_extensions/test_ext_autodoc.py | 66 +++++++++++ 8 files changed, 195 insertions(+), 46 deletions(-) create mode 100644 tests/roots/test-ext-autodoc/target/pep695.py diff --git a/.ruff.toml b/.ruff.toml index f82928eca65..8011e7ffc55 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -9,6 +9,9 @@ extend-exclude = [ "tests/roots/test-pycode/cp_1251_coded.py", # Not UTF-8 ] +[per-file-target-version] +"tests/roots/test-ext-autodoc/target/pep695.py" = "py312" + [format] preview = true quote-style = "single" diff --git a/AUTHORS.rst b/AUTHORS.rst index 43a8da3469d..0c58baecb88 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -87,6 +87,7 @@ Contributors * Martin Larralde -- additional napoleon admonitions * Martin Liška -- option directive and role improvements * Martin Mahner -- nature theme +* Martin Matouš -- initial support for PEP 695 * Matthew Fernandez -- todo extension fix * Matthew Woodcraft -- text output improvements * Matthias Geier -- style improvements diff --git a/CHANGES.rst b/CHANGES.rst index 57e8e0efdf9..4736b44e1b3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -25,6 +25,8 @@ Features added * #13704: autodoc: Detect :py:func:`typing_extensions.overload ` and :py:func:`~typing.final` decorators. Patch by Spencer Brown. +* #13508: Initial support for PEP 695 type aliases. + Patch by Martin Matouš. Bugs fixed ---------- diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index 560b6905208..e7461d91644 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -54,6 +54,11 @@ [Sphinx, _AutodocObjType, str, Any, dict[str, bool], list[str]], None ] +if sys.version_info[:2] < (3, 12): + from typing_extensions import TypeAliasType +else: + from typing import TypeAliasType + logger = logging.getLogger(__name__) @@ -1690,11 +1695,13 @@ def __init__(self, *args: Any) -> None: @classmethod def can_document_member( - cls: type[Documenter], member: Any, membername: str, isattr: bool, parent: Any + cls, member: Any, membername: str, isattr: bool, parent: Any ) -> bool: - return isinstance(member, type) or ( - isattr and isinstance(member, NewType | TypeVar) - ) + return isinstance(member, type) or (isattr and cls._is_typelike(member)) + + @staticmethod + def _is_typelike(obj: Any) -> bool: + return isinstance(obj, NewType | TypeVar | TypeAliasType) def import_object(self, raiseerror: bool = False) -> bool: ret = super().import_object(raiseerror) @@ -1705,7 +1712,7 @@ def import_object(self, raiseerror: bool = False) -> bool: self.doc_as_attr = self.objpath[-1] != self.object.__name__ else: self.doc_as_attr = True - if isinstance(self.object, NewType | TypeVar): + if self._is_typelike(self.object): modname = getattr(self.object, '__module__', self.modname) if modname != self.modname and self.modname.startswith(modname): bases = self.modname[len(modname) :].strip('.').split('.') @@ -1714,7 +1721,7 @@ def import_object(self, raiseerror: bool = False) -> bool: return ret def _get_signature(self) -> tuple[Any | None, str | None, Signature | None]: - if isinstance(self.object, NewType | TypeVar): + if self._is_typelike(self.object): # Suppress signature return None, None, None @@ -1925,6 +1932,8 @@ def add_directive_header(self, sig: str) -> None: if self.doc_as_attr: self.directivetype = 'attribute' + if isinstance(self.object, TypeAliasType): + self.directivetype = 'type' super().add_directive_header(sig) if isinstance(self.object, NewType | TypeVar): @@ -1942,6 +1951,11 @@ def add_directive_header(self, sig: str) -> None: ): self.add_line(' :canonical: %s' % canonical_fullname, sourcename) + if isinstance(self.object, TypeAliasType): + aliased = stringify_annotation(self.object.__value__) + self.add_line(' :canonical: %s' % aliased, sourcename) + return + # add inheritance info, if wanted if not self.doc_as_attr and self.options.show_inheritance: if inspect.getorigbases(self.object): diff --git a/sphinx/pycode/parser.py b/sphinx/pycode/parser.py index 43081c61f13..2474fb70619 100644 --- a/sphinx/pycode/parser.py +++ b/sphinx/pycode/parser.py @@ -9,6 +9,7 @@ import itertools import operator import re +import sys import tokenize from token import DEDENT, INDENT, NAME, NEWLINE, NUMBER, OP, STRING from tokenize import COMMENT, NL @@ -332,6 +333,48 @@ def get_line(self, lineno: int) -> str: """Returns specified line.""" return self.buffers[lineno - 1] + def collect_doc_comment( + self, + # exists for >= 3.12, irrelevant for runtime + node: ast.Assign | ast.TypeAlias, # type: ignore[name-defined] + varnames: list[str], + current_line: str, + ) -> None: + # check comments after assignment + parser = AfterCommentParser([ + current_line[node.col_offset :], + *self.buffers[node.lineno :], + ]) + parser.parse() + if parser.comment and comment_re.match(parser.comment): + for varname in varnames: + self.add_variable_comment( + varname, comment_re.sub('\\1', parser.comment) + ) + self.add_entry(varname) + return + + # check comments before assignment + if indent_re.match(current_line[: node.col_offset]): + comment_lines = [] + for i in range(node.lineno - 1): + before_line = self.get_line(node.lineno - 1 - i) + if comment_re.match(before_line): + comment_lines.append(comment_re.sub('\\1', before_line)) + else: + break + + if comment_lines: + comment = dedent_docstring('\n'.join(reversed(comment_lines))) + for varname in varnames: + self.add_variable_comment(varname, comment) + self.add_entry(varname) + return + + # not commented (record deforders only) + for varname in varnames: + self.add_entry(varname) + def visit(self, node: ast.AST) -> None: """Updates self.previous to the given node.""" super().visit(node) @@ -381,41 +424,7 @@ def visit_Assign(self, node: ast.Assign) -> None: elif hasattr(node, 'type_comment') and node.type_comment: for varname in varnames: self.add_variable_annotation(varname, node.type_comment) # type: ignore[arg-type] - - # check comments after assignment - parser = AfterCommentParser([ - current_line[node.col_offset :], - *self.buffers[node.lineno :], - ]) - parser.parse() - if parser.comment and comment_re.match(parser.comment): - for varname in varnames: - self.add_variable_comment( - varname, comment_re.sub('\\1', parser.comment) - ) - self.add_entry(varname) - return - - # check comments before assignment - if indent_re.match(current_line[: node.col_offset]): - comment_lines = [] - for i in range(node.lineno - 1): - before_line = self.get_line(node.lineno - 1 - i) - if comment_re.match(before_line): - comment_lines.append(comment_re.sub('\\1', before_line)) - else: - break - - if comment_lines: - comment = dedent_docstring('\n'.join(reversed(comment_lines))) - for varname in varnames: - self.add_variable_comment(varname, comment) - self.add_entry(varname) - return - - # not commented (record deforders only) - for varname in varnames: - self.add_entry(varname) + self.collect_doc_comment(node, varnames, current_line) def visit_AnnAssign(self, node: ast.AnnAssign) -> None: """Handles AnnAssign node and pick up a variable comment.""" @@ -423,11 +432,11 @@ def visit_AnnAssign(self, node: ast.AnnAssign) -> None: def visit_Expr(self, node: ast.Expr) -> None: """Handles Expr node and pick up a comment if string.""" - if ( - isinstance(self.previous, ast.Assign | ast.AnnAssign) - and isinstance(node.value, ast.Constant) - and isinstance(node.value.value, str) + if not ( + isinstance(node.value, ast.Constant) and isinstance(node.value.value, str) ): + return + if isinstance(self.previous, ast.Assign | ast.AnnAssign): try: targets = get_assign_targets(self.previous) varnames = get_lvar_names(targets[0], self.get_self()) @@ -441,6 +450,13 @@ def visit_Expr(self, node: ast.Expr) -> None: self.add_entry(varname) except TypeError: pass # this assignment is not new definition! + if (sys.version_info[:2] >= (3, 12)) and isinstance( + self.previous, ast.TypeAlias + ): + varname = self.previous.name.id + docstring = node.value.value + self.add_variable_comment(varname, dedent_docstring(docstring)) + self.add_entry(varname) def visit_Try(self, node: ast.Try) -> None: """Handles Try node and processes body and else-clause. @@ -485,6 +501,17 @@ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: """Handles AsyncFunctionDef node and set context.""" self.visit_FunctionDef(node) # type: ignore[arg-type] + if sys.version_info[:2] >= (3, 12): + + def visit_TypeAlias(self, node: ast.TypeAlias) -> None: + """Handles TypeAlias node and picks up a variable comment. + + .. note:: TypeAlias node refers to `type Foo = Bar` (PEP 695) assignment, + NOT `Foo: TypeAlias = Bar` (PEP 613). + """ + current_line = self.get_line(node.lineno) + self.collect_doc_comment(node, [node.name.id], current_line) + class DefinitionFinder(TokenProcessor): """Python source code parser to detect location of functions, diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index 1a68a18e29a..2231cf46b4d 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -33,6 +33,11 @@ 'smart', ] +if sys.version_info[:2] < (3, 12): + from typing_extensions import TypeAliasType +else: + from typing import TypeAliasType + logger = logging.getLogger(__name__) @@ -309,6 +314,8 @@ def restify(cls: Any, mode: _RestifyMode = 'fully-qualified-except-typing') -> s # are printed natively and ``None``-like types are kept as is. # *cls* is defined in ``typing``, and thus ``__args__`` must exist return ' | '.join(restify(a, mode) for a in cls.__args__) + elif isinstance(cls, TypeAliasType): + return f':py:type:`{module_prefix}{cls.__module__}.{cls.__name__}`' elif cls.__module__ in {'__builtin__', 'builtins'}: if hasattr(cls, '__args__'): if not cls.__args__: # Empty tuple, list, ... @@ -440,7 +447,9 @@ def stringify_annotation( annotation_module_is_typing = True # Extract the annotation's base type by considering formattable cases - if isinstance(annotation, typing.TypeVar) and not _is_unpack_form(annotation): + if isinstance(annotation, typing.TypeVar | TypeAliasType) and not _is_unpack_form( + annotation + ): # typing_extensions.Unpack is incorrectly determined as a TypeVar if annotation_module_is_typing and mode in { 'fully-qualified-except-typing', diff --git a/tests/roots/test-ext-autodoc/target/pep695.py b/tests/roots/test-ext-autodoc/target/pep695.py new file mode 100644 index 00000000000..0f3f8bd64cd --- /dev/null +++ b/tests/roots/test-ext-autodoc/target/pep695.py @@ -0,0 +1,27 @@ +from typing import NewType + + +class Foo: + """This is class Foo.""" + + +type Pep695Alias = Foo +"""This is PEP695 type alias.""" + +type Pep695AliasC = dict[ + str, Foo +] #: This is PEP695 complex type alias with doc comment. + +type Pep695AliasUnion = str | int +"""This is PEP695 type alias for union.""" + +type Pep695AliasOfAlias = Pep695AliasC +"""This is PEP695 type alias of PEP695 alias.""" + +Bar = NewType('Bar', Pep695Alias) +"""This is newtype of Pep695Alias.""" + + +def ret_pep695(a: Pep695Alias) -> Pep695Alias: + """This fn accepts and returns PEP695 alias.""" + ... diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index d7c41291e01..ce32ee660c0 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -2451,6 +2451,72 @@ def test_autodoc_GenericAlias(app): ] +@pytest.mark.skipif( + sys.version_info[:2] < (3, 12), + reason='PEP 695 is Python 3.12 feature. Older versions fail to parse source into AST.', +) +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_autodoc_pep695_type_alias(app): + options = { + 'members': None, + 'undoc-members': None, + } + actual = do_autodoc(app, 'module', 'target.pep695', options) + assert list(actual) == [ + '', + '.. py:module:: target.pep695', + '', + '', + '.. py:class:: Bar', + ' :module: target.pep695', + '', + ' This is newtype of Pep695Alias.', + '', + ' alias of :py:type:`~target.pep695.Pep695Alias`', + '', + '', + '.. py:class:: Foo()', + ' :module: target.pep695', + '', + ' This is class Foo.', + '', + '', + '.. py:type:: Pep695Alias', + ' :module: target.pep695', + ' :canonical: target.pep695.Foo', + '', + ' This is PEP695 type alias.', + '', + '', + '.. py:type:: Pep695AliasC', + ' :module: target.pep695', + ' :canonical: dict[str, target.pep695.Foo]', + '', + ' This is PEP695 complex type alias with doc comment.', + '', + '', + '.. py:type:: Pep695AliasOfAlias', + ' :module: target.pep695', + ' :canonical: target.pep695.Pep695AliasC', + '', + ' This is PEP695 type alias of PEP695 alias.', + '', + '', + '.. py:type:: Pep695AliasUnion', + ' :module: target.pep695', + ' :canonical: str | int', + '', + ' This is PEP695 type alias for union.', + '', + '', + '.. py:function:: ret_pep695(a: ~target.pep695.Pep695Alias) -> ~target.pep695.Pep695Alias', + ' :module: target.pep695', + '', + ' This fn accepts and returns PEP695 alias.', + '', + ] + + @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_autodoc_TypeVar(app): options = { From d213f88f418e41ac58e5eeeff5e31b8dd8c701e2 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Tue, 29 Jul 2025 03:44:26 +0100 Subject: [PATCH 02/17] Merge branch 'master' into pep695 --- sphinx/ext/autodoc/_documenters.py | 22 ++++++++++++++++++---- sphinx/ext/autodoc/importer.py | 6 +++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/sphinx/ext/autodoc/_documenters.py b/sphinx/ext/autodoc/_documenters.py index ca35448bc33..c3b8de4e0f8 100644 --- a/sphinx/ext/autodoc/_documenters.py +++ b/sphinx/ext/autodoc/_documenters.py @@ -66,6 +66,11 @@ from sphinx.registry import SphinxComponentRegistry from sphinx.util.typing import OptionSpec, _RestifyMode +if sys.version_info[:2] < (3, 12): + from typing_extensions import TypeAliasType +else: + from typing import TypeAliasType + logger = logging.getLogger('sphinx.ext.autodoc') #: extended signature RE: with explicit module name separated by :: @@ -1481,9 +1486,11 @@ def __init__(self, *args: Any) -> None: def can_document_member( cls: type[Documenter], member: Any, membername: str, isattr: bool, parent: Any ) -> bool: - return isinstance(member, type) or ( - isattr and isinstance(member, NewType | TypeVar) - ) + return isinstance(member, type) or (isattr and cls._is_typelike(member)) + + @staticmethod + def _is_typelike(obj: Any) -> bool: + return isinstance(obj, NewType | TypeVar | TypeAliasType) def import_object(self, raiseerror: bool = False) -> bool: try: @@ -1507,7 +1514,7 @@ def import_object(self, raiseerror: bool = False) -> bool: return True def _get_signature(self) -> tuple[Any | None, str | None, Signature | None]: - if isinstance(self.object, NewType | TypeVar): + if self._is_typelike(self.object): # Suppress signature return None, None, None @@ -1718,6 +1725,8 @@ def add_directive_header(self, sig: str) -> None: if self.doc_as_attr: self.directivetype = 'attribute' + if isinstance(self.object, TypeAliasType): + self.directivetype = 'type' super().add_directive_header(sig) if isinstance(self.object, NewType | TypeVar): @@ -1735,6 +1744,11 @@ def add_directive_header(self, sig: str) -> None: ): self.add_line(' :canonical: %s' % canonical_fullname, sourcename) + if isinstance(self.object, TypeAliasType): + aliased = stringify_annotation(self.object.__value__) + self.add_line(' :canonical: %s' % aliased, sourcename) + return + # add inheritance info, if wanted if not self.doc_as_attr and self.options.show_inheritance: if inspect.getorigbases(self.object): diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index 88cbc230023..02bef5a7895 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -47,6 +47,10 @@ class _AttrGetter(Protocol): def __call__(self, obj: Any, name: str, default: Any = ..., /) -> Any: ... +if sys.version_info[:2] < (3, 12): + from typing_extensions import TypeAliasType +else: + from typing import TypeAliasType _NATIVE_SUFFIXES: frozenset[str] = frozenset({'.pyx', *EXTENSION_SUFFIXES}) logger = logging.getLogger(__name__) @@ -635,7 +639,7 @@ def _import_class( else: im.doc_as_attr = True - if isinstance(im.obj, NewType | TypeVar): + if isinstance(im.obj, NewType | TypeVar | TypeAliasType): obj_module_name = getattr(im.obj, '__module__', module_name) if obj_module_name != module_name and module_name.startswith(obj_module_name): bases = module_name[len(obj_module_name) :].strip('.').split('.') From 67c721fd3f182904c88a83bd7b6bc65e95a0bb40 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Tue, 29 Jul 2025 03:48:36 +0100 Subject: [PATCH 03/17] _is_type_like() --- sphinx/ext/autodoc/_documenters.py | 9 +++------ sphinx/ext/autodoc/importer.py | 9 ++++++++- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/sphinx/ext/autodoc/_documenters.py b/sphinx/ext/autodoc/_documenters.py index c3b8de4e0f8..552442fab7a 100644 --- a/sphinx/ext/autodoc/_documenters.py +++ b/sphinx/ext/autodoc/_documenters.py @@ -37,6 +37,7 @@ _import_object, _import_property, _is_runtime_instance_attribute_not_commented, + _is_type_like, get_class_members, ) from sphinx.ext.autodoc.mock import ismock, mock, undecorate @@ -1486,11 +1487,7 @@ def __init__(self, *args: Any) -> None: def can_document_member( cls: type[Documenter], member: Any, membername: str, isattr: bool, parent: Any ) -> bool: - return isinstance(member, type) or (isattr and cls._is_typelike(member)) - - @staticmethod - def _is_typelike(obj: Any) -> bool: - return isinstance(obj, NewType | TypeVar | TypeAliasType) + return isinstance(member, type) or (isattr and _is_type_like(member)) def import_object(self, raiseerror: bool = False) -> bool: try: @@ -1514,7 +1511,7 @@ def import_object(self, raiseerror: bool = False) -> bool: return True def _get_signature(self) -> tuple[Any | None, str | None, Signature | None]: - if self._is_typelike(self.object): + if _is_type_like(self.object): # Suppress signature return None, None, None diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index 02bef5a7895..35b42b211d2 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -42,11 +42,14 @@ from types import ModuleType from typing import Any, Protocol + from typing_extensions import TypeIs + from sphinx.ext.autodoc import ObjectMember class _AttrGetter(Protocol): def __call__(self, obj: Any, name: str, default: Any = ..., /) -> Any: ... + if sys.version_info[:2] < (3, 12): from typing_extensions import TypeAliasType else: @@ -639,7 +642,7 @@ def _import_class( else: im.doc_as_attr = True - if isinstance(im.obj, NewType | TypeVar | TypeAliasType): + if _is_type_like(im.obj): obj_module_name = getattr(im.obj, '__module__', module_name) if obj_module_name != module_name and module_name.startswith(obj_module_name): bases = module_name[len(obj_module_name) :].strip('.').split('.') @@ -907,3 +910,7 @@ def _is_slots_attribute(*, parent: Any, obj_path: Sequence[str]) -> bool: return False except (ValueError, TypeError): return False + + +def _is_type_like(obj: Any) -> TypeIs[NewType | TypeVar | TypeAliasType]: + return isinstance(obj, (NewType, TypeVar, TypeAliasType)) From 73fdd3251bd2209761103301284385719df739fc Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Sun, 5 Oct 2025 14:24:38 -0700 Subject: [PATCH 04/17] Support both typing_extensions.TypeAliasType and typing.TypeAliasType For Python 3.12 and 3.13, both exist and are not the same type. --- sphinx/ext/autodoc/_documenters.py | 16 ++++++++-------- sphinx/ext/autodoc/importer.py | 12 ++++-------- sphinx/util/typing.py | 14 +++++++++----- tests/roots/test-ext-autodoc/target/pep695.py | 10 +++++++++- tests/test_extensions/test_ext_autodoc.py | 14 ++++++++++++++ 5 files changed, 44 insertions(+), 22 deletions(-) diff --git a/sphinx/ext/autodoc/_documenters.py b/sphinx/ext/autodoc/_documenters.py index ddc974021c4..264014faf70 100644 --- a/sphinx/ext/autodoc/_documenters.py +++ b/sphinx/ext/autodoc/_documenters.py @@ -47,7 +47,12 @@ safe_getattr, stringify_signature, ) -from sphinx.util.typing import get_type_hints, restify, stringify_annotation +from sphinx.util.typing import ( + AnyTypeAliasType, + get_type_hints, + restify, + stringify_annotation, +) if TYPE_CHECKING: from collections.abc import Callable, Iterator, Sequence @@ -69,11 +74,6 @@ from sphinx.registry import SphinxComponentRegistry from sphinx.util.typing import OptionSpec, _RestifyMode -if sys.version_info[:2] < (3, 12): - from typing_extensions import TypeAliasType -else: - from typing import TypeAliasType - logger = logging.getLogger('sphinx.ext.autodoc') #: extended signature RE: with explicit module name separated by :: @@ -1362,7 +1362,7 @@ def add_directive_header(self, sig: str) -> None: if self.props.doc_as_attr: self.directivetype = 'attribute' - if isinstance(self.object, TypeAliasType): + if isinstance(self.props._obj, AnyTypeAliasType): self.directivetype = 'type' super().add_directive_header(sig) @@ -1381,7 +1381,7 @@ def add_directive_header(self, sig: str) -> None: ): self.add_line(' :canonical: %s' % canonical_fullname, sourcename) - if isinstance(self.object, TypeAliasType): + if isinstance(self.props._obj, AnyTypeAliasType): aliased = stringify_annotation(self.object.__value__) self.add_line(' :canonical: %s' % aliased, sourcename) return diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index 5ea3ecdfe48..38cd11b221d 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -35,7 +35,7 @@ isclass, safe_getattr, ) -from sphinx.util.typing import get_type_hints +from sphinx.util.typing import AnyTypeAliasType, get_type_hints if TYPE_CHECKING: from collections.abc import Sequence @@ -44,6 +44,7 @@ from typing import Any, Protocol from typing_extensions import TypeIs + from sphinx.environment import BuildEnvironment, _CurrentDocument from sphinx.ext.autodoc._property_types import _AutodocFuncProperty, _AutodocObjType @@ -51,11 +52,6 @@ class _AttrGetter(Protocol): def __call__(self, obj: Any, name: str, default: Any = ..., /) -> Any: ... -if sys.version_info[:2] < (3, 12): - from typing_extensions import TypeAliasType -else: - from typing import TypeAliasType - _NATIVE_SUFFIXES: frozenset[str] = frozenset({'.pyx', *EXTENSION_SUFFIXES}) logger = logging.getLogger(__name__) @@ -459,8 +455,8 @@ def _is_slots_attribute(*, parent: Any, obj_path: Sequence[str]) -> bool: return False -def _is_type_like(obj: Any) -> TypeIs[NewType | TypeVar | TypeAliasType]: - return isinstance(obj, (NewType, TypeVar, TypeAliasType)) +def _is_type_like(obj: Any) -> TypeIs[NewType | TypeVar | AnyTypeAliasType]: + return isinstance(obj, (NewType, TypeVar, AnyTypeAliasType)) def _load_object_by_name( diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index 6816ed111ad..3f3d173a43f 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -33,11 +33,15 @@ 'smart', ] +from typing_extensions import TypeAliasType as ExtensionTypeAliasType + if sys.version_info[:2] < (3, 12): - from typing_extensions import TypeAliasType + AnyTypeAliasType = ExtensionTypeAliasType else: from typing import TypeAliasType + AnyTypeAliasType = TypeAliasType | ExtensionTypeAliasType + logger = logging.getLogger(__name__) @@ -314,7 +318,7 @@ def restify(cls: Any, mode: _RestifyMode = 'fully-qualified-except-typing') -> s # are printed natively and ``None``-like types are kept as is. # *cls* is defined in ``typing``, and thus ``__args__`` must exist return ' | '.join(restify(a, mode) for a in cls.__args__) - elif isinstance(cls, TypeAliasType): + elif isinstance(cls, AnyTypeAliasType): return f':py:type:`{module_prefix}{cls.__module__}.{cls.__name__}`' elif cls.__module__ in {'__builtin__', 'builtins'}: if hasattr(cls, '__args__'): @@ -447,9 +451,9 @@ def stringify_annotation( annotation_module_is_typing = True # Extract the annotation's base type by considering formattable cases - if isinstance(annotation, typing.TypeVar | TypeAliasType) and not _is_unpack_form( - annotation - ): + if isinstance( + annotation, typing.TypeVar | AnyTypeAliasType + ) and not _is_unpack_form(annotation): # typing_extensions.Unpack is incorrectly determined as a TypeVar if annotation_module_is_typing and mode in { 'fully-qualified-except-typing', diff --git a/tests/roots/test-ext-autodoc/target/pep695.py b/tests/roots/test-ext-autodoc/target/pep695.py index 0f3f8bd64cd..49807d9f3be 100644 --- a/tests/roots/test-ext-autodoc/target/pep695.py +++ b/tests/roots/test-ext-autodoc/target/pep695.py @@ -1,4 +1,6 @@ -from typing import NewType +from typing import NewType, TypeAliasType + +import typing_extensions class Foo: @@ -8,6 +10,12 @@ class Foo: type Pep695Alias = Foo """This is PEP695 type alias.""" +TypeAliasTypeExplicit = TypeAliasType('TypeAliasTypeExplicit', Foo) # noqa: UP040 +"""This is an explicitly constructed typing.TypeAlias.""" + +TypeAliasTypeExtension = typing_extensions.TypeAliasType('TypeAliasTypeExtension', Foo) # noqa: UP040 +"""This is an explicitly constructed typing_extensions.TypeAlias.""" + type Pep695AliasC = dict[ str, Foo ] #: This is PEP695 complex type alias with doc comment. diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index 67ee7e16abe..adb6ee8eb64 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -2540,6 +2540,20 @@ def test_autodoc_pep695_type_alias(app): ' This is PEP695 type alias for union.', '', '', + '.. py:type:: TypeAliasTypeExplicit', + ' :module: target.pep695', + ' :canonical: target.pep695.Foo', + '', + ' This is an explicitly constructed typing.TypeAlias.', + '', + '', + '.. py:type:: TypeAliasTypeExtension', + ' :module: target.pep695', + ' :canonical: target.pep695.Foo', + '', + ' This is an explicitly constructed typing_extensions.TypeAlias.', + '', + '', '.. py:function:: ret_pep695(a: ~target.pep695.Pep695Alias) -> ~target.pep695.Pep695Alias', ' :module: target.pep695', '', From b2efec6d86595b3c901283c5c58c9a51c3947de9 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Tue, 7 Oct 2025 09:56:33 -0700 Subject: [PATCH 05/17] Fix review comments --- CHANGES.rst | 2 +- sphinx/ext/autodoc/_documenters.py | 11 +++++------ sphinx/pycode/parser.py | 29 ++++++++++++++--------------- 3 files changed, 20 insertions(+), 22 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index c5f5f8917af..0e945ee50cd 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -42,7 +42,7 @@ Deprecated Features added -------------- -* #13508: Initial support for PEP 695 type aliases. +* #13508: Initial support for :pep:`695` type aliases. Patch by Martin Matouš. * #13332: Add :confval:`doctest_fail_fast` option to exit after the first failed test. diff --git a/sphinx/ext/autodoc/_documenters.py b/sphinx/ext/autodoc/_documenters.py index 264014faf70..7389d1af7d3 100644 --- a/sphinx/ext/autodoc/_documenters.py +++ b/sphinx/ext/autodoc/_documenters.py @@ -1369,23 +1369,22 @@ def add_directive_header(self, sig: str) -> None: if isinstance(self.props._obj, (NewType, TypeVar)): return + if isinstance(self.props._obj, AnyTypeAliasType): + aliased = stringify_annotation(self.object.__value__) + self.add_line(' :canonical: %s' % aliased, sourcename) + return + if self.analyzer and self.props.dotted_parts in self.analyzer.finals: self.add_line(' :final:', sourcename) canonical_fullname = self.get_canonical_fullname() if ( not self.props.doc_as_attr - and not isinstance(self.props._obj, NewType) and canonical_fullname and self.props.full_name != canonical_fullname ): self.add_line(' :canonical: %s' % canonical_fullname, sourcename) - if isinstance(self.props._obj, AnyTypeAliasType): - aliased = stringify_annotation(self.object.__value__) - self.add_line(' :canonical: %s' % aliased, sourcename) - return - # add inheritance info, if wanted if not self.props.doc_as_attr and self.options.show_inheritance: if inspect.getorigbases(self.props._obj): diff --git a/sphinx/pycode/parser.py b/sphinx/pycode/parser.py index e7f8b85a325..f5660e2158a 100644 --- a/sphinx/pycode/parser.py +++ b/sphinx/pycode/parser.py @@ -21,6 +21,11 @@ from inspect import Signature from typing import Any +if sys.version_info[:2] >= (3, 12): + AssignmentLike = ast.Assign | ast.AnnAssign | ast.TypeAlias +else: + AssignmentLike = ast.Assign | ast.AnnAssign + comment_re = re.compile('^\\s*#: ?(.*)\r?\n?$') indent_re = re.compile('^\\s*$') emptyline_re = re.compile('^\\s*(#.*)?$') @@ -30,12 +35,14 @@ def filter_whitespace(code: str) -> str: return code.replace('\f', ' ') # replace FF (form feed) with whitespace -def get_assign_targets(node: ast.AST) -> list[ast.expr]: - """Get list of targets from Assign and AnnAssign node.""" +def get_assign_targets(node: AssignmentLike) -> list[ast.expr]: + """Get list of targets from AssignmentLike node.""" if isinstance(node, ast.Assign): return node.targets + elif isinstance(node, ast.AnnAssign): + return [node.target] else: - return [node.target] # type: ignore[attr-defined] + return [node.name] def get_lvar_names(node: ast.AST, self: ast.arg | None = None) -> list[str]: @@ -335,8 +342,7 @@ def get_line(self, lineno: int) -> str: def collect_doc_comment( self, - # exists for >= 3.12, irrelevant for runtime - node: ast.Assign | ast.TypeAlias, # type: ignore[name-defined] + node: AssignmentLike, varnames: list[str], current_line: str, ) -> None: @@ -404,7 +410,7 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> None: elif name.name == 'overload': self.typing_overload_names.add(name.asname or name.name) - def visit_Assign(self, node: ast.Assign) -> None: + def visit_Assign(self, node: ast.Assign | ast.AnnAssign) -> None: """Handles Assign node and pick up a variable comment.""" try: targets = get_assign_targets(node) @@ -428,7 +434,7 @@ def visit_Assign(self, node: ast.Assign) -> None: def visit_AnnAssign(self, node: ast.AnnAssign) -> None: """Handles AnnAssign node and pick up a variable comment.""" - self.visit_Assign(node) # type: ignore[arg-type] + self.visit_Assign(node) def visit_Expr(self, node: ast.Expr) -> None: """Handles Expr node and pick up a comment if string.""" @@ -436,7 +442,7 @@ def visit_Expr(self, node: ast.Expr) -> None: isinstance(node.value, ast.Constant) and isinstance(node.value.value, str) ): return - if isinstance(self.previous, ast.Assign | ast.AnnAssign): + if isinstance(self.previous, AssignmentLike): try: targets = get_assign_targets(self.previous) varnames = get_lvar_names(targets[0], self.get_self()) @@ -450,13 +456,6 @@ def visit_Expr(self, node: ast.Expr) -> None: self.add_entry(varname) except TypeError: pass # this assignment is not new definition! - if (sys.version_info[:2] >= (3, 12)) and isinstance( - self.previous, ast.TypeAlias - ): - varname = self.previous.name.id - docstring = node.value.value - self.add_variable_comment(varname, dedent_docstring(docstring)) - self.add_entry(varname) def visit_Try(self, node: ast.Try) -> None: """Handles Try node and processes body and else-clause. From 2b18570a36f4a3bf4e6ee58a3e9c602fdf3d801d Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Tue, 7 Oct 2025 13:26:21 -0700 Subject: [PATCH 06/17] Allow `py:class` role to resolve `py:type` objects --- sphinx/domains/python/__init__.py | 2 +- .../roots/test-ext-autodoc-type-alias-xref/alias_module.py | 7 ++++++- .../test_ext_autodoc_type_alias_nitpicky.py | 4 ++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/sphinx/domains/python/__init__.py b/sphinx/domains/python/__init__.py index f8402b4be79..3cca270abf6 100644 --- a/sphinx/domains/python/__init__.py +++ b/sphinx/domains/python/__init__.py @@ -742,7 +742,7 @@ class PythonDomain(Domain): 'staticmethod': ObjType(_('static method'), 'meth', 'obj'), 'attribute': ObjType(_('attribute'), 'attr', 'obj'), 'property': ObjType(_('property'), 'attr', '_prop', 'obj'), - 'type': ObjType(_('type alias'), 'type', 'obj'), + 'type': ObjType(_('type alias'), 'type', 'class', 'obj'), 'module': ObjType(_('module'), 'mod', 'obj'), } diff --git a/tests/roots/test-ext-autodoc-type-alias-xref/alias_module.py b/tests/roots/test-ext-autodoc-type-alias-xref/alias_module.py index b169e75fa00..8e3afb02d6b 100644 --- a/tests/roots/test-ext-autodoc-type-alias-xref/alias_module.py +++ b/tests/roots/test-ext-autodoc-type-alias-xref/alias_module.py @@ -4,12 +4,17 @@ import pathlib +import typing_extensions + #: Any type of path pathlike = str | pathlib.Path #: A generic type alias for error handlers Handler = type[Exception] +#: A PEP 695 type alias for error handlers +HandlerType = typing_extensions.TypeAliasType('HandlerType', type[Exception]) + def read_file(path: pathlike) -> bytes: """Read a file and return its contents. @@ -20,7 +25,7 @@ def read_file(path: pathlike) -> bytes: return f.read() -def process_error(handler: Handler) -> str: +def process_error(handler: Handler, other: HandlerType) -> str: """Process an error with a custom handler type. Tests generic type alias cross-reference resolution. diff --git a/tests/test_ext_autodoc/test_ext_autodoc_type_alias_nitpicky.py b/tests/test_ext_autodoc/test_ext_autodoc_type_alias_nitpicky.py index a23ae489246..8d4fbeaf2e0 100644 --- a/tests/test_ext_autodoc/test_ext_autodoc_type_alias_nitpicky.py +++ b/tests/test_ext_autodoc/test_ext_autodoc_type_alias_nitpicky.py @@ -78,3 +78,7 @@ def test_type_alias_xref_resolution(app: SphinxTestApp) -> None: ' Date: Tue, 7 Oct 2025 13:33:32 -0700 Subject: [PATCH 07/17] Add typing_extensions as dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 42556be6a88..b25b4e5c610 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,7 @@ dependencies = [ "roman-numerals-py>=1.0.0", "packaging>=23.0", "colorama>=0.4.6; sys_platform == 'win32'", + "typing_extensions>=4.10" # For TypeAliasType and TypeIs ] dynamic = ["version"] From fa5585c1da0b9a66cee8335ead72974a2776d2d5 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Tue, 7 Oct 2025 17:11:18 -0700 Subject: [PATCH 08/17] Update autodoc documentation --- doc/usage/extensions/autodoc.rst | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/doc/usage/extensions/autodoc.rst b/doc/usage/extensions/autodoc.rst index 1b873f0d819..f714acacf4d 100644 --- a/doc/usage/extensions/autodoc.rst +++ b/doc/usage/extensions/autodoc.rst @@ -529,7 +529,7 @@ Automatically document classes or exceptions .. rst:directive:: autoclass autoexception - Document a class. + Document a class or :pep:`695` type alias. For exception classes, prefer ``.. autoexception::``. By default, the directive only inserts the docstring of the class itself: @@ -577,6 +577,11 @@ Automatically document classes or exceptions .. versionadded:: 0.4 + .. versionchanged:: 8.3.0 + + :pep:`695` type aliases (without :pep:`695` type parameters) are now + supported. + .. rubric:: Options .. rst:directive:option:: no-index From 808a9e9e1e6d39c2df84fe89ec7cc6b3d727f04b Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Thu, 9 Oct 2025 02:00:33 +0100 Subject: [PATCH 09/17] Extract `_handle_assigmnent` --- sphinx/pycode/parser.py | 77 ++++++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/sphinx/pycode/parser.py b/sphinx/pycode/parser.py index f5660e2158a..f1e4fbebefa 100644 --- a/sphinx/pycode/parser.py +++ b/sphinx/pycode/parser.py @@ -23,8 +23,10 @@ if sys.version_info[:2] >= (3, 12): AssignmentLike = ast.Assign | ast.AnnAssign | ast.TypeAlias + AssignmentLikeType = (ast.Assign, ast.AnnAssign, ast.TypeAlias) else: AssignmentLike = ast.Assign | ast.AnnAssign + AssignmentLikeType = (ast.Assign, ast.AnnAssign) comment_re = re.compile('^\\s*#: ?(.*)\r?\n?$') indent_re = re.compile('^\\s*$') @@ -42,7 +44,7 @@ def get_assign_targets(node: AssignmentLike) -> list[ast.expr]: elif isinstance(node, ast.AnnAssign): return [node.target] else: - return [node.name] + return [node.name] # ast.TypeAlias def get_lvar_names(node: ast.AST, self: ast.arg | None = None) -> list[str]: @@ -340,7 +342,29 @@ def get_line(self, lineno: int) -> str: """Returns specified line.""" return self.buffers[lineno - 1] - def collect_doc_comment( + def _handle_assignment(self, node: ast.Assign | ast.AnnAssign) -> None: + """Handles Assign node and pick up a variable comment.""" + try: + targets = get_assign_targets(node) + varnames: list[str] = functools.reduce( + operator.iadd, + [get_lvar_names(t, self=self.get_self()) for t in targets], + [], + ) + current_line = self.get_line(node.lineno) + except TypeError: + return # this assignment is not new definition! + + # record annotation + if hasattr(node, 'annotation') and node.annotation: + for varname in varnames: + self.add_variable_annotation(varname, node.annotation) + elif hasattr(node, 'type_comment') and node.type_comment: + for varname in varnames: + self.add_variable_annotation(varname, node.type_comment) # type: ignore[arg-type] + self._collect_doc_comment(node, varnames, current_line) + + def _collect_doc_comment( self, node: AssignmentLike, varnames: list[str], @@ -410,39 +434,21 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> None: elif name.name == 'overload': self.typing_overload_names.add(name.asname or name.name) - def visit_Assign(self, node: ast.Assign | ast.AnnAssign) -> None: + def visit_Assign(self, node: ast.Assign) -> None: """Handles Assign node and pick up a variable comment.""" - try: - targets = get_assign_targets(node) - varnames: list[str] = functools.reduce( - operator.iadd, - [get_lvar_names(t, self=self.get_self()) for t in targets], - [], - ) - current_line = self.get_line(node.lineno) - except TypeError: - return # this assignment is not new definition! - - # record annotation - if hasattr(node, 'annotation') and node.annotation: - for varname in varnames: - self.add_variable_annotation(varname, node.annotation) - elif hasattr(node, 'type_comment') and node.type_comment: - for varname in varnames: - self.add_variable_annotation(varname, node.type_comment) # type: ignore[arg-type] - self.collect_doc_comment(node, varnames, current_line) + self._handle_assignment(node) def visit_AnnAssign(self, node: ast.AnnAssign) -> None: """Handles AnnAssign node and pick up a variable comment.""" - self.visit_Assign(node) + self._handle_assignment(node) def visit_Expr(self, node: ast.Expr) -> None: """Handles Expr node and pick up a comment if string.""" - if not ( - isinstance(node.value, ast.Constant) and isinstance(node.value.value, str) + if ( + isinstance(self.previous, AssignmentLikeType) + and isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) ): - return - if isinstance(self.previous, AssignmentLike): try: targets = get_assign_targets(self.previous) varnames = get_lvar_names(targets[0], self.get_self()) @@ -500,16 +506,15 @@ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: """Handles AsyncFunctionDef node and set context.""" self.visit_FunctionDef(node) # type: ignore[arg-type] - if sys.version_info[:2] >= (3, 12): - - def visit_TypeAlias(self, node: ast.TypeAlias) -> None: - """Handles TypeAlias node and picks up a variable comment. + def visit_TypeAlias(self, node: ast.TypeAlias) -> None: + """Handles TypeAlias node and picks up a variable comment. - .. note:: TypeAlias node refers to `type Foo = Bar` (PEP 695) assignment, - NOT `Foo: TypeAlias = Bar` (PEP 613). - """ - current_line = self.get_line(node.lineno) - self.collect_doc_comment(node, [node.name.id], current_line) + .. note:: TypeAlias node refers to `type Foo = Bar` (PEP 695) assignment, + NOT `Foo: TypeAlias = Bar` (PEP 613). + """ + # Python 3.12+ + current_line = self.get_line(node.lineno) + self._collect_doc_comment(node, [node.name.id], current_line) class DefinitionFinder(TokenProcessor): From 3d638c6c297f4dccbe4d08c9de55259a67c24266 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Thu, 9 Oct 2025 02:24:12 +0100 Subject: [PATCH 10/17] don't require typing-extensions --- pyproject.toml | 1 - sphinx/ext/autodoc/_documenters.py | 2 +- sphinx/ext/autodoc/importer.py | 7 +------ sphinx/util/typing.py | 30 +++++++++++++++++++++++------- 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b30b1f11da3..0ff9868c2c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,7 +85,6 @@ dependencies = [ "roman-numerals-py>=1.0.0", "packaging>=23.0", "colorama>=0.4.6; sys_platform == 'win32'", - "typing_extensions>=4.10" # For TypeAliasType and TypeIs ] dynamic = ["version"] diff --git a/sphinx/ext/autodoc/_documenters.py b/sphinx/ext/autodoc/_documenters.py index fff16c2142e..23621573925 100644 --- a/sphinx/ext/autodoc/_documenters.py +++ b/sphinx/ext/autodoc/_documenters.py @@ -36,7 +36,6 @@ from sphinx.ext.autodoc.importer import ( _get_attribute_comment, _is_runtime_instance_attribute_not_commented, - _is_type_like, _load_object_by_name, _resolve_name, ) @@ -52,6 +51,7 @@ stringify_signature, ) from sphinx.util.typing import ( + _is_type_like, restify, stringify_annotation, ) diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index eda1e169b59..8cf68579406 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -39,6 +39,7 @@ ) from sphinx.util.typing import ( AnyTypeAliasType, + _is_type_like, get_type_hints, restify, stringify_annotation, @@ -49,8 +50,6 @@ from importlib.machinery import ModuleSpec from typing import Any, Protocol - from typing_extensions import TypeIs - from sphinx.config import Config from sphinx.environment import BuildEnvironment, _CurrentDocument from sphinx.events import EventManager @@ -463,10 +462,6 @@ def _is_slots_attribute(*, parent: Any, obj_path: Sequence[str]) -> bool: return False -def _is_type_like(obj: Any) -> TypeIs[NewType | TypeVar | AnyTypeAliasType]: - return isinstance(obj, (NewType, TypeVar, AnyTypeAliasType)) - - def _load_object_by_name( *, name: str, diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index 3f3d173a43f..9f5accfce52 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -7,7 +7,7 @@ import types import typing from collections.abc import Callable, Sequence -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NewType, TypeVar from docutils import nodes from docutils.parsers.rst.states import Inliner @@ -33,14 +33,19 @@ 'smart', ] -from typing_extensions import TypeAliasType as ExtensionTypeAliasType +if sys.version_info[:2] >= (3, 12): + from typing import TypeAliasType -if sys.version_info[:2] < (3, 12): - AnyTypeAliasType = ExtensionTypeAliasType + AnyTypeAliasType = (TypeAliasType,) else: - from typing import TypeAliasType + AnyTypeAliasType = () - AnyTypeAliasType = TypeAliasType | ExtensionTypeAliasType +try: + import typing_extensions +except ImportError: + pass +else: + AnyTypeAliasType += (typing_extensions.TypeAliasType,) logger = logging.getLogger(__name__) @@ -238,6 +243,17 @@ def _is_unpack_form(obj: Any) -> bool: return typing.get_origin(obj) is typing.Unpack +if sys.version_info[:2] >= (3, 12): + + def _is_type_like(obj: Any) -> TypeIs[NewType | TypeVar | TypeAliasType]: + return isinstance(obj, (NewType, TypeVar, AnyTypeAliasType)) + +else: + + def _is_type_like(obj: Any) -> TypeIs[NewType | TypeVar]: + return isinstance(obj, (NewType, TypeVar)) + + def restify(cls: Any, mode: _RestifyMode = 'fully-qualified-except-typing') -> str: """Convert a type-like object to a reST reference. @@ -452,7 +468,7 @@ def stringify_annotation( # Extract the annotation's base type by considering formattable cases if isinstance( - annotation, typing.TypeVar | AnyTypeAliasType + annotation, (typing.TypeVar, AnyTypeAliasType) ) and not _is_unpack_form(annotation): # typing_extensions.Unpack is incorrectly determined as a TypeVar if annotation_module_is_typing and mode in { From 38e29583ae7db735263b89a2cd53bf581fa8a8d3 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Thu, 9 Oct 2025 02:25:03 +0100 Subject: [PATCH 11/17] rename _obj_is_typealias --- sphinx/ext/autodoc/_documenters.py | 4 ++-- sphinx/ext/autodoc/_property_types.py | 4 ++-- sphinx/ext/autodoc/_renderer.py | 2 +- sphinx/ext/autodoc/importer.py | 2 +- tests/test_ext_autodoc/test_ext_autodoc.py | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/sphinx/ext/autodoc/_documenters.py b/sphinx/ext/autodoc/_documenters.py index 23621573925..cddb191901e 100644 --- a/sphinx/ext/autodoc/_documenters.py +++ b/sphinx/ext/autodoc/_documenters.py @@ -766,7 +766,7 @@ def _generate( has_members = isinstance(self, ModuleDocumenter) or ( isinstance(self, ClassDocumenter) and not self.props.doc_as_attr - and not self.props._obj_is_typealias + and not self.props._obj_is_type_alias ) # If there is no real module defined, figure out which to use. @@ -1369,7 +1369,7 @@ def get_user_defined_function_or_method(obj: Any, attr: str) -> Any: return None, None, None def format_args(self, **kwargs: Any) -> str: - if self.props._obj_is_typealias: + if self.props._obj_is_type_alias: self.props._obj_aliased_annotation = stringify_annotation( self.props._obj.__value__, mode=_get_render_mode(self.config.autodoc_typehints_format), diff --git a/sphinx/ext/autodoc/_property_types.py b/sphinx/ext/autodoc/_property_types.py index 1a110e62b16..d0553eb2b65 100644 --- a/sphinx/ext/autodoc/_property_types.py +++ b/sphinx/ext/autodoc/_property_types.py @@ -108,11 +108,11 @@ class _ClassDefProperties(_ItemProperties): _obj___name__: str | None _obj___qualname__: str | None + _obj_aliased_annotation: str | None = None _obj_bases: tuple[str, ...] _obj_is_new_type: bool + _obj_is_type_alias: bool _obj_is_typevar: bool - _obj_is_typealias: bool - _obj_aliased_annotation: str | None = None @property def doc_as_attr(self) -> bool: diff --git a/sphinx/ext/autodoc/_renderer.py b/sphinx/ext/autodoc/_renderer.py index abba8d5bcb4..c083f9e6087 100644 --- a/sphinx/ext/autodoc/_renderer.py +++ b/sphinx/ext/autodoc/_renderer.py @@ -76,7 +76,7 @@ def _directive_header_lines( if props._obj_is_new_type or props._obj_is_typevar: return - if props._obj_is_typealias: + if props._obj_is_type_alias: if not docstrings_has_hide_value: yield f' :canonical: {props._obj_aliased_annotation}' return diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index 8cf68579406..fa0dfb52e03 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -592,8 +592,8 @@ def _load_object_by_name( _obj___qualname__=getattr(obj, '__qualname__', None), _obj_bases=base_classes, _obj_is_new_type=isinstance(obj, NewType), + _obj_is_type_alias=isinstance(obj, AnyTypeAliasType), _obj_is_typevar=isinstance(obj, TypeVar), - _obj_is_typealias=isinstance(obj, AnyTypeAliasType), ) elif objtype in {'function', 'decorator'}: if inspect.isstaticmethod(obj, cls=parent, name=object_name): diff --git a/tests/test_ext_autodoc/test_ext_autodoc.py b/tests/test_ext_autodoc/test_ext_autodoc.py index 0ac6197f354..d1aa998b259 100644 --- a/tests/test_ext_autodoc/test_ext_autodoc.py +++ b/tests/test_ext_autodoc/test_ext_autodoc.py @@ -175,8 +175,8 @@ def formatsig(objtype, name, obj, args, retann): _obj___name__=name, _obj_bases=(), _obj_is_new_type=False, + _obj_is_type_alias=False, _obj_is_typevar=False, - _obj_is_typealias=False, ) res = inst.format_signature() print(res) From 22fe0587f4e30a6fa45a6ba1ad7f3568993d6717 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Thu, 9 Oct 2025 02:25:37 +0100 Subject: [PATCH 12/17] cosmetics --- CHANGES.rst | 4 ++-- doc/usage/extensions/autodoc.rst | 2 +- sphinx/ext/autodoc/_documenters.py | 13 +++++-------- sphinx/util/typing.py | 1 + tests/roots/test-ext-autodoc/target/pep695.py | 5 ++--- tests/test_ext_autodoc/test_ext_autodoc.py | 2 +- 6 files changed, 12 insertions(+), 15 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 154cdd85d44..792f6ce2201 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -42,8 +42,6 @@ Deprecated Features added -------------- -* #13508: Initial support for :pep:`695` type aliases. - Patch by Martin Matouš. * #13332: Add :confval:`doctest_fail_fast` option to exit after the first failed test. Patch by Till Hoffmann. @@ -68,6 +66,8 @@ Features added Patch by Adam Turner. * #13805: LaTeX: add support for ``fontawesome7`` package. Patch by Jean-François B. +* #13508: Initial support for :pep:`695` type aliases. + Patch by Martin Matouš, Jeremy Maitin-Shepard, and Adam Turner. Bugs fixed ---------- diff --git a/doc/usage/extensions/autodoc.rst b/doc/usage/extensions/autodoc.rst index f714acacf4d..a6b39daf476 100644 --- a/doc/usage/extensions/autodoc.rst +++ b/doc/usage/extensions/autodoc.rst @@ -577,7 +577,7 @@ Automatically document classes or exceptions .. versionadded:: 0.4 - .. versionchanged:: 8.3.0 + .. versionchanged:: 8.3 :pep:`695` type aliases (without :pep:`695` type parameters) are now supported. diff --git a/sphinx/ext/autodoc/_documenters.py b/sphinx/ext/autodoc/_documenters.py index cddb191901e..ec3c556e1c8 100644 --- a/sphinx/ext/autodoc/_documenters.py +++ b/sphinx/ext/autodoc/_documenters.py @@ -454,6 +454,11 @@ def add_directive_header(self, sig: str) -> None: """Add the directive header and options to the generated content.""" domain_name = getattr(self, 'domain', 'py') directive_name = getattr(self, 'directivetype', self.objtype) + if self.objtype in {'class', 'exception'}: + if self.props._obj_is_type_alias: # type: ignore[attr-defined] + directive_name = 'type' + elif self.props.doc_as_attr: # type: ignore[attr-defined] + directive_name = 'attribute' directive_name = f'{domain_name}:{directive_name}' docstrings = self.get_doc() @@ -1481,14 +1486,6 @@ def get_canonical_fullname(self) -> str | None: else: return None - @property - def directivetype(self) -> str: - if self.props._obj_is_typealias: - return 'type' - if self.props.doc_as_attr: - return 'attribute' - return self.objtype - def get_doc(self) -> list[list[str]] | None: if isinstance(self.props._obj, TypeVar): if self.props._obj.__doc__ == TypeVar.__doc__: diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index 9f5accfce52..17bc54f75e7 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -335,6 +335,7 @@ def restify(cls: Any, mode: _RestifyMode = 'fully-qualified-except-typing') -> s # *cls* is defined in ``typing``, and thus ``__args__`` must exist return ' | '.join(restify(a, mode) for a in cls.__args__) elif isinstance(cls, AnyTypeAliasType): + # TODO: Use ``__qualname__`` here (not yet supported) return f':py:type:`{module_prefix}{cls.__module__}.{cls.__name__}`' elif cls.__module__ in {'__builtin__', 'builtins'}: if hasattr(cls, '__args__'): diff --git a/tests/roots/test-ext-autodoc/target/pep695.py b/tests/roots/test-ext-autodoc/target/pep695.py index 49807d9f3be..bb0699ff84a 100644 --- a/tests/roots/test-ext-autodoc/target/pep695.py +++ b/tests/roots/test-ext-autodoc/target/pep695.py @@ -16,9 +16,8 @@ class Foo: TypeAliasTypeExtension = typing_extensions.TypeAliasType('TypeAliasTypeExtension', Foo) # noqa: UP040 """This is an explicitly constructed typing_extensions.TypeAlias.""" -type Pep695AliasC = dict[ - str, Foo -] #: This is PEP695 complex type alias with doc comment. +#: This is PEP695 complex type alias with doc comment. +type Pep695AliasC = dict[str, Foo] type Pep695AliasUnion = str | int """This is PEP695 type alias for union.""" diff --git a/tests/test_ext_autodoc/test_ext_autodoc.py b/tests/test_ext_autodoc/test_ext_autodoc.py index d1aa998b259..916b026367f 100644 --- a/tests/test_ext_autodoc/test_ext_autodoc.py +++ b/tests/test_ext_autodoc/test_ext_autodoc.py @@ -2499,7 +2499,7 @@ def test_autodoc_GenericAlias(app): @pytest.mark.skipif( sys.version_info[:2] < (3, 12), - reason='PEP 695 is Python 3.12 feature. Older versions fail to parse source into AST.', + reason='type statement introduced in Python 3.12', ) @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_autodoc_pep695_type_alias(app): From 5d0077fef4c63be41ca9b4d2eaf55f4556088c07 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Thu, 9 Oct 2025 02:48:25 +0100 Subject: [PATCH 13/17] mypy --- sphinx/pycode/parser.py | 2 +- sphinx/util/typing.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/sphinx/pycode/parser.py b/sphinx/pycode/parser.py index f1e4fbebefa..4a96cedc990 100644 --- a/sphinx/pycode/parser.py +++ b/sphinx/pycode/parser.py @@ -506,7 +506,7 @@ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: """Handles AsyncFunctionDef node and set context.""" self.visit_FunctionDef(node) # type: ignore[arg-type] - def visit_TypeAlias(self, node: ast.TypeAlias) -> None: + def visit_TypeAlias(self, node: ast.TypeAlias) -> None: # type: ignore[name-defined] """Handles TypeAlias node and picks up a variable comment. .. note:: TypeAlias node refers to `type Foo = Bar` (PEP 695) assignment, diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index 17bc54f75e7..d4be0e42bf3 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -33,12 +33,11 @@ 'smart', ] +AnyTypeAliasType: tuple[type, ...] = () if sys.version_info[:2] >= (3, 12): from typing import TypeAliasType - AnyTypeAliasType = (TypeAliasType,) -else: - AnyTypeAliasType = () + AnyTypeAliasType += (TypeAliasType,) try: import typing_extensions @@ -336,7 +335,7 @@ def restify(cls: Any, mode: _RestifyMode = 'fully-qualified-except-typing') -> s return ' | '.join(restify(a, mode) for a in cls.__args__) elif isinstance(cls, AnyTypeAliasType): # TODO: Use ``__qualname__`` here (not yet supported) - return f':py:type:`{module_prefix}{cls.__module__}.{cls.__name__}`' + return f':py:type:`{module_prefix}{cls.__module__}.{cls.__name__}`' # type: ignore[attr-defined] elif cls.__module__ in {'__builtin__', 'builtins'}: if hasattr(cls, '__args__'): if not cls.__args__: # Empty tuple, list, ... From 54743da3585acb159658545f4f17e1293c9ba47a Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Fri, 10 Oct 2025 00:54:45 +0100 Subject: [PATCH 14/17] .. autotype:: --- sphinx/ext/autodoc/__init__.py | 2 + sphinx/ext/autodoc/_documenters.py | 54 +++++++++++++--------- sphinx/ext/autodoc/_property_types.py | 12 ++++- sphinx/ext/autodoc/_renderer.py | 12 +++-- sphinx/ext/autodoc/importer.py | 42 +++++++++++++---- sphinx/ext/autosummary/generate.py | 4 +- sphinx/util/typing.py | 17 ++----- tests/test_ext_autodoc/test_ext_autodoc.py | 1 - 8 files changed, 90 insertions(+), 54 deletions(-) diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index d97b2d9a4bf..25a2f2420ea 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -37,6 +37,7 @@ ModuleDocumenter, ModuleLevelDocumenter, PropertyDocumenter, + TypeAliasDocumenter, autodoc_attrgetter, py_ext_sig_re, ) @@ -114,6 +115,7 @@ def setup(app: Sphinx) -> ExtensionMetadata: app.add_autodocumenter(MethodDocumenter) app.add_autodocumenter(AttributeDocumenter) app.add_autodocumenter(PropertyDocumenter) + app.add_autodocumenter(TypeAliasDocumenter) app.add_config_value( 'autoclass_content', diff --git a/sphinx/ext/autodoc/_documenters.py b/sphinx/ext/autodoc/_documenters.py index a4c9a068f78..d1ddfc822c2 100644 --- a/sphinx/ext/autodoc/_documenters.py +++ b/sphinx/ext/autodoc/_documenters.py @@ -41,11 +41,7 @@ safe_getattr, stringify_signature, ) -from sphinx.util.typing import ( - _is_type_like, - restify, - stringify_annotation, -) +from sphinx.util.typing import AnyTypeAliasType, restify, stringify_annotation if TYPE_CHECKING: from collections.abc import Callable, Iterator, Sequence @@ -62,6 +58,7 @@ _FunctionDefProperties, _ItemProperties, _ModuleProperties, + _TypeStatementProperties, ) from sphinx.ext.autodoc.directive import DocumenterBridge from sphinx.registry import SphinxComponentRegistry @@ -454,12 +451,10 @@ def format_signature(self, **kwargs: Any) -> str: def add_directive_header(self, sig: str) -> None: """Add the directive header and options to the generated content.""" domain_name = getattr(self, 'domain', 'py') - directive_name = getattr(self, 'directivetype', self.objtype) - if self.objtype in {'class', 'exception'}: - if self.props._obj_is_type_alias: # type: ignore[attr-defined] - directive_name = 'type' - elif self.props.doc_as_attr: # type: ignore[attr-defined] - directive_name = 'attribute' + if self.objtype in {'class', 'exception'} and self.props.doc_as_attr: # type: ignore[attr-defined] + directive_name = 'attribute' + else: + directive_name = getattr(self, 'directivetype', self.objtype) directive_name = f'{domain_name}:{directive_name}' if self.analyzer: @@ -722,9 +717,7 @@ def _generate( all_members: bool = False, ) -> None: has_members = isinstance(self, ModuleDocumenter) or ( - isinstance(self, ClassDocumenter) - and not self.props.doc_as_attr - and not self.props._obj_is_type_alias + isinstance(self, ClassDocumenter) and not self.props.doc_as_attr ) # If there is no real module defined, figure out which to use. @@ -1223,10 +1216,12 @@ def __init__(self, *args: Any) -> None: def can_document_member( cls: type[Documenter], member: Any, membername: str, isattr: bool, parent: Any ) -> bool: - return isinstance(member, type) or (isattr and _is_type_like(member)) + return isinstance(member, type) or ( + isattr and isinstance(member, (NewType, TypeVar)) + ) def _get_signature(self) -> tuple[Any | None, str | None, Signature | None]: - if _is_type_like(self.props._obj): + if isinstance(self.props._obj, (NewType, TypeVar)): # Suppress signature return None, None, None @@ -1325,12 +1320,6 @@ def get_user_defined_function_or_method(obj: Any, attr: str) -> Any: return None, None, None def format_args(self, **kwargs: Any) -> str: - if self.props._obj_is_type_alias: - self.props._obj_aliased_annotation = stringify_annotation( - self.props._obj.__value__, - mode=_get_render_mode(self.config.autodoc_typehints_format), - short_literals=self.config.python_display_short_literal_types, - ) if self.config.autodoc_typehints in {'none', 'description'}: kwargs.setdefault('show_annotation', False) if self.config.autodoc_typehints_format == 'short': @@ -1812,6 +1801,27 @@ def _get_property_getter(self) -> Callable[..., Any] | None: return None +class TypeAliasDocumenter(Documenter): + """Specialized Documenter subclass for type aliases.""" + + props: _TypeStatementProperties + + objtype = 'type' + member_order = 70 + option_spec: ClassVar[OptionSpec] = { + 'no-index': bool_option, + 'no-index-entry': bool_option, + 'annotation': annotation_option, + 'no-value': bool_option, + } + + @classmethod + def can_document_member( + cls: type[Documenter], member: Any, membername: str, isattr: bool, parent: Any + ) -> bool: + return isinstance(member, AnyTypeAliasType) + + class DocstringSignatureMixin: """Retained for compatibility.""" diff --git a/sphinx/ext/autodoc/_property_types.py b/sphinx/ext/autodoc/_property_types.py index c14d23957fa..4f460d9a940 100644 --- a/sphinx/ext/autodoc/_property_types.py +++ b/sphinx/ext/autodoc/_property_types.py @@ -26,6 +26,7 @@ 'property', 'attribute', 'data', + 'type', ] _AutodocFuncProperty: TypeAlias = Literal[ 'abstractmethod', @@ -110,10 +111,8 @@ class _ClassDefProperties(_ItemProperties): _obj___name__: str | None _obj___qualname__: str | None - _obj_aliased_annotation: str | None = None _obj_bases: tuple[str, ...] _obj_is_new_type: bool - _obj_is_type_alias: bool _obj_is_typevar: bool @property @@ -191,3 +190,12 @@ class _AssignStatementProperties(_ItemProperties): ) _obj_repr_rst: str _obj_type_annotation: str | None + + +@dataclasses.dataclass(frozen=False, kw_only=True, slots=True) +class _TypeStatementProperties(_ItemProperties): + obj_type: Literal['type'] + + _obj___name__: str | None + _obj___qualname__: str | None + _obj___value__: str # The aliased annotation diff --git a/sphinx/ext/autodoc/_renderer.py b/sphinx/ext/autodoc/_renderer.py index 366d1c560a5..9ac0a974e3c 100644 --- a/sphinx/ext/autodoc/_renderer.py +++ b/sphinx/ext/autodoc/_renderer.py @@ -6,6 +6,7 @@ _AssignStatementProperties, _ClassDefProperties, _FunctionDefProperties, + _TypeStatementProperties, ) from sphinx.ext.autodoc._sentinels import SUPPRESS from sphinx.locale import _ @@ -73,11 +74,6 @@ def _directive_header_lines( if props._obj_is_new_type or props._obj_is_typevar: return - if props._obj_is_type_alias: - if not docstrings_has_hide_value: - yield f' :canonical: {props._obj_aliased_annotation}' - return - if props.doc_as_attr: return @@ -169,6 +165,12 @@ def _directive_header_lines( ): yield f' :value: {props._obj_repr_rst}' + if props.obj_type == 'type': + assert isinstance(props, _TypeStatementProperties) + + if not props._docstrings_has_hide_value: + yield f' :canonical: {props._obj___value__}' + def _add_content(content: StringList, *, result: StringList, indent: str) -> None: for line, src in zip(content.data, content.items, strict=True): diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index 0b54fae9610..0c76ade16bf 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -26,6 +26,7 @@ _FunctionDefProperties, _ItemProperties, _ModuleProperties, + _TypeStatementProperties, ) from sphinx.ext.autodoc._sentinels import ( RUNTIME_INSTANCE_ATTRIBUTE, @@ -40,13 +41,7 @@ isclass, safe_getattr, ) -from sphinx.util.typing import ( - AnyTypeAliasType, - _is_type_like, - get_type_hints, - restify, - stringify_annotation, -) +from sphinx.util.typing import get_type_hints, restify, stringify_annotation if TYPE_CHECKING: from collections.abc import Sequence @@ -555,7 +550,7 @@ def _load_object_by_name( _obj___module__=obj.__name__, ) elif objtype in {'class', 'exception'}: - if _is_type_like(obj): + if isinstance(obj, (NewType, TypeVar)): obj_module_name = getattr(obj, '__module__', module_name) if obj_module_name != module_name and module_name.startswith( obj_module_name @@ -599,7 +594,6 @@ def _load_object_by_name( _obj___qualname__=getattr(obj, '__qualname__', None), _obj_bases=base_classes, _obj_is_new_type=isinstance(obj, NewType), - _obj_is_type_alias=isinstance(obj, AnyTypeAliasType), _obj_is_typevar=isinstance(obj, TypeVar), ) elif objtype in {'function', 'decorator'}: @@ -781,6 +775,34 @@ def _load_object_by_name( _obj_repr_rst=inspect.object_description(obj), _obj_type_annotation=type_annotation, ) + elif objtype == 'type': + obj_module_name = getattr(obj, '__module__', module_name) + if obj_module_name != module_name and module_name.startswith(obj_module_name): + bases = module_name[len(obj_module_name) :].strip('.').split('.') + parts = tuple(bases) + parts + module_name = obj_module_name + + if config.autodoc_typehints_format == 'short': + mode = 'smart' + else: + mode = 'fully-qualified-except-typing' + short_literals = config.python_display_short_literal_types + ann = stringify_annotation( + obj.__value__, + mode, # type: ignore[arg-type] + short_literals=short_literals, + ) + props = _TypeStatementProperties( + obj_type=objtype, + module_name=module_name, + parts=parts, + docstring_lines=(), + _obj=obj, + _obj___module__=get_attr(obj, '__module__', None), + _obj___name__=getattr(obj, '__name__', None), + _obj___qualname__=getattr(obj, '__qualname__', None), + _obj___value__=ann, + ) else: props = _ItemProperties( obj_type=objtype, @@ -919,7 +941,7 @@ def _resolve_name( ) return (path or '') + base, () - if objtype in {'class', 'exception', 'function', 'decorator', 'data'}: + if objtype in {'class', 'exception', 'function', 'decorator', 'data', 'type'}: if module_name is not None: return module_name, (*parents, base) if path: diff --git a/sphinx/ext/autosummary/generate.py b/sphinx/ext/autosummary/generate.py index 2102cd9a205..7416d45952c 100644 --- a/sphinx/ext/autosummary/generate.py +++ b/sphinx/ext/autosummary/generate.py @@ -107,7 +107,7 @@ class AutosummaryEntry(NamedTuple): def setup_documenters(app: Sphinx) -> None: - from sphinx.ext.autodoc import ( + from sphinx.ext.autodoc import ( # type: ignore[attr-defined] AttributeDocumenter, ClassDocumenter, DataDocumenter, @@ -117,6 +117,7 @@ def setup_documenters(app: Sphinx) -> None: MethodDocumenter, ModuleDocumenter, PropertyDocumenter, + TypeAliasDocumenter, ) documenters: list[type[Documenter]] = [ @@ -129,6 +130,7 @@ def setup_documenters(app: Sphinx) -> None: AttributeDocumenter, DecoratorDocumenter, PropertyDocumenter, + TypeAliasDocumenter, ] for documenter in documenters: app.registry.add_documenter(documenter.objtype, documenter) diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index d4be0e42bf3..3d65cdd3b22 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -7,7 +7,7 @@ import types import typing from collections.abc import Callable, Sequence -from typing import TYPE_CHECKING, NewType, TypeVar +from typing import TYPE_CHECKING from docutils import nodes from docutils.parsers.rst.states import Inliner @@ -242,17 +242,6 @@ def _is_unpack_form(obj: Any) -> bool: return typing.get_origin(obj) is typing.Unpack -if sys.version_info[:2] >= (3, 12): - - def _is_type_like(obj: Any) -> TypeIs[NewType | TypeVar | TypeAliasType]: - return isinstance(obj, (NewType, TypeVar, AnyTypeAliasType)) - -else: - - def _is_type_like(obj: Any) -> TypeIs[NewType | TypeVar]: - return isinstance(obj, (NewType, TypeVar)) - - def restify(cls: Any, mode: _RestifyMode = 'fully-qualified-except-typing') -> str: """Convert a type-like object to a reST reference. @@ -334,7 +323,9 @@ def restify(cls: Any, mode: _RestifyMode = 'fully-qualified-except-typing') -> s # *cls* is defined in ``typing``, and thus ``__args__`` must exist return ' | '.join(restify(a, mode) for a in cls.__args__) elif isinstance(cls, AnyTypeAliasType): - # TODO: Use ``__qualname__`` here (not yet supported) + # TODO: Use ``__qualname__`` here unconditionally (not yet supported) + if hasattr(cls, '__qualname__'): + return f':py:type:`{module_prefix}{cls.__module__}.{cls.__qualname__}`' return f':py:type:`{module_prefix}{cls.__module__}.{cls.__name__}`' # type: ignore[attr-defined] elif cls.__module__ in {'__builtin__', 'builtins'}: if hasattr(cls, '__args__'): diff --git a/tests/test_ext_autodoc/test_ext_autodoc.py b/tests/test_ext_autodoc/test_ext_autodoc.py index 424fcfb3f50..b1fb1fb33ce 100644 --- a/tests/test_ext_autodoc/test_ext_autodoc.py +++ b/tests/test_ext_autodoc/test_ext_autodoc.py @@ -173,7 +173,6 @@ def formatsig(objtype, name, obj, args, retann): _obj___name__=name, _obj_bases=(), _obj_is_new_type=False, - _obj_is_type_alias=False, _obj_is_typevar=False, ) inst.props._docstrings = _get_docstring_lines( From 8be21f0f188918e17b6451725b329c697cb38423 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Fri, 10 Oct 2025 01:20:52 +0100 Subject: [PATCH 15/17] initial docs --- doc/usage/extensions/autodoc.rst | 39 +++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/doc/usage/extensions/autodoc.rst b/doc/usage/extensions/autodoc.rst index a6b39daf476..736a370805d 100644 --- a/doc/usage/extensions/autodoc.rst +++ b/doc/usage/extensions/autodoc.rst @@ -529,7 +529,7 @@ Automatically document classes or exceptions .. rst:directive:: autoclass autoexception - Document a class or :pep:`695` type alias. + Document a class. For exception classes, prefer ``.. autoexception::``. By default, the directive only inserts the docstring of the class itself: @@ -577,11 +577,6 @@ Automatically document classes or exceptions .. versionadded:: 0.4 - .. versionchanged:: 8.3 - - :pep:`695` type aliases (without :pep:`695` type parameters) are now - supported. - .. rubric:: Options .. rst:directive:option:: no-index @@ -971,6 +966,38 @@ Automatically document attributes or data ``:no-value:`` has no effect. +Automatically document type aliases +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. rst:directive:: autotype + + .. versionadded:: 8.3 + + Document a :pep:`695` type alias (the :keyword:`type` statement). + By default, the directive only inserts the docstring of the alias itself: + + The directive can also contain content of its own, + which will be inserted into the resulting non-auto directive source + after the docstring (but before any automatic member documentation). + + Therefore, you can also mix automatic and non-automatic member documentation. + + .. rubric:: Options + + .. rst:directive:option:: no-index + :type: + + Do not generate an index entry for the documented class + or any auto-documented members. + + .. rst:directive:option:: no-index-entry + :type: + + Do not generate an index entry for the documented class + or any auto-documented members. + Unlike ``:no-index:``, cross-references are still created. + + Configuration ------------- From 723cf3e0a80f4a7b28f26506d97de844af9a67f5 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Fri, 10 Oct 2025 01:21:08 +0100 Subject: [PATCH 16/17] revert now-unneeded classprops changes --- sphinx/ext/autodoc/_renderer.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/sphinx/ext/autodoc/_renderer.py b/sphinx/ext/autodoc/_renderer.py index 9ac0a974e3c..794abc2cae4 100644 --- a/sphinx/ext/autodoc/_renderer.py +++ b/sphinx/ext/autodoc/_renderer.py @@ -74,18 +74,20 @@ def _directive_header_lines( if props._obj_is_new_type or props._obj_is_typevar: return - if props.doc_as_attr: - return - if is_final: yield ' :final:' canonical_fullname = props.canonical_full_name - if canonical_fullname and props.full_name != canonical_fullname: + if ( + not props.doc_as_attr + and not props._obj_is_new_type + and canonical_fullname + and props.full_name != canonical_fullname + ): yield f' :canonical: {canonical_fullname}' # add inheritance info, if wanted - if options.show_inheritance: + if not props.doc_as_attr and options.show_inheritance: yield '' yield ' ' + _('Bases: %s') % ', '.join(props._obj_bases) From f0bdc1facabb2bbb4415d8489acfff0d89125441 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Fri, 10 Oct 2025 01:34:00 +0100 Subject: [PATCH 17/17] Fix `:no-value:` --- sphinx/ext/autodoc/_renderer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx/ext/autodoc/_renderer.py b/sphinx/ext/autodoc/_renderer.py index 794abc2cae4..46c8ad991c5 100644 --- a/sphinx/ext/autodoc/_renderer.py +++ b/sphinx/ext/autodoc/_renderer.py @@ -170,7 +170,7 @@ def _directive_header_lines( if props.obj_type == 'type': assert isinstance(props, _TypeStatementProperties) - if not props._docstrings_has_hide_value: + if not options.no_value and not props._docstrings_has_hide_value: yield f' :canonical: {props._obj___value__}'