Skip to content

Commit 685d1b1

Browse files
committed
Add partial support for PEP 695 and PEP 696 syntax (#11438)
1 parent d3c91f9 commit 685d1b1

File tree

8 files changed

+402
-12
lines changed

8 files changed

+402
-12
lines changed

CHANGES

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ Deprecated
1818
Features added
1919
--------------
2020

21+
* #11438: Add support for the :rst:dir:`py:class` and :rst:dir:`py:function`
22+
directives for PEP 695 (generic classes and functions declarations) and
23+
PEP 696 (default type parameters).
24+
Patch by Bénédikt Tran.
2125
* #11415: Add a checksum to JavaScript and CSS asset URIs included within
2226
generated HTML, using the CRC32 algorithm.
2327
* :meth:`~sphinx.application.Sphinx.require_sphinx` now allows the version

sphinx/addnodes.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,9 +253,17 @@ class desc_parameterlist(nodes.Part, nodes.Inline, nodes.FixedTextElement):
253253
In that case each parameter will then be written on its own, indented line.
254254
"""
255255
child_text_separator = ', '
256+
list_left_delim = '('
257+
list_right_delim = ')'
256258

257259
def astext(self):
258-
return f'({super().astext()})'
260+
return f'{self.list_left_delim}{super().astext()}{self.list_right_delim}'
261+
262+
263+
class desc_tparameterlist(desc_parameterlist):
264+
"""Node for a general type parameter list."""
265+
list_left_delim = '['
266+
list_right_delim = ']'
259267

260268

261269
class desc_parameter(nodes.Part, nodes.Inline, nodes.FixedTextElement):
@@ -537,6 +545,7 @@ def setup(app: Sphinx) -> dict[str, Any]:
537545
app.add_node(desc_type)
538546
app.add_node(desc_returns)
539547
app.add_node(desc_parameterlist)
548+
app.add_node(desc_tparameterlist)
540549
app.add_node(desc_parameter)
541550
app.add_node(desc_optional)
542551
app.add_node(desc_annotation)

sphinx/domains/python.py

Lines changed: 152 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from sphinx.domains import Domain, Index, IndexEntry, ObjType
2424
from sphinx.environment import BuildEnvironment
2525
from sphinx.locale import _, __
26+
from sphinx.pycode.parser import Token, TokenProcessor
2627
from sphinx.roles import XRefRole
2728
from sphinx.util import logging
2829
from sphinx.util.docfields import Field, GroupedField, TypedField
@@ -39,10 +40,11 @@
3940
logger = logging.getLogger(__name__)
4041

4142

42-
# REs for Python signatures
43+
# REs for Python signatures (supports PEP 695)
4344
py_sig_re = re.compile(
4445
r'''^ ([\w.]*\.)? # class name(s)
4546
(\w+) \s* # thing name
47+
(?: \[\s*(.*)\s*])? # optional: type parameters list (PEP 695)
4648
(?: \(\s*(.*)\s*\) # optional: arguments
4749
(?:\s* -> \s* (.*))? # return annotation
4850
)? $ # and nothing more
@@ -257,6 +259,146 @@ def _unparse_pep_604_annotation(node: ast.Subscript) -> list[Node]:
257259
return [type_to_xref(annotation, env)]
258260

259261

262+
class _TypeParameterListParser(TokenProcessor):
263+
def __init__(self, sig: str) -> None:
264+
from token import ERRORTOKEN
265+
266+
# By default, tokenizing "[T = dict[str, Any]]" gives "dict[str,Any]"
267+
# instead of dict[str, Any]. In particular, the default parameter value
268+
# will not be formatted properly. Therefore, whitespaces are replaced
269+
# by some sentinel (bad) value that can be detected as an ERRORTOKEN.
270+
self.virtual_ws = '\x00'
271+
self.virtual_ws_tok = [ERRORTOKEN, self.virtual_ws]
272+
273+
signature = ''.join(sig.splitlines()).replace(' ', self.virtual_ws)
274+
super().__init__([signature])
275+
# Each item is a tuple (name, kind, default, bound) mimicking
276+
# inspect.Parameter to allow default values on VAR_POSITIONAL
277+
# or VAR_KEYWORD parameters.
278+
self.tparams: list[tuple[str, int, Any, Any]] = []
279+
# When true, (leading) whitespaces are dropped when fetching a token.
280+
# Set it to false when parsing a type bound or a default value so that
281+
# they are properly rendered.
282+
self.ignore_ws = False
283+
284+
def fetch_token(self) -> Token | None:
285+
if not self.ignore_ws:
286+
return super().fetch_token()
287+
288+
while super().fetch_token() == self.virtual_ws_tok:
289+
assert self.current
290+
return self.current
291+
292+
def fetch_tparam_spec(self) -> list[Token]:
293+
from token import DEDENT, INDENT, OP
294+
295+
tokens = []
296+
self.ignore_ws = False
297+
while self.fetch_token():
298+
tokens.append(self.current)
299+
for ldelim, rdelim in ['()', '{}', '[]']:
300+
if self.current == [OP, ldelim]:
301+
tokens += self.fetch_until([OP, rdelim])
302+
break
303+
else:
304+
if self.current == INDENT:
305+
tokens += self.fetch_until(DEDENT)
306+
elif self.current.match([OP, ':'], [OP, '='], [OP, ',']):
307+
tokens.pop()
308+
break
309+
self.ignore_ws = True
310+
return tokens
311+
312+
def parse(self) -> None:
313+
from token import NAME, OP
314+
315+
def build_identifier(tokens: Iterable[Token]) -> str:
316+
ws = self.virtual_ws_tok
317+
ident = ''.join(' ' if tok == ws else tok.value for tok in tokens)
318+
return ident.strip()
319+
320+
while self.fetch_token():
321+
if self.current == NAME:
322+
tpname: list[str] = build_identifier([self.current])
323+
if self.previous and self.previous.match([OP, '*'], [OP, '**']):
324+
if self.previous == [OP, '*']:
325+
tpkind = Parameter.VAR_POSITIONAL
326+
else:
327+
tpkind = Parameter.VAR_KEYWORD
328+
else:
329+
tpkind = Parameter.POSITIONAL_OR_KEYWORD
330+
331+
tpbound, tpdefault = Parameter.empty, Parameter.empty
332+
333+
self.fetch_token() # whitespaces (before) will be ignored
334+
if self.current and self.current.match([OP, ':'], [OP, '=']):
335+
if self.current == [OP, ':']:
336+
tpbound = build_identifier(self.fetch_tparam_spec())
337+
if self.current == [OP, '=']:
338+
tpdefault = build_identifier(self.fetch_tparam_spec())
339+
340+
if tpkind != Parameter.POSITIONAL_OR_KEYWORD and tpbound != Parameter.empty:
341+
raise SyntaxError('type parameter bound or constraint is not allowed '
342+
f'for {tpkind.description} parameters')
343+
344+
tparam = (tpname, tpkind, tpdefault, tpbound)
345+
self.tparams.append(tparam)
346+
347+
348+
def _parse_tplist(
349+
tplist: str, env: BuildEnvironment | None = None,
350+
multi_line_parameter_list: bool = False,
351+
) -> addnodes.desc_tparameterlist:
352+
"""Parse a list of type parameters according to PEP 695."""
353+
tparams = addnodes.desc_tparameterlist(tplist)
354+
tparams['multi_line_parameter_list'] = multi_line_parameter_list
355+
# formal parameter names are interpreted as type parameter names and
356+
# type annotations are interpreted as type parameter bounds
357+
parser = _TypeParameterListParser(tplist)
358+
parser.parse()
359+
for (tpname, tpkind, tpdefault, tpbound) in parser.tparams:
360+
# no positional-only or keyword-only allowed in a type parameters list
361+
assert tpkind not in {Parameter.POSITIONAL_ONLY, Parameter.KEYWORD_ONLY}
362+
363+
node = addnodes.desc_parameter()
364+
if tpkind == Parameter.VAR_POSITIONAL:
365+
node += addnodes.desc_sig_operator('', '*')
366+
elif tpkind == Parameter.VAR_KEYWORD:
367+
node += addnodes.desc_sig_operator('', '**')
368+
node += addnodes.desc_sig_name('', tpname)
369+
370+
if tpbound is not Parameter.empty:
371+
type_bound = _parse_annotation(tpbound, env)
372+
if not type_bound:
373+
continue
374+
375+
node += addnodes.desc_sig_punctuation('', ':')
376+
node += addnodes.desc_sig_space()
377+
378+
type_bound_expr = addnodes.desc_sig_name('', '', *type_bound) # type: ignore
379+
380+
# add delimiters around type bounds written as e.g., "(T1, T2)"
381+
if tpbound.startswith('(') and tpbound.endswith(')'):
382+
node += addnodes.desc_sig_punctuation('', '(')
383+
node += type_bound_expr
384+
node += addnodes.desc_sig_punctuation('', ')')
385+
else:
386+
node += type_bound_expr
387+
388+
if tpdefault is not Parameter.empty:
389+
if tpbound is not Parameter.empty or tpkind != Parameter.POSITIONAL_OR_KEYWORD:
390+
node += addnodes.desc_sig_space()
391+
node += addnodes.desc_sig_operator('', '=')
392+
node += addnodes.desc_sig_space()
393+
else:
394+
node += addnodes.desc_sig_operator('', '=')
395+
node += nodes.inline('', tpdefault, classes=['default_value'],
396+
support_smartquotes=False)
397+
398+
tparams += node
399+
return tparams
400+
401+
260402
def _parse_arglist(
261403
arglist: str, env: BuildEnvironment | None = None, multi_line_parameter_list: bool = False,
262404
) -> addnodes.desc_parameterlist:
@@ -514,7 +656,7 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]
514656
m = py_sig_re.match(sig)
515657
if m is None:
516658
raise ValueError
517-
prefix, name, arglist, retann = m.groups()
659+
prefix, name, tplist, arglist, retann = m.groups()
518660

519661
# determine module and class name (if applicable), as well as full name
520662
modname = self.options.get('module', self.env.ref_context.get('py:module'))
@@ -570,6 +712,14 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]
570712
signode += addnodes.desc_addname(nodetext, nodetext)
571713

572714
signode += addnodes.desc_name(name, name)
715+
716+
if tplist:
717+
try:
718+
signode += _parse_tplist(tplist, self.env, multi_line_parameter_list)
719+
except Exception as exc:
720+
logger.warning("could not parse tplist (%r): %s", tplist, exc,
721+
location=signode)
722+
573723
if arglist:
574724
try:
575725
signode += _parse_arglist(arglist, self.env, multi_line_parameter_list)

sphinx/writers/html5.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,8 @@ def depart_desc_returns(self, node: Element) -> None:
149149
self.body.append('</span></span>')
150150

151151
def visit_desc_parameterlist(self, node: Element) -> None:
152-
self.body.append('<span class="sig-paren">(</span>')
152+
list_left_delim = node.list_left_delim # type: ignore[attr-defined]
153+
self.body.append(f'<span class="sig-paren">{list_left_delim}</span>')
153154
self.is_first_param = True
154155
self.optional_param_level = 0
155156
self.params_left_at_level = 0
@@ -170,7 +171,8 @@ def visit_desc_parameterlist(self, node: Element) -> None:
170171
def depart_desc_parameterlist(self, node: Element) -> None:
171172
if node.get('multi_line_parameter_list'):
172173
self.body.append('</dl>\n\n')
173-
self.body.append('<span class="sig-paren">)</span>')
174+
list_right_delim = node.list_right_delim # type: ignore[attr-defined]
175+
self.body.append(f'<span class="sig-paren">{list_right_delim}</span>')
174176

175177
# If required parameters are still to come, then put the comma after
176178
# the parameter. Otherwise, put the comma before. This ensures that

sphinx/writers/manpage.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,11 +184,11 @@ def depart_desc_returns(self, node: Element) -> None:
184184
pass
185185

186186
def visit_desc_parameterlist(self, node: Element) -> None:
187-
self.body.append('(')
187+
self.body.append(node.list_left_delim) # type: ignore[attr-defined]
188188
self.first_param = 1
189189

190190
def depart_desc_parameterlist(self, node: Element) -> None:
191-
self.body.append(')')
191+
self.body.append(node.list_right_delim) # type: ignore[attr-defined]
192192

193193
def visit_desc_parameter(self, node: Element) -> None:
194194
if not self.first_param:

sphinx/writers/texinfo.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1462,11 +1462,11 @@ def depart_desc_returns(self, node: Element) -> None:
14621462
pass
14631463

14641464
def visit_desc_parameterlist(self, node: Element) -> None:
1465-
self.body.append(' (')
1465+
self.body.append(f' {node.list_left_delim}') # type: ignore[attr-defined]
14661466
self.first_param = 1
14671467

14681468
def depart_desc_parameterlist(self, node: Element) -> None:
1469-
self.body.append(')')
1469+
self.body.append(node.list_right_delim) # type: ignore[attr-defined]
14701470

14711471
def visit_desc_parameter(self, node: Element) -> None:
14721472
if not self.first_param:

sphinx/writers/text.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -593,7 +593,7 @@ def depart_desc_returns(self, node: Element) -> None:
593593
pass
594594

595595
def visit_desc_parameterlist(self, node: Element) -> None:
596-
self.add_text('(')
596+
self.add_text(node.list_left_delim) # type: ignore[attr-defined]
597597
self.is_first_param = True
598598
self.optional_param_level = 0
599599
self.params_left_at_level = 0
@@ -609,7 +609,7 @@ def visit_desc_parameterlist(self, node: Element) -> None:
609609
self.param_separator = self.param_separator.rstrip()
610610

611611
def depart_desc_parameterlist(self, node: Element) -> None:
612-
self.add_text(')')
612+
self.add_text(node.list_right_delim) # type: ignore[attr-defined]
613613

614614
def visit_desc_parameter(self, node: Element) -> None:
615615
on_separate_line = self.multi_line_parameter_list

0 commit comments

Comments
 (0)