Skip to content

Commit 89caa94

Browse files
committed
Initial support for PEP 695 type aliases
Signed-off-by: Martin Matous <[email protected]>
1 parent 021d6a8 commit 89caa94

File tree

6 files changed

+158
-42
lines changed

6 files changed

+158
-42
lines changed

AUTHORS.rst

+1
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ Contributors
8787
* Martin Larralde -- additional napoleon admonitions
8888
* Martin Liška -- option directive and role improvements
8989
* Martin Mahner -- nature theme
90+
* Martin Matouš -- initial support for PEP 695
9091
* Matthew Fernandez -- todo extension fix
9192
* Matthew Woodcraft -- text output improvements
9293
* Matthias Geier -- style improvements

CHANGES.rst

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ Features added
1919
* #13439: linkcheck: Permit warning on every redirect with
2020
``linkcheck_allowed_redirects = {}``.
2121
Patch by Adam Turner.
22+
* #13508: Initial support for PEP 695 type aliases.
23+
Patch by Martin Matouš.
2224

2325
Bugs fixed
2426
----------

sphinx/ext/autodoc/__init__.py

+15-3
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@
5454
[Sphinx, _AutodocObjType, str, Any, dict[str, bool], list[str]], None
5555
]
5656

57+
if sys.version_info[:2] < (3, 12):
58+
from typing_extensions import TypeAliasType
59+
else:
60+
from typing import TypeAliasType
61+
5762
logger = logging.getLogger(__name__)
5863

5964

@@ -1693,7 +1698,7 @@ def can_document_member(
16931698
cls: type[Documenter], member: Any, membername: str, isattr: bool, parent: Any
16941699
) -> bool:
16951700
return isinstance(member, type) or (
1696-
isattr and isinstance(member, NewType | TypeVar)
1701+
isattr and isinstance(member, NewType | TypeVar | TypeAliasType)
16971702
)
16981703

16991704
def import_object(self, raiseerror: bool = False) -> bool:
@@ -1705,7 +1710,7 @@ def import_object(self, raiseerror: bool = False) -> bool:
17051710
self.doc_as_attr = self.objpath[-1] != self.object.__name__
17061711
else:
17071712
self.doc_as_attr = True
1708-
if isinstance(self.object, NewType | TypeVar):
1713+
if isinstance(self.object, NewType | TypeVar | TypeAliasType):
17091714
modname = getattr(self.object, '__module__', self.modname)
17101715
if modname != self.modname and self.modname.startswith(modname):
17111716
bases = self.modname[len(modname) :].strip('.').split('.')
@@ -1714,7 +1719,7 @@ def import_object(self, raiseerror: bool = False) -> bool:
17141719
return ret
17151720

17161721
def _get_signature(self) -> tuple[Any | None, str | None, Signature | None]:
1717-
if isinstance(self.object, NewType | TypeVar):
1722+
if isinstance(self.object, NewType | TypeVar | TypeAliasType):
17181723
# Suppress signature
17191724
return None, None, None
17201725

@@ -1925,6 +1930,8 @@ def add_directive_header(self, sig: str) -> None:
19251930

19261931
if self.doc_as_attr:
19271932
self.directivetype = 'attribute'
1933+
if isinstance(self.object, TypeAliasType):
1934+
self.directivetype = 'type'
19281935
super().add_directive_header(sig)
19291936

19301937
if isinstance(self.object, NewType | TypeVar):
@@ -1942,6 +1949,11 @@ def add_directive_header(self, sig: str) -> None:
19421949
):
19431950
self.add_line(' :canonical: %s' % canonical_fullname, sourcename)
19441951

1952+
if isinstance(self.object, TypeAliasType):
1953+
aliased = stringify_annotation(self.object.__value__)
1954+
self.add_line(' :canonical: %s' % aliased, sourcename)
1955+
return
1956+
19451957
# add inheritance info, if wanted
19461958
if not self.doc_as_attr and self.options.show_inheritance:
19471959
if inspect.getorigbases(self.object):

sphinx/pycode/parser.py

+64-38
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import itertools
1010
import operator
1111
import re
12+
import sys
1213
import tokenize
1314
from token import DEDENT, INDENT, NAME, NEWLINE, NUMBER, OP, STRING
1415
from tokenize import COMMENT, NL
@@ -338,6 +339,47 @@ def get_line(self, lineno: int) -> str:
338339
"""Returns specified line."""
339340
return self.buffers[lineno - 1]
340341

342+
def collect_doc_comment(
343+
self,
344+
# exists for >= 3.12, irrelevant for runtime
345+
node: ast.Assign | ast.TypeAlias, # type: ignore[name-defined]
346+
varnames: list[str],
347+
current_line: str,
348+
) -> None:
349+
# check comments after assignment
350+
parser = AfterCommentParser(
351+
[current_line[node.col_offset :]] + self.buffers[node.lineno :]
352+
)
353+
parser.parse()
354+
if parser.comment and comment_re.match(parser.comment):
355+
for varname in varnames:
356+
self.add_variable_comment(
357+
varname, comment_re.sub('\\1', parser.comment)
358+
)
359+
self.add_entry(varname)
360+
return
361+
362+
# check comments before assignment
363+
if indent_re.match(current_line[: node.col_offset]):
364+
comment_lines = []
365+
for i in range(node.lineno - 1):
366+
before_line = self.get_line(node.lineno - 1 - i)
367+
if comment_re.match(before_line):
368+
comment_lines.append(comment_re.sub('\\1', before_line))
369+
else:
370+
break
371+
372+
if comment_lines:
373+
comment = dedent_docstring('\n'.join(reversed(comment_lines)))
374+
for varname in varnames:
375+
self.add_variable_comment(varname, comment)
376+
self.add_entry(varname)
377+
return
378+
379+
# not commented (record deforders only)
380+
for varname in varnames:
381+
self.add_entry(varname)
382+
341383
def visit(self, node: ast.AST) -> None:
342384
"""Updates self.previous to the given node."""
343385
super().visit(node)
@@ -385,52 +427,19 @@ def visit_Assign(self, node: ast.Assign) -> None:
385427
elif hasattr(node, 'type_comment') and node.type_comment:
386428
for varname in varnames:
387429
self.add_variable_annotation(varname, node.type_comment) # type: ignore[arg-type]
388-
389-
# check comments after assignment
390-
parser = AfterCommentParser(
391-
[current_line[node.col_offset :]] + self.buffers[node.lineno :]
392-
)
393-
parser.parse()
394-
if parser.comment and comment_re.match(parser.comment):
395-
for varname in varnames:
396-
self.add_variable_comment(
397-
varname, comment_re.sub('\\1', parser.comment)
398-
)
399-
self.add_entry(varname)
400-
return
401-
402-
# check comments before assignment
403-
if indent_re.match(current_line[: node.col_offset]):
404-
comment_lines = []
405-
for i in range(node.lineno - 1):
406-
before_line = self.get_line(node.lineno - 1 - i)
407-
if comment_re.match(before_line):
408-
comment_lines.append(comment_re.sub('\\1', before_line))
409-
else:
410-
break
411-
412-
if comment_lines:
413-
comment = dedent_docstring('\n'.join(reversed(comment_lines)))
414-
for varname in varnames:
415-
self.add_variable_comment(varname, comment)
416-
self.add_entry(varname)
417-
return
418-
419-
# not commented (record deforders only)
420-
for varname in varnames:
421-
self.add_entry(varname)
430+
self.collect_doc_comment(node, varnames, current_line)
422431

423432
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
424433
"""Handles AnnAssign node and pick up a variable comment."""
425434
self.visit_Assign(node) # type: ignore[arg-type]
426435

427436
def visit_Expr(self, node: ast.Expr) -> None:
428437
"""Handles Expr node and pick up a comment if string."""
429-
if (
430-
isinstance(self.previous, ast.Assign | ast.AnnAssign)
431-
and isinstance(node.value, ast.Constant)
432-
and isinstance(node.value.value, str)
438+
if not (
439+
isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)
433440
):
441+
return
442+
if isinstance(self.previous, ast.Assign | ast.AnnAssign):
434443
try:
435444
targets = get_assign_targets(self.previous)
436445
varnames = get_lvar_names(targets[0], self.get_self())
@@ -444,6 +453,13 @@ def visit_Expr(self, node: ast.Expr) -> None:
444453
self.add_entry(varname)
445454
except TypeError:
446455
pass # this assignment is not new definition!
456+
if (sys.version_info[:2] >= (3, 12)) and isinstance(
457+
self.previous, ast.TypeAlias
458+
):
459+
varname = self.previous.name.id
460+
docstring = node.value.value
461+
self.add_variable_comment(varname, dedent_docstring(docstring))
462+
self.add_entry(varname)
447463

448464
def visit_Try(self, node: ast.Try) -> None:
449465
"""Handles Try node and processes body and else-clause.
@@ -488,6 +504,16 @@ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
488504
"""Handles AsyncFunctionDef node and set context."""
489505
self.visit_FunctionDef(node) # type: ignore[arg-type]
490506

507+
if sys.version_info[:2] >= (3, 12):
508+
def visit_TypeAlias(self, node: ast.TypeAlias) -> None:
509+
"""Handles TypeAlias node and picks up a variable comment.
510+
511+
.. note:: TypeAlias node refers to `type Foo = Bar` (PEP 695) assignment,
512+
NOT `Foo: TypeAlias = Bar` (PEP 613).
513+
"""
514+
current_line = self.get_line(node.lineno)
515+
self.collect_doc_comment(node, [node.name.id], current_line)
516+
491517

492518
class DefinitionFinder(TokenProcessor):
493519
"""Python source code parser to detect location of functions,

sphinx/util/typing.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@
3333
'smart',
3434
]
3535

36+
if sys.version_info[:2] < (3, 12):
37+
from typing_extensions import TypeAliasType
38+
else:
39+
from typing import TypeAliasType
40+
3641
logger = logging.getLogger(__name__)
3742

3843

@@ -309,6 +314,8 @@ def restify(cls: Any, mode: _RestifyMode = 'fully-qualified-except-typing') -> s
309314
# are printed natively and ``None``-like types are kept as is.
310315
# *cls* is defined in ``typing``, and thus ``__args__`` must exist
311316
return ' | '.join(restify(a, mode) for a in cls.__args__)
317+
elif isinstance(cls, TypeAliasType):
318+
return f':py:type:`{module_prefix}{cls.__module__}.{cls.__name__}`'
312319
elif cls.__module__ in {'__builtin__', 'builtins'}:
313320
if hasattr(cls, '__args__'):
314321
if not cls.__args__: # Empty tuple, list, ...
@@ -440,7 +447,9 @@ def stringify_annotation(
440447
annotation_module_is_typing = True
441448

442449
# Extract the annotation's base type by considering formattable cases
443-
if isinstance(annotation, typing.TypeVar) and not _is_unpack_form(annotation):
450+
if isinstance(annotation, typing.TypeVar | TypeAliasType) and not _is_unpack_form(
451+
annotation
452+
):
444453
# typing_extensions.Unpack is incorrectly determined as a TypeVar
445454
if annotation_module_is_typing and mode in {
446455
'fully-qualified-except-typing',

tests/test_extensions/test_ext_autodoc.py

+66
Original file line numberDiff line numberDiff line change
@@ -2447,6 +2447,72 @@ def test_autodoc_GenericAlias(app):
24472447
]
24482448

24492449

2450+
@pytest.mark.skipif(
2451+
sys.version_info[:2] < (3, 12),
2452+
reason='PEP 695 is Python 3.12 feature. Older versions fail to parse source into AST.',
2453+
)
2454+
@pytest.mark.sphinx('html', testroot='ext-autodoc')
2455+
def test_autodoc_Pep695Alias(app):
2456+
options = {
2457+
'members': None,
2458+
'undoc-members': None,
2459+
}
2460+
actual = do_autodoc(app, 'module', 'target.pep695', options)
2461+
assert list(actual) == [
2462+
'',
2463+
'.. py:module:: target.pep695',
2464+
'',
2465+
'',
2466+
'.. py:class:: Bar',
2467+
' :module: target.pep695',
2468+
'',
2469+
' This is newtype of Pep695Alias.',
2470+
'',
2471+
' alias of :py:type:`~target.pep695.Pep695Alias`',
2472+
'',
2473+
'',
2474+
'.. py:class:: Foo()',
2475+
' :module: target.pep695',
2476+
'',
2477+
' This is class Foo.',
2478+
'',
2479+
'',
2480+
'.. py:type:: Pep695Alias',
2481+
' :module: target.pep695',
2482+
' :canonical: target.pep695.Foo',
2483+
'',
2484+
' This is PEP695 type alias.',
2485+
'',
2486+
'',
2487+
'.. py:type:: Pep695AliasC',
2488+
' :module: target.pep695',
2489+
' :canonical: dict[str, target.pep695.Foo]',
2490+
'',
2491+
' This is PEP695 complex type alias with doc comment.',
2492+
'',
2493+
'',
2494+
'.. py:type:: Pep695AliasOfAlias',
2495+
' :module: target.pep695',
2496+
' :canonical: target.pep695.Pep695AliasC',
2497+
'',
2498+
' This is PEP695 type alias of PEP695 alias.',
2499+
'',
2500+
'',
2501+
'.. py:type:: Pep695AliasUnion',
2502+
' :module: target.pep695',
2503+
' :canonical: str | int',
2504+
'',
2505+
' This is PEP695 type alias for union.',
2506+
'',
2507+
'',
2508+
'.. py:function:: ret_pep695(a: ~target.pep695.Pep695Alias) -> ~target.pep695.Pep695Alias',
2509+
' :module: target.pep695',
2510+
'',
2511+
' This fn accepts and returns PEP695 alias.',
2512+
'',
2513+
]
2514+
2515+
24502516
@pytest.mark.sphinx('html', testroot='ext-autodoc')
24512517
def test_autodoc_TypeVar(app):
24522518
options = {

0 commit comments

Comments
 (0)