From 882d9d8684a96b26d86398dc579e39d7c946de2c 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 01/23] Add partial support for PEP 695 and PEP 696 syntax (#11438) --- CHANGES | 4 + sphinx/addnodes.py | 11 +- sphinx/domains/python.py | 273 +++++++++++++++++++++++++++++++++++++- sphinx/writers/html5.py | 6 +- sphinx/writers/manpage.py | 4 +- sphinx/writers/texinfo.py | 4 +- sphinx/writers/text.py | 4 +- tests/test_domain_py.py | 255 ++++++++++++++++++++++++++++++++++- 8 files changed, 549 insertions(+), 12 deletions(-) diff --git a/CHANGES b/CHANGES index e66f11cbf7e..95def0f3059 100644 --- a/CHANGES +++ b/CHANGES @@ -18,6 +18,10 @@ Deprecated Features added -------------- +* #11438: Add support for the :rst:dir:`py:class` and :rst:dir:`py:function` + directives for PEP 695 (generic classes and functions declarations) and + PEP 696 (default type parameters). + 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..23adb4c6bde 100644 --- a/sphinx/domains/python.py +++ b/sphinx/domains/python.py @@ -23,6 +23,7 @@ from sphinx.domains import Domain, Index, IndexEntry, ObjType from sphinx.environment import BuildEnvironment from sphinx.locale import _, __ +from sphinx.pycode.parser import Token, TokenProcessor from sphinx.roles import XRefRole from sphinx.util import logging from sphinx.util.docfields import Field, GroupedField, TypedField @@ -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: type parameters list (PEP 695) (?: \(\s*(.*)\s*\) # optional: arguments (?:\s* -> \s* (.*))? # return annotation )? $ # and nothing more @@ -257,6 +259,265 @@ def _unparse_pep_604_annotation(node: ast.Subscript) -> list[Node]: return [type_to_xref(annotation, env)] +class _TypeParameterListParser(TokenProcessor): + def __init__(self, sig: str) -> None: + signature = ''.join(sig.splitlines()).strip() + super().__init__([signature]) + # Each item is a tuple (name, kind, default, bound) mimicking + # inspect.Parameter to allow default values on VAR_POSITIONAL + # or VAR_KEYWORD parameters. + self.tparams: list[tuple[str, int, Any, Any]] = [] + + def fetch_tparam_spec(self) -> list[Token]: + from token import DEDENT, INDENT, OP + + tokens = [] + while self.fetch_token(): + tokens.append(self.current) + for ldelim, rdelim in [('(', ')'), ('{', '}'), ('[', ']')]: + if self.current == [OP, ldelim]: + tokens += self.fetch_until([OP, rdelim]) + break + else: + if self.current == INDENT: + tokens += self.fetch_until(DEDENT) + elif self.current.match([OP, ':'], [OP, '='], [OP, ',']): + tokens.pop() + break + return tokens + + def parse(self) -> None: + from itertools import chain, tee + from token import ENDMARKER, NAME, NEWLINE, NUMBER, OP, STRING + + def pairwise(iterable): + a, b = tee(iterable) + next(b, None) + return zip(a, b) + + def triplewise(iterable): + for (a, _), (b, c) in pairwise(pairwise(iterable)): + yield a, b, c + + def pformat_token(token: Token) -> str: + if token.match(NEWLINE, ENDMARKER): + return '' + + if token.match([OP, ':'], [OP, ','], [OP, '#']): + return f'{token.value} ' + + # Arithmetic operators are allowed because PEP 695 specifies the + # default type parameter to be *any* expression (so "T1 << T2" is + # allowed if it makes sense). The caller is responsible to ensure + # that a multiplication operator ("*") is not to be confused with + # an unpack operator (which will not be surrounded by spaces). + # + # The operators are ordered according to how likely they are to + # be used and for (possible) future implementations (e.g., "&" for + # an intersection type). + if token.match( + # most likely operators to appear + [OP, '='], [OP, '|'], + # type composition (future compatibility) + [OP, '&'], [OP, '^'], [OP, '<'], [OP, '>'], + # unlikely type composition + [OP, '+'], [OP, '-'], [OP, '*'], [OP, '**'], + # unlikely operators but included for completeness + [OP, '@'], [OP, '/'], [OP, '//'], [OP, '%'], + [OP, '<<'], [OP, '>>'], [OP, '>>>'], + [OP, '<='], [OP, '>='], [OP, '=='], [OP, '!='], + ): + return f' {token.value} ' + + return token.value + + def build_identifier(tokens: list[Token]) -> str: + idents: list[str] = [] + + fillvalue = Token(ENDMARKER, '', (-1, -1), (-1, -1), '') + groups = triplewise(chain(tokens, [fillvalue, fillvalue])) + head, _, _ = next(groups, (fillvalue,) * 3) + + if head.match([OP, '*'], [OP, '**']): + idents.append(head.value) + else: + idents.append(pformat_token(head)) + + is_unpack_operator = False + for token, op, after in groups: + if is_unpack_operator: + idents.append(token.value) + is_unpack_operator = False + else: + idents.append(pformat_token(token)) + + is_unpack_operator = ( + op.match([OP, '*'], [OP, '**']) and not ( + token.match(NAME, NUMBER, STRING) + and after.match(NAME, NUMBER, STRING) + ) + ) + return ''.join(idents).strip() + + while self.fetch_token(): + if self.current == NAME: + tpname = self.current.value.strip() + if self.previous and self.previous.match([OP, '*'], [OP, '**']): + if self.previous == [OP, '*']: + tpkind = Parameter.VAR_POSITIONAL + else: + tpkind = Parameter.VAR_KEYWORD + else: + tpkind = Parameter.POSITIONAL_OR_KEYWORD + + tpbound: Any = Parameter.empty + tpdefault: Any = Parameter.empty + + self.fetch_token() + if self.current and self.current.match([OP, ':'], [OP, '=']): + if self.current == [OP, ':']: + tpbound = build_identifier(self.fetch_tparam_spec()) + if self.current == [OP, '=']: + tpdefault = build_identifier(self.fetch_tparam_spec()) + + if tpkind != Parameter.POSITIONAL_OR_KEYWORD and tpbound != Parameter.empty: + raise SyntaxError('type parameter bound or constraint is not allowed ' + f'for {tpkind.description} parameters') + + tparam = (tpname, tpkind, tpdefault, tpbound) + self.tparams.append(tparam) + + def _build_identifier(self, tokens: list[Token]) -> str: + from itertools import chain, tee + from token import ENDMARKER, NAME, NUMBER, OP, STRING + + def pairwise(iterable): + a, b = tee(iterable) + next(b, None) + return zip(a, b) + + def triplewise(iterable): + for (a, _), (b, c) in pairwise(pairwise(iterable)): + yield a, b, c + + idents: list[str] = [] + end = Token(ENDMARKER, '', (-1, -1), (-1, -1), '') + groups = triplewise(chain(tokens, [end, end])) + + head, _, _ = next(groups, (end,) * 3) + is_unpack_operator = head.match([OP, '*'], [OP, '**']) + idents.append(self._pformat_token(head, native=is_unpack_operator)) + + is_unpack_operator = False + for token, op, after in groups: + ident = self._pformat_token(token, native=is_unpack_operator) + idents.append(ident) + # determine if the next token is an unpack operator depending + # on the left and right hand side of the operator symbol + is_unpack_operator = ( + op.match([OP, '*'], [OP, '**']) and not ( + token.match(NAME, NUMBER, STRING) + and after.match(NAME, NUMBER, STRING) + ) + ) + + return ''.join(idents).strip() + + def _pformat_token(self, token: Token, native=False) -> str: + from token import ENDMARKER, NEWLINE, OP + + if native: + return token.value + + if token.match(NEWLINE, ENDMARKER): + return '' + + if token.match([OP, ':'], [OP, ','], [OP, '#']): + return f'{token.value} ' + + # Arithmetic operators are allowed because PEP 695 specifies the + # default type parameter to be *any* expression (so "T1 << T2" is + # allowed if it makes sense). The caller is responsible to ensure + # that a multiplication operator ("*") is not to be confused with + # an unpack operator (which will not be surrounded by spaces). + # + # The operators are ordered according to how likely they are to + # be used and for (possible) future implementations (e.g., "&" for + # an intersection type). + if token.match( + # most likely operators to appear + [OP, '='], [OP, '|'], + # type composition (future compatibility) + [OP, '&'], [OP, '^'], [OP, '<'], [OP, '>'], + # unlikely type composition + [OP, '+'], [OP, '-'], [OP, '*'], [OP, '**'], + # unlikely operators but included for completeness + [OP, '@'], [OP, '/'], [OP, '//'], [OP, '%'], + [OP, '<<'], [OP, '>>'], [OP, '>>>'], + [OP, '<='], [OP, '>='], [OP, '=='], [OP, '!='], + ): + return f' {token.value} ' + + return token.value + + +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 + # formal parameter names are interpreted as type parameter names and + # type annotations are interpreted as type parameter bounds + parser = _TypeParameterListParser(tplist) + parser.parse() + for (tpname, tpkind, tpdefault, tpbound) in parser.tparams: + # no positional-only or keyword-only allowed in a type parameters list + assert tpkind not in {Parameter.POSITIONAL_ONLY, Parameter.KEYWORD_ONLY} + + node = addnodes.desc_parameter() + if tpkind == Parameter.VAR_POSITIONAL: + node += addnodes.desc_sig_operator('', '*') + elif tpkind == Parameter.VAR_KEYWORD: + node += addnodes.desc_sig_operator('', '**') + node += addnodes.desc_sig_name('', tpname) + + if tpbound is not Parameter.empty: + type_bound = _parse_annotation(tpbound, 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) + # add delimiters around type bounds written as e.g., "(T1, T2)" + if tpbound.startswith('(') and tpbound.endswith(')'): + type_bound_text = type_bound_expr.astext() + if type_bound_text.startswith('(') and type_bound_text.endswith(')'): + node += type_bound_expr + else: + node += addnodes.desc_sig_punctuation('', '(') + node += type_bound_expr + node += addnodes.desc_sig_punctuation('', ')') + else: + node += type_bound_expr + + if tpdefault is not Parameter.empty: + if tpbound is not Parameter.empty or tpkind != Parameter.POSITIONAL_OR_KEYWORD: + node += addnodes.desc_sig_space() + node += addnodes.desc_sig_operator('', '=') + node += addnodes.desc_sig_space() + else: + node += addnodes.desc_sig_operator('', '=') + node += nodes.inline('', tpdefault, classes=['default_value'], + support_smartquotes=False) + + tparams += node + return tparams + + def _parse_arglist( arglist: str, env: BuildEnvironment | None = None, multi_line_parameter_list: bool = False, ) -> addnodes.desc_parameterlist: @@ -514,7 +775,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 +831,14 @@ 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 Exception as exc: + logger.warning("could not parse tplist (%r): %s", tplist, exc, + location=signode) + 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..14198b66ed0 100644 --- a/sphinx/writers/html5.py +++ b/sphinx/writers/html5.py @@ -149,7 +149,8 @@ def depart_desc_returns(self, node: Element) -> None: self.body.append('') def visit_desc_parameterlist(self, node: Element) -> None: - self.body.append('(') + list_left_delim = node.list_left_delim # type: ignore[attr-defined] + self.body.append(f'{list_left_delim}') self.is_first_param = True self.optional_param_level = 0 self.params_left_at_level = 0 @@ -170,7 +171,8 @@ 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(')') + list_right_delim = node.list_right_delim # type: ignore[attr-defined] + self.body.append(f'{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..66b3ffa6e92 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) # type: ignore[attr-defined] self.first_param = 1 def depart_desc_parameterlist(self, node: Element) -> None: - self.body.append(')') + self.body.append(node.list_right_delim) # type: ignore[attr-defined] 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..c421846613a 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}') # type: ignore[attr-defined] self.first_param = 1 def depart_desc_parameterlist(self, node: Element) -> None: - self.body.append(')') + self.body.append(node.list_right_delim) # type: ignore[attr-defined] 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..616151a2ea7 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) # type: ignore[attr-defined] 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) # type: ignore[attr-defined] 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..b3825ad6827 100644 --- a/tests/test_domain_py.py +++ b/tests/test_domain_py.py @@ -1,5 +1,7 @@ """Tests the Python Domain""" +from __future__ import annotations + import re from unittest.mock import Mock @@ -26,6 +28,7 @@ desc_sig_punctuation, desc_sig_space, desc_signature, + desc_tparameterlist, pending_xref, ) from sphinx.domains import IndexEntry @@ -45,7 +48,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 +1843,253 @@ def test_short_literal_types(app): [desc_content, ()], )], )) + + +def test_function_pep_695(app): + text = """.. py:function:: func[\ + S,\ + T: int,\ + U: (int, str),\ + R: int | int,\ + A: int | Annotated[int, ctype("char")],\ + *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, 'S'])], + [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_name, 'R'], + [desc_sig_punctuation, ':'], + desc_sig_space, + [desc_sig_name, ( + [pending_xref, 'int'], + desc_sig_space, + [desc_sig_punctuation, '|'], + desc_sig_space, + [pending_xref, 'int'], + )], + )], + [desc_parameter, ( + [desc_sig_name, 'A'], + [desc_sig_punctuation, ':'], + desc_sig_space, + [desc_sig_name, ([pending_xref, 'int | Annotated[int, ctype("char")]'])], + )], + [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): + # Non-concrete unbound generics are allowed at runtime but type checkers + # should fail (https://peps.python.org/pep-0695/#type-parameter-scopes) + text = """.. py:class:: Class[S: Sequence[T], T, KT, VT](Dict[KT, VT])""" + 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_parameter, ([desc_sig_name, 'KT'])], + [desc_parameter, ([desc_sig_name, 'VT'])], + )], + [desc_parameterlist, ([desc_parameter, 'Dict[KT, VT]'])], + )], + [desc_content, ()], + )], + )) + + +def test_class_def_pep_696(app): + # test default values for type variables without using PEP 696 AST parser + text = """.. py:class:: Class[\ + T, KT, VT,\ + J: int,\ + S: str = str,\ + L: (T, tuple[T, ...], collections.abc.Iterable[T]) = set[T],\ + Q: collections.abc.Mapping[KT, VT] = dict[KT, VT],\ + *V = *tuple[*Ts, bool],\ + **P = [int, Annotated[int, ValueRange(3, 10), ctype("char")]]\ + ](Other[T, KT, VT, J, S, L, Q, *V, **P]) + """ + 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, 'T'])], + [desc_parameter, ([desc_sig_name, 'KT'])], + [desc_parameter, ([desc_sig_name, 'VT'])], + # J: int + [desc_parameter, ( + [desc_sig_name, 'J'], + [desc_sig_punctuation, ':'], + desc_sig_space, + [desc_sig_name, ([pending_xref, 'int'])], + )], + # S: str = str + [desc_parameter, ( + [desc_sig_name, 'S'], + [desc_sig_punctuation, ':'], + desc_sig_space, + [desc_sig_name, ([pending_xref, 'str'])], + desc_sig_space, + [desc_sig_operator, '='], + desc_sig_space, + [nodes.inline, 'str'], + )], + [desc_parameter, ( + [desc_sig_name, 'L'], + [desc_sig_punctuation, ':'], + desc_sig_space, + [desc_sig_punctuation, '('], + [desc_sig_name, ( + # T + [pending_xref, 'T'], + [desc_sig_punctuation, ','], + desc_sig_space, + # tuple[T, ...] + [pending_xref, 'tuple'], + [desc_sig_punctuation, '['], + [pending_xref, 'T'], + [desc_sig_punctuation, ','], + desc_sig_space, + [desc_sig_punctuation, '...'], + [desc_sig_punctuation, ']'], + [desc_sig_punctuation, ','], + desc_sig_space, + # collections.abc.Iterable[T] + [pending_xref, 'collections.abc.Iterable'], + [desc_sig_punctuation, '['], + [pending_xref, 'T'], + [desc_sig_punctuation, ']'], + )], + [desc_sig_punctuation, ')'], + desc_sig_space, + [desc_sig_operator, '='], + desc_sig_space, + [nodes.inline, 'set[T]'], + )], + [desc_parameter, ( + [desc_sig_name, 'Q'], + [desc_sig_punctuation, ':'], + desc_sig_space, + [desc_sig_name, ( + [pending_xref, 'collections.abc.Mapping'], + [desc_sig_punctuation, '['], + [pending_xref, 'KT'], + [desc_sig_punctuation, ','], + desc_sig_space, + [pending_xref, 'VT'], + [desc_sig_punctuation, ']'], + )], + desc_sig_space, + [desc_sig_operator, '='], + desc_sig_space, + [nodes.inline, 'dict[KT, VT]'], + )], + [desc_parameter, ( + [desc_sig_operator, '*'], + [desc_sig_name, 'V'], + desc_sig_space, + [desc_sig_operator, '='], + desc_sig_space, + [nodes.inline, '*tuple[*Ts, bool]'], + )], + [desc_parameter, ( + [desc_sig_operator, '**'], + [desc_sig_name, 'P'], + desc_sig_space, + [desc_sig_operator, '='], + desc_sig_space, + [nodes.inline, '[int, Annotated[int, ValueRange(3, 10), ctype("char")]]'], + )], + )], + [desc_parameterlist, ( + [desc_parameter, 'Other[T, KT, VT, J, S, L, Q, *V, **P]'], + )], + )], + [desc_content, ()], + )], + )) + + +def test_class_def_pep_695_whitespaces(app): + # test that spaces are removed around some operators + text = '.. py:function:: f[X: int]()' + doctree = restructuredtext.parse(app, text) + assert doctree.astext() == '\n\nf[X: int]()\n\n' + + text = '.. py:function:: g[ A: (T, X,Y), B: (MyType* 2131, *Ts, U * U)]()' + doctree = restructuredtext.parse(app, text) + assert doctree.astext() == '\n\ng[A: (T, X, Y), B: (MyType * 2131, *Ts, U * U)]()\n\n' + + text = '.. py:function:: foo[* V=* tuple [ *Ts, bool] ]()' + doctree = restructuredtext.parse(app, text) + assert doctree.astext() == '\n\nfoo[*V = *tuple[*Ts, bool]]()\n\n' + + # "* *P" consists of three tokens and "** P" or " **P" of two. + text = '.. py:function:: bar[**P =[int&Annotated[Some(" int ")]]]' + doctree = restructuredtext.parse(app, text) + assert doctree.astext() == '\n\nbar[**P = [int & Annotated[Some(" int ")]]]()\n\n' + + # "* *P" would raise a SyntaxError but since the parser is based on tokens + # and not on AST, such error is left undetected. This issue does not happen + # if "**P" is written compactly (no spaces). + text = '.. py:class:: I[ * *P]' + doctree = restructuredtext.parse(app, text) + assert doctree.astext() == '\n\nclass I[*P]\n\n' From 031afb8be51226d3b220694280c1fe006d99a778 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 30 May 2023 12:07:56 +0200 Subject: [PATCH 02/23] Fix linter --- sphinx/domains/python.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/sphinx/domains/python.py b/sphinx/domains/python.py index 23adb4c6bde..ce10fa588f7 100644 --- a/sphinx/domains/python.py +++ b/sphinx/domains/python.py @@ -336,7 +336,7 @@ def build_identifier(tokens: list[Token]) -> str: fillvalue = Token(ENDMARKER, '', (-1, -1), (-1, -1), '') groups = triplewise(chain(tokens, [fillvalue, fillvalue])) - head, _, _ = next(groups, (fillvalue,) * 3) + head, _op, _after = next(groups, (fillvalue,) * 3) if head.match([OP, '*'], [OP, '**']): idents.append(head.value) @@ -366,9 +366,9 @@ def build_identifier(tokens: list[Token]) -> str: if self.previous == [OP, '*']: tpkind = Parameter.VAR_POSITIONAL else: - tpkind = Parameter.VAR_KEYWORD + tpkind = Parameter.VAR_KEYWORD # type: ignore[assignment] else: - tpkind = Parameter.POSITIONAL_OR_KEYWORD + tpkind = Parameter.POSITIONAL_OR_KEYWORD # type: ignore[assignment] tpbound: Any = Parameter.empty tpdefault: Any = Parameter.empty @@ -423,7 +423,7 @@ def triplewise(iterable): return ''.join(idents).strip() - def _pformat_token(self, token: Token, native=False) -> str: + def _pformat_token(self, token: Token, native: bool = False) -> str: from token import ENDMARKER, NEWLINE, OP if native: @@ -491,7 +491,7 @@ def _parse_tplist( node += addnodes.desc_sig_punctuation('', ':') node += addnodes.desc_sig_space() - type_bound_expr = addnodes.desc_sig_name('', '', *type_bound) + type_bound_expr = addnodes.desc_sig_name('', '', *type_bound) # type: ignore # add delimiters around type bounds written as e.g., "(T1, T2)" if tpbound.startswith('(') and tpbound.endswith(')'): type_bound_text = type_bound_expr.astext() From da9525376ec3427e21347af6dc73d37d99c91e52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 30 May 2023 12:09:55 +0200 Subject: [PATCH 03/23] rename a test for better clarity --- tests/test_domain_py.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_domain_py.py b/tests/test_domain_py.py index b3825ad6827..be9b15aca10 100644 --- a/tests/test_domain_py.py +++ b/tests/test_domain_py.py @@ -2068,7 +2068,7 @@ def test_class_def_pep_696(app): )) -def test_class_def_pep_695_whitespaces(app): +def test_type_parameter_list_pep_695_and_pep_696_whitespaces(app): # test that spaces are removed around some operators text = '.. py:function:: f[X: int]()' doctree = restructuredtext.parse(app, text) From 0f985da5d7dad3d87bc483055797979941184812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 30 May 2023 12:10:52 +0200 Subject: [PATCH 04/23] fix linter check --- sphinx/domains/python.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx/domains/python.py b/sphinx/domains/python.py index ce10fa588f7..4a9d76f5194 100644 --- a/sphinx/domains/python.py +++ b/sphinx/domains/python.py @@ -296,7 +296,7 @@ def pairwise(iterable): return zip(a, b) def triplewise(iterable): - for (a, _), (b, c) in pairwise(pairwise(iterable)): + for (a, __), (b, c) in pairwise(pairwise(iterable)): yield a, b, c def pformat_token(token: Token) -> str: From 5c353f2ff5526a768045b632bb6f625c7922c61e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 30 May 2023 12:11:18 +0200 Subject: [PATCH 05/23] fix linter check --- sphinx/domains/python.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx/domains/python.py b/sphinx/domains/python.py index 4a9d76f5194..6d24d85bc53 100644 --- a/sphinx/domains/python.py +++ b/sphinx/domains/python.py @@ -296,7 +296,7 @@ def pairwise(iterable): return zip(a, b) def triplewise(iterable): - for (a, __), (b, c) in pairwise(pairwise(iterable)): + for (a, _x), (b, c) in pairwise(pairwise(iterable)): yield a, b, c def pformat_token(token: Token) -> str: From cf0d946fd352565d83d427aaa487ae5915362c46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 30 May 2023 12:33:56 +0200 Subject: [PATCH 06/23] remove unused code --- sphinx/domains/python.py | 78 +++------------------------------------- 1 file changed, 4 insertions(+), 74 deletions(-) diff --git a/sphinx/domains/python.py b/sphinx/domains/python.py index 6d24d85bc53..9d6eba7701a 100644 --- a/sphinx/domains/python.py +++ b/sphinx/domains/python.py @@ -287,77 +287,7 @@ def fetch_tparam_spec(self) -> list[Token]: return tokens def parse(self) -> None: - from itertools import chain, tee - from token import ENDMARKER, NAME, NEWLINE, NUMBER, OP, STRING - - def pairwise(iterable): - a, b = tee(iterable) - next(b, None) - return zip(a, b) - - def triplewise(iterable): - for (a, _x), (b, c) in pairwise(pairwise(iterable)): - yield a, b, c - - def pformat_token(token: Token) -> str: - if token.match(NEWLINE, ENDMARKER): - return '' - - if token.match([OP, ':'], [OP, ','], [OP, '#']): - return f'{token.value} ' - - # Arithmetic operators are allowed because PEP 695 specifies the - # default type parameter to be *any* expression (so "T1 << T2" is - # allowed if it makes sense). The caller is responsible to ensure - # that a multiplication operator ("*") is not to be confused with - # an unpack operator (which will not be surrounded by spaces). - # - # The operators are ordered according to how likely they are to - # be used and for (possible) future implementations (e.g., "&" for - # an intersection type). - if token.match( - # most likely operators to appear - [OP, '='], [OP, '|'], - # type composition (future compatibility) - [OP, '&'], [OP, '^'], [OP, '<'], [OP, '>'], - # unlikely type composition - [OP, '+'], [OP, '-'], [OP, '*'], [OP, '**'], - # unlikely operators but included for completeness - [OP, '@'], [OP, '/'], [OP, '//'], [OP, '%'], - [OP, '<<'], [OP, '>>'], [OP, '>>>'], - [OP, '<='], [OP, '>='], [OP, '=='], [OP, '!='], - ): - return f' {token.value} ' - - return token.value - - def build_identifier(tokens: list[Token]) -> str: - idents: list[str] = [] - - fillvalue = Token(ENDMARKER, '', (-1, -1), (-1, -1), '') - groups = triplewise(chain(tokens, [fillvalue, fillvalue])) - head, _op, _after = next(groups, (fillvalue,) * 3) - - if head.match([OP, '*'], [OP, '**']): - idents.append(head.value) - else: - idents.append(pformat_token(head)) - - is_unpack_operator = False - for token, op, after in groups: - if is_unpack_operator: - idents.append(token.value) - is_unpack_operator = False - else: - idents.append(pformat_token(token)) - - is_unpack_operator = ( - op.match([OP, '*'], [OP, '**']) and not ( - token.match(NAME, NUMBER, STRING) - and after.match(NAME, NUMBER, STRING) - ) - ) - return ''.join(idents).strip() + from token import NAME, OP while self.fetch_token(): if self.current == NAME: @@ -376,9 +306,9 @@ def build_identifier(tokens: list[Token]) -> str: self.fetch_token() if self.current and self.current.match([OP, ':'], [OP, '=']): if self.current == [OP, ':']: - tpbound = build_identifier(self.fetch_tparam_spec()) + tpbound = self._build_identifier(self.fetch_tparam_spec()) if self.current == [OP, '=']: - tpdefault = build_identifier(self.fetch_tparam_spec()) + tpdefault = self._build_identifier(self.fetch_tparam_spec()) if tpkind != Parameter.POSITIONAL_OR_KEYWORD and tpbound != Parameter.empty: raise SyntaxError('type parameter bound or constraint is not allowed ' @@ -397,7 +327,7 @@ def pairwise(iterable): return zip(a, b) def triplewise(iterable): - for (a, _), (b, c) in pairwise(pairwise(iterable)): + for (a, _z), (b, c) in pairwise(pairwise(iterable)): yield a, b, c idents: list[str] = [] From 81c3bbcb89b4d8b64784828fb8fa7cf4736d5641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Thu, 1 Jun 2023 18:22:32 +0200 Subject: [PATCH 07/23] improve tests --- sphinx/addnodes.py | 5 ++ sphinx/domains/python.py | 79 +++++++++++++++------------- sphinx/writers/latex.py | 14 +++++ tests/test_domain_py.py | 111 ++++++++++++++++++++++++--------------- 4 files changed, 131 insertions(+), 78 deletions(-) diff --git a/sphinx/addnodes.py b/sphinx/addnodes.py index 5f9daea88a8..337b8559a84 100644 --- a/sphinx/addnodes.py +++ b/sphinx/addnodes.py @@ -270,6 +270,10 @@ class desc_parameter(nodes.Part, nodes.Inline, nodes.FixedTextElement): """Node for a single parameter.""" +class desc_tparameter(desc_parameter): + """Node for a single type parameter.""" + + class desc_optional(nodes.Part, nodes.Inline, nodes.FixedTextElement): """Node for marking optional parts of the parameter list.""" child_text_separator = ', ' @@ -547,6 +551,7 @@ def setup(app: Sphinx) -> dict[str, Any]: app.add_node(desc_parameterlist) app.add_node(desc_tparameterlist) app.add_node(desc_parameter) + app.add_node(desc_tparameter) app.add_node(desc_optional) app.add_node(desc_annotation) diff --git a/sphinx/domains/python.py b/sphinx/domains/python.py index 9d6eba7701a..76f264031d3 100644 --- a/sphinx/domains/python.py +++ b/sphinx/domains/python.py @@ -263,7 +263,7 @@ class _TypeParameterListParser(TokenProcessor): def __init__(self, sig: str) -> None: signature = ''.join(sig.splitlines()).strip() super().__init__([signature]) - # Each item is a tuple (name, kind, default, bound) mimicking + # Each item is a tuple (name, kind, default, annotation) mimicking # inspect.Parameter to allow default values on VAR_POSITIONAL # or VAR_KEYWORD parameters. self.tparams: list[tuple[str, int, Any, Any]] = [] @@ -300,21 +300,24 @@ def parse(self) -> None: else: tpkind = Parameter.POSITIONAL_OR_KEYWORD # type: ignore[assignment] - tpbound: Any = Parameter.empty + tpann: Any = Parameter.empty tpdefault: Any = Parameter.empty self.fetch_token() if self.current and self.current.match([OP, ':'], [OP, '=']): if self.current == [OP, ':']: - tpbound = self._build_identifier(self.fetch_tparam_spec()) + tokens = self.fetch_tparam_spec() + tpann = self._build_identifier(tokens) + if self.current == [OP, '=']: - tpdefault = self._build_identifier(self.fetch_tparam_spec()) + tokens = self.fetch_tparam_spec() + tpdefault = self._build_identifier(tokens) - if tpkind != Parameter.POSITIONAL_OR_KEYWORD and tpbound != Parameter.empty: + if tpkind != Parameter.POSITIONAL_OR_KEYWORD and tpann != Parameter.empty: raise SyntaxError('type parameter bound or constraint is not allowed ' f'for {tpkind.description} parameters') - tparam = (tpname, tpkind, tpdefault, tpbound) + tparam = (tpname, tpkind, tpdefault, tpann) self.tparams.append(tparam) def _build_identifier(self, tokens: list[Token]) -> str: @@ -331,23 +334,28 @@ def triplewise(iterable): yield a, b, c idents: list[str] = [] - end = Token(ENDMARKER, '', (-1, -1), (-1, -1), '') - groups = triplewise(chain(tokens, [end, end])) - - head, _, _ = next(groups, (end,) * 3) - is_unpack_operator = head.match([OP, '*'], [OP, '**']) - idents.append(self._pformat_token(head, native=is_unpack_operator)) - + tokens: Iterable[Token] = iter(tokens) # type: ignore + # do not format opening brackets + for token in tokens: + if not token.match([OP, '('], [OP, '['], [OP, '{']): + # check if the first non-delimiter character is an unpack operator + is_unpack_operator = token.match([OP, '*'], [OP, ['**']]) + idents.append(self._pformat_token(token, native=is_unpack_operator)) + break + idents.append(token.value) + + # check the remaining tokens + stop = Token(ENDMARKER, '', (-1, -1), (-1, -1), '') is_unpack_operator = False - for token, op, after in groups: + for token, op, after in triplewise(chain(tokens, [stop, stop])): ident = self._pformat_token(token, native=is_unpack_operator) idents.append(ident) # determine if the next token is an unpack operator depending # on the left and right hand side of the operator symbol is_unpack_operator = ( op.match([OP, '*'], [OP, '**']) and not ( - token.match(NAME, NUMBER, STRING) - and after.match(NAME, NUMBER, STRING) + token.match(NAME, NUMBER, STRING, [OP, ')'], [OP, ']'], [OP, '}']) + and after.match(NAME, NUMBER, STRING, [OP, '('], [OP, '['], [OP, '{']) ) ) @@ -399,48 +407,47 @@ def _parse_tplist( tparams = addnodes.desc_tparameterlist(tplist) tparams['multi_line_parameter_list'] = multi_line_parameter_list # formal parameter names are interpreted as type parameter names and - # type annotations are interpreted as type parameter bounds + # type annotations are interpreted as type parameter bound or constraints parser = _TypeParameterListParser(tplist) parser.parse() - for (tpname, tpkind, tpdefault, tpbound) in parser.tparams: + for (tpname, tpkind, tpdefault, tpann) in parser.tparams: # no positional-only or keyword-only allowed in a type parameters list assert tpkind not in {Parameter.POSITIONAL_ONLY, Parameter.KEYWORD_ONLY} - node = addnodes.desc_parameter() + node = addnodes.desc_tparameter() if tpkind == Parameter.VAR_POSITIONAL: node += addnodes.desc_sig_operator('', '*') elif tpkind == Parameter.VAR_KEYWORD: node += addnodes.desc_sig_operator('', '**') node += addnodes.desc_sig_name('', tpname) - if tpbound is not Parameter.empty: - type_bound = _parse_annotation(tpbound, env) - if not type_bound: + if tpann is not Parameter.empty: + annotation = _parse_annotation(tpann, env) + if not annotation: 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 tpbound.startswith('(') and tpbound.endswith(')'): - type_bound_text = type_bound_expr.astext() - if type_bound_text.startswith('(') and type_bound_text.endswith(')'): - node += type_bound_expr + type_ann_expr = addnodes.desc_sig_name('', '', *annotation) # type: ignore + # a type bound is `T: U` whereas type constraints are `T: (U, V)` + if tpann.startswith('(') and tpann.endswith(')'): + type_ann_text = type_ann_expr.astext() + if type_ann_text.startswith('(') and type_ann_text.endswith(')'): + node += type_ann_expr else: + # surrounding braces are lost when using _parse_annotation() node += addnodes.desc_sig_punctuation('', '(') - node += type_bound_expr + node += type_ann_expr # type constraint node += addnodes.desc_sig_punctuation('', ')') else: - node += type_bound_expr + node += type_ann_expr # type bound if tpdefault is not Parameter.empty: - if tpbound is not Parameter.empty or tpkind != Parameter.POSITIONAL_OR_KEYWORD: - node += addnodes.desc_sig_space() - node += addnodes.desc_sig_operator('', '=') - node += addnodes.desc_sig_space() - else: - node += addnodes.desc_sig_operator('', '=') + # Always surround '=' with spaces, even if there is no annotation + node += addnodes.desc_sig_space() + node += addnodes.desc_sig_operator('', '=') + node += addnodes.desc_sig_space() node += nodes.inline('', tpdefault, classes=['default_value'], support_smartquotes=False) diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py index 37c73ae5a60..7269e58f8f3 100644 --- a/sphinx/writers/latex.py +++ b/sphinx/writers/latex.py @@ -804,6 +804,13 @@ def depart_desc_parameterlist(self, node: Element) -> None: # close parameterlist, open return annotation self.body.append('}{') + def visit_desc_tparameterlist(self, node: Element): + # not supported yet + raise nodes.SkipNode + + def depart_desc_tparameterlist(self, node: Element): + pass + def visit_desc_parameter(self, node: Element) -> None: if self.is_first_param: self.is_first_param = False @@ -836,6 +843,13 @@ def depart_desc_parameter(self, node: Element) -> None: if is_required: self.param_group_index += 1 + def visit_desc_tparameter(self, node: Element): + # not supported yet + raise nodes.SkipNode + + def depart_desc_tparameter(self, node: Element): + pass + def visit_desc_optional(self, node: Element) -> None: self.params_left_at_level = sum([isinstance(c, addnodes.desc_parameter) for c in node.children]) diff --git a/tests/test_domain_py.py b/tests/test_domain_py.py index be9b15aca10..2d37ccfa1f4 100644 --- a/tests/test_domain_py.py +++ b/tests/test_domain_py.py @@ -28,6 +28,7 @@ desc_sig_punctuation, desc_sig_space, desc_signature, + desc_tparameter, desc_tparameterlist, pending_xref, ) @@ -48,7 +49,7 @@ def parse(sig): m = py_sig_re.match(sig) if m is None: raise ValueError - name_prefix, generics, name, arglist, retann = m.groups() + name_prefix, tplist, name, arglist, retann = m.groups() signode = addnodes.desc_signature(sig, '') _pseudo_parse_arglist(signode, arglist) return signode.astext() @@ -1863,14 +1864,14 @@ def test_function_pep_695(app): [desc_signature, ( [desc_name, 'func'], [desc_tparameterlist, ( - [desc_parameter, ([desc_sig_name, 'S'])], - [desc_parameter, ( + [desc_tparameter, ([desc_sig_name, 'S'])], + [desc_tparameter, ( [desc_sig_name, 'T'], [desc_sig_punctuation, ':'], desc_sig_space, [desc_sig_name, ([pending_xref, 'int'])], )], - [desc_parameter, ( + [desc_tparameter, ( [desc_sig_name, 'U'], [desc_sig_punctuation, ':'], desc_sig_space, @@ -1883,7 +1884,7 @@ def test_function_pep_695(app): )], [desc_sig_punctuation, ')'], )], - [desc_parameter, ( + [desc_tparameter, ( [desc_sig_name, 'R'], [desc_sig_punctuation, ':'], desc_sig_space, @@ -1895,17 +1896,17 @@ def test_function_pep_695(app): [pending_xref, 'int'], )], )], - [desc_parameter, ( + [desc_tparameter, ( [desc_sig_name, 'A'], [desc_sig_punctuation, ':'], desc_sig_space, [desc_sig_name, ([pending_xref, 'int | Annotated[int, ctype("char")]'])], )], - [desc_parameter, ( + [desc_tparameter, ( [desc_sig_operator, '*'], [desc_sig_name, 'V'], )], - [desc_parameter, ( + [desc_tparameter, ( [desc_sig_operator, '**'], [desc_sig_name, 'P'], )], @@ -1929,7 +1930,7 @@ def test_class_def_pep_695(app): [desc_annotation, ('class', desc_sig_space)], [desc_name, 'Class'], [desc_tparameterlist, ( - [desc_parameter, ( + [desc_tparameter, ( [desc_sig_name, 'S'], [desc_sig_punctuation, ':'], desc_sig_space, @@ -1940,9 +1941,9 @@ def test_class_def_pep_695(app): [desc_sig_punctuation, ']'], )], )], - [desc_parameter, ([desc_sig_name, 'T'])], - [desc_parameter, ([desc_sig_name, 'KT'])], - [desc_parameter, ([desc_sig_name, 'VT'])], + [desc_tparameter, ([desc_sig_name, 'T'])], + [desc_tparameter, ([desc_sig_name, 'KT'])], + [desc_tparameter, ([desc_sig_name, 'VT'])], )], [desc_parameterlist, ([desc_parameter, 'Dict[KT, VT]'])], )], @@ -1956,6 +1957,7 @@ def test_class_def_pep_696(app): text = """.. py:class:: Class[\ T, KT, VT,\ J: int,\ + K = list,\ S: str = str,\ L: (T, tuple[T, ...], collections.abc.Iterable[T]) = set[T],\ Q: collections.abc.Mapping[KT, VT] = dict[KT, VT],\ @@ -1971,18 +1973,26 @@ def test_class_def_pep_696(app): [desc_annotation, ('class', desc_sig_space)], [desc_name, 'Class'], [desc_tparameterlist, ( - [desc_parameter, ([desc_sig_name, 'T'])], - [desc_parameter, ([desc_sig_name, 'KT'])], - [desc_parameter, ([desc_sig_name, 'VT'])], + [desc_tparameter, ([desc_sig_name, 'T'])], + [desc_tparameter, ([desc_sig_name, 'KT'])], + [desc_tparameter, ([desc_sig_name, 'VT'])], # J: int - [desc_parameter, ( + [desc_tparameter, ( [desc_sig_name, 'J'], [desc_sig_punctuation, ':'], desc_sig_space, [desc_sig_name, ([pending_xref, 'int'])], )], + # K = list + [desc_tparameter, ( + [desc_sig_name, 'K'], + desc_sig_space, + [desc_sig_operator, '='], + desc_sig_space, + [nodes.inline, 'list'], + )], # S: str = str - [desc_parameter, ( + [desc_tparameter, ( [desc_sig_name, 'S'], [desc_sig_punctuation, ':'], desc_sig_space, @@ -1992,7 +2002,7 @@ def test_class_def_pep_696(app): desc_sig_space, [nodes.inline, 'str'], )], - [desc_parameter, ( + [desc_tparameter, ( [desc_sig_name, 'L'], [desc_sig_punctuation, ':'], desc_sig_space, @@ -2024,7 +2034,7 @@ def test_class_def_pep_696(app): desc_sig_space, [nodes.inline, 'set[T]'], )], - [desc_parameter, ( + [desc_tparameter, ( [desc_sig_name, 'Q'], [desc_sig_punctuation, ':'], desc_sig_space, @@ -2042,7 +2052,7 @@ def test_class_def_pep_696(app): desc_sig_space, [nodes.inline, 'dict[KT, VT]'], )], - [desc_parameter, ( + [desc_tparameter, ( [desc_sig_operator, '*'], [desc_sig_name, 'V'], desc_sig_space, @@ -2050,7 +2060,7 @@ def test_class_def_pep_696(app): desc_sig_space, [nodes.inline, '*tuple[*Ts, bool]'], )], - [desc_parameter, ( + [desc_tparameter, ( [desc_sig_operator, '**'], [desc_sig_name, 'P'], desc_sig_space, @@ -2067,29 +2077,46 @@ def test_class_def_pep_696(app): )], )) - -def test_type_parameter_list_pep_695_and_pep_696_whitespaces(app): - # test that spaces are removed around some operators - text = '.. py:function:: f[X: int]()' +@pytest.mark.parametrize('tplist,tptext', [ + ('[T:int]', '[T: int]'), + ('[T:*Ts]', '[T: *Ts]'), + ('[T:int|(*Ts)]', '[T: int | (*Ts)]'), + ('[T:(*Ts)|int]', '[T: (*Ts) | int]'), + ('[T:(int|(*Ts))]', '[T: (int | (*Ts))]'), + ('[T:((*Ts)|int)]', '[T: ((*Ts) | int)]'), + ('[T:Annotated[int,ctype("char")]]', '[T: Annotated[int, ctype("char")]]'), +]) +def test_pep_695_and_pep_696_whitespaces_in_bound(app, tplist, tptext): + text = f'.. py:function:: f{tplist}()' doctree = restructuredtext.parse(app, text) - assert doctree.astext() == '\n\nf[X: int]()\n\n' + assert doctree.astext() == f'\n\nf{tptext}()\n\n' - text = '.. py:function:: g[ A: (T, X,Y), B: (MyType* 2131, *Ts, U * U)]()' - doctree = restructuredtext.parse(app, text) - assert doctree.astext() == '\n\ng[A: (T, X, Y), B: (MyType * 2131, *Ts, U * U)]()\n\n' - text = '.. py:function:: foo[* V=* tuple [ *Ts, bool] ]()' +@pytest.mark.parametrize('tplist,tptext', [ + ('[T:(int,str)]', '[T: (int, str)]'), + ('[T:(int|str,*Ts)]', '[T: (int | str, *Ts)]'), +]) +def test_pep_695_and_pep_696_whitespaces_in_constraints(app, tplist, tptext): + text = f'.. py:function:: f{tplist}()' doctree = restructuredtext.parse(app, text) - assert doctree.astext() == '\n\nfoo[*V = *tuple[*Ts, bool]]()\n\n' - - # "* *P" consists of three tokens and "** P" or " **P" of two. - text = '.. py:function:: bar[**P =[int&Annotated[Some(" int ")]]]' - doctree = restructuredtext.parse(app, text) - assert doctree.astext() == '\n\nbar[**P = [int & Annotated[Some(" int ")]]]()\n\n' - - # "* *P" would raise a SyntaxError but since the parser is based on tokens - # and not on AST, such error is left undetected. This issue does not happen - # if "**P" is written compactly (no spaces). - text = '.. py:class:: I[ * *P]' + assert doctree.astext() == f'\n\nf{tptext}()\n\n' + + +@pytest.mark.parametrize('tplist,tptext', [ + ('[T=int]', '[T = int]'), + ('[T:int=int]', '[T: int = int]'), + ('[*V=*Ts]', '[*V = *Ts]'), + ('[*V=(*Ts)]', '[*V = (*Ts)]'), + ('[*V=*tuple[str,...]]', '[*V = *tuple[str, ...]]'), + ('[*V=*tuple[*Ts,...]]', '[*V = *tuple[*Ts, ...]]'), + ('[*V=*tuple[int,*Ts]]', '[*V = *tuple[int, *Ts]]'), + ('[*V=*tuple[*Ts,int]]', '[*V = *tuple[*Ts, int]]'), + ('[**P=[int,*Ts]]', '[**P = [int, *Ts]]'), + ('[**P=[int, int*3]]', '[**P = [int, int * 3]]'), + ('[**P=[int, *Ts*3]]', '[**P = [int, *Ts * 3]]'), + ('[**P=[int,A[int,ctype("char")]]]', '[**P = [int, A[int, ctype("char")]]]'), +]) +def test_pep_695_and_pep_696_whitespaces_in_default(app, tplist, tptext): + text = f'.. py:function:: f{tplist}()' doctree = restructuredtext.parse(app, text) - assert doctree.astext() == '\n\nclass I[*P]\n\n' + assert doctree.astext() == f'\n\nf{tptext}()\n\n' From f553df104f06cad26e9dbb25ffd4d1a6f53246af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Thu, 1 Jun 2023 18:25:13 +0200 Subject: [PATCH 08/23] fix linter check --- sphinx/writers/latex.py | 8 ++++---- tests/test_domain_py.py | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py index 7269e58f8f3..5ee2936dd59 100644 --- a/sphinx/writers/latex.py +++ b/sphinx/writers/latex.py @@ -804,11 +804,11 @@ def depart_desc_parameterlist(self, node: Element) -> None: # close parameterlist, open return annotation self.body.append('}{') - def visit_desc_tparameterlist(self, node: Element): + def visit_desc_tparameterlist(self, node: Element) -> None: # not supported yet raise nodes.SkipNode - def depart_desc_tparameterlist(self, node: Element): + def depart_desc_tparameterlist(self, node: Element) -> None: pass def visit_desc_parameter(self, node: Element) -> None: @@ -843,11 +843,11 @@ def depart_desc_parameter(self, node: Element) -> None: if is_required: self.param_group_index += 1 - def visit_desc_tparameter(self, node: Element): + def visit_desc_tparameter(self, node: Element) -> None: # not supported yet raise nodes.SkipNode - def depart_desc_tparameter(self, node: Element): + def depart_desc_tparameter(self, node: Element) -> None: pass def visit_desc_optional(self, node: Element) -> None: diff --git a/tests/test_domain_py.py b/tests/test_domain_py.py index 2d37ccfa1f4..87a3d58d962 100644 --- a/tests/test_domain_py.py +++ b/tests/test_domain_py.py @@ -2077,6 +2077,7 @@ def test_class_def_pep_696(app): )], )) + @pytest.mark.parametrize('tplist,tptext', [ ('[T:int]', '[T: int]'), ('[T:*Ts]', '[T: *Ts]'), From 08a6840d76ea97bebb48611fa61b40b2170e1384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 3 Jun 2023 13:37:10 +0200 Subject: [PATCH 09/23] update .gitignore to ignore the autogenerated (persistent) test-file lock --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8d33409d5bb..a2f7d63b3ad 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ doc/_build/ doc/locale/ tests/.coverage tests/build/ +tests/test-server.lock utils/regression_test.js node_modules/ From 489ec30a9a6adcfc659c73ee347e528295900f6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 3 Jun 2023 13:37:42 +0200 Subject: [PATCH 10/23] PEP 695/696 skeleton support for autodoc --- sphinx/ext/autodoc/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index 0bb5cb6a8fe..76526c9f5f2 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -61,6 +61,7 @@ r'''^ ([\w.]+::)? # explicit module name ([\w.]+\.)? # module and/or class name(s) (\w+) \s* # thing name + (?: \[\s*(.*)\s*])? # optional: type parameters list (PEP 695) (?: \((.*)\) # optional: arguments (?:\s* -> \s* (.*))? # return annotation )? $ # and nothing more @@ -388,7 +389,7 @@ def parse_name(self) -> bool: # an autogenerated one try: matched = py_ext_sig_re.match(self.name) - explicit_modname, path, base, args, retann = matched.groups() + explicit_modname, path, base, tplist, args, retann = matched.groups() except AttributeError: logger.warning(__('invalid signature for auto%s (%r)') % (self.objtype, self.name), type='autodoc') @@ -1192,7 +1193,7 @@ def _find_signature(self) -> tuple[str | None, str | None]: match = py_ext_sig_re.match(line) if not match: break - exmod, path, base, args, retann = match.groups() + exmod, path, base, tplist, args, retann = match.groups() # the base name must match ours if base not in valid_names: From 4eaa0ed504d86d68885875343bd29d9c7e013fff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 3 Jun 2023 18:43:42 +0200 Subject: [PATCH 11/23] allow independent multi-line support for type parameters and arguments list --- sphinx/addnodes.py | 21 +++++++++++++-------- sphinx/domains/python.py | 21 ++++++++++++++++++--- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/sphinx/addnodes.py b/sphinx/addnodes.py index 337b8559a84..164b9040c26 100644 --- a/sphinx/addnodes.py +++ b/sphinx/addnodes.py @@ -253,24 +253,29 @@ 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'{self.list_left_delim}{super().astext()}{self.list_right_delim}' + return f'({super().astext()})' -class desc_tparameterlist(desc_parameterlist): - """Node for a general type parameter list.""" - list_left_delim = '[' - list_right_delim = ']' +class desc_tparameterlist(nodes.Part, nodes.Inline, nodes.FixedTextElement): + """Node for a general type parameter list. + + As default the type parameters list is written in line with the rest of the signature. + Set ``multi_line_parameter_list = True`` to describe a multi-line type parameters list. + In that case each type parameter will then be written on its own, indented line. + """ + child_text_separator = ', ' + + def astext(self): + return f'[{super().astext()}]' class desc_parameter(nodes.Part, nodes.Inline, nodes.FixedTextElement): """Node for a single parameter.""" -class desc_tparameter(desc_parameter): +class desc_tparameter(nodes.Part, nodes.Inline, nodes.FixedTextElement): """Node for a single type parameter.""" diff --git a/sphinx/domains/python.py b/sphinx/domains/python.py index 76f264031d3..ac9bdaaa770 100644 --- a/sphinx/domains/python.py +++ b/sphinx/domains/python.py @@ -662,6 +662,7 @@ class PyObject(ObjectDescription[Tuple[str, str]]): 'noindexentry': directives.flag, 'nocontentsentry': directives.flag, 'single-line-parameter-list': directives.flag, + 'single-line-type-parameter-list': directives.flag, 'module': directives.unchanged, 'canonical': directives.unchanged, 'annotation': directives.unchanged, @@ -747,9 +748,22 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str] max_len = (self.env.config.python_maximum_signature_line_length or self.env.config.maximum_signature_line_length or 0) + + # determine if the function arguments (without its type parameters) + # should be formatted on a multiline or not by removing the width of + # the type parameters list (if any) + siglen = len(sig) + tplist_span = m.span(3) multi_line_parameter_list = ( 'single-line-parameter-list' not in self.options - and (len(sig) > max_len > 0) + and (siglen - (tplist_span[1] - tplist_span[0])) > max_len > 0 + ) + + # determine whether the type parameter list must be wrapped or not + arglist_span = m.span(4) + multi_line_type_parameter_list = ( + 'single-line-type-parameter-list' not in self.options + and (siglen - (arglist_span[1] - arglist_span[0])) > max_len > 0 ) sig_prefix = self.get_signature_prefix(sig) @@ -771,7 +785,7 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str] if tplist: try: - signode += _parse_tplist(tplist, self.env, multi_line_parameter_list) + signode += _parse_tplist(tplist, self.env, multi_line_type_parameter_list) except Exception as exc: logger.warning("could not parse tplist (%r): %s", tplist, exc, location=signode) @@ -783,7 +797,8 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str] # fallback to parse arglist original parser. # it supports to represent optional arguments (ex. "func(foo [, bar])") _pseudo_parse_arglist(signode, arglist, multi_line_parameter_list) - except NotImplementedError as exc: + except (NotImplementedError, ValueError) as exc: + # duplicate parameter names raise ValueError and not a SyntaxError logger.warning("could not parse arglist (%r): %s", arglist, exc, location=signode) _pseudo_parse_arglist(signode, arglist, multi_line_parameter_list) From a843d7cba3318885f84481262369493e8ed36ad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 3 Jun 2023 18:44:21 +0200 Subject: [PATCH 12/23] implement visitor methods for type parameters --- sphinx/writers/manpage.py | 17 ++++++++++++++-- sphinx/writers/texinfo.py | 14 +++++++++++-- sphinx/writers/text.py | 43 ++++++++++++++++++++++++++++++++++----- 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/sphinx/writers/manpage.py b/sphinx/writers/manpage.py index 66b3ffa6e92..6538e69d60c 100644 --- a/sphinx/writers/manpage.py +++ b/sphinx/writers/manpage.py @@ -184,11 +184,18 @@ def depart_desc_returns(self, node: Element) -> None: pass def visit_desc_parameterlist(self, node: Element) -> None: - self.body.append(node.list_left_delim) # type: ignore[attr-defined] + self.body.append('(') self.first_param = 1 def depart_desc_parameterlist(self, node: Element) -> None: - self.body.append(node.list_right_delim) # type: ignore[attr-defined] + self.body.append(')') + + def visit_desc_tparameterlist(self, node: Element) -> None: + self.body.append('[') + self.first_param = 1 + + def depart_desc_tparameterlist(self, node: Element) -> None: + self.body.append(']') def visit_desc_parameter(self, node: Element) -> None: if not self.first_param: @@ -199,6 +206,12 @@ def visit_desc_parameter(self, node: Element) -> None: def depart_desc_parameter(self, node: Element) -> None: pass + def visit_desc_tparameter(self, node: Element) -> None: + self.visit_desc_parameter(node) + + def depart_desc_tparameter(self, node: Element) -> None: + self.depart_desc_parameter(node) + def visit_desc_optional(self, node: Element) -> None: self.body.append('[') diff --git a/sphinx/writers/texinfo.py b/sphinx/writers/texinfo.py index c421846613a..54ff4eb1714 100644 --- a/sphinx/writers/texinfo.py +++ b/sphinx/writers/texinfo.py @@ -1462,11 +1462,18 @@ def depart_desc_returns(self, node: Element) -> None: pass def visit_desc_parameterlist(self, node: Element) -> None: - self.body.append(f' {node.list_left_delim}') # type: ignore[attr-defined] + self.body.append(' (') self.first_param = 1 def depart_desc_parameterlist(self, node: Element) -> None: - self.body.append(node.list_right_delim) # type: ignore[attr-defined] + self.body.append(')') + + def visit_desc_tparameterlist(self, node: Element) -> None: + self.body.append(' [') + self.first_param = 1 + + def depart_desc_tparameterlist(self, node: Element) -> None: + self.body.append(']') def visit_desc_parameter(self, node: Element) -> None: if not self.first_param: @@ -1479,6 +1486,9 @@ def visit_desc_parameter(self, node: Element) -> None: self.body.append(text) raise nodes.SkipNode + def visit_desc_tparameter(self, node: Element) -> None: + self.visit_desc_parameter(node) + def visit_desc_optional(self, node: Element) -> None: self.body.append('[') diff --git a/sphinx/writers/text.py b/sphinx/writers/text.py index 616151a2ea7..7a25c2bfade 100644 --- a/sphinx/writers/text.py +++ b/sphinx/writers/text.py @@ -392,6 +392,12 @@ def __init__(self, document: nodes.document, builder: TextBuilder) -> None: self.sectionlevel = 0 self.lineblocklevel = 0 self.table: Table = None + """Heterogeneous stack. + + Used by visit_* and depart_* functions in conjunction with the tree + traversal. Make sure that the pops correspond to the pushes. + """ + self.context = [] def add_text(self, text: str) -> None: self.states[-1].append((-1, text)) @@ -592,24 +598,48 @@ def visit_desc_returns(self, node: Element) -> None: def depart_desc_returns(self, node: Element) -> None: pass - def visit_desc_parameterlist(self, node: Element) -> None: - self.add_text(node.list_left_delim) # type: ignore[attr-defined] + def _visit_sig_parameter_list( + self, + node: Element, + parameter_group: type[Element], + sig_open_paren: str, + sig_close_paren: str, + ) -> None: + """Visit a signature parameters or type parameters list. + + The *parameter_group* value is the type of a child node acting as a required parameter + or as a set of contiguous optional parameters. + """ + self.add_text(sig_open_paren) self.is_first_param = True self.optional_param_level = 0 self.params_left_at_level = 0 self.param_group_index = 0 # Counts as what we call a parameter group are either a required parameter, or a # set of contiguous optional ones. - self.list_is_required_param = [isinstance(c, addnodes.desc_parameter) - for c in node.children] + self.list_is_required_param = [isinstance(c, parameter_group) for c in node.children] self.required_params_left = sum(self.list_is_required_param) self.param_separator = ', ' self.multi_line_parameter_list = node.get('multi_line_parameter_list', False) if self.multi_line_parameter_list: self.param_separator = self.param_separator.rstrip() + self.context.append(sig_close_paren) + + def _depart_sig_parameter_list(self, node: Element) -> None: + sig_close_paren = self.context.pop() + self.add_text(sig_close_paren) + + def visit_desc_parameterlist(self, node: Element) -> None: + self._visit_sig_parameter_list(node, addnodes.desc_parameter, '(', ')') def depart_desc_parameterlist(self, node: Element) -> None: - self.add_text(node.list_right_delim) # type: ignore[attr-defined] + self._depart_sig_parameter_list(node) + + def visit_desc_tparameterlist(self, node: Element) -> None: + self._visit_sig_parameter_list(node, addnodes.desc_tparameter, '[', ']') + + def depart_desc_tparameterlist(self, node: Element) -> None: + self._depart_sig_parameter_list(node) def visit_desc_parameter(self, node: Element) -> None: on_separate_line = self.multi_line_parameter_list @@ -645,6 +675,9 @@ def visit_desc_parameter(self, node: Element) -> None: self.param_group_index += 1 raise nodes.SkipNode + def visit_desc_tparameter(self, node: Element) -> None: + self.visit_desc_parameter(node) + def visit_desc_optional(self, node: Element) -> None: self.params_left_at_level = sum([isinstance(c, addnodes.desc_parameter) for c in node.children]) From 178b8a21e9528ef61ed8deef1bf8018abea74d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 3 Jun 2023 18:44:43 +0200 Subject: [PATCH 13/23] refactor visitor methods for parameters list-like nodes --- sphinx/writers/html5.py | 44 +++++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/sphinx/writers/html5.py b/sphinx/writers/html5.py index 14198b66ed0..8ea8900f987 100644 --- a/sphinx/writers/html5.py +++ b/sphinx/writers/html5.py @@ -148,17 +148,26 @@ def visit_desc_returns(self, node: Element) -> None: def depart_desc_returns(self, node: Element) -> None: self.body.append('') - def visit_desc_parameterlist(self, node: Element) -> None: - list_left_delim = node.list_left_delim # type: ignore[attr-defined] - self.body.append(f'{list_left_delim}') + def _visit_sig_parameter_list( + self, + node: Element, + parameter_group: type[Element], + sig_open_paren: str, + sig_close_paren: str, + ) -> None: + """Visit a signature parameters or type parameters list. + + The *parameter_group* value is the type of a child node acting as a required parameter + or as a set of contiguous optional parameters. + """ + self.body.append(f'{sig_open_paren}') self.is_first_param = True self.optional_param_level = 0 self.params_left_at_level = 0 self.param_group_index = 0 # Counts as what we call a parameter group either a required parameter, or a # set of contiguous optional ones. - self.list_is_required_param = [isinstance(c, addnodes.desc_parameter) - for c in node.children] + self.list_is_required_param = [isinstance(c, parameter_group) for c in node.children] # How many required parameters are left. self.required_params_left = sum(self.list_is_required_param) self.param_separator = node.child_text_separator @@ -167,12 +176,25 @@ def visit_desc_parameterlist(self, node: Element) -> None: self.body.append('\n\n') self.body.append(self.starttag(node, 'dl')) self.param_separator = self.param_separator.rstrip() + self.context.append(sig_close_paren) - def depart_desc_parameterlist(self, node: Element) -> None: + def _depart_sig_parameter_list(self, node: Element) -> None: if node.get('multi_line_parameter_list'): self.body.append('\n\n') - list_right_delim = node.list_right_delim # type: ignore[attr-defined] - self.body.append(f'{list_right_delim}') + sig_close_paren = self.context.pop() + self.body.append(f'{sig_close_paren}') + + def visit_desc_parameterlist(self, node: Element) -> None: + self._visit_sig_parameter_list(node, addnodes.desc_parameter, '(', ')') + + def depart_desc_parameterlist(self, node: Element) -> None: + self._depart_sig_parameter_list(node) + + def visit_desc_tparameterlist(self, node: Element) -> None: + self._visit_sig_parameter_list(node, addnodes.desc_tparameter, '[', ']') + + def depart_desc_tparameterlist(self, node: Element) -> None: + self._depart_sig_parameter_list(node) # If required parameters are still to come, then put the comma after # the parameter. Otherwise, put the comma before. This ensures that @@ -216,6 +238,12 @@ def depart_desc_parameter(self, node: Element) -> None: if is_required: self.param_group_index += 1 + def visit_desc_tparameter(self, node: Element) -> None: + self.visit_desc_parameter(node) + + def depart_desc_tparameter(self, node: Element) -> None: + self.depart_desc_parameter(node) + def visit_desc_optional(self, node: Element) -> None: self.params_left_at_level = sum([isinstance(c, addnodes.desc_parameter) for c in node.children]) From d1293f7debb198bdb80afdd3ba34b99028223068 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 3 Jun 2023 18:45:15 +0200 Subject: [PATCH 14/23] implement LaTeX support --- sphinx/texinputs/sphinxlatexobjects.sty | 86 +++++++++++++++++++++++ sphinx/texinputs/sphinxlatexstyletext.sty | 1 + sphinx/writers/latex.py | 80 +++++++++++++++++---- 3 files changed, 153 insertions(+), 14 deletions(-) diff --git a/sphinx/texinputs/sphinxlatexobjects.sty b/sphinx/texinputs/sphinxlatexobjects.sty index a2038a9f160..1cc9931bd76 100644 --- a/sphinx/texinputs/sphinxlatexobjects.sty +++ b/sphinx/texinputs/sphinxlatexobjects.sty @@ -128,6 +128,13 @@ \parbox[t]{\py@argswidth}{\raggedright #1\sphinxcode{)}#2\strut}% % final strut is to help get correct vertical separation } +\newcommand{\py@sigparamswithtplist}[3]{% + % similar to \py@sigparams but with different delimiters and an additional + % type parameters list given as #1, the argument list as #2 and the return + % annotation as #3 + \parbox[t]{\py@argswidth}{\raggedright #1\sphinxcode{]}\sphinxcode{(}#2\sphinxcode{)}#3\strut}% +} + \newcommand{\pysigline}[1]{% % as \py@argswidth is available, we use it but no "args" here % the \relax\relax is because \py@argswidth is a "skip" variable @@ -146,6 +153,15 @@ \item[{#1\sphinxcode{(}\py@sigparams{#2}{#3}\strut}] \pysigadjustitemsep } +\newcommand{\pysiglinewithargsretwithtplist}[4]{ +% #1 = name, #2 = tplist, #3 = arglist, #4 = retann +\let\spx@label\label\let\label\@gobble + \settowidth{\py@argswidth}{#1\sphinxcode{[}}% +\let\label\spx@label + \py@argswidth=\dimexpr\linewidth+\labelwidth-\py@argswidth\relax\relax + \item[{#1\sphinxcode{[}\py@sigparamswithtplist{#2}{#3}{#4}\strut}] + \pysigadjustitemsep +} \def\sphinxoptionalextraspace{0.5mm} \newcommand{\pysigwithonelineperarg}[3]{% @@ -167,6 +183,76 @@ \nopagebreak\noindent\kern-\labelwidth\sphinxcode{)}{#3} \pysigadjustitemsep } +\newcommand{\pysigwithonelineperargwithonelinepertparg}[4]{ + % #1 = name, #2 = tplist, #3 = arglist, #4 = retann + % render each type parameters and argument lists on its own line + \item[#1\sphinxcode{[}\strut] + \leavevmode\par\nopagebreak + \begingroup + \let\sphinxparamcomma\sphinxparamcommaoneperline + % \sphinxtypeparam is treated similarly to \sphinxparam but since + % \sphinxoptional is not accepted in a type parameters list, we do + % not need the hook or the global definition + \let\spx@sphinxtypeparam\sphinxtypeparam + \def\sphinxtypeparam{\def\sphinxtypeparam{\par\spx@sphinxtypeparam}\spx@sphinxtypeparam}% + #2\par + \endgroup + \nopagebreak\noindent\kern-\labelwidth\sphinxcode{]} + % render the rest of the signature like in \pysigwithonelineperarg + \sphinxcode{(}\strut\leavevmode\par\nopagebreak + \begingroup + \let\sphinxparamcomma\sphinxparamcommaoneperline + \def\sphinxoptionalhook{\ifvmode\else\kern\sphinxoptionalextraspace\relax\fi}% + \global\let\spx@sphinxparam\sphinxparam + \gdef\sphinxparam{\gdef\sphinxparam{\par\spx@sphinxparam}\spx@sphinxparam}% + #3\par + \endgroup + \global\let\sphinxparam\spx@sphinxparam + \nopagebreak\noindent\kern-\labelwidth\sphinxcode{)}{#4} + \pysigadjustitemsep +} +\newcommand{\pysiglinewithargsretwithonelinepertparg}[4]{ + % #1 = name, #2 = tplist, #3 = arglist, #4 = retann + % render each type parameters on its own line but the argument list is rendered inline + \item[#1\sphinxcode{[}\strut] + \leavevmode\par\nopagebreak + \begingroup + \let\sphinxparamcomma\sphinxparamcommaoneperline + % \sphinxtypeparam is treated similarly to \sphinxparam but since + % \sphinxoptional is not accepted in a type parameters list, we do + % not need the hook or the global definition + \let\spx@sphinxtypeparam\sphinxtypeparam + \def\sphinxtypeparam{\def\sphinxtypeparam{\par\spx@sphinxtypeparam}\spx@sphinxtypeparam}% + #2\par + \endgroup + \nopagebreak\noindent\kern-\labelwidth\sphinxcode{]} + % render the arguments list on one line + \settowidth{\py@argswidth}{\sphinxcode{]}\sphinxcode{()}}% + \py@argswidth=\dimexpr\linewidth+\labelwidth-\py@argswidth\relax\relax + \sphinxcode{(}\parbox[t]{\py@argswidth}{\raggedright #3\sphinxcode{)}#4\strut}% + \pysigadjustitemsep +} +\newcommand{\pysigwithonelineperargwithtplist}[4]{ + % #1 = name, #2 = tplist, #3 = arglist, #4 = retann + % render the type parameters list on one line, but each argument is rendered on its own line +\let\spx@label\label\let\label\@gobble + \settowidth{\py@argswidth}{#1\sphinxcode{[}}% +\let\label\spx@label + \py@argswidth=\dimexpr\linewidth+\labelwidth-\py@argswidth\relax\relax + \item[{#1\sphinxcode{[}\parbox[t]{\py@argswidth}{\raggedright #2\sphinxcode{]}\sphinxcode{(}\strut}\strut}] + % render the rest of the signature like in \pysigwithonelineperarg + \begingroup + \let\sphinxparamcomma\sphinxparamcommaoneperline + \def\sphinxoptionalhook{\ifvmode\else\kern\sphinxoptionalextraspace\relax\fi}% + \global\let\spx@sphinxparam\sphinxparam + \gdef\sphinxparam{\gdef\sphinxparam{\par\spx@sphinxparam}\spx@sphinxparam}% + #3\par + \endgroup + \global\let\sphinxparam\spx@sphinxparam + \nopagebreak\noindent\kern-\labelwidth\sphinxcode{)}{#4} + \pysigadjustitemsep +} + \newcommand{\pysigadjustitemsep}{% % adjust \itemsep to control the separation with the next signature % sharing common description diff --git a/sphinx/texinputs/sphinxlatexstyletext.sty b/sphinx/texinputs/sphinxlatexstyletext.sty index 292facc9132..0a23fa7b798 100644 --- a/sphinx/texinputs/sphinxlatexstyletext.sty +++ b/sphinx/texinputs/sphinxlatexstyletext.sty @@ -56,6 +56,7 @@ \protected\def\sphinxtermref#1{\emph{#1}} \protected\def\sphinxsamedocref#1{\emph{#1}} \protected\def\sphinxparam#1{\emph{#1}} +\protected\def\sphinxtypeparam#1{\emph{#1}} % \optional is used for ``[, arg]``, i.e. desc_optional nodes. \long\protected\def\sphinxoptional#1{% {\sphinxoptionalhook\textnormal{\Large[}}{#1}\hspace{0.5mm}{\textnormal{\Large]}}} diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py index 5ee2936dd59..52132022d14 100644 --- a/sphinx/writers/latex.py +++ b/sphinx/writers/latex.py @@ -700,14 +700,51 @@ def depart_desc(self, node: Element) -> None: self.body.append(CR + r'\end{fulllineitems}' + BLANKLINE) def _visit_signature_line(self, node: Element) -> None: + def next_sibling(e: Element) -> Element | None: + try: + return e.parent[e.parent.index(e) + 1] + except (AttributeError, IndexError): + return None + + def has_multi_line(e: Element) -> bool: + return e.get('multi_line_parameter_list') + + self.has_tplist = False + for child in node: + if isinstance(child, addnodes.desc_tparameterlist): + self.has_tplist = True + # recall that return annotations must follow an argument list, + # so signatures of the form "foo[tplist] -> retann" will not + # be encountered (if they should, the `domains.python.py_sig_re` + # pattern must be modified accordingly) + arglist = next_sibling(child) + assert isinstance(arglist, addnodes.desc_parameterlist) + # tplist + arglist: \macro{name}{tplist}{arglist}{return} + multi_tplist = has_multi_line(child) + multi_arglist = has_multi_line(arglist) + + if multi_tplist: + if multi_arglist: + self.body.append(CR + r'\pysigwithonelineperargwithonelinepertparg{') + else: + self.body.append(CR + r'\pysiglinewithargsretwithonelinepertparg{') + else: + if multi_arglist: + self.body.append(CR + r'\pysigwithonelineperargwithtplist{') + else: + self.body.append(CR + r'\pysiglinewithargsretwithtplist{') + break + if isinstance(child, addnodes.desc_parameterlist): - if child.get('multi_line_parameter_list'): + # arglist only: \macro{name}{arglist}{return} + if has_multi_line(child): self.body.append(CR + r'\pysigwithonelineperarg{') else: self.body.append(CR + r'\pysiglinewithargsret{') break else: + # no tplist, no arglist: \macro{name} self.body.append(CR + r'\pysigline{') def _depart_signature_line(self, node: Element) -> None: @@ -784,34 +821,47 @@ def visit_desc_returns(self, node: Element) -> None: def depart_desc_returns(self, node: Element) -> None: self.body.append(r'}') - def visit_desc_parameterlist(self, node: Element) -> None: - # close name, open parameterlist - self.body.append('}{') + def _visit_sig_parameter_list(self, node: Element, parameter_group: type[Element]) -> None: + """Visit a signature parameters or type parameters list. + + The *parameter_group* value is the type of a child node acting as a required parameter + or as a set of contiguous optional parameters. + + The caller is responsible for closing adding surrounding LaTeX macro argument start + and stop tokens. + """ self.is_first_param = True self.optional_param_level = 0 self.params_left_at_level = 0 self.param_group_index = 0 # Counts as what we call a parameter group either a required parameter, or a # set of contiguous optional ones. - self.list_is_required_param = [isinstance(c, addnodes.desc_parameter) - for c in node.children] + self.list_is_required_param = [isinstance(c, parameter_group) for c in node.children] # How many required parameters are left. self.required_params_left = sum(self.list_is_required_param) self.param_separator = r'\sphinxparamcomma ' self.multi_line_parameter_list = node.get('multi_line_parameter_list', False) + def visit_desc_parameterlist(self, node: Element) -> None: + if not self.has_tplist: + # close name argument (#1), open parameters list argument (#2) + self.body.append('}{') + self._visit_sig_parameter_list(node, addnodes.desc_parameter) + def depart_desc_parameterlist(self, node: Element) -> None: # close parameterlist, open return annotation self.body.append('}{') def visit_desc_tparameterlist(self, node: Element) -> None: - # not supported yet - raise nodes.SkipNode + # close name argument (#1), open type parameters list argument (#2) + self.body.append('}{') + self._visit_sig_parameter_list(node, addnodes.desc_tparameter) def depart_desc_tparameterlist(self, node: Element) -> None: - pass + # close type parameters list, open parameters list argument (#3) + self.body.append('}{') - def visit_desc_parameter(self, node: Element) -> None: + def _visit_sig_parameter(self, node: Element, parameter_macro: str) -> None: if self.is_first_param: self.is_first_param = False elif not self.multi_line_parameter_list and not self.required_params_left: @@ -821,7 +871,10 @@ def visit_desc_parameter(self, node: Element) -> None: else: self.params_left_at_level -= 1 if not node.hasattr('noemph'): - self.body.append(r'\sphinxparam{') + self.body.append(parameter_macro) + + def visit_desc_parameter(self, node: Element) -> None: + self._visit_sig_parameter(node, r'\sphinxparam{') def depart_desc_parameter(self, node: Element) -> None: if not node.hasattr('noemph'): @@ -844,11 +897,10 @@ def depart_desc_parameter(self, node: Element) -> None: self.param_group_index += 1 def visit_desc_tparameter(self, node: Element) -> None: - # not supported yet - raise nodes.SkipNode + self._visit_sig_parameter(node, r'\sphinxtypeparam{') def depart_desc_tparameter(self, node: Element) -> None: - pass + self.depart_desc_parameter(node) def visit_desc_optional(self, node: Element) -> None: self.params_left_at_level = sum([isinstance(c, addnodes.desc_parameter) From e28c44d1653efb0ef808233baa078ca5f389df5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 3 Jun 2023 18:46:50 +0200 Subject: [PATCH 15/23] fix linter issues --- sphinx/writers/latex.py | 2 +- sphinx/writers/text.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py index 52132022d14..980fd47a790 100644 --- a/sphinx/writers/latex.py +++ b/sphinx/writers/latex.py @@ -700,7 +700,7 @@ def depart_desc(self, node: Element) -> None: self.body.append(CR + r'\end{fulllineitems}' + BLANKLINE) def _visit_signature_line(self, node: Element) -> None: - def next_sibling(e: Element) -> Element | None: + def next_sibling(e: Node) -> Node | None: try: return e.parent[e.parent.index(e) + 1] except (AttributeError, IndexError): diff --git a/sphinx/writers/text.py b/sphinx/writers/text.py index 7a25c2bfade..f2c327de106 100644 --- a/sphinx/writers/text.py +++ b/sphinx/writers/text.py @@ -397,7 +397,7 @@ def __init__(self, document: nodes.document, builder: TextBuilder) -> None: Used by visit_* and depart_* functions in conjunction with the tree traversal. Make sure that the pops correspond to the pushes. """ - self.context = [] + self.context: list[str] = [] def add_text(self, text: str) -> None: self.states[-1].append((-1, text)) From 9a887712a63325c24dbd389e6cbc23f31dfd62d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 3 Jun 2023 19:05:32 +0200 Subject: [PATCH 16/23] update LaTeX info strings --- sphinx/texinputs/sphinxlatexobjects.sty | 2 +- sphinx/texinputs/sphinxlatexstyletext.sty | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sphinx/texinputs/sphinxlatexobjects.sty b/sphinx/texinputs/sphinxlatexobjects.sty index 1cc9931bd76..54f7b05b971 100644 --- a/sphinx/texinputs/sphinxlatexobjects.sty +++ b/sphinx/texinputs/sphinxlatexobjects.sty @@ -1,7 +1,7 @@ %% MODULE RELEASE DATA AND OBJECT DESCRIPTIONS % % change this info string if making any custom modification -\ProvidesFile{sphinxlatexobjects.sty}[2022/01/13 documentation environments] +\ProvidesFile{sphinxlatexobjects.sty}[2023/06/03 documentation environments] % Provides support for this output mark-up from Sphinx latex writer: % diff --git a/sphinx/texinputs/sphinxlatexstyletext.sty b/sphinx/texinputs/sphinxlatexstyletext.sty index 0a23fa7b798..d4887d59447 100644 --- a/sphinx/texinputs/sphinxlatexstyletext.sty +++ b/sphinx/texinputs/sphinxlatexstyletext.sty @@ -1,7 +1,7 @@ %% TEXT STYLING % % change this info string if making any custom modification -\ProvidesFile{sphinxlatexstyletext.sty}[2023/03/26 text styling] +\ProvidesFile{sphinxlatexstyletext.sty}[2023/06/03 text styling] % Basically everything here consists of macros which are part of the latex % markup produced by the Sphinx latex writer From d66e98a874ed1fe6b79c72fdcb601dd859d04e75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 3 Jun 2023 19:53:28 +0200 Subject: [PATCH 17/23] update CHANGES --- CHANGES | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index 95def0f3059..e3beb1e8286 100644 --- a/CHANGES +++ b/CHANGES @@ -20,7 +20,9 @@ Features added * #11438: Add support for the :rst:dir:`py:class` and :rst:dir:`py:function` directives for PEP 695 (generic classes and functions declarations) and - PEP 696 (default type parameters). + PEP 696 (default type parameters). Multi-line support (#11011) is enabled + for type parameters list and can be locally controlled on object description + directives, e.g., :rst:dir:`py:function:single-line-type-parameter-list`. Patch by Bénédikt Tran. * #11415: Add a checksum to JavaScript and CSS asset URIs included within generated HTML, using the CRC32 algorithm. From a1b89bb7498d341beb2a8f8768177f81b3e71ebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 3 Jun 2023 19:53:40 +0200 Subject: [PATCH 18/23] update documentation --- doc/usage/configuration.rst | 17 +++++- doc/usage/restructuredtext/domains.rst | 75 ++++++++++++++++++++++++-- sphinx/domains/python.py | 6 ++- 3 files changed, 89 insertions(+), 9 deletions(-) diff --git a/doc/usage/configuration.rst b/doc/usage/configuration.rst index 133e2099df9..aed543e045b 100644 --- a/doc/usage/configuration.rst +++ b/doc/usage/configuration.rst @@ -3025,11 +3025,24 @@ Options for the Python domain .. confval:: python_maximum_signature_line_length - If a signature's length in characters exceeds the number set, each - argument will be displayed on an individual logical line. This is a + If a signature's length in characters exceeds the number set, each argument + or type parameter will be displayed on an individual logical line. This is a domain-specific setting, overriding :confval:`maximum_signature_line_length`. .. versionadded:: 7.1 + .. versionchanged:: 7.2 + + For the Python domain, the signature length depends on whether the type + parameters or the list of arguments are being formatted. For the former, + the signature length ignores the length of the arguments list; for the + latter, the signature length ignores the length of the type parameters + list. + + For instance, with `python_maximum_signature_line_length = 20`, only + the list of type parameters will be wrapped while the arguments list + is rendered on a single line:: + + .. py:function:: add[T: VERY_LONG_SUPER_TYPE, U: VERY_LONG_SUPER_TYPE](a: T, b: U) Options for the Javascript domain --------------------------------- diff --git a/doc/usage/restructuredtext/domains.rst b/doc/usage/restructuredtext/domains.rst index cbece86e826..88a6276c745 100644 --- a/doc/usage/restructuredtext/domains.rst +++ b/doc/usage/restructuredtext/domains.rst @@ -192,12 +192,14 @@ declarations: The following directives are provided for module and class contents: .. rst:directive:: .. py:function:: name(parameters) + .. py:function:: name[type parameters](parameters) Describes a module-level function. The signature should include the - parameters as given in the Python function definition, see :ref:`signatures`. - For example:: + parameters, together with optional type parameters, as given in the Python + function definition, see :ref:`signatures`. For example:: .. py:function:: Timer.repeat(repeat=3, number=1000000) + .. py:function:: add[T](x: T, y: T) -> T For methods you should use :rst:dir:`py:method`. @@ -240,6 +242,15 @@ The following directives are provided for module and class contents: .. versionadded:: 7.1 + .. rst:directive:option:: single-line-type-parameter-list + :type: no value + + Ensure that the function's type parameters are emitted on a single + logical line, overriding :confval:`python_maximum_signature_line_length` + and :confval:`maximum_signature_line_length`. + + .. versionadded:: 7.2 + .. rst:directive:: .. py:data:: name @@ -274,6 +285,8 @@ The following directives are provided for module and class contents: the module specified by :rst:dir:`py:currentmodule`. .. rst:directive:: .. py:exception:: name + .. py:exception:: name(parameters) + .. py:exception:: name[type parmeters](parameters) Describes an exception class. The signature can, but need not include parentheses with constructor arguments. @@ -293,12 +306,27 @@ The following directives are provided for module and class contents: Describe the location where the object is defined. The default value is the module specified by :rst:dir:`py:currentmodule`. + .. rst:directive:option:: single-line-parameter-list + :type: no value + + See :rst:dir:`py:class:single-line-parameter-list`. + + .. versionadded:: 7.1 + + .. rst:directive:option:: single-line-type-parameter-list + :type: no value + + See :rst:dir:`py:class:single-line-type-parameter-list`. + + .. versionadded:: 7.2 + .. rst:directive:: .. py:class:: name .. py:class:: name(parameters) + .. py:class:: name[type parmeters](parameters) - Describes a class. The signature can optionally include parentheses with - parameters which will be shown as the constructor arguments. See also - :ref:`signatures`. + Describes a class. The signature can optionally include type parameters + (see :pep:`695`) or parentheses with parameters which will be shown as the + constructor arguments. See also :ref:`signatures`. Methods and attributes belonging to the class should be placed in this directive's body. If they are placed outside, the supplied name should @@ -348,6 +376,13 @@ The following directives are provided for module and class contents: .. versionadded:: 7.1 + .. rst:directive:option:: single-line-type-parameter-list + :type: no value + + Ensure that the class constructor's type parameters are emitted on a + single logical line, overriding :confval:`python_maximum_signature_line_length` + and :confval:`maximum_signature_line_length`. + .. rst:directive:: .. py:attribute:: name Describes an object data attribute. The description should include @@ -410,6 +445,7 @@ The following directives are provided for module and class contents: the module specified by :rst:dir:`py:currentmodule`. .. rst:directive:: .. py:method:: name(parameters) + .. py:method:: name[type parameters](parameters) Describes an object method. The parameters should not include the ``self`` parameter. The description should include similar information to that @@ -469,6 +505,15 @@ The following directives are provided for module and class contents: .. versionadded:: 7.1 + .. rst:directive:option:: single-line-type-parameter-list + :type: no value + + Ensure that the method's type parameters are emitted on a single logical + line, overriding :confval:`python_maximum_signature_line_length` and + :confval:`maximum_signature_line_length`. + + .. versionadded:: 7.2 + .. rst:directive:option:: staticmethod :type: no value @@ -478,12 +523,14 @@ The following directives are provided for module and class contents: .. rst:directive:: .. py:staticmethod:: name(parameters) + .. py:staticmethod:: name[type parameters](parameters) Like :rst:dir:`py:method`, but indicates that the method is a static method. .. versionadded:: 0.4 .. rst:directive:: .. py:classmethod:: name(parameters) + .. py:classmethod:: name[type parameters](parameters) Like :rst:dir:`py:method`, but indicates that the method is a class method. @@ -491,6 +538,7 @@ The following directives are provided for module and class contents: .. rst:directive:: .. py:decorator:: name .. py:decorator:: name(parameters) + .. py:decorator:: name[type parameters](parameters) Describes a decorator function. The signature should represent the usage as a decorator. For example, given the functions @@ -531,8 +579,18 @@ The following directives are provided for module and class contents: .. versionadded:: 7.1 + .. rst:directive:option:: single-line-type-parameter-list + :type: no value + + Ensure that the decorator's type parameters are emitted on a single + logical line, overriding :confval:`python_maximum_signature_line_length` + and :confval:`maximum_signature_line_length`. + + .. versionadded:: 7.2 + .. rst:directive:: .. py:decoratormethod:: name .. py:decoratormethod:: name(signature) + .. py:decoratormethod:: name[type parameters](signature) Same as :rst:dir:`py:decorator`, but for decorators that are methods. @@ -561,6 +619,13 @@ argument support), you can use brackets to specify the optional parts: It is customary to put the opening bracket before the comma. +Since Python 3.12, it is possible to indicate type parameters directly at the +function or class definition site:: + + .. py:function:: add[T](x: T, y: T) -> T + +See :pep:`695` and :pep:`696` for details. + .. _info-field-lists: Info field lists diff --git a/sphinx/domains/python.py b/sphinx/domains/python.py index ac9bdaaa770..6cf72602342 100644 --- a/sphinx/domains/python.py +++ b/sphinx/domains/python.py @@ -794,11 +794,13 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str] try: signode += _parse_arglist(arglist, self.env, multi_line_parameter_list) except SyntaxError: - # fallback to parse arglist original parser. + # fallback to parse arglist original parser (this may happen + # if the argument list is incorrectly used as a list of bases + # when documenting a class) # it supports to represent optional arguments (ex. "func(foo [, bar])") _pseudo_parse_arglist(signode, arglist, multi_line_parameter_list) except (NotImplementedError, ValueError) as exc: - # duplicate parameter names raise ValueError and not a SyntaxError + # duplicated parameter names raise ValueError and not a SyntaxError logger.warning("could not parse arglist (%r): %s", arglist, exc, location=signode) _pseudo_parse_arglist(signode, arglist, multi_line_parameter_list) From 02df3002b60730d31571be0a174a43e3ebe80a0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 6 Jun 2023 15:56:47 +0200 Subject: [PATCH 19/23] update LaTeX code and writer --- sphinx/texinputs/sphinxlatexobjects.sty | 54 +++++++++++++++---------- sphinx/writers/latex.py | 13 +++--- 2 files changed, 40 insertions(+), 27 deletions(-) diff --git a/sphinx/texinputs/sphinxlatexobjects.sty b/sphinx/texinputs/sphinxlatexobjects.sty index 54f7b05b971..593d72caa5c 100644 --- a/sphinx/texinputs/sphinxlatexobjects.sty +++ b/sphinx/texinputs/sphinxlatexobjects.sty @@ -118,6 +118,14 @@ % now restore \itemsep and \parskip \pysig@restore@itemsep@and@parskip } +% Each signature is rendered as NAME[TPLIST](ARGLIST) where the +% size of is parametrized by \sphinxsignaturelistskip (0pt by default). +\newlength\sphinxsignaturelistskip +\setlength\sphinxsignaturelistskip{0pt} +\newcommand{\pysigtplistopen}{\hskip\sphinxsignaturelistskip\sphinxcode{[}} +\newcommand{\pysigtplistclose}{\sphinxcode{]}} +\newcommand{\pysigarglistopen}{\hskip\sphinxsignaturelistskip\sphinxcode{(}} +\newcommand{\pysigarglistclose}{\sphinxcode{)}} % % Use a \parbox to accommodate long argument list in signatures % LaTeX did not imagine that an \item label could need multi-line rendering @@ -125,14 +133,17 @@ \newcommand{\py@sigparams}[2]{% % The \py@argswidth has been computed in \pysiglinewithargsret to make the % argument list use full available width - \parbox[t]{\py@argswidth}{\raggedright #1\sphinxcode{)}#2\strut}% + \parbox[t]{\py@argswidth}{\raggedright #1\pysigarglistclose#2\strut}% % final strut is to help get correct vertical separation } \newcommand{\py@sigparamswithtplist}[3]{% % similar to \py@sigparams but with different delimiters and an additional % type parameters list given as #1, the argument list as #2 and the return % annotation as #3 - \parbox[t]{\py@argswidth}{\raggedright #1\sphinxcode{]}\sphinxcode{(}#2\sphinxcode{)}#3\strut}% + \parbox[t]{\py@argswidth}{% + \raggedright #1\pysigtplistclose% + \pysigarglistopen#2\pysigarglistclose% + #3\strut}% } \newcommand{\pysigline}[1]{% @@ -147,26 +158,26 @@ % as #1 may contain a footnote using \label we need to make \label % a no-op here to avoid LaTeX complaining about duplicates \let\spx@label\label\let\label\@gobble - \settowidth{\py@argswidth}{#1\sphinxcode{(}}% + \settowidth{\py@argswidth}{#1\pysigarglistopen}% \let\label\spx@label \py@argswidth=\dimexpr\linewidth+\labelwidth-\py@argswidth\relax\relax - \item[{#1\sphinxcode{(}\py@sigparams{#2}{#3}\strut}] + \item[{#1\pysigarglistopen\py@sigparams{#2}{#3}\strut}] \pysigadjustitemsep } \newcommand{\pysiglinewithargsretwithtplist}[4]{ % #1 = name, #2 = tplist, #3 = arglist, #4 = retann \let\spx@label\label\let\label\@gobble - \settowidth{\py@argswidth}{#1\sphinxcode{[}}% + \settowidth{\py@argswidth}{#1\pysigtplistopen}% \let\label\spx@label \py@argswidth=\dimexpr\linewidth+\labelwidth-\py@argswidth\relax\relax - \item[{#1\sphinxcode{[}\py@sigparamswithtplist{#2}{#3}{#4}\strut}] + \item[{#1\pysigtplistopen\py@sigparamswithtplist{#2}{#3}{#4}\strut}] \pysigadjustitemsep } \def\sphinxoptionalextraspace{0.5mm} \newcommand{\pysigwithonelineperarg}[3]{% % render each argument on its own line - \item[#1\sphinxcode{(}\strut] + \item[#1\pysigarglistopen\strut] \leavevmode\par\nopagebreak % this relies on \pysigstartsignatures having set \parskip to zero \begingroup @@ -180,13 +191,13 @@ \endgroup \global\let\sphinxparam\spx@sphinxparam % fulllineitems sets \labelwidth to be like \leftmargin - \nopagebreak\noindent\kern-\labelwidth\sphinxcode{)}{#3} + \nopagebreak\noindent\kern-\labelwidth\pysigarglistclose{#3} \pysigadjustitemsep } \newcommand{\pysigwithonelineperargwithonelinepertparg}[4]{ % #1 = name, #2 = tplist, #3 = arglist, #4 = retann - % render each type parameters and argument lists on its own line - \item[#1\sphinxcode{[}\strut] + % render each type parameter and argument on its own line + \item[#1\pysigtplistopen\strut] \leavevmode\par\nopagebreak \begingroup \let\sphinxparamcomma\sphinxparamcommaoneperline @@ -197,9 +208,9 @@ \def\sphinxtypeparam{\def\sphinxtypeparam{\par\spx@sphinxtypeparam}\spx@sphinxtypeparam}% #2\par \endgroup - \nopagebreak\noindent\kern-\labelwidth\sphinxcode{]} + \nopagebreak\noindent\kern-\labelwidth\pysigtplistclose% % render the rest of the signature like in \pysigwithonelineperarg - \sphinxcode{(}\strut\leavevmode\par\nopagebreak + \pysigarglistopen\strut\par\nopagebreak \begingroup \let\sphinxparamcomma\sphinxparamcommaoneperline \def\sphinxoptionalhook{\ifvmode\else\kern\sphinxoptionalextraspace\relax\fi}% @@ -208,13 +219,13 @@ #3\par \endgroup \global\let\sphinxparam\spx@sphinxparam - \nopagebreak\noindent\kern-\labelwidth\sphinxcode{)}{#4} + \nopagebreak\noindent\kern-\labelwidth\pysigarglistclose{#4} \pysigadjustitemsep } \newcommand{\pysiglinewithargsretwithonelinepertparg}[4]{ % #1 = name, #2 = tplist, #3 = arglist, #4 = retann - % render each type parameters on its own line but the argument list is rendered inline - \item[#1\sphinxcode{[}\strut] + % render each type parameter on its own line but the arguments list inline + \item[#1\pysigtplistopen\strut] \leavevmode\par\nopagebreak \begingroup \let\sphinxparamcomma\sphinxparamcommaoneperline @@ -225,21 +236,20 @@ \def\sphinxtypeparam{\def\sphinxtypeparam{\par\spx@sphinxtypeparam}\spx@sphinxtypeparam}% #2\par \endgroup - \nopagebreak\noindent\kern-\labelwidth\sphinxcode{]} + \nopagebreak\noindent\kern-\labelwidth\pysigtplistclose% % render the arguments list on one line - \settowidth{\py@argswidth}{\sphinxcode{]}\sphinxcode{()}}% - \py@argswidth=\dimexpr\linewidth+\labelwidth-\py@argswidth\relax\relax - \sphinxcode{(}\parbox[t]{\py@argswidth}{\raggedright #3\sphinxcode{)}#4\strut}% + \pysigarglistopen#3\pysigarglistclose#4\strut \pysigadjustitemsep } \newcommand{\pysigwithonelineperargwithtplist}[4]{ % #1 = name, #2 = tplist, #3 = arglist, #4 = retann % render the type parameters list on one line, but each argument is rendered on its own line \let\spx@label\label\let\label\@gobble - \settowidth{\py@argswidth}{#1\sphinxcode{[}}% + \settowidth{\py@argswidth}{#1\pysigtplistopen}% \let\label\spx@label \py@argswidth=\dimexpr\linewidth+\labelwidth-\py@argswidth\relax\relax - \item[{#1\sphinxcode{[}\parbox[t]{\py@argswidth}{\raggedright #2\sphinxcode{]}\sphinxcode{(}\strut}\strut}] + \item[{#1\pysigtplistopen\parbox[t]{\py@argswidth}{% + \raggedright #2\pysigtplistclose\pysigarglistopen\strut}\strut}] % render the rest of the signature like in \pysigwithonelineperarg \begingroup \let\sphinxparamcomma\sphinxparamcommaoneperline @@ -249,7 +259,7 @@ #3\par \endgroup \global\let\sphinxparam\spx@sphinxparam - \nopagebreak\noindent\kern-\labelwidth\sphinxcode{)}{#4} + \nopagebreak\noindent\kern-\labelwidth\pysigarglistclose{#4} \pysigadjustitemsep } diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py index 980fd47a790..69fd9f950de 100644 --- a/sphinx/writers/latex.py +++ b/sphinx/writers/latex.py @@ -873,10 +873,7 @@ def _visit_sig_parameter(self, node: Element, parameter_macro: str) -> None: if not node.hasattr('noemph'): self.body.append(parameter_macro) - def visit_desc_parameter(self, node: Element) -> None: - self._visit_sig_parameter(node, r'\sphinxparam{') - - def depart_desc_parameter(self, node: Element) -> None: + def _depart_sig_parameter(self, node: Element) -> None: if not node.hasattr('noemph'): self.body.append('}') is_required = self.list_is_required_param[self.param_group_index] @@ -896,11 +893,17 @@ def depart_desc_parameter(self, node: Element) -> None: if is_required: self.param_group_index += 1 + def visit_desc_parameter(self, node: Element) -> None: + self._visit_sig_parameter(node, r'\sphinxparam{') + + def depart_desc_parameter(self, node: Element) -> None: + self._depart_sig_parameter(node) + def visit_desc_tparameter(self, node: Element) -> None: self._visit_sig_parameter(node, r'\sphinxtypeparam{') def depart_desc_tparameter(self, node: Element) -> None: - self.depart_desc_parameter(node) + self._depart_sig_parameter(node) def visit_desc_optional(self, node: Element) -> None: self.params_left_at_level = sum([isinstance(c, addnodes.desc_parameter) From 160b5ccc3f0a18f3814169b1da02c031d348e5dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 6 Jun 2023 15:57:22 +0200 Subject: [PATCH 20/23] update LaTeX doc --- doc/latex.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/doc/latex.rst b/doc/latex.rst index a451ae6a43c..89ff56518ac 100644 --- a/doc/latex.rst +++ b/doc/latex.rst @@ -1464,6 +1464,7 @@ Macros ``\sphinxtermref``; ``\emph{#1}`` ``\sphinxsamedocref``; ``\emph{#1}`` ``\sphinxparam``; ``\emph{#1}`` + ``\sphinxtypeparam``; ``\emph{#1}`` ``\sphinxoptional``; ``[#1]`` with larger brackets, see source .. versionadded:: 1.4.5 @@ -1485,6 +1486,12 @@ Macros signatures (see :confval:`maximum_signature_line_length`). It defaults to ``\texttt{,}`` to make these end-of-line separators more distinctive. + .. versionadded:: 7.2.0 + Signatures of Python functions are rendered as ``name(parameters)`` + or ``name[type parameters](parameters)`` (see :pep:`695`) + where the length of ```` (``0pt`` by default) can be changed via + ``\renewcommand{\sphinxsignaturelistskip}{1em}`` . + - More text styling: .. csv-table:: From a924d4ad04c76e53b1ec0d8f42c69fcd9b208e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 6 Jun 2023 15:57:34 +0200 Subject: [PATCH 21/23] update Python domain doc --- doc/usage/restructuredtext/domains.rst | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/doc/usage/restructuredtext/domains.rst b/doc/usage/restructuredtext/domains.rst index 88a6276c745..a432e41d6a2 100644 --- a/doc/usage/restructuredtext/domains.rst +++ b/doc/usage/restructuredtext/domains.rst @@ -289,7 +289,8 @@ The following directives are provided for module and class contents: .. py:exception:: name[type parmeters](parameters) Describes an exception class. The signature can, but need not include - parentheses with constructor arguments. + parentheses with constructor arguments, or may optionally include type + parameters (see :pep:`695`). .. rubric:: options @@ -379,9 +380,9 @@ The following directives are provided for module and class contents: .. rst:directive:option:: single-line-type-parameter-list :type: no value - Ensure that the class constructor's type parameters are emitted on a - single logical line, overriding :confval:`python_maximum_signature_line_length` - and :confval:`maximum_signature_line_length`. + Ensure that the class type parameters are emitted on a single logical + line, overriding :confval:`python_maximum_signature_line_length` and + :confval:`maximum_signature_line_length`. .. rst:directive:: .. py:attribute:: name @@ -620,7 +621,19 @@ argument support), you can use brackets to specify the optional parts: It is customary to put the opening bracket before the comma. Since Python 3.12, it is possible to indicate type parameters directly at the -function or class definition site:: +function or class definition site: + +.. code-block:: python + + class MyDict[T](dict[str, T]): + ... + + def add[T](x: T, y: T) -> T: + return x + y + +The corresponding documentation would look like:: + + .. py:class:: MyDict[T] .. py:function:: add[T](x: T, y: T) -> T From 79a2b0697dfd8c4b4ef2df6080c7b308ea338034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 6 Jun 2023 16:04:35 +0200 Subject: [PATCH 22/23] fix typo in LaTeX doc --- doc/latex.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/latex.rst b/doc/latex.rst index 89ff56518ac..33a10e5025b 100644 --- a/doc/latex.rst +++ b/doc/latex.rst @@ -1489,8 +1489,8 @@ Macros .. versionadded:: 7.2.0 Signatures of Python functions are rendered as ``name(parameters)`` or ``name[type parameters](parameters)`` (see :pep:`695`) - where the length of ```` (``0pt`` by default) can be changed via - ``\renewcommand{\sphinxsignaturelistskip}{1em}`` . + where the length of ```` is set to ``0pt`` by default. This can be + changed via ``\setlength{\sphinxsignaturelistskip}{1ex}`` for instance. - More text styling: From ba39b4f71b30ccf793906c9c0ee1b559282bf6f6 Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Sun, 23 Jul 2023 23:05:21 +0100 Subject: [PATCH 23/23] Updates --- .gitignore | 1 - CHANGES | 12 +- doc/latex.rst | 6 +- doc/usage/configuration.rst | 30 ++-- doc/usage/restructuredtext/domains.rst | 51 +++--- sphinx/addnodes.py | 8 +- sphinx/domains/python.py | 202 +++++++++++----------- sphinx/ext/autodoc/__init__.py | 6 +- sphinx/texinputs/sphinxlatexobjects.sty | 41 +++-- sphinx/texinputs/sphinxlatexstyletext.sty | 2 +- sphinx/writers/html5.py | 12 +- sphinx/writers/latex.py | 32 ++-- sphinx/writers/manpage.py | 8 +- sphinx/writers/texinfo.py | 6 +- sphinx/writers/text.py | 8 +- tests/test_domain_py.py | 72 ++++---- 16 files changed, 252 insertions(+), 245 deletions(-) diff --git a/.gitignore b/.gitignore index a2f7d63b3ad..8d33409d5bb 100644 --- a/.gitignore +++ b/.gitignore @@ -30,7 +30,6 @@ doc/_build/ doc/locale/ tests/.coverage tests/build/ -tests/test-server.lock utils/regression_test.js node_modules/ diff --git a/CHANGES b/CHANGES index b27fc18f978..294e80e524d 100644 --- a/CHANGES +++ b/CHANGES @@ -18,12 +18,6 @@ Deprecated Features added -------------- -* #11438: Add support for the :rst:dir:`py:class` and :rst:dir:`py:function` - directives for PEP 695 (generic classes and functions declarations) and - PEP 696 (default type parameters). Multi-line support (#11011) is enabled - for type parameters list and can be locally controlled on object description - directives, e.g., :rst:dir:`py:function:single-line-type-parameter-list`. - 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 @@ -40,6 +34,12 @@ Features added * #11157: Keep the ``translated`` attribute on translated nodes. * #11451: Improve the traceback displayed when using :option:`sphinx-build -T` in parallel builds. Patch by Bénédikt Tran +* #11438: Add support for the :rst:dir:`py:class` and :rst:dir:`py:function` + directives for PEP 695 (generic classes and functions declarations) and + PEP 696 (default type parameters). Multi-line support (#11011) is enabled + for type parameters list and can be locally controlled on object description + directives, e.g., :rst:dir:`py:function:single-line-type-parameter-list`. + Patch by Bénédikt Tran. Bugs fixed ---------- diff --git a/doc/latex.rst b/doc/latex.rst index 33a10e5025b..4c9a4e0f811 100644 --- a/doc/latex.rst +++ b/doc/latex.rst @@ -1486,11 +1486,11 @@ Macros signatures (see :confval:`maximum_signature_line_length`). It defaults to ``\texttt{,}`` to make these end-of-line separators more distinctive. - .. versionadded:: 7.2.0 Signatures of Python functions are rendered as ``name(parameters)`` or ``name[type parameters](parameters)`` (see :pep:`695`) - where the length of ```` is set to ``0pt`` by default. This can be - changed via ``\setlength{\sphinxsignaturelistskip}{1ex}`` for instance. + where the length of ```` is set to ``0pt`` by default. + This can be changed via ``\setlength{\sphinxsignaturelistskip}{1ex}`` + for instance. - More text styling: diff --git a/doc/usage/configuration.rst b/doc/usage/configuration.rst index aed543e045b..235b1cc2626 100644 --- a/doc/usage/configuration.rst +++ b/doc/usage/configuration.rst @@ -3025,24 +3025,26 @@ Options for the Python domain .. confval:: python_maximum_signature_line_length - If a signature's length in characters exceeds the number set, each argument - or type parameter will be displayed on an individual logical line. This is a - domain-specific setting, overriding :confval:`maximum_signature_line_length`. + If a signature's length in characters exceeds the number set, + each argument or type parameter will be displayed on an individual logical line. + This is a domain-specific setting, + overriding :confval:`maximum_signature_line_length`. - .. versionadded:: 7.1 - .. versionchanged:: 7.2 + For the Python domain, the signature length depends on whether + the type parameters or the list of arguments are being formatted. + For the former, the signature length ignores the length of the arguments list; + for the latter, the signature length ignores the length of + the type parameters list. - For the Python domain, the signature length depends on whether the type - parameters or the list of arguments are being formatted. For the former, - the signature length ignores the length of the arguments list; for the - latter, the signature length ignores the length of the type parameters - list. + For instance, with `python_maximum_signature_line_length = 20`, + only the list of type parameters will be wrapped + while the arguments list will be rendered on a single line - For instance, with `python_maximum_signature_line_length = 20`, only - the list of type parameters will be wrapped while the arguments list - is rendered on a single line:: + .. code:: rst - .. py:function:: add[T: VERY_LONG_SUPER_TYPE, U: VERY_LONG_SUPER_TYPE](a: T, b: U) + .. py:function:: add[T: VERY_LONG_SUPER_TYPE, U: VERY_LONG_SUPER_TYPE](a: T, b: U) + + .. versionadded:: 7.1 Options for the Javascript domain --------------------------------- diff --git a/doc/usage/restructuredtext/domains.rst b/doc/usage/restructuredtext/domains.rst index ff2ea525c61..dbaa36e3d98 100644 --- a/doc/usage/restructuredtext/domains.rst +++ b/doc/usage/restructuredtext/domains.rst @@ -194,12 +194,14 @@ The following directives are provided for module and class contents: .. rst:directive:: .. py:function:: name(parameters) .. py:function:: name[type parameters](parameters) - Describes a module-level function. The signature should include the - parameters, together with optional type parameters, as given in the Python - function definition, see :ref:`signatures`. For example:: + Describes a module-level function. + The signature should include the parameters, + together with optional type parameters, + as given in the Python function definition, see :ref:`signatures`. + For example:: - .. py:function:: Timer.repeat(repeat=3, number=1000000) - .. py:function:: add[T](x: T, y: T) -> T + .. py:function:: Timer.repeat(repeat=3, number=1_000_000) + .. py:function:: add[T](a: T, b: T) -> T For methods you should use :rst:dir:`py:method`. @@ -249,7 +251,7 @@ The following directives are provided for module and class contents: logical line, overriding :confval:`python_maximum_signature_line_length` and :confval:`maximum_signature_line_length`. - .. versionadded:: 7.2 + .. versionadded:: 7.1 .. rst:directive:: .. py:data:: name @@ -288,9 +290,9 @@ The following directives are provided for module and class contents: .. py:exception:: name(parameters) .. py:exception:: name[type parmeters](parameters) - Describes an exception class. The signature can, but need not include - parentheses with constructor arguments, or may optionally include type - parameters (see :pep:`695`). + Describes an exception class. + The signature can, but need not include parentheses with constructor arguments, + or may optionally include type parameters (see :pep:`695`). .. rubric:: options @@ -319,15 +321,16 @@ The following directives are provided for module and class contents: See :rst:dir:`py:class:single-line-type-parameter-list`. - .. versionadded:: 7.2 + .. versionadded:: 7.1 .. rst:directive:: .. py:class:: name .. py:class:: name(parameters) .. py:class:: name[type parmeters](parameters) - Describes a class. The signature can optionally include type parameters - (see :pep:`695`) or parentheses with parameters which will be shown as the - constructor arguments. See also :ref:`signatures`. + Describes a class. + The signature can optionally include type parameters (see :pep:`695`) + or parentheses with parameters which will be shown as the constructor arguments. + See also :ref:`signatures`. Methods and attributes belonging to the class should be placed in this directive's body. If they are placed outside, the supplied name should @@ -620,24 +623,26 @@ argument support), you can use brackets to specify the optional parts: It is customary to put the opening bracket before the comma. -Since Python 3.12, it is possible to indicate type parameters directly at the -function or class definition site: +Python 3.12 introduced *type parameters*, which are type variables +declared directly within the class or function definition: -.. code-block:: python +.. code:: python - class MyDict[T](dict[str, T]): + class AnimalList[AnimalT](list[AnimalT]): ... - def add[T](x: T, y: T) -> T: - return x + y + def add[T](a: T, b: T) -> T: + return a + b + +The corresponding reStructuredText documentation would be: -The corresponding documentation would look like:: +.. code:: rst - .. py:class:: MyDict[T] + .. py:class:: AnimalList[AnimalT] - .. py:function:: add[T](x: T, y: T) -> T + .. py:function:: add[T](a: T, b: T) -> T -See :pep:`695` and :pep:`696` for details. +See :pep:`695` and :pep:`696` for details and the full specification. .. _info-field-lists: diff --git a/sphinx/addnodes.py b/sphinx/addnodes.py index 164b9040c26..d85d9309ea4 100644 --- a/sphinx/addnodes.py +++ b/sphinx/addnodes.py @@ -258,7 +258,7 @@ def astext(self): return f'({super().astext()})' -class desc_tparameterlist(nodes.Part, nodes.Inline, nodes.FixedTextElement): +class desc_type_parameter_list(nodes.Part, nodes.Inline, nodes.FixedTextElement): """Node for a general type parameter list. As default the type parameters list is written in line with the rest of the signature. @@ -275,7 +275,7 @@ class desc_parameter(nodes.Part, nodes.Inline, nodes.FixedTextElement): """Node for a single parameter.""" -class desc_tparameter(nodes.Part, nodes.Inline, nodes.FixedTextElement): +class desc_type_parameter(nodes.Part, nodes.Inline, nodes.FixedTextElement): """Node for a single type parameter.""" @@ -554,9 +554,9 @@ 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_type_parameter_list) app.add_node(desc_parameter) - app.add_node(desc_tparameter) + app.add_node(desc_type_parameter) app.add_node(desc_optional) app.add_node(desc_annotation) diff --git a/sphinx/domains/python.py b/sphinx/domains/python.py index 6cf72602342..6ebe7458d01 100644 --- a/sphinx/domains/python.py +++ b/sphinx/domains/python.py @@ -6,6 +6,7 @@ import builtins import inspect import re +import token import typing from inspect import Parameter from typing import Any, Iterable, Iterator, List, NamedTuple, Tuple, cast @@ -40,11 +41,11 @@ logger = logging.getLogger(__name__) -# REs for Python signatures (supports PEP 695) +# REs for Python signatures py_sig_re = re.compile( r'''^ ([\w.]*\.)? # class name(s) (\w+) \s* # thing name - (?: \[\s*(.*)\s*])? # optional: type parameters list (PEP 695) + (?: \[\s*(.*)\s*])? # optional: type parameters list (?: \(\s*(.*)\s*\) # optional: arguments (?:\s* -> \s* (.*))? # return annotation )? $ # and nothing more @@ -261,68 +262,64 @@ def _unparse_pep_604_annotation(node: ast.Subscript) -> list[Node]: class _TypeParameterListParser(TokenProcessor): def __init__(self, sig: str) -> None: - signature = ''.join(sig.splitlines()).strip() + signature = sig.replace('\n', '').strip() super().__init__([signature]) # Each item is a tuple (name, kind, default, annotation) mimicking - # inspect.Parameter to allow default values on VAR_POSITIONAL + # ``inspect.Parameter`` to allow default values on VAR_POSITIONAL # or VAR_KEYWORD parameters. - self.tparams: list[tuple[str, int, Any, Any]] = [] - - def fetch_tparam_spec(self) -> list[Token]: - from token import DEDENT, INDENT, OP + self.type_params: list[tuple[str, int, Any, Any]] = [] + def fetch_type_param_spec(self) -> list[Token]: tokens = [] while self.fetch_token(): tokens.append(self.current) - for ldelim, rdelim in [('(', ')'), ('{', '}'), ('[', ']')]: - if self.current == [OP, ldelim]: - tokens += self.fetch_until([OP, rdelim]) + for ldelim, rdelim in ('(', ')'), ('{', '}'), ('[', ']'): + if self.current == [token.OP, ldelim]: + tokens += self.fetch_until([token.OP, rdelim]) break else: - if self.current == INDENT: - tokens += self.fetch_until(DEDENT) - elif self.current.match([OP, ':'], [OP, '='], [OP, ',']): + if self.current == token.INDENT: + tokens += self.fetch_until(token.DEDENT) + elif self.current.match( + [token.OP, ':'], [token.OP, '='], [token.OP, ',']): tokens.pop() break return tokens def parse(self) -> None: - from token import NAME, OP - while self.fetch_token(): - if self.current == NAME: - tpname = self.current.value.strip() - if self.previous and self.previous.match([OP, '*'], [OP, '**']): - if self.previous == [OP, '*']: - tpkind = Parameter.VAR_POSITIONAL + if self.current == token.NAME: + tp_name = self.current.value.strip() + if self.previous and self.previous.match([token.OP, '*'], [token.OP, '**']): + if self.previous == [token.OP, '*']: + tp_kind = Parameter.VAR_POSITIONAL else: - tpkind = Parameter.VAR_KEYWORD # type: ignore[assignment] + tp_kind = Parameter.VAR_KEYWORD # type: ignore[assignment] else: - tpkind = Parameter.POSITIONAL_OR_KEYWORD # type: ignore[assignment] + tp_kind = Parameter.POSITIONAL_OR_KEYWORD # type: ignore[assignment] - tpann: Any = Parameter.empty - tpdefault: Any = Parameter.empty + tp_ann: Any = Parameter.empty + tp_default: Any = Parameter.empty self.fetch_token() - if self.current and self.current.match([OP, ':'], [OP, '=']): - if self.current == [OP, ':']: - tokens = self.fetch_tparam_spec() - tpann = self._build_identifier(tokens) + if self.current and self.current.match([token.OP, ':'], [token.OP, '=']): + if self.current == [token.OP, ':']: + tokens = self.fetch_type_param_spec() + tp_ann = self._build_identifier(tokens) - if self.current == [OP, '=']: - tokens = self.fetch_tparam_spec() - tpdefault = self._build_identifier(tokens) + if self.current == [token.OP, '=']: + tokens = self.fetch_type_param_spec() + tp_default = self._build_identifier(tokens) - if tpkind != Parameter.POSITIONAL_OR_KEYWORD and tpann != Parameter.empty: + if tp_kind != Parameter.POSITIONAL_OR_KEYWORD and tp_ann != Parameter.empty: raise SyntaxError('type parameter bound or constraint is not allowed ' - f'for {tpkind.description} parameters') + f'for {tp_kind.description} parameters') - tparam = (tpname, tpkind, tpdefault, tpann) - self.tparams.append(tparam) + type_param = (tp_name, tp_kind, tp_default, tp_ann) + self.type_params.append(type_param) def _build_identifier(self, tokens: list[Token]) -> str: from itertools import chain, tee - from token import ENDMARKER, NAME, NUMBER, OP, STRING def pairwise(iterable): a, b = tee(iterable) @@ -336,42 +333,42 @@ def triplewise(iterable): idents: list[str] = [] tokens: Iterable[Token] = iter(tokens) # type: ignore # do not format opening brackets - for token in tokens: - if not token.match([OP, '('], [OP, '['], [OP, '{']): + for tok in tokens: + if not tok.match([token.OP, '('], [token.OP, '['], [token.OP, '{']): # check if the first non-delimiter character is an unpack operator - is_unpack_operator = token.match([OP, '*'], [OP, ['**']]) - idents.append(self._pformat_token(token, native=is_unpack_operator)) + is_unpack_operator = tok.match([token.OP, '*'], [token.OP, ['**']]) + idents.append(self._pformat_token(tok, native=is_unpack_operator)) break - idents.append(token.value) + idents.append(tok.value) # check the remaining tokens - stop = Token(ENDMARKER, '', (-1, -1), (-1, -1), '') + stop = Token(token.ENDMARKER, '', (-1, -1), (-1, -1), '') is_unpack_operator = False - for token, op, after in triplewise(chain(tokens, [stop, stop])): - ident = self._pformat_token(token, native=is_unpack_operator) + for tok, op, after in triplewise(chain(tokens, [stop, stop])): + ident = self._pformat_token(tok, native=is_unpack_operator) idents.append(ident) # determine if the next token is an unpack operator depending # on the left and right hand side of the operator symbol is_unpack_operator = ( - op.match([OP, '*'], [OP, '**']) and not ( - token.match(NAME, NUMBER, STRING, [OP, ')'], [OP, ']'], [OP, '}']) - and after.match(NAME, NUMBER, STRING, [OP, '('], [OP, '['], [OP, '{']) + op.match([token.OP, '*'], [token.OP, '**']) and not ( + tok.match(token.NAME, token.NUMBER, token.STRING, + [token.OP, ')'], [token.OP, ']'], [token.OP, '}']) + and after.match(token.NAME, token.NUMBER, token.STRING, + [token.OP, '('], [token.OP, '['], [token.OP, '{']) ) ) return ''.join(idents).strip() - def _pformat_token(self, token: Token, native: bool = False) -> str: - from token import ENDMARKER, NEWLINE, OP - + def _pformat_token(self, tok: Token, native: bool = False) -> str: if native: - return token.value + return tok.value - if token.match(NEWLINE, ENDMARKER): + if tok.match(token.NEWLINE, token.ENDMARKER): return '' - if token.match([OP, ':'], [OP, ','], [OP, '#']): - return f'{token.value} ' + if tok.match([token.OP, ':'], [token.OP, ','], [token.OP, '#']): + return f'{tok.value} ' # Arithmetic operators are allowed because PEP 695 specifies the # default type parameter to be *any* expression (so "T1 << T2" is @@ -382,56 +379,60 @@ def _pformat_token(self, token: Token, native: bool = False) -> str: # The operators are ordered according to how likely they are to # be used and for (possible) future implementations (e.g., "&" for # an intersection type). - if token.match( - # most likely operators to appear - [OP, '='], [OP, '|'], - # type composition (future compatibility) - [OP, '&'], [OP, '^'], [OP, '<'], [OP, '>'], - # unlikely type composition - [OP, '+'], [OP, '-'], [OP, '*'], [OP, '**'], - # unlikely operators but included for completeness - [OP, '@'], [OP, '/'], [OP, '//'], [OP, '%'], - [OP, '<<'], [OP, '>>'], [OP, '>>>'], - [OP, '<='], [OP, '>='], [OP, '=='], [OP, '!='], + if tok.match( + # Most likely operators to appear + [token.OP, '='], [token.OP, '|'], + # Type composition (future compatibility) + [token.OP, '&'], [token.OP, '^'], [token.OP, '<'], [token.OP, '>'], + # Unlikely type composition + [token.OP, '+'], [token.OP, '-'], [token.OP, '*'], [token.OP, '**'], + # Unlikely operators but included for completeness + [token.OP, '@'], [token.OP, '/'], [token.OP, '//'], [token.OP, '%'], + [token.OP, '<<'], [token.OP, '>>'], [token.OP, '>>>'], + [token.OP, '<='], [token.OP, '>='], [token.OP, '=='], [token.OP, '!='], ): - return f' {token.value} ' + return f' {tok.value} ' - return token.value + return tok.value -def _parse_tplist( - tplist: str, env: BuildEnvironment | None = None, +def _parse_type_list( + tp_list: str, env: BuildEnvironment | None = None, multi_line_parameter_list: bool = False, -) -> addnodes.desc_tparameterlist: +) -> addnodes.desc_type_parameter_list: """Parse a list of type parameters according to PEP 695.""" - tparams = addnodes.desc_tparameterlist(tplist) - tparams['multi_line_parameter_list'] = multi_line_parameter_list + type_params = addnodes.desc_type_parameter_list(tp_list) + type_params['multi_line_parameter_list'] = multi_line_parameter_list # formal parameter names are interpreted as type parameter names and # type annotations are interpreted as type parameter bound or constraints - parser = _TypeParameterListParser(tplist) + parser = _TypeParameterListParser(tp_list) parser.parse() - for (tpname, tpkind, tpdefault, tpann) in parser.tparams: + for (tp_name, tp_kind, tp_default, tp_ann) in parser.type_params: # no positional-only or keyword-only allowed in a type parameters list - assert tpkind not in {Parameter.POSITIONAL_ONLY, Parameter.KEYWORD_ONLY} + if tp_kind in {Parameter.POSITIONAL_ONLY, Parameter.KEYWORD_ONLY}: + raise SyntaxError('positional-only or keyword-only parameters' + ' are prohibited in type parameter lists') - node = addnodes.desc_tparameter() - if tpkind == Parameter.VAR_POSITIONAL: + node = addnodes.desc_type_parameter() + if tp_kind == Parameter.VAR_POSITIONAL: node += addnodes.desc_sig_operator('', '*') - elif tpkind == Parameter.VAR_KEYWORD: + elif tp_kind == Parameter.VAR_KEYWORD: node += addnodes.desc_sig_operator('', '**') - node += addnodes.desc_sig_name('', tpname) + node += addnodes.desc_sig_name('', tp_name) - if tpann is not Parameter.empty: - annotation = _parse_annotation(tpann, env) + if tp_ann is not Parameter.empty: + annotation = _parse_annotation(tp_ann, env) if not annotation: continue node += addnodes.desc_sig_punctuation('', ':') node += addnodes.desc_sig_space() - type_ann_expr = addnodes.desc_sig_name('', '', *annotation) # type: ignore - # a type bound is `T: U` whereas type constraints are `T: (U, V)` - if tpann.startswith('(') and tpann.endswith(')'): + type_ann_expr = addnodes.desc_sig_name('', '', + *annotation) # type: ignore[arg-type] + # a type bound is ``T: U`` whereas type constraints + # must be enclosed with parentheses. ``T: (U, V)`` + if tp_ann.startswith('(') and tp_ann.endswith(')'): type_ann_text = type_ann_expr.astext() if type_ann_text.startswith('(') and type_ann_text.endswith(')'): node += type_ann_expr @@ -443,16 +444,17 @@ def _parse_tplist( else: node += type_ann_expr # type bound - if tpdefault is not Parameter.empty: + if tp_default is not Parameter.empty: # Always surround '=' with spaces, even if there is no annotation node += addnodes.desc_sig_space() node += addnodes.desc_sig_operator('', '=') node += addnodes.desc_sig_space() - node += nodes.inline('', tpdefault, classes=['default_value'], + node += nodes.inline('', tp_default, + classes=['default_value'], support_smartquotes=False) - tparams += node - return tparams + type_params += node + return type_params def _parse_arglist( @@ -713,7 +715,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, tplist, arglist, retann = m.groups() + prefix, name, tp_list, 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')) @@ -752,18 +754,18 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str] # determine if the function arguments (without its type parameters) # should be formatted on a multiline or not by removing the width of # the type parameters list (if any) - siglen = len(sig) - tplist_span = m.span(3) + sig_len = len(sig) + tp_list_span = m.span(3) multi_line_parameter_list = ( 'single-line-parameter-list' not in self.options - and (siglen - (tplist_span[1] - tplist_span[0])) > max_len > 0 + and (sig_len - (tp_list_span[1] - tp_list_span[0])) > max_len > 0 ) # determine whether the type parameter list must be wrapped or not arglist_span = m.span(4) multi_line_type_parameter_list = ( 'single-line-type-parameter-list' not in self.options - and (siglen - (arglist_span[1] - arglist_span[0])) > max_len > 0 + and (sig_len - (arglist_span[1] - arglist_span[0])) > max_len > 0 ) sig_prefix = self.get_signature_prefix(sig) @@ -783,20 +785,20 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str] signode += addnodes.desc_name(name, name) - if tplist: + if tp_list: try: - signode += _parse_tplist(tplist, self.env, multi_line_type_parameter_list) + signode += _parse_type_list(tp_list, self.env, multi_line_type_parameter_list) except Exception as exc: - logger.warning("could not parse tplist (%r): %s", tplist, exc, + logger.warning("could not parse tp_list (%r): %s", tp_list, exc, location=signode) if arglist: try: signode += _parse_arglist(arglist, self.env, multi_line_parameter_list) except SyntaxError: - # fallback to parse arglist original parser (this may happen - # if the argument list is incorrectly used as a list of bases - # when documenting a class) + # fallback to parse arglist original parser + # (this may happen if the argument list is incorrectly used + # as a list of bases when documenting a class) # it supports to represent optional arguments (ex. "func(foo [, bar])") _pseudo_parse_arglist(signode, arglist, multi_line_parameter_list) except (NotImplementedError, ValueError) as exc: diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index 76526c9f5f2..6974da2fe52 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -61,7 +61,7 @@ r'''^ ([\w.]+::)? # explicit module name ([\w.]+\.)? # module and/or class name(s) (\w+) \s* # thing name - (?: \[\s*(.*)\s*])? # optional: type parameters list (PEP 695) + (?: \[\s*(.*)\s*])? # optional: type parameters list (?: \((.*)\) # optional: arguments (?:\s* -> \s* (.*))? # return annotation )? $ # and nothing more @@ -389,7 +389,7 @@ def parse_name(self) -> bool: # an autogenerated one try: matched = py_ext_sig_re.match(self.name) - explicit_modname, path, base, tplist, args, retann = matched.groups() + explicit_modname, path, base, tp_list, args, retann = matched.groups() except AttributeError: logger.warning(__('invalid signature for auto%s (%r)') % (self.objtype, self.name), type='autodoc') @@ -1193,7 +1193,7 @@ def _find_signature(self) -> tuple[str | None, str | None]: match = py_ext_sig_re.match(line) if not match: break - exmod, path, base, tplist, args, retann = match.groups() + exmod, path, base, tp_list, args, retann = match.groups() # the base name must match ours if base not in valid_names: diff --git a/sphinx/texinputs/sphinxlatexobjects.sty b/sphinx/texinputs/sphinxlatexobjects.sty index 593d72caa5c..2f7e095b942 100644 --- a/sphinx/texinputs/sphinxlatexobjects.sty +++ b/sphinx/texinputs/sphinxlatexobjects.sty @@ -1,7 +1,7 @@ %% MODULE RELEASE DATA AND OBJECT DESCRIPTIONS % % change this info string if making any custom modification -\ProvidesFile{sphinxlatexobjects.sty}[2023/06/03 documentation environments] +\ProvidesFile{sphinxlatexobjects.sty}[2023/07/23 documentation environments] % Provides support for this output mark-up from Sphinx latex writer: % @@ -122,8 +122,8 @@ % size of is parametrized by \sphinxsignaturelistskip (0pt by default). \newlength\sphinxsignaturelistskip \setlength\sphinxsignaturelistskip{0pt} -\newcommand{\pysigtplistopen}{\hskip\sphinxsignaturelistskip\sphinxcode{[}} -\newcommand{\pysigtplistclose}{\sphinxcode{]}} +\newcommand{\pysigtypelistopen}{\hskip\sphinxsignaturelistskip\sphinxcode{[}} +\newcommand{\pysigtypelistclose}{\sphinxcode{]}} \newcommand{\pysigarglistopen}{\hskip\sphinxsignaturelistskip\sphinxcode{(}} \newcommand{\pysigarglistclose}{\sphinxcode{)}} % @@ -136,12 +136,12 @@ \parbox[t]{\py@argswidth}{\raggedright #1\pysigarglistclose#2\strut}% % final strut is to help get correct vertical separation } -\newcommand{\py@sigparamswithtplist}[3]{% +\newcommand{\py@sigparamswithtypelist}[3]{% % similar to \py@sigparams but with different delimiters and an additional % type parameters list given as #1, the argument list as #2 and the return % annotation as #3 \parbox[t]{\py@argswidth}{% - \raggedright #1\pysigtplistclose% + \raggedright #1\pysigtypelistclose% \pysigarglistopen#2\pysigarglistclose% #3\strut}% } @@ -164,13 +164,13 @@ \item[{#1\pysigarglistopen\py@sigparams{#2}{#3}\strut}] \pysigadjustitemsep } -\newcommand{\pysiglinewithargsretwithtplist}[4]{ -% #1 = name, #2 = tplist, #3 = arglist, #4 = retann +\newcommand{\pysiglinewithargsretwithtypelist}[4]{ +% #1 = name, #2 = typelist, #3 = arglist, #4 = retann \let\spx@label\label\let\label\@gobble - \settowidth{\py@argswidth}{#1\pysigtplistopen}% + \settowidth{\py@argswidth}{#1\pysigtypelistopen}% \let\label\spx@label \py@argswidth=\dimexpr\linewidth+\labelwidth-\py@argswidth\relax\relax - \item[{#1\pysigtplistopen\py@sigparamswithtplist{#2}{#3}{#4}\strut}] + \item[{#1\pysigtypelistopen\py@sigparamswithtypelist{#2}{#3}{#4}\strut}] \pysigadjustitemsep } @@ -195,9 +195,9 @@ \pysigadjustitemsep } \newcommand{\pysigwithonelineperargwithonelinepertparg}[4]{ - % #1 = name, #2 = tplist, #3 = arglist, #4 = retann + % #1 = name, #2 = typelist, #3 = arglist, #4 = retann % render each type parameter and argument on its own line - \item[#1\pysigtplistopen\strut] + \item[#1\pysigtypelistopen\strut] \leavevmode\par\nopagebreak \begingroup \let\sphinxparamcomma\sphinxparamcommaoneperline @@ -208,7 +208,7 @@ \def\sphinxtypeparam{\def\sphinxtypeparam{\par\spx@sphinxtypeparam}\spx@sphinxtypeparam}% #2\par \endgroup - \nopagebreak\noindent\kern-\labelwidth\pysigtplistclose% + \nopagebreak\noindent\kern-\labelwidth\pysigtypelistclose% % render the rest of the signature like in \pysigwithonelineperarg \pysigarglistopen\strut\par\nopagebreak \begingroup @@ -223,9 +223,9 @@ \pysigadjustitemsep } \newcommand{\pysiglinewithargsretwithonelinepertparg}[4]{ - % #1 = name, #2 = tplist, #3 = arglist, #4 = retann + % #1 = name, #2 = typelist, #3 = arglist, #4 = retann % render each type parameter on its own line but the arguments list inline - \item[#1\pysigtplistopen\strut] + \item[#1\pysigtypelistopen\strut] \leavevmode\par\nopagebreak \begingroup \let\sphinxparamcomma\sphinxparamcommaoneperline @@ -236,20 +236,20 @@ \def\sphinxtypeparam{\def\sphinxtypeparam{\par\spx@sphinxtypeparam}\spx@sphinxtypeparam}% #2\par \endgroup - \nopagebreak\noindent\kern-\labelwidth\pysigtplistclose% + \nopagebreak\noindent\kern-\labelwidth\pysigtypelistclose% % render the arguments list on one line \pysigarglistopen#3\pysigarglistclose#4\strut \pysigadjustitemsep } -\newcommand{\pysigwithonelineperargwithtplist}[4]{ - % #1 = name, #2 = tplist, #3 = arglist, #4 = retann +\newcommand{\pysigwithonelineperargwithtypelist}[4]{ + % #1 = name, #2 = typelist, #3 = arglist, #4 = retann % render the type parameters list on one line, but each argument is rendered on its own line \let\spx@label\label\let\label\@gobble - \settowidth{\py@argswidth}{#1\pysigtplistopen}% + \settowidth{\py@argswidth}{#1\pysigtypelistopen}% \let\label\spx@label \py@argswidth=\dimexpr\linewidth+\labelwidth-\py@argswidth\relax\relax - \item[{#1\pysigtplistopen\parbox[t]{\py@argswidth}{% - \raggedright #2\pysigtplistclose\pysigarglistopen\strut}\strut}] + \item[{#1\pysigtypelistopen\parbox[t]{\py@argswidth}{% + \raggedright #2\pysigtypelistclose\pysigarglistopen\strut}\strut}] % render the rest of the signature like in \pysigwithonelineperarg \begingroup \let\sphinxparamcomma\sphinxparamcommaoneperline @@ -262,7 +262,6 @@ \nopagebreak\noindent\kern-\labelwidth\pysigarglistclose{#4} \pysigadjustitemsep } - \newcommand{\pysigadjustitemsep}{% % adjust \itemsep to control the separation with the next signature % sharing common description diff --git a/sphinx/texinputs/sphinxlatexstyletext.sty b/sphinx/texinputs/sphinxlatexstyletext.sty index d4887d59447..d90009035d2 100644 --- a/sphinx/texinputs/sphinxlatexstyletext.sty +++ b/sphinx/texinputs/sphinxlatexstyletext.sty @@ -1,7 +1,7 @@ %% TEXT STYLING % % change this info string if making any custom modification -\ProvidesFile{sphinxlatexstyletext.sty}[2023/06/03 text styling] +\ProvidesFile{sphinxlatexstyletext.sty}[2023/07/23 text styling] % Basically everything here consists of macros which are part of the latex % markup produced by the Sphinx latex writer diff --git a/sphinx/writers/html5.py b/sphinx/writers/html5.py index 13147fe7292..8298e44c9c8 100644 --- a/sphinx/writers/html5.py +++ b/sphinx/writers/html5.py @@ -157,7 +157,7 @@ def _visit_sig_parameter_list( ) -> None: """Visit a signature parameters or type parameters list. - The *parameter_group* value is the type of a child node acting as a required parameter + The *parameter_group* value is the type of child nodes acting as required parameters or as a set of contiguous optional parameters. """ self.body.append(f'{sig_open_paren}') @@ -190,10 +190,10 @@ def visit_desc_parameterlist(self, node: Element) -> None: def depart_desc_parameterlist(self, node: Element) -> None: self._depart_sig_parameter_list(node) - def visit_desc_tparameterlist(self, node: Element) -> None: - self._visit_sig_parameter_list(node, addnodes.desc_tparameter, '[', ']') + def visit_desc_type_parameter_list(self, node: Element) -> None: + self._visit_sig_parameter_list(node, addnodes.desc_type_parameter, '[', ']') - def depart_desc_tparameterlist(self, node: Element) -> None: + def depart_desc_type_parameter_list(self, node: Element) -> None: self._depart_sig_parameter_list(node) # If required parameters are still to come, then put the comma after @@ -238,10 +238,10 @@ def depart_desc_parameter(self, node: Element) -> None: if is_required: self.param_group_index += 1 - def visit_desc_tparameter(self, node: Element) -> None: + def visit_desc_type_parameter(self, node: Element) -> None: self.visit_desc_parameter(node) - def depart_desc_tparameter(self, node: Element) -> None: + def depart_desc_type_parameter(self, node: Element) -> None: self.depart_desc_parameter(node) def visit_desc_optional(self, node: Element) -> None: diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py index 69fd9f950de..2d1898ac5c6 100644 --- a/sphinx/writers/latex.py +++ b/sphinx/writers/latex.py @@ -709,31 +709,31 @@ def next_sibling(e: Node) -> Node | None: def has_multi_line(e: Element) -> bool: return e.get('multi_line_parameter_list') - self.has_tplist = False + self.has_tp_list = False for child in node: - if isinstance(child, addnodes.desc_tparameterlist): - self.has_tplist = True + if isinstance(child, addnodes.desc_type_parameter_list): + self.has_tp_list = True # recall that return annotations must follow an argument list, - # so signatures of the form "foo[tplist] -> retann" will not + # so signatures of the form "foo[tp_list] -> retann" will not # be encountered (if they should, the `domains.python.py_sig_re` # pattern must be modified accordingly) arglist = next_sibling(child) assert isinstance(arglist, addnodes.desc_parameterlist) - # tplist + arglist: \macro{name}{tplist}{arglist}{return} - multi_tplist = has_multi_line(child) + # tp_list + arglist: \macro{name}{tp_list}{arglist}{return} + multi_tp_list = has_multi_line(child) multi_arglist = has_multi_line(arglist) - if multi_tplist: + if multi_tp_list: if multi_arglist: self.body.append(CR + r'\pysigwithonelineperargwithonelinepertparg{') else: self.body.append(CR + r'\pysiglinewithargsretwithonelinepertparg{') else: if multi_arglist: - self.body.append(CR + r'\pysigwithonelineperargwithtplist{') + self.body.append(CR + r'\pysigwithonelineperargwithtypelist{') else: - self.body.append(CR + r'\pysiglinewithargsretwithtplist{') + self.body.append(CR + r'\pysiglinewithargsretwithtypelist{') break if isinstance(child, addnodes.desc_parameterlist): @@ -744,7 +744,7 @@ def has_multi_line(e: Element) -> bool: self.body.append(CR + r'\pysiglinewithargsret{') break else: - # no tplist, no arglist: \macro{name} + # no tp_list, no arglist: \macro{name} self.body.append(CR + r'\pysigline{') def _depart_signature_line(self, node: Element) -> None: @@ -843,7 +843,7 @@ def _visit_sig_parameter_list(self, node: Element, parameter_group: type[Element self.multi_line_parameter_list = node.get('multi_line_parameter_list', False) def visit_desc_parameterlist(self, node: Element) -> None: - if not self.has_tplist: + if not self.has_tp_list: # close name argument (#1), open parameters list argument (#2) self.body.append('}{') self._visit_sig_parameter_list(node, addnodes.desc_parameter) @@ -852,12 +852,12 @@ def depart_desc_parameterlist(self, node: Element) -> None: # close parameterlist, open return annotation self.body.append('}{') - def visit_desc_tparameterlist(self, node: Element) -> None: + def visit_desc_type_parameter_list(self, node: Element) -> None: # close name argument (#1), open type parameters list argument (#2) self.body.append('}{') - self._visit_sig_parameter_list(node, addnodes.desc_tparameter) + self._visit_sig_parameter_list(node, addnodes.desc_type_parameter) - def depart_desc_tparameterlist(self, node: Element) -> None: + def depart_desc_type_parameter_list(self, node: Element) -> None: # close type parameters list, open parameters list argument (#3) self.body.append('}{') @@ -899,10 +899,10 @@ def visit_desc_parameter(self, node: Element) -> None: def depart_desc_parameter(self, node: Element) -> None: self._depart_sig_parameter(node) - def visit_desc_tparameter(self, node: Element) -> None: + def visit_desc_type_parameter(self, node: Element) -> None: self._visit_sig_parameter(node, r'\sphinxtypeparam{') - def depart_desc_tparameter(self, node: Element) -> None: + def depart_desc_type_parameter(self, node: Element) -> None: self._depart_sig_parameter(node) def visit_desc_optional(self, node: Element) -> None: diff --git a/sphinx/writers/manpage.py b/sphinx/writers/manpage.py index 6538e69d60c..d4fe0a91c25 100644 --- a/sphinx/writers/manpage.py +++ b/sphinx/writers/manpage.py @@ -190,11 +190,11 @@ def visit_desc_parameterlist(self, node: Element) -> None: def depart_desc_parameterlist(self, node: Element) -> None: self.body.append(')') - def visit_desc_tparameterlist(self, node: Element) -> None: + def visit_desc_type_parameter_list(self, node: Element) -> None: self.body.append('[') self.first_param = 1 - def depart_desc_tparameterlist(self, node: Element) -> None: + def depart_desc_type_parameter_list(self, node: Element) -> None: self.body.append(']') def visit_desc_parameter(self, node: Element) -> None: @@ -206,10 +206,10 @@ def visit_desc_parameter(self, node: Element) -> None: def depart_desc_parameter(self, node: Element) -> None: pass - def visit_desc_tparameter(self, node: Element) -> None: + def visit_desc_type_parameter(self, node: Element) -> None: self.visit_desc_parameter(node) - def depart_desc_tparameter(self, node: Element) -> None: + def depart_desc_type_parameter(self, node: Element) -> None: self.depart_desc_parameter(node) def visit_desc_optional(self, node: Element) -> None: diff --git a/sphinx/writers/texinfo.py b/sphinx/writers/texinfo.py index 54ff4eb1714..267c9aa47cf 100644 --- a/sphinx/writers/texinfo.py +++ b/sphinx/writers/texinfo.py @@ -1468,11 +1468,11 @@ def visit_desc_parameterlist(self, node: Element) -> None: def depart_desc_parameterlist(self, node: Element) -> None: self.body.append(')') - def visit_desc_tparameterlist(self, node: Element) -> None: + def visit_desc_type_parameter_list(self, node: Element) -> None: self.body.append(' [') self.first_param = 1 - def depart_desc_tparameterlist(self, node: Element) -> None: + def depart_desc_type_parameter_list(self, node: Element) -> None: self.body.append(']') def visit_desc_parameter(self, node: Element) -> None: @@ -1486,7 +1486,7 @@ def visit_desc_parameter(self, node: Element) -> None: self.body.append(text) raise nodes.SkipNode - def visit_desc_tparameter(self, node: Element) -> None: + def visit_desc_type_parameter(self, node: Element) -> None: self.visit_desc_parameter(node) def visit_desc_optional(self, node: Element) -> None: diff --git a/sphinx/writers/text.py b/sphinx/writers/text.py index 0079097a02e..8614ee25ee5 100644 --- a/sphinx/writers/text.py +++ b/sphinx/writers/text.py @@ -645,10 +645,10 @@ def visit_desc_parameterlist(self, node: Element) -> None: def depart_desc_parameterlist(self, node: Element) -> None: self._depart_sig_parameter_list(node) - def visit_desc_tparameterlist(self, node: Element) -> None: - self._visit_sig_parameter_list(node, addnodes.desc_tparameter, '[', ']') + def visit_desc_type_parameter_list(self, node: Element) -> None: + self._visit_sig_parameter_list(node, addnodes.desc_type_parameter, '[', ']') - def depart_desc_tparameterlist(self, node: Element) -> None: + def depart_desc_type_parameter_list(self, node: Element) -> None: self._depart_sig_parameter_list(node) def visit_desc_parameter(self, node: Element) -> None: @@ -685,7 +685,7 @@ def visit_desc_parameter(self, node: Element) -> None: self.param_group_index += 1 raise nodes.SkipNode - def visit_desc_tparameter(self, node: Element) -> None: + def visit_desc_type_parameter(self, node: Element) -> None: self.visit_desc_parameter(node) def visit_desc_optional(self, node: Element) -> None: diff --git a/tests/test_domain_py.py b/tests/test_domain_py.py index 87a3d58d962..8a3e378b103 100644 --- a/tests/test_domain_py.py +++ b/tests/test_domain_py.py @@ -28,8 +28,8 @@ desc_sig_punctuation, desc_sig_space, desc_signature, - desc_tparameter, - desc_tparameterlist, + desc_type_parameter, + desc_type_parameter_list, pending_xref, ) from sphinx.domains import IndexEntry @@ -49,7 +49,7 @@ def parse(sig): m = py_sig_re.match(sig) if m is None: raise ValueError - name_prefix, tplist, name, arglist, retann = m.groups() + name_prefix, tp_list, name, arglist, retann = m.groups() signode = addnodes.desc_signature(sig, '') _pseudo_parse_arglist(signode, arglist) return signode.astext() @@ -1863,15 +1863,15 @@ def test_function_pep_695(app): [desc, ( [desc_signature, ( [desc_name, 'func'], - [desc_tparameterlist, ( - [desc_tparameter, ([desc_sig_name, 'S'])], - [desc_tparameter, ( + [desc_type_parameter_list, ( + [desc_type_parameter, ([desc_sig_name, 'S'])], + [desc_type_parameter, ( [desc_sig_name, 'T'], [desc_sig_punctuation, ':'], desc_sig_space, [desc_sig_name, ([pending_xref, 'int'])], )], - [desc_tparameter, ( + [desc_type_parameter, ( [desc_sig_name, 'U'], [desc_sig_punctuation, ':'], desc_sig_space, @@ -1884,7 +1884,7 @@ def test_function_pep_695(app): )], [desc_sig_punctuation, ')'], )], - [desc_tparameter, ( + [desc_type_parameter, ( [desc_sig_name, 'R'], [desc_sig_punctuation, ':'], desc_sig_space, @@ -1896,17 +1896,17 @@ def test_function_pep_695(app): [pending_xref, 'int'], )], )], - [desc_tparameter, ( + [desc_type_parameter, ( [desc_sig_name, 'A'], [desc_sig_punctuation, ':'], desc_sig_space, [desc_sig_name, ([pending_xref, 'int | Annotated[int, ctype("char")]'])], )], - [desc_tparameter, ( + [desc_type_parameter, ( [desc_sig_operator, '*'], [desc_sig_name, 'V'], )], - [desc_tparameter, ( + [desc_type_parameter, ( [desc_sig_operator, '**'], [desc_sig_name, 'P'], )], @@ -1929,8 +1929,8 @@ def test_class_def_pep_695(app): [desc_signature, ( [desc_annotation, ('class', desc_sig_space)], [desc_name, 'Class'], - [desc_tparameterlist, ( - [desc_tparameter, ( + [desc_type_parameter_list, ( + [desc_type_parameter, ( [desc_sig_name, 'S'], [desc_sig_punctuation, ':'], desc_sig_space, @@ -1941,9 +1941,9 @@ def test_class_def_pep_695(app): [desc_sig_punctuation, ']'], )], )], - [desc_tparameter, ([desc_sig_name, 'T'])], - [desc_tparameter, ([desc_sig_name, 'KT'])], - [desc_tparameter, ([desc_sig_name, 'VT'])], + [desc_type_parameter, ([desc_sig_name, 'T'])], + [desc_type_parameter, ([desc_sig_name, 'KT'])], + [desc_type_parameter, ([desc_sig_name, 'VT'])], )], [desc_parameterlist, ([desc_parameter, 'Dict[KT, VT]'])], )], @@ -1972,19 +1972,19 @@ def test_class_def_pep_696(app): [desc_signature, ( [desc_annotation, ('class', desc_sig_space)], [desc_name, 'Class'], - [desc_tparameterlist, ( - [desc_tparameter, ([desc_sig_name, 'T'])], - [desc_tparameter, ([desc_sig_name, 'KT'])], - [desc_tparameter, ([desc_sig_name, 'VT'])], + [desc_type_parameter_list, ( + [desc_type_parameter, ([desc_sig_name, 'T'])], + [desc_type_parameter, ([desc_sig_name, 'KT'])], + [desc_type_parameter, ([desc_sig_name, 'VT'])], # J: int - [desc_tparameter, ( + [desc_type_parameter, ( [desc_sig_name, 'J'], [desc_sig_punctuation, ':'], desc_sig_space, [desc_sig_name, ([pending_xref, 'int'])], )], # K = list - [desc_tparameter, ( + [desc_type_parameter, ( [desc_sig_name, 'K'], desc_sig_space, [desc_sig_operator, '='], @@ -1992,7 +1992,7 @@ def test_class_def_pep_696(app): [nodes.inline, 'list'], )], # S: str = str - [desc_tparameter, ( + [desc_type_parameter, ( [desc_sig_name, 'S'], [desc_sig_punctuation, ':'], desc_sig_space, @@ -2002,7 +2002,7 @@ def test_class_def_pep_696(app): desc_sig_space, [nodes.inline, 'str'], )], - [desc_tparameter, ( + [desc_type_parameter, ( [desc_sig_name, 'L'], [desc_sig_punctuation, ':'], desc_sig_space, @@ -2034,7 +2034,7 @@ def test_class_def_pep_696(app): desc_sig_space, [nodes.inline, 'set[T]'], )], - [desc_tparameter, ( + [desc_type_parameter, ( [desc_sig_name, 'Q'], [desc_sig_punctuation, ':'], desc_sig_space, @@ -2052,7 +2052,7 @@ def test_class_def_pep_696(app): desc_sig_space, [nodes.inline, 'dict[KT, VT]'], )], - [desc_tparameter, ( + [desc_type_parameter, ( [desc_sig_operator, '*'], [desc_sig_name, 'V'], desc_sig_space, @@ -2060,7 +2060,7 @@ def test_class_def_pep_696(app): desc_sig_space, [nodes.inline, '*tuple[*Ts, bool]'], )], - [desc_tparameter, ( + [desc_type_parameter, ( [desc_sig_operator, '**'], [desc_sig_name, 'P'], desc_sig_space, @@ -2078,7 +2078,7 @@ def test_class_def_pep_696(app): )) -@pytest.mark.parametrize('tplist,tptext', [ +@pytest.mark.parametrize('tp_list,tptext', [ ('[T:int]', '[T: int]'), ('[T:*Ts]', '[T: *Ts]'), ('[T:int|(*Ts)]', '[T: int | (*Ts)]'), @@ -2087,23 +2087,23 @@ def test_class_def_pep_696(app): ('[T:((*Ts)|int)]', '[T: ((*Ts) | int)]'), ('[T:Annotated[int,ctype("char")]]', '[T: Annotated[int, ctype("char")]]'), ]) -def test_pep_695_and_pep_696_whitespaces_in_bound(app, tplist, tptext): - text = f'.. py:function:: f{tplist}()' +def test_pep_695_and_pep_696_whitespaces_in_bound(app, tp_list, tptext): + text = f'.. py:function:: f{tp_list}()' doctree = restructuredtext.parse(app, text) assert doctree.astext() == f'\n\nf{tptext}()\n\n' -@pytest.mark.parametrize('tplist,tptext', [ +@pytest.mark.parametrize('tp_list,tptext', [ ('[T:(int,str)]', '[T: (int, str)]'), ('[T:(int|str,*Ts)]', '[T: (int | str, *Ts)]'), ]) -def test_pep_695_and_pep_696_whitespaces_in_constraints(app, tplist, tptext): - text = f'.. py:function:: f{tplist}()' +def test_pep_695_and_pep_696_whitespaces_in_constraints(app, tp_list, tptext): + text = f'.. py:function:: f{tp_list}()' doctree = restructuredtext.parse(app, text) assert doctree.astext() == f'\n\nf{tptext}()\n\n' -@pytest.mark.parametrize('tplist,tptext', [ +@pytest.mark.parametrize('tp_list,tptext', [ ('[T=int]', '[T = int]'), ('[T:int=int]', '[T: int = int]'), ('[*V=*Ts]', '[*V = *Ts]'), @@ -2117,7 +2117,7 @@ def test_pep_695_and_pep_696_whitespaces_in_constraints(app, tplist, tptext): ('[**P=[int, *Ts*3]]', '[**P = [int, *Ts * 3]]'), ('[**P=[int,A[int,ctype("char")]]]', '[**P = [int, A[int, ctype("char")]]]'), ]) -def test_pep_695_and_pep_696_whitespaces_in_default(app, tplist, tptext): - text = f'.. py:function:: f{tplist}()' +def test_pep_695_and_pep_696_whitespaces_in_default(app, tp_list, tptext): + text = f'.. py:function:: f{tp_list}()' doctree = restructuredtext.parse(app, text) assert doctree.astext() == f'\n\nf{tptext}()\n\n'