Skip to content

Commit bfb843b

Browse files
committed
Add partial support for PEP 695 syntax (#11438)
1 parent d3c91f9 commit bfb843b

File tree

8 files changed

+151
-12
lines changed

8 files changed

+151
-12
lines changed

Diff for: CHANGES

+3
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ Deprecated
1818
Features added
1919
--------------
2020

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

Diff for: sphinx/addnodes.py

+10-1
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)

Diff for: sphinx/domains/python.py

+52-2
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,11 @@
3939
logger = logging.getLogger(__name__)
4040

4141

42-
# REs for Python signatures
42+
# REs for Python signatures (supports PEP 695)
4343
py_sig_re = re.compile(
4444
r'''^ ([\w.]*\.)? # class name(s)
4545
(\w+) \s* # thing name
46+
(?: \[\s*(.*)\s*])? # optional: generics (PEP 695)
4647
(?: \(\s*(.*)\s*\) # optional: arguments
4748
(?:\s* -> \s* (.*))? # return annotation
4849
)? $ # and nothing more
@@ -257,6 +258,48 @@ def _unparse_pep_604_annotation(node: ast.Subscript) -> list[Node]:
257258
return [type_to_xref(annotation, env)]
258259

259260

261+
def _parse_tplist(
262+
tplist: str, env: BuildEnvironment | None = None,
263+
multi_line_parameter_list: bool = False,
264+
) -> addnodes.desc_tparameterlist:
265+
"""Parse a list of type parameters according to PEP 695."""
266+
tparams = addnodes.desc_tparameterlist(tplist)
267+
tparams['multi_line_parameter_list'] = multi_line_parameter_list
268+
sig = signature_from_str('(%s)' % tplist)
269+
# formal parameter names are interpreted as type parameter names and
270+
# type annotations are interpreted as type parameter bounds
271+
for tparam in sig.parameters.values():
272+
node = addnodes.desc_parameter()
273+
if tparam.kind == tparam.VAR_POSITIONAL:
274+
node += addnodes.desc_sig_operator('', '*')
275+
node += addnodes.desc_sig_name('', tparam.name)
276+
elif tparam.kind == tparam.VAR_KEYWORD:
277+
node += addnodes.desc_sig_operator('', '**')
278+
node += addnodes.desc_sig_name('', tparam.name)
279+
else:
280+
node += addnodes.desc_sig_name('', tparam.name)
281+
if tparam.annotation is not tparam.empty:
282+
type_bound = _parse_annotation(tparam.annotation, env)
283+
if not type_bound:
284+
continue
285+
286+
node += addnodes.desc_sig_punctuation('', ':')
287+
node += addnodes.desc_sig_space()
288+
289+
type_bound_expr = addnodes.desc_sig_name('', '', *type_bound) # type: ignore
290+
291+
# add delimiters around type bounds written as e.g., "(T1, T2)"
292+
if tparam.annotation.startswith('(') and tparam.annotation.endswith(')'):
293+
node += addnodes.desc_sig_punctuation('', '(')
294+
node += type_bound_expr
295+
node += addnodes.desc_sig_punctuation('', ')')
296+
else:
297+
node += type_bound_expr
298+
299+
tparams += node
300+
return tparams
301+
302+
260303
def _parse_arglist(
261304
arglist: str, env: BuildEnvironment | None = None, multi_line_parameter_list: bool = False,
262305
) -> addnodes.desc_parameterlist:
@@ -514,7 +557,7 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]
514557
m = py_sig_re.match(sig)
515558
if m is None:
516559
raise ValueError
517-
prefix, name, arglist, retann = m.groups()
560+
prefix, name, tplist, arglist, retann = m.groups()
518561

519562
# determine module and class name (if applicable), as well as full name
520563
modname = self.options.get('module', self.env.ref_context.get('py:module'))
@@ -570,6 +613,13 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]
570613
signode += addnodes.desc_addname(nodetext, nodetext)
571614

572615
signode += addnodes.desc_name(name, name)
616+
617+
if tplist:
618+
try:
619+
signode += _parse_tplist(tplist, self.env, multi_line_parameter_list)
620+
except SyntaxError:
621+
pass
622+
573623
if arglist:
574624
try:
575625
signode += _parse_arglist(arglist, self.env, multi_line_parameter_list)

Diff for: sphinx/writers/html5.py

+4-2
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

Diff for: sphinx/writers/manpage.py

+2-2
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:

Diff for: sphinx/writers/texinfo.py

+2-2
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:

Diff for: sphinx/writers/text.py

+2-2
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

Diff for: tests/test_domain_py.py

+76-1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
desc_sig_punctuation,
2727
desc_sig_space,
2828
desc_signature,
29+
desc_tparameterlist,
2930
pending_xref,
3031
)
3132
from sphinx.domains import IndexEntry
@@ -45,7 +46,7 @@ def parse(sig):
4546
m = py_sig_re.match(sig)
4647
if m is None:
4748
raise ValueError
48-
name_prefix, name, arglist, retann = m.groups()
49+
name_prefix, generics, name, arglist, retann = m.groups()
4950
signode = addnodes.desc_signature(sig, '')
5051
_pseudo_parse_arglist(signode, arglist)
5152
return signode.astext()
@@ -1840,3 +1841,77 @@ def test_short_literal_types(app):
18401841
[desc_content, ()],
18411842
)],
18421843
))
1844+
1845+
1846+
def test_function_pep_695(app):
1847+
text = """.. py:function:: func[T: int, U: (int, str), *V, **P]"""
1848+
doctree = restructuredtext.parse(app, text)
1849+
assert_node(doctree, (
1850+
addnodes.index,
1851+
[desc, (
1852+
[desc_signature, (
1853+
[desc_name, 'func'],
1854+
[desc_tparameterlist, (
1855+
[desc_parameter, (
1856+
[desc_sig_name, 'T'],
1857+
[desc_sig_punctuation, ':'],
1858+
desc_sig_space,
1859+
[desc_sig_name, ([pending_xref, 'int'])],
1860+
)],
1861+
[desc_parameter, (
1862+
[desc_sig_name, 'U'],
1863+
[desc_sig_punctuation, ':'],
1864+
desc_sig_space,
1865+
[desc_sig_punctuation, '('],
1866+
[desc_sig_name, (
1867+
[pending_xref, 'int'],
1868+
[desc_sig_punctuation, ','],
1869+
desc_sig_space,
1870+
[pending_xref, 'str'],
1871+
)],
1872+
[desc_sig_punctuation, ')'],
1873+
)],
1874+
[desc_parameter, (
1875+
[desc_sig_operator, '*'],
1876+
[desc_sig_name, 'V'],
1877+
)],
1878+
[desc_parameter, (
1879+
[desc_sig_operator, '**'],
1880+
[desc_sig_name, 'P'],
1881+
)],
1882+
)],
1883+
[desc_parameterlist, ()],
1884+
)],
1885+
[desc_content, ()],
1886+
)],
1887+
))
1888+
1889+
1890+
def test_class_def_pep_695(app):
1891+
# type checkers should reject this but it does not raise a compilation error
1892+
text = """.. py:class:: Class[S: Sequence[T], T]"""
1893+
doctree = restructuredtext.parse(app, text)
1894+
assert_node(doctree, (
1895+
addnodes.index,
1896+
[desc, (
1897+
[desc_signature, (
1898+
[desc_annotation, ('class', desc_sig_space)],
1899+
[desc_name, 'Class'],
1900+
[desc_tparameterlist, (
1901+
[desc_parameter, (
1902+
[desc_sig_name, 'S'],
1903+
[desc_sig_punctuation, ':'],
1904+
desc_sig_space,
1905+
[desc_sig_name, (
1906+
[pending_xref, 'Sequence'],
1907+
[desc_sig_punctuation, '['],
1908+
[pending_xref, 'T'],
1909+
[desc_sig_punctuation, ']'],
1910+
)],
1911+
)],
1912+
[desc_parameter, ([desc_sig_name, 'T'])],
1913+
)],
1914+
)],
1915+
[desc_content, ()],
1916+
)],
1917+
))

0 commit comments

Comments
 (0)