From 86d6ddd67d06951d9e3fe3e679f38ffc0866eda2 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 | 55 +++++++++++++++++++++++++++- 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(+), 13 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..2ccc1c3d00a 100644 --- a/sphinx/domains/python.py +++ b/sphinx/domains/python.py @@ -6,6 +6,7 @@ import builtins import inspect import re +import sys import typing from inspect import Parameter from typing import Any, Iterable, Iterator, List, NamedTuple, Tuple, cast @@ -39,10 +40,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 +259,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 +558,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 +614,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..86a1dc4831a 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() @@ -1776,7 +1777,7 @@ def test_module_content_line_number(app): @pytest.mark.sphinx(freshenv=True, confoverrides={'python_display_short_literal_types': True}) -def test_short_literal_types(app): +def test_short_literal_types(app, status): text = """\ .. py:function:: literal_ints(x: Literal[1, 2, 3] = 1) -> None .. py:function:: literal_union(x: Union[Literal["a"], Literal["b"], Literal["c"]]) -> None @@ -1840,3 +1841,75 @@ 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, ()]) + ] + )) \ No newline at end of file