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 5bcd74c943b..ea363fd118f 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -89,6 +89,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 2886c55cd03..792f6ce2201 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -66,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 1b873f0d819..736a370805d 100644 --- a/doc/usage/extensions/autodoc.rst +++ b/doc/usage/extensions/autodoc.rst @@ -966,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 ------------- 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/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 d07286803af..d1ddfc822c2 100644 --- a/sphinx/ext/autodoc/_documenters.py +++ b/sphinx/ext/autodoc/_documenters.py @@ -41,7 +41,7 @@ safe_getattr, stringify_signature, ) -from sphinx.util.typing import restify, stringify_annotation +from sphinx.util.typing import AnyTypeAliasType, restify, stringify_annotation if TYPE_CHECKING: from collections.abc import Callable, Iterator, Sequence @@ -58,6 +58,7 @@ _FunctionDefProperties, _ItemProperties, _ModuleProperties, + _TypeStatementProperties, ) from sphinx.ext.autodoc.directive import DocumenterBridge from sphinx.registry import SphinxComponentRegistry @@ -1800,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 74e4aca1623..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', @@ -189,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 20782fe4cc0..46c8ad991c5 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 _ @@ -166,6 +167,12 @@ def _directive_header_lines( ): yield f' :value: {props._obj_repr_rst}' + if props.obj_type == 'type': + assert isinstance(props, _TypeStatementProperties) + + if not options.no_value and 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 6a396047cdd..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, @@ -774,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, @@ -912,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/pycode/parser.py b/sphinx/pycode/parser.py index e52f2604eb8..4a96cedc990 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 @@ -20,6 +21,13 @@ from inspect import Signature from typing import Any +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*$') emptyline_re = re.compile('^\\s*(#.*)?$') @@ -29,12 +37,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] # ast.TypeAlias def get_lvar_names(node: ast.AST, self: ast.arg | None = None) -> list[str]: @@ -332,36 +342,7 @@ def get_line(self, lineno: int) -> str: """Returns specified line.""" return self.buffers[lineno - 1] - def visit(self, node: ast.AST) -> None: - """Updates self.previous to the given node.""" - super().visit(node) - self.previous = node - - def visit_Import(self, node: ast.Import) -> None: - """Handles Import node and record the order of definitions.""" - for name in node.names: - self.add_entry(name.asname or name.name) - - if name.name in {'typing', 'typing_extensions'}: - self.typing_mods.add(name.asname or name.name) - elif name.name in {'typing.final', 'typing_extensions.final'}: - self.typing_final_names.add(name.asname or name.name) - elif name.name in {'typing.overload', 'typing_extensions.overload'}: - self.typing_overload_names.add(name.asname or name.name) - - def visit_ImportFrom(self, node: ast.ImportFrom) -> None: - """Handles Import node and record the order of definitions.""" - for name in node.names: - self.add_entry(name.asname or name.name) - - if node.module not in {'typing', 'typing_extensions'}: - continue - if name.name == 'final': - self.typing_final_names.add(name.asname or name.name) - elif name.name == 'overload': - self.typing_overload_names.add(name.asname or name.name) - - def visit_Assign(self, node: ast.Assign) -> None: + 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) @@ -381,7 +362,14 @@ 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] + self._collect_doc_comment(node, varnames, current_line) + def _collect_doc_comment( + self, + node: AssignmentLike, + varnames: list[str], + current_line: str, + ) -> None: # check comments after assignment parser = AfterCommentParser([ current_line[node.col_offset :], @@ -417,14 +405,47 @@ def visit_Assign(self, node: ast.Assign) -> None: 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) + self.previous = node + + def visit_Import(self, node: ast.Import) -> None: + """Handles Import node and record the order of definitions.""" + for name in node.names: + self.add_entry(name.asname or name.name) + + if name.name in {'typing', 'typing_extensions'}: + self.typing_mods.add(name.asname or name.name) + elif name.name in {'typing.final', 'typing_extensions.final'}: + self.typing_final_names.add(name.asname or name.name) + elif name.name in {'typing.overload', 'typing_extensions.overload'}: + self.typing_overload_names.add(name.asname or name.name) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + """Handles Import node and record the order of definitions.""" + for name in node.names: + self.add_entry(name.asname or name.name) + + if node.module not in {'typing', 'typing_extensions'}: + continue + if name.name == 'final': + self.typing_final_names.add(name.asname or name.name) + elif name.name == 'overload': + self.typing_overload_names.add(name.asname or name.name) + + def visit_Assign(self, node: ast.Assign) -> None: + """Handles Assign node and pick up a variable comment.""" + 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) # type: ignore[arg-type] + self._handle_assignment(node) 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)) + isinstance(self.previous, AssignmentLikeType) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str) ): @@ -485,6 +506,16 @@ 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: # type: ignore[name-defined] + """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). + """ + # Python 3.12+ + 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 bc393423c7d..3d65cdd3b22 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -33,6 +33,19 @@ 'smart', ] +AnyTypeAliasType: tuple[type, ...] = () +if sys.version_info[:2] >= (3, 12): + from typing import TypeAliasType + + AnyTypeAliasType += (TypeAliasType,) + +try: + import typing_extensions +except ImportError: + pass +else: + AnyTypeAliasType += (typing_extensions.TypeAliasType,) + logger = logging.getLogger(__name__) @@ -309,6 +322,11 @@ 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, AnyTypeAliasType): + # 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__'): if not cls.__args__: # Empty tuple, list, ... @@ -440,7 +458,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, 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-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/roots/test-ext-autodoc/target/pep695.py b/tests/roots/test-ext-autodoc/target/pep695.py new file mode 100644 index 00000000000..bb0699ff84a --- /dev/null +++ b/tests/roots/test-ext-autodoc/target/pep695.py @@ -0,0 +1,34 @@ +from typing import NewType, TypeAliasType + +import typing_extensions + + +class Foo: + """This is 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.""" + +#: 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.""" + +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_ext_autodoc/test_ext_autodoc.py b/tests/test_ext_autodoc/test_ext_autodoc.py index b744998af9d..b1fb1fb33ce 100644 --- a/tests/test_ext_autodoc/test_ext_autodoc.py +++ b/tests/test_ext_autodoc/test_ext_autodoc.py @@ -2514,6 +2514,86 @@ def test_autodoc_GenericAlias(app): ] +@pytest.mark.skipif( + sys.version_info[:2] < (3, 12), + reason='type statement introduced in Python 3.12', +) +@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: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', + '', + ' This fn accepts and returns PEP695 alias.', + '', + ] + + @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_autodoc_TypeVar(app): options = { 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: '