From a8553f5060f6c42c2544c7ea3dcc10408feb0beb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?=
<10796600+picnixz@users.noreply.github.com>
Date: Sun, 28 May 2023 18:52:14 +0200
Subject: [PATCH] Add partial support for PEP 695 syntax (#11438)
---
CHANGES | 3 ++
sphinx/addnodes.py | 11 +++++-
sphinx/domains/python.py | 54 ++++++++++++++++++++++++++-
sphinx/writers/html5.py | 4 +-
sphinx/writers/manpage.py | 4 +-
sphinx/writers/texinfo.py | 4 +-
sphinx/writers/text.py | 4 +-
tests/test_domain_py.py | 77 ++++++++++++++++++++++++++++++++++++++-
8 files changed, 149 insertions(+), 12 deletions(-)
diff --git a/CHANGES b/CHANGES
index e66f11cbf7e..5948d366401 100644
--- a/CHANGES
+++ b/CHANGES
@@ -18,6 +18,9 @@ Deprecated
Features added
--------------
+* #11438: Add support to the :rst:dir:`py:class` and :rst:dir:`py:function`
+ directives for PEP 695 (generic classes and functions declarations).
+ Patch by Bénédikt Tran.
* #11415: Add a checksum to JavaScript and CSS asset URIs included within
generated HTML, using the CRC32 algorithm.
* :meth:`~sphinx.application.Sphinx.require_sphinx` now allows the version
diff --git a/sphinx/addnodes.py b/sphinx/addnodes.py
index e92d32a0ef8..5f9daea88a8 100644
--- a/sphinx/addnodes.py
+++ b/sphinx/addnodes.py
@@ -253,9 +253,17 @@ class desc_parameterlist(nodes.Part, nodes.Inline, nodes.FixedTextElement):
In that case each parameter will then be written on its own, indented line.
"""
child_text_separator = ', '
+ list_left_delim = '('
+ list_right_delim = ')'
def astext(self):
- return f'({super().astext()})'
+ return f'{self.list_left_delim}{super().astext()}{self.list_right_delim}'
+
+
+class desc_tparameterlist(desc_parameterlist):
+ """Node for a general type parameter list."""
+ list_left_delim = '['
+ list_right_delim = ']'
class desc_parameter(nodes.Part, nodes.Inline, nodes.FixedTextElement):
@@ -537,6 +545,7 @@ def setup(app: Sphinx) -> dict[str, Any]:
app.add_node(desc_type)
app.add_node(desc_returns)
app.add_node(desc_parameterlist)
+ app.add_node(desc_tparameterlist)
app.add_node(desc_parameter)
app.add_node(desc_optional)
app.add_node(desc_annotation)
diff --git a/sphinx/domains/python.py b/sphinx/domains/python.py
index 3fda5270351..fed34d32a47 100644
--- a/sphinx/domains/python.py
+++ b/sphinx/domains/python.py
@@ -39,10 +39,11 @@
logger = logging.getLogger(__name__)
-# REs for Python signatures
+# REs for Python signatures (supports PEP 695)
py_sig_re = re.compile(
r'''^ ([\w.]*\.)? # class name(s)
(\w+) \s* # thing name
+ (?: \[\s*(.*)\s*])? # optional: generics (PEP 695)
(?: \(\s*(.*)\s*\) # optional: arguments
(?:\s* -> \s* (.*))? # return annotation
)? $ # and nothing more
@@ -257,6 +258,48 @@ def _unparse_pep_604_annotation(node: ast.Subscript) -> list[Node]:
return [type_to_xref(annotation, env)]
+def _parse_tplist(
+ tplist: str, env: BuildEnvironment | None = None,
+ multi_line_parameter_list: bool = False,
+) -> addnodes.desc_tparameterlist:
+ """Parse a list of type parameters according to PEP 695."""
+ tparams = addnodes.desc_tparameterlist(tplist)
+ tparams['multi_line_parameter_list'] = multi_line_parameter_list
+ sig = signature_from_str('(%s)' % tplist)
+ # formal parameter names are interpreted as type parameter names and
+ # type annotations are interpreted as type parameter bounds
+ for tparam in sig.parameters.values():
+ node = addnodes.desc_parameter()
+ if tparam.kind == tparam.VAR_POSITIONAL:
+ node += addnodes.desc_sig_operator('', '*')
+ node += addnodes.desc_sig_name('', tparam.name)
+ elif tparam.kind == tparam.VAR_KEYWORD:
+ node += addnodes.desc_sig_operator('', '**')
+ node += addnodes.desc_sig_name('', tparam.name)
+ else:
+ node += addnodes.desc_sig_name('', tparam.name)
+ if tparam.annotation is not tparam.empty:
+ type_bound = _parse_annotation(tparam.annotation, env)
+ if not type_bound:
+ continue
+
+ node += addnodes.desc_sig_punctuation('', ':')
+ node += addnodes.desc_sig_space()
+
+ type_bound_expr = addnodes.desc_sig_name('', '', *type_bound) # type: ignore
+
+ # add delimiters around type bounds written as e.g., "(T1, T2)"
+ if tparam.annotation.startswith('(') and tparam.annotation.endswith(')'):
+ node += addnodes.desc_sig_punctuation('', '(')
+ node += type_bound_expr
+ node += addnodes.desc_sig_punctuation('', ')')
+ else:
+ node += type_bound_expr
+
+ tparams += node
+ return tparams
+
+
def _parse_arglist(
arglist: str, env: BuildEnvironment | None = None, multi_line_parameter_list: bool = False,
) -> addnodes.desc_parameterlist:
@@ -514,7 +557,7 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]
m = py_sig_re.match(sig)
if m is None:
raise ValueError
- prefix, name, arglist, retann = m.groups()
+ prefix, name, tplist, arglist, retann = m.groups()
# determine module and class name (if applicable), as well as full name
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]
signode += addnodes.desc_addname(nodetext, nodetext)
signode += addnodes.desc_name(name, name)
+
+ if tplist:
+ try:
+ signode += _parse_tplist(tplist, self.env, multi_line_parameter_list)
+ except SyntaxError:
+ pass
+
if arglist:
try:
signode += _parse_arglist(arglist, self.env, multi_line_parameter_list)
diff --git a/sphinx/writers/html5.py b/sphinx/writers/html5.py
index e7d932286c5..a445293b8d9 100644
--- a/sphinx/writers/html5.py
+++ b/sphinx/writers/html5.py
@@ -149,7 +149,7 @@ def depart_desc_returns(self, node: Element) -> None:
self.body.append('')
def visit_desc_parameterlist(self, node: Element) -> None:
- self.body.append('(')
+ self.body.append(f'{node.list_left_delim}')
self.is_first_param = True
self.optional_param_level = 0
self.params_left_at_level = 0
@@ -170,7 +170,7 @@ def visit_desc_parameterlist(self, node: Element) -> None:
def depart_desc_parameterlist(self, node: Element) -> None:
if node.get('multi_line_parameter_list'):
self.body.append('\n\n')
- self.body.append(')')
+ self.body.append(f'{node.list_right_delim}')
# If required parameters are still to come, then put the comma after
# the parameter. Otherwise, put the comma before. This ensures that
diff --git a/sphinx/writers/manpage.py b/sphinx/writers/manpage.py
index 1e57f48addc..528070d0e4a 100644
--- a/sphinx/writers/manpage.py
+++ b/sphinx/writers/manpage.py
@@ -184,11 +184,11 @@ def depart_desc_returns(self, node: Element) -> None:
pass
def visit_desc_parameterlist(self, node: Element) -> None:
- self.body.append('(')
+ self.body.append(node.list_left_delim)
self.first_param = 1
def depart_desc_parameterlist(self, node: Element) -> None:
- self.body.append(')')
+ self.body.append(node.list_right_delim)
def visit_desc_parameter(self, node: Element) -> None:
if not self.first_param:
diff --git a/sphinx/writers/texinfo.py b/sphinx/writers/texinfo.py
index 927e74f3487..fc9acb22c26 100644
--- a/sphinx/writers/texinfo.py
+++ b/sphinx/writers/texinfo.py
@@ -1462,11 +1462,11 @@ def depart_desc_returns(self, node: Element) -> None:
pass
def visit_desc_parameterlist(self, node: Element) -> None:
- self.body.append(' (')
+ self.body.append(f' {node.list_left_delim}')
self.first_param = 1
def depart_desc_parameterlist(self, node: Element) -> None:
- self.body.append(')')
+ self.body.append(node.list_right_delim)
def visit_desc_parameter(self, node: Element) -> None:
if not self.first_param:
diff --git a/sphinx/writers/text.py b/sphinx/writers/text.py
index 8e3d9df240d..51ab45e29ed 100644
--- a/sphinx/writers/text.py
+++ b/sphinx/writers/text.py
@@ -593,7 +593,7 @@ def depart_desc_returns(self, node: Element) -> None:
pass
def visit_desc_parameterlist(self, node: Element) -> None:
- self.add_text('(')
+ self.add_text(node.list_left_delim)
self.is_first_param = True
self.optional_param_level = 0
self.params_left_at_level = 0
@@ -609,7 +609,7 @@ def visit_desc_parameterlist(self, node: Element) -> None:
self.param_separator = self.param_separator.rstrip()
def depart_desc_parameterlist(self, node: Element) -> None:
- self.add_text(')')
+ self.add_text(node.list_right_delim)
def visit_desc_parameter(self, node: Element) -> None:
on_separate_line = self.multi_line_parameter_list
diff --git a/tests/test_domain_py.py b/tests/test_domain_py.py
index 2b84f01c00d..84bfbf7daaf 100644
--- a/tests/test_domain_py.py
+++ b/tests/test_domain_py.py
@@ -17,6 +17,7 @@
desc_optional,
desc_parameter,
desc_parameterlist,
+ desc_tparameterlist,
desc_returns,
desc_sig_keyword,
desc_sig_literal_number,
@@ -45,7 +46,7 @@ def parse(sig):
m = py_sig_re.match(sig)
if m is None:
raise ValueError
- name_prefix, name, arglist, retann = m.groups()
+ name_prefix, generics, name, arglist, retann = m.groups()
signode = addnodes.desc_signature(sig, '')
_pseudo_parse_arglist(signode, arglist)
return signode.astext()
@@ -1840,3 +1841,77 @@ def test_short_literal_types(app):
[desc_content, ()],
)],
))
+
+
+def test_function_pep_695(app):
+ text = """.. py:function:: func[T: int, U: (int, str), *V, **P]"""
+ doctree = restructuredtext.parse(app, text)
+ assert_node(doctree, (
+ addnodes.index,
+ [desc, (
+ [desc_signature, (
+ [desc_name, 'func'],
+ [desc_tparameterlist, (
+ [desc_parameter, (
+ [desc_sig_name, 'T'],
+ [desc_sig_punctuation, ':'],
+ desc_sig_space,
+ [desc_sig_name, ([pending_xref, 'int'])],
+ )],
+ [desc_parameter, (
+ [desc_sig_name, 'U'],
+ [desc_sig_punctuation, ':'],
+ desc_sig_space,
+ [desc_sig_punctuation, '('],
+ [desc_sig_name, (
+ [pending_xref, 'int'],
+ [desc_sig_punctuation, ','],
+ desc_sig_space,
+ [pending_xref, 'str']
+ )],
+ [desc_sig_punctuation, ')'],
+ )],
+ [desc_parameter, (
+ [desc_sig_operator, '*'],
+ [desc_sig_name, 'V']
+ )],
+ [desc_parameter, (
+ [desc_sig_operator, '**'],
+ [desc_sig_name, 'P']
+ )]
+ )],
+ [desc_parameterlist, ()]
+ )],
+ [desc_content, ()])
+ ]
+ ))
+
+
+def test_class_def_pep_695(app):
+ # type checkers should reject this but it does not raise a compilation error
+ text = """.. py:class:: Class[S: Sequence[T], T]"""
+ doctree = restructuredtext.parse(app, text)
+ assert_node(doctree, (
+ addnodes.index,
+ [desc, (
+ [desc_signature, (
+ [desc_annotation, ('class', desc_sig_space)],
+ [desc_name, 'Class'],
+ [desc_tparameterlist, (
+ [desc_parameter, (
+ [desc_sig_name, 'S'],
+ [desc_sig_punctuation, ':'],
+ desc_sig_space,
+ [desc_sig_name, (
+ [pending_xref, 'Sequence'],
+ [desc_sig_punctuation, '['],
+ [pending_xref, 'T'],
+ [desc_sig_punctuation, ']'],
+ )],
+ )],
+ [desc_parameter, ([desc_sig_name, 'T'])],
+ )]
+ )],
+ [desc_content, ()])
+ ]
+ ))