Skip to content

Commit 74e759d

Browse files
committed
Initial support for PEP 695 type aliases
Signed-off-by: Martin Matous <[email protected]>
1 parent ad360fd commit 74e759d

File tree

8 files changed

+193
-46
lines changed

8 files changed

+193
-46
lines changed

.ruff.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ extend-exclude = [
99
"tests/roots/test-pycode/cp_1251_coded.py", # Not UTF-8
1010
]
1111

12+
[per-file-target-version]
13+
"tests/roots/test-ext-autodoc/target/pep695.py" = "py312"
14+
1215
[format]
1316
preview = true
1417
quote-style = "single"

AUTHORS.rst

Lines changed: 1 addition & 0 deletions
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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ Features added
2525
* #13704: autodoc: Detect :py:func:`typing_extensions.overload <typing.overload>`
2626
and :py:func:`~typing.final` decorators.
2727
Patch by Spencer Brown.
28+
* #13508: Initial support for PEP 695 type aliases.
29+
Patch by Martin Matouš.
2830

2931
Bugs fixed
3032
----------

sphinx/ext/autodoc/__init__.py

Lines changed: 20 additions & 6 deletions
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

@@ -1690,11 +1695,13 @@ def __init__(self, *args: Any) -> None:
16901695

16911696
@classmethod
16921697
def can_document_member(
1693-
cls: type[Documenter], member: Any, membername: str, isattr: bool, parent: Any
1698+
cls, member: Any, membername: str, isattr: bool, parent: Any
16941699
) -> bool:
1695-
return isinstance(member, type) or (
1696-
isattr and isinstance(member, NewType | TypeVar)
1697-
)
1700+
return isinstance(member, type) or (isattr and cls._is_typelike(member))
1701+
1702+
@staticmethod
1703+
def _is_typelike(obj: Any) -> bool:
1704+
return isinstance(obj, NewType | TypeVar | TypeAliasType)
16981705

16991706
def import_object(self, raiseerror: bool = False) -> bool:
17001707
ret = super().import_object(raiseerror)
@@ -1705,7 +1712,7 @@ def import_object(self, raiseerror: bool = False) -> bool:
17051712
self.doc_as_attr = self.objpath[-1] != self.object.__name__
17061713
else:
17071714
self.doc_as_attr = True
1708-
if isinstance(self.object, NewType | TypeVar):
1715+
if self._is_typelike(self.object):
17091716
modname = getattr(self.object, '__module__', self.modname)
17101717
if modname != self.modname and self.modname.startswith(modname):
17111718
bases = self.modname[len(modname) :].strip('.').split('.')
@@ -1714,7 +1721,7 @@ def import_object(self, raiseerror: bool = False) -> bool:
17141721
return ret
17151722

17161723
def _get_signature(self) -> tuple[Any | None, str | None, Signature | None]:
1717-
if isinstance(self.object, NewType | TypeVar):
1724+
if self._is_typelike(self.object):
17181725
# Suppress signature
17191726
return None, None, None
17201727

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

19261933
if self.doc_as_attr:
19271934
self.directivetype = 'attribute'
1935+
if isinstance(self.object, TypeAliasType):
1936+
self.directivetype = 'type'
19281937
super().add_directive_header(sig)
19291938

19301939
if isinstance(self.object, NewType | TypeVar):
@@ -1942,6 +1951,11 @@ def add_directive_header(self, sig: str) -> None:
19421951
):
19431952
self.add_line(' :canonical: %s' % canonical_fullname, sourcename)
19441953

1954+
if isinstance(self.object, TypeAliasType):
1955+
aliased = stringify_annotation(self.object.__value__)
1956+
self.add_line(' :canonical: %s' % aliased, sourcename)
1957+
return
1958+
19451959
# add inheritance info, if wanted
19461960
if not self.doc_as_attr and self.options.show_inheritance:
19471961
if inspect.getorigbases(self.object):

sphinx/pycode/parser.py

Lines changed: 66 additions & 39 deletions
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
@@ -332,6 +333,48 @@ def get_line(self, lineno: int) -> str:
332333
"""Returns specified line."""
333334
return self.buffers[lineno - 1]
334335

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

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

424433
def visit_Expr(self, node: ast.Expr) -> None:
425434
"""Handles Expr node and pick up a comment if string."""
426-
if (
427-
isinstance(self.previous, ast.Assign | ast.AnnAssign)
428-
and isinstance(node.value, ast.Constant)
429-
and isinstance(node.value.value, str)
435+
if not (
436+
isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)
430437
):
438+
return
439+
if isinstance(self.previous, ast.Assign | ast.AnnAssign):
431440
try:
432441
targets = get_assign_targets(self.previous)
433442
varnames = get_lvar_names(targets[0], self.get_self())
@@ -441,6 +450,13 @@ def visit_Expr(self, node: ast.Expr) -> None:
441450
self.add_entry(varname)
442451
except TypeError:
443452
pass # this assignment is not new definition!
453+
if (sys.version_info[:2] >= (3, 12)) and isinstance(
454+
self.previous, ast.TypeAlias
455+
):
456+
varname = self.previous.name.id
457+
docstring = node.value.value
458+
self.add_variable_comment(varname, dedent_docstring(docstring))
459+
self.add_entry(varname)
444460

445461
def visit_Try(self, node: ast.Try) -> None:
446462
"""Handles Try node and processes body and else-clause.
@@ -485,6 +501,17 @@ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
485501
"""Handles AsyncFunctionDef node and set context."""
486502
self.visit_FunctionDef(node) # type: ignore[arg-type]
487503

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

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

sphinx/util/typing.py

Lines changed: 10 additions & 1 deletion
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',
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from typing import NewType
2+
3+
4+
class Foo:
5+
"""This is class Foo."""
6+
7+
8+
type Pep695Alias = Foo
9+
"""This is PEP695 type alias."""
10+
11+
type Pep695AliasC = dict[str, Foo] #: This is PEP695 complex type alias with doc comment.
12+
13+
type Pep695AliasUnion = str | int
14+
"""This is PEP695 type alias for union."""
15+
16+
type Pep695AliasOfAlias = Pep695AliasC
17+
"""This is PEP695 type alias of PEP695 alias."""
18+
19+
Bar = NewType('Bar', Pep695Alias)
20+
"""This is newtype of Pep695Alias."""
21+
22+
23+
def ret_pep695(a: Pep695Alias) -> Pep695Alias:
24+
"""This fn accepts and returns PEP695 alias."""
25+
...

tests/test_extensions/test_ext_autodoc.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2451,6 +2451,72 @@ def test_autodoc_GenericAlias(app):
24512451
]
24522452

24532453

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

0 commit comments

Comments
 (0)