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 11e0206f7ca..5b29e07aebd 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -88,6 +88,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 7f405121bc3..4c974a4e3e9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -58,6 +58,8 @@ Features added The location of the cache directory must not be relied upon externally, as it may change without notice or warning in future releases. Patch by Adam Turner. +* #13508: Initial support for PEP 695 type aliases. + Patch by Martin Matouš. Bugs fixed ---------- diff --git a/sphinx/ext/autodoc/_documenters.py b/sphinx/ext/autodoc/_documenters.py index ca35448bc33..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 @@ -66,6 +67,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 +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 isinstance(member, NewType | TypeVar) - ) + return isinstance(member, type) or (isattr and _is_type_like(member)) def import_object(self, raiseerror: bool = False) -> bool: try: @@ -1507,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 isinstance(self.object, NewType | TypeVar): + if _is_type_like(self.object): # Suppress signature return None, None, None @@ -1718,6 +1722,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 +1741,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..35b42b211d2 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -42,12 +42,19 @@ 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: + from typing import TypeAliasType + _NATIVE_SUFFIXES: frozenset[str] = frozenset({'.pyx', *EXTENSION_SUFFIXES}) logger = logging.getLogger(__name__) @@ -635,7 +642,7 @@ def _import_class( else: im.doc_as_attr = True - if isinstance(im.obj, NewType | TypeVar): + 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('.') @@ -903,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)) 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 4761c5560e0..06f94d328cc 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -2455,6 +2455,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 = {