Skip to content

Initial support for PEP 695 type aliases #13508

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand Down
18 changes: 15 additions & 3 deletions sphinx/ext/autodoc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)


Expand Down Expand Up @@ -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:
Expand All @@ -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('.')
Expand All @@ -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

Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down
103 changes: 65 additions & 38 deletions sphinx/pycode/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -385,52 +427,19 @@ 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."""
self.visit_Assign(node) # type: ignore[arg-type]

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())
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion sphinx/util/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)


Expand Down Expand Up @@ -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, ...
Expand Down Expand Up @@ -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',
Expand Down
25 changes: 25 additions & 0 deletions tests/roots/test-ext-autodoc/target/pep695.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from typing import NewType


class Foo:
"""This is class Foo."""

...

Check failure on line 7 in tests/roots/test-ext-autodoc/target/pep695.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (PYI013)

tests/roots/test-ext-autodoc/target/pep695.py:7:5: PYI013 Non-empty class body must not contain `...`

type Pep695Alias = Foo

Check failure on line 9 in tests/roots/test-ext-autodoc/target/pep695.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (E305)

tests/roots/test-ext-autodoc/target/pep695.py:9:1: E305 Expected 2 blank lines after class or function definition, found (1)

Check failure on line 9 in tests/roots/test-ext-autodoc/target/pep695.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff

tests/roots/test-ext-autodoc/target/pep695.py:9:1: SyntaxError: Cannot use `type` alias statement on Python 3.11 (syntax was added in Python 3.12)
"""This is PEP695 type alias."""

type Pep695AliasC = dict[str, Foo] #: This is PEP695 complex type alias with doc comment.

Check failure on line 12 in tests/roots/test-ext-autodoc/target/pep695.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff

tests/roots/test-ext-autodoc/target/pep695.py:12:1: SyntaxError: Cannot use `type` alias statement on Python 3.11 (syntax was added in Python 3.12)

type Pep695AliasUnion = str | int

Check failure on line 14 in tests/roots/test-ext-autodoc/target/pep695.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff

tests/roots/test-ext-autodoc/target/pep695.py:14:1: SyntaxError: Cannot use `type` alias statement on Python 3.11 (syntax was added in Python 3.12)
"""This is PEP695 type alias for union."""

type Pep695AliasOfAlias = Pep695AliasC

Check failure on line 17 in tests/roots/test-ext-autodoc/target/pep695.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff

tests/roots/test-ext-autodoc/target/pep695.py:17:1: SyntaxError: Cannot use `type` alias statement on Python 3.11 (syntax was added in Python 3.12)
"""This is PEP695 type alias of PEP695 alias."""

Bar = NewType('Bar', Pep695Alias)
"""This is newtype of Pep695Alias."""

def ret_pep695(a: Pep695Alias) -> Pep695Alias:

Check failure on line 23 in tests/roots/test-ext-autodoc/target/pep695.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (E302)

tests/roots/test-ext-autodoc/target/pep695.py:23:1: E302 Expected 2 blank lines, found 1
"""This fn accepts and returns PEP695 alias."""
...
66 changes: 66 additions & 0 deletions tests/test_extensions/test_ext_autodoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading