diff --git a/CHANGES b/CHANGES index 984f1981b71..294e80e524d 100644 --- a/CHANGES +++ b/CHANGES @@ -34,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 a451ae6a43c..4c9a4e0f811 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. + 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. + - More text styling: .. csv-table:: diff --git a/doc/usage/configuration.rst b/doc/usage/configuration.rst index 133e2099df9..235b1cc2626 100644 --- a/doc/usage/configuration.rst +++ b/doc/usage/configuration.rst @@ -3025,9 +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 - 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`. + + 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 + + .. code:: rst + + .. py:function:: add[T: VERY_LONG_SUPER_TYPE, U: VERY_LONG_SUPER_TYPE](a: T, b: U) .. versionadded:: 7.1 diff --git a/doc/usage/restructuredtext/domains.rst b/doc/usage/restructuredtext/domains.rst index 23ac543a41f..dbaa36e3d98 100644 --- a/doc/usage/restructuredtext/domains.rst +++ b/doc/usage/restructuredtext/domains.rst @@ -192,12 +192,16 @@ 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`. + 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:: 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`. @@ -240,6 +244,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.1 + .. rst:directive:: .. py:data:: name @@ -274,9 +287,12 @@ 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. + 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 @@ -293,12 +309,28 @@ 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.1 + .. 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 +380,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 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 +449,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 +509,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 +527,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 +542,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 +583,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 +623,27 @@ argument support), you can use brackets to specify the optional parts: It is customary to put the opening bracket before the comma. +Python 3.12 introduced *type parameters*, which are type variables +declared directly within the class or function definition: + +.. code:: python + + class AnimalList[AnimalT](list[AnimalT]): + ... + + def add[T](a: T, b: T) -> T: + return a + b + +The corresponding reStructuredText documentation would be: + +.. code:: rst + + .. py:class:: AnimalList[AnimalT] + + .. py:function:: add[T](a: T, b: T) -> T + +See :pep:`695` and :pep:`696` for details and the full specification. + .. _info-field-lists: Info field lists diff --git a/sphinx/addnodes.py b/sphinx/addnodes.py index e92d32a0ef8..d85d9309ea4 100644 --- a/sphinx/addnodes.py +++ b/sphinx/addnodes.py @@ -258,10 +258,27 @@ def astext(self): return f'({super().astext()})' +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. + 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_type_parameter(nodes.Part, nodes.Inline, nodes.FixedTextElement): + """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 = ', ' @@ -537,7 +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_type_parameter_list) app.add_node(desc_parameter) + 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 3fda5270351..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 @@ -23,6 +24,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 @@ -43,6 +45,7 @@ py_sig_re = re.compile( r'''^ ([\w.]*\.)? # class name(s) (\w+) \s* # thing name + (?: \[\s*(.*)\s*])? # optional: type parameters list (?: \(\s*(.*)\s*\) # optional: arguments (?:\s* -> \s* (.*))? # return annotation )? $ # and nothing more @@ -257,6 +260,203 @@ 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 = 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 + # or VAR_KEYWORD parameters. + 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 == [token.OP, ldelim]: + tokens += self.fetch_until([token.OP, rdelim]) + break + else: + 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: + while self.fetch_token(): + 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: + tp_kind = Parameter.VAR_KEYWORD # type: ignore[assignment] + else: + tp_kind = Parameter.POSITIONAL_OR_KEYWORD # type: ignore[assignment] + + tp_ann: Any = Parameter.empty + tp_default: Any = Parameter.empty + + self.fetch_token() + 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 == [token.OP, '=']: + tokens = self.fetch_type_param_spec() + tp_default = self._build_identifier(tokens) + + if tp_kind != Parameter.POSITIONAL_OR_KEYWORD and tp_ann != Parameter.empty: + raise SyntaxError('type parameter bound or constraint is not allowed ' + f'for {tp_kind.description} parameters') + + 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 + + def pairwise(iterable): + a, b = tee(iterable) + next(b, None) + return zip(a, b) + + def triplewise(iterable): + for (a, _z), (b, c) in pairwise(pairwise(iterable)): + yield a, b, c + + idents: list[str] = [] + tokens: Iterable[Token] = iter(tokens) # type: ignore + # do not format opening brackets + 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 = tok.match([token.OP, '*'], [token.OP, ['**']]) + idents.append(self._pformat_token(tok, native=is_unpack_operator)) + break + idents.append(tok.value) + + # check the remaining tokens + stop = Token(token.ENDMARKER, '', (-1, -1), (-1, -1), '') + is_unpack_operator = False + 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([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, tok: Token, native: bool = False) -> str: + if native: + return tok.value + + if tok.match(token.NEWLINE, token.ENDMARKER): + return '' + + 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 + # 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 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' {tok.value} ' + + return tok.value + + +def _parse_type_list( + tp_list: str, env: BuildEnvironment | None = None, + multi_line_parameter_list: bool = False, +) -> addnodes.desc_type_parameter_list: + """Parse a list of type parameters according to PEP 695.""" + 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(tp_list) + parser.parse() + 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 + 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_type_parameter() + if tp_kind == Parameter.VAR_POSITIONAL: + node += addnodes.desc_sig_operator('', '*') + elif tp_kind == Parameter.VAR_KEYWORD: + node += addnodes.desc_sig_operator('', '**') + node += addnodes.desc_sig_name('', tp_name) + + 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[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 + else: + # surrounding braces are lost when using _parse_annotation() + node += addnodes.desc_sig_punctuation('', '(') + node += type_ann_expr # type constraint + node += addnodes.desc_sig_punctuation('', ')') + else: + node += type_ann_expr # type bound + + 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('', tp_default, + classes=['default_value'], + support_smartquotes=False) + + type_params += node + return type_params + + def _parse_arglist( arglist: str, env: BuildEnvironment | None = None, multi_line_parameter_list: bool = False, ) -> addnodes.desc_parameterlist: @@ -464,6 +664,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, @@ -514,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, 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')) @@ -549,9 +750,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) + sig_len = len(sig) + tp_list_span = m.span(3) multi_line_parameter_list = ( 'single-line-parameter-list' not in self.options - and (len(sig) > 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 (sig_len - (arglist_span[1] - arglist_span[0])) > max_len > 0 ) sig_prefix = self.get_signature_prefix(sig) @@ -570,14 +784,25 @@ 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 tp_list: + try: + signode += _parse_type_list(tp_list, self.env, multi_line_type_parameter_list) + except Exception as 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. + # 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 as exc: + except (NotImplementedError, ValueError) as exc: + # 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) diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index 0bb5cb6a8fe..6974da2fe52 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 (?: \((.*)\) # 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, tp_list, 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, 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 a2038a9f160..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}[2022/01/13 documentation environments] +\ProvidesFile{sphinxlatexobjects.sty}[2023/07/23 documentation environments] % Provides support for this output mark-up from Sphinx latex writer: % @@ -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{\pysigtypelistopen}{\hskip\sphinxsignaturelistskip\sphinxcode{[}} +\newcommand{\pysigtypelistclose}{\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,9 +133,19 @@ \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@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\pysigtypelistclose% + \pysigarglistopen#2\pysigarglistclose% + #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 @@ -140,17 +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\pysigarglistopen\py@sigparams{#2}{#3}\strut}] + \pysigadjustitemsep +} +\newcommand{\pysiglinewithargsretwithtypelist}[4]{ +% #1 = name, #2 = typelist, #3 = arglist, #4 = retann +\let\spx@label\label\let\label\@gobble + \settowidth{\py@argswidth}{#1\pysigtypelistopen}% \let\label\spx@label \py@argswidth=\dimexpr\linewidth+\labelwidth-\py@argswidth\relax\relax - \item[{#1\sphinxcode{(}\py@sigparams{#2}{#3}\strut}] + \item[{#1\pysigtypelistopen\py@sigparamswithtypelist{#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 @@ -164,7 +191,75 @@ \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 = typelist, #3 = arglist, #4 = retann + % render each type parameter and argument on its own line + \item[#1\pysigtypelistopen\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\pysigtypelistclose% + % render the rest of the signature like in \pysigwithonelineperarg + \pysigarglistopen\strut\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\pysigarglistclose{#4} + \pysigadjustitemsep +} +\newcommand{\pysiglinewithargsretwithonelinepertparg}[4]{ + % #1 = name, #2 = typelist, #3 = arglist, #4 = retann + % render each type parameter on its own line but the arguments list inline + \item[#1\pysigtypelistopen\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\pysigtypelistclose% + % render the arguments list on one line + \pysigarglistopen#3\pysigarglistclose#4\strut + \pysigadjustitemsep +} +\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\pysigtypelistopen}% +\let\label\spx@label + \py@argswidth=\dimexpr\linewidth+\labelwidth-\py@argswidth\relax\relax + \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 + \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\pysigarglistclose{#4} \pysigadjustitemsep } \newcommand{\pysigadjustitemsep}{% diff --git a/sphinx/texinputs/sphinxlatexstyletext.sty b/sphinx/texinputs/sphinxlatexstyletext.sty index 292facc9132..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/03/26 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 @@ -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/html5.py b/sphinx/writers/html5.py index 5aea3ddb817..8298e44c9c8 100644 --- a/sphinx/writers/html5.py +++ b/sphinx/writers/html5.py @@ -148,16 +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: - self.body.append('(') + 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 child nodes acting as required parameters + 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 @@ -166,11 +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') - self.body.append(')') + 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_type_parameter_list(self, node: Element) -> None: + self._visit_sig_parameter_list(node, addnodes.desc_type_parameter, '[', ']') + + 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 # the parameter. Otherwise, put the comma before. This ensures that @@ -214,6 +238,12 @@ def depart_desc_parameter(self, node: Element) -> None: if is_required: self.param_group_index += 1 + def visit_desc_type_parameter(self, node: Element) -> None: + self.visit_desc_parameter(node) + + def depart_desc_type_parameter(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]) diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py index 37c73ae5a60..2d1898ac5c6 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: Node) -> Node | 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_tp_list = False + for child in node: + 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[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) + # tp_list + arglist: \macro{name}{tp_list}{arglist}{return} + multi_tp_list = has_multi_line(child) + multi_arglist = has_multi_line(arglist) + + 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'\pysigwithonelineperargwithtypelist{') + else: + self.body.append(CR + r'\pysiglinewithargsretwithtypelist{') + 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 tp_list, no arglist: \macro{name} self.body.append(CR + r'\pysigline{') def _depart_signature_line(self, node: Element) -> None: @@ -784,27 +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_tp_list: + # 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_parameter(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_type_parameter) + + def depart_desc_type_parameter_list(self, node: Element) -> None: + # close type parameters list, open parameters list argument (#3) + self.body.append('}{') + + 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: @@ -814,9 +871,9 @@ 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 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] @@ -836,6 +893,18 @@ 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_type_parameter(self, node: Element) -> None: + self._visit_sig_parameter(node, r'\sphinxtypeparam{') + + def depart_desc_type_parameter(self, node: Element) -> None: + self._depart_sig_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]) diff --git a/sphinx/writers/manpage.py b/sphinx/writers/manpage.py index 1e57f48addc..d4fe0a91c25 100644 --- a/sphinx/writers/manpage.py +++ b/sphinx/writers/manpage.py @@ -190,6 +190,13 @@ def visit_desc_parameterlist(self, node: Element) -> None: def depart_desc_parameterlist(self, node: Element) -> None: self.body.append(')') + def visit_desc_type_parameter_list(self, node: Element) -> None: + self.body.append('[') + self.first_param = 1 + + def depart_desc_type_parameter_list(self, node: Element) -> None: + self.body.append(']') + def visit_desc_parameter(self, node: Element) -> None: if not self.first_param: self.body.append(', ') @@ -199,6 +206,12 @@ def visit_desc_parameter(self, node: Element) -> None: def depart_desc_parameter(self, node: Element) -> None: pass + def visit_desc_type_parameter(self, node: Element) -> None: + self.visit_desc_parameter(node) + + def depart_desc_type_parameter(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 927e74f3487..267c9aa47cf 100644 --- a/sphinx/writers/texinfo.py +++ b/sphinx/writers/texinfo.py @@ -1468,6 +1468,13 @@ def visit_desc_parameterlist(self, node: Element) -> None: def depart_desc_parameterlist(self, node: Element) -> None: self.body.append(')') + def visit_desc_type_parameter_list(self, node: Element) -> None: + self.body.append(' [') + self.first_param = 1 + + def depart_desc_type_parameter_list(self, node: Element) -> None: + self.body.append(']') + def visit_desc_parameter(self, node: Element) -> None: if not self.first_param: self.body.append(', ') @@ -1479,6 +1486,9 @@ def visit_desc_parameter(self, node: Element) -> None: self.body.append(text) raise nodes.SkipNode + def visit_desc_type_parameter(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 7efb77abe34..8614ee25ee5 100644 --- a/sphinx/writers/text.py +++ b/sphinx/writers/text.py @@ -400,6 +400,13 @@ def __init__(self, document: nodes.document, builder: TextBuilder) -> None: self.lineblocklevel = 0 self.table: Table + self.context: list[str] = [] + """Heterogeneous stack. + + Used by visit_* and depart_* functions in conjunction with the tree + traversal. Make sure that the pops correspond to the pushes. + """ + def add_text(self, text: str) -> None: self.states[-1].append((-1, text)) @@ -601,24 +608,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('(') + 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(')') + self._depart_sig_parameter_list(node) + + def visit_desc_type_parameter_list(self, node: Element) -> None: + self._visit_sig_parameter_list(node, addnodes.desc_type_parameter, '[', ']') + + def depart_desc_type_parameter_list(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 @@ -654,6 +685,9 @@ def visit_desc_parameter(self, node: Element) -> None: self.param_group_index += 1 raise nodes.SkipNode + def visit_desc_type_parameter(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]) diff --git a/tests/test_domain_py.py b/tests/test_domain_py.py index 2b84f01c00d..8a3e378b103 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,8 @@ desc_sig_punctuation, desc_sig_space, desc_signature, + desc_type_parameter, + desc_type_parameter_list, pending_xref, ) from sphinx.domains import IndexEntry @@ -45,7 +49,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, tp_list, name, arglist, retann = m.groups() signode = addnodes.desc_signature(sig, '') _pseudo_parse_arglist(signode, arglist) return signode.astext() @@ -1840,3 +1844,280 @@ 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_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_type_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_type_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_type_parameter, ( + [desc_sig_name, 'A'], + [desc_sig_punctuation, ':'], + desc_sig_space, + [desc_sig_name, ([pending_xref, 'int | Annotated[int, ctype("char")]'])], + )], + [desc_type_parameter, ( + [desc_sig_operator, '*'], + [desc_sig_name, 'V'], + )], + [desc_type_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_type_parameter_list, ( + [desc_type_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_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]'])], + )], + [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,\ + K = list,\ + 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_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_type_parameter, ( + [desc_sig_name, 'J'], + [desc_sig_punctuation, ':'], + desc_sig_space, + [desc_sig_name, ([pending_xref, 'int'])], + )], + # K = list + [desc_type_parameter, ( + [desc_sig_name, 'K'], + desc_sig_space, + [desc_sig_operator, '='], + desc_sig_space, + [nodes.inline, 'list'], + )], + # S: str = str + [desc_type_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_type_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_type_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_type_parameter, ( + [desc_sig_operator, '*'], + [desc_sig_name, 'V'], + desc_sig_space, + [desc_sig_operator, '='], + desc_sig_space, + [nodes.inline, '*tuple[*Ts, bool]'], + )], + [desc_type_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, ()], + )], + )) + + +@pytest.mark.parametrize('tp_list,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, 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('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, 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('tp_list,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, tp_list, tptext): + text = f'.. py:function:: f{tp_list}()' + doctree = restructuredtext.parse(app, text) + assert doctree.astext() == f'\n\nf{tptext}()\n\n'