diff --git a/AUTHORS.rst b/AUTHORS.rst index 5ff09219c02..b321eec080f 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 fede8b5177b..881d8f95c33 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -19,6 +19,8 @@ Features added * #13439: linkcheck: Permit warning on every redirect with ``linkcheck_allowed_redirects = {}``. Patch by Adam Turner. +* #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..f1a91c6ef63 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__) @@ -1693,7 +1698,7 @@ 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) + isattr and isinstance(member, NewType | TypeVar | TypeAliasType) ) def import_object(self, raiseerror: bool = False) -> bool: @@ -1705,7 +1710,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 isinstance(self.object, NewType | TypeVar | TypeAliasType): 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 +1719,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 isinstance(self.object, NewType | TypeVar | TypeAliasType): # Suppress signature return None, None, None @@ -1925,6 +1930,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 +1949,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 34d30200f75..10b7aa72374 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 @@ -338,6 +339,47 @@ 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) @@ -385,40 +427,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.""" @@ -426,11 +435,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()) @@ -444,6 +453,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. @@ -488,6 +504,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..39b1bb3cefa --- /dev/null +++ b/tests/roots/test-ext-autodoc/target/pep695.py @@ -0,0 +1,25 @@ +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 a06c1bbe30d..1cd8fc188ae 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -2447,6 +2447,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_Pep695Alias(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 = {