diff --git a/doc/conf.py b/doc/conf.py index 28dbdb58c46..ccb1adc2c91 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -243,6 +243,7 @@ ('py:exc', 'sphinx.environment.NoUri'), ('py:func', 'setup'), ('py:func', 'sphinx.util.nodes.nested_parse_with_titles'), + ('py:class', 'IndexExpression'), # doc/usage/domains/python.rst # Error in sphinxcontrib.websupport.core::WebSupport.add_comment ('py:meth', 'get_comments'), ('py:mod', 'autodoc'), diff --git a/doc/usage/domains/python.rst b/doc/usage/domains/python.rst index 8d47a6e0221..9b5aba8ee87 100644 --- a/doc/usage/domains/python.rst +++ b/doc/usage/domains/python.rst @@ -120,6 +120,45 @@ The following directives are provided for module and class contents: .. versionadded:: 7.1 + .. rst:directive:option:: subscript + :type: no value + + Indicate that the function's parameters should be displayed + within square brackets instead of parentheses, in order to + indicate that it must be invoked as a *subscript function*.. + + For example: + + .. code-block:: rst + + .. py:function:: numpy.s_(indexing_expr: tuple[int, ...]) -> IndexExpression + :subscript: + + Creates an index expression object. + + This is rendered as: + + .. py:method:: numpy.s_(indexing_expr: tuple[int, ...]) -> IndexExpression + :no-contents-entry: + :no-index-entry: + :subscript: + + Creates an index expression object. + + A *subscript function* can be implemented as follows: + + .. code-block:: python + + class _S: + @staticmethod + def __getitem__(indexing_expr: tuple[int, ...]) -> IndexExpression: + ... + + s_ = _S() + + .. versionadded:: 8.3 + + .. rst:directive:: .. py:data:: name @@ -516,6 +555,46 @@ The following directives are provided for module and class contents: .. versionadded:: 2.1 + .. rst:directive:option:: subscript + :type: no value + + Indicate that the method's parameters should be displayed within + square brackets instead of parentheses, in order to indicate + that it must be invoked as a *subscript method*. + + For example: + + .. code-block:: rst + + .. py:method:: Array.vindex(self, indexing_expr: tuple[int, ...]) -> list[int] + :subscript: + + Index the array using *vindex* semantics. + + This is rendered as: + + .. py:method:: Array.vindex(self, indexing_expr: tuple[int, ...]) -> list[int] + :no-contents-entry: + :no-index-entry: + :subscript: + + Index the array using *vindex* semantics. + + A *subscript method* can be implemented as follows: + + .. code-block:: python + + class Array: + class _Vindex: + def __init__(self, parent: Array): + self.parent = parent + def __getitem__(self, indexing_expr: tuple[int, ...]) -> list[int]: + ... + @property + def vindex(self) -> Array._Vindex: + return Array._Vindex(self) + + .. versionadded:: 8.3 .. rst:directive:: .. py:staticmethod:: name(parameters) .. py:staticmethod:: name[type parameters](parameters) diff --git a/sphinx/addnodes.py b/sphinx/addnodes.py index 4ce85dabcf2..114fa6b4b92 100644 --- a/sphinx/addnodes.py +++ b/sphinx/addnodes.py @@ -246,12 +246,20 @@ class desc_parameterlist(nodes.Part, nodes.Inline, nodes.FixedTextElement): In that case each parameter will then be written on its own, indented line. A trailing comma will be added on the last line if ``multi_line_trailing_comma`` is True. + + By default, it is surrounded by parentheses ``("(", ")")``, but this may be + overridden by specifying a ``brackets`` attribute. """ child_text_separator = ', ' + @property + def brackets(self) -> tuple[str, str]: + return self.get('brackets', ('(', ')')) + def astext(self) -> str: - return f'({super().astext()})' + open_punct, close_punct = self.brackets + return f'{open_punct}{super().astext()}{close_punct}' class desc_type_parameter_list(nodes.Part, nodes.Inline, nodes.FixedTextElement): diff --git a/sphinx/domains/python/__init__.py b/sphinx/domains/python/__init__.py index 3cca270abf6..1e35ae77e5a 100644 --- a/sphinx/domains/python/__init__.py +++ b/sphinx/domains/python/__init__.py @@ -88,6 +88,7 @@ class PyFunction(PyObject): option_spec: ClassVar[OptionSpec] = PyObject.option_spec.copy() option_spec.update({ 'async': directives.flag, + 'subscript': directives.flag, }) def get_signature_prefix(self, sig: str) -> Sequence[nodes.Node]: @@ -235,6 +236,7 @@ class PyMethod(PyObject): 'classmethod': directives.flag, 'final': directives.flag, 'staticmethod': directives.flag, + 'subscript': directives.flag, }) def needs_arglist(self) -> bool: @@ -293,6 +295,10 @@ class PyClassMethod(PyMethod): """Description of a classmethod.""" option_spec: ClassVar[OptionSpec] = PyObject.option_spec.copy() + option_spec.update({ + 'async': directives.flag, + 'subscript': directives.flag, + }) def run(self) -> list[Node]: self.name = 'py:method' @@ -305,6 +311,10 @@ class PyStaticMethod(PyMethod): """Description of a staticmethod.""" option_spec: ClassVar[OptionSpec] = PyObject.option_spec.copy() + option_spec.update({ + 'async': directives.flag, + 'subscript': directives.flag, + }) def run(self) -> list[Node]: self.name = 'py:method' diff --git a/sphinx/domains/python/_object.py b/sphinx/domains/python/_object.py index 6a0f0ff7334..0ca327045d4 100644 --- a/sphinx/domains/python/_object.py +++ b/sphinx/domains/python/_object.py @@ -384,6 +384,10 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str] # for callables, add an empty parameter list signode += addnodes.desc_parameterlist() + if 'subscript' in self.options: + for node in signode.findall(addnodes.desc_parameterlist): + node['brackets'] = '[', ']' + if retann: children = _parse_annotation(retann, self.env) signode += addnodes.desc_returns(retann, '', *children) diff --git a/sphinx/texinputs/sphinxlatexobjects.sty b/sphinx/texinputs/sphinxlatexobjects.sty index 2a05dd6de8c..4f9f66282e1 100644 --- a/sphinx/texinputs/sphinxlatexobjects.sty +++ b/sphinx/texinputs/sphinxlatexobjects.sty @@ -124,8 +124,8 @@ \setlength\sphinxsignaturelistskip{0pt} \newcommand{\pysigtypelistopen}{\hskip\sphinxsignaturelistskip\sphinxcode{[}} \newcommand{\pysigtypelistclose}{\sphinxcode{]}} -\newcommand{\pysigarglistopen}{\hskip\sphinxsignaturelistskip\sphinxcode{(}} -\newcommand{\pysigarglistclose}{\sphinxcode{)}} +\newcommand{\pysigarglistopen}{\hskip\sphinxsignaturelistskip\sphinxcode{\pysigarglistopenpunct}} +\newcommand{\pysigarglistclose}{\sphinxcode{\pysigarglistclosepunct}} % % Use a \parbox to accommodate long argument list in signatures % LaTeX did not imagine that an \item label could need multi-line rendering diff --git a/sphinx/writers/html5.py b/sphinx/writers/html5.py index bbcd247e33c..3ab3d0ef7f4 100644 --- a/sphinx/writers/html5.py +++ b/sphinx/writers/html5.py @@ -186,7 +186,10 @@ def _depart_sig_parameter_list(self, node: Element) -> None: self.body.append(f'{sig_close_paren}') def visit_desc_parameterlist(self, node: Element) -> None: - self._visit_sig_parameter_list(node, addnodes.desc_parameter, '(', ')') + open_punct, close_punct = node.brackets # type: ignore[attr-defined] + self._visit_sig_parameter_list( + node, addnodes.desc_parameter, open_punct, close_punct + ) def depart_desc_parameterlist(self, node: Element) -> None: self._depart_sig_parameter_list(node) diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py index f204f585f6a..fd180db0624 100644 --- a/sphinx/writers/latex.py +++ b/sphinx/writers/latex.py @@ -816,6 +816,16 @@ def depart_desc(self, node: Element) -> None: else: self.body.append(CR + r'\end{fulllineitems}' + BLANKLINE) + def _define_parameterlist_brackets(self, open_punct: str, close_punct: str) -> None: + self.body.append( + r'\def\pysigarglistopenpunct{' + + self.escape(open_punct) + + '}' + + r'\def\pysigarglistclosepunct{' + + self.escape(close_punct) + + '}' + ) + def _visit_signature_line(self, node: Element) -> None: def next_sibling(e: Node) -> Node | None: try: @@ -837,6 +847,7 @@ def has_multi_line(e: Element) -> bool: if isinstance(arglist, addnodes.desc_parameterlist): # tp_list + arglist: \macro{name}{tp_list}{arglist}{retann} multi_arglist = has_multi_line(arglist) + self._define_parameterlist_brackets(*arglist.brackets) else: # orphan tp_list: \macro{name}{tp_list}{}{retann} # see: https://github.com/sphinx-doc/sphinx/issues/12543 @@ -867,6 +878,7 @@ def has_multi_line(e: Element) -> bool: break if isinstance(child, addnodes.desc_parameterlist): + self._define_parameterlist_brackets(*child.brackets) # arglist only: \macro{name}{arglist}{retann} if has_multi_line(child): self.body.append(CR + r'\pysigwithonelineperarg' + CR + '{') diff --git a/sphinx/writers/manpage.py b/sphinx/writers/manpage.py index 282cd0ed14c..f8bb93d712d 100644 --- a/sphinx/writers/manpage.py +++ b/sphinx/writers/manpage.py @@ -188,11 +188,13 @@ def depart_desc_returns(self, node: Element) -> None: pass def visit_desc_parameterlist(self, node: Element) -> None: - self.body.append('(') + open_punct, _ = node.brackets # type: ignore[attr-defined] + self.body.append(open_punct) self.first_param = 1 def depart_desc_parameterlist(self, node: Element) -> None: - self.body.append(')') + _, close_punct = node.brackets # type: ignore[attr-defined] + self.body.append(close_punct) def visit_desc_type_parameter_list(self, node: Element) -> None: self.body.append('[') diff --git a/sphinx/writers/texinfo.py b/sphinx/writers/texinfo.py index 0731f168733..5a1bc2036ba 100644 --- a/sphinx/writers/texinfo.py +++ b/sphinx/writers/texinfo.py @@ -1480,11 +1480,13 @@ def depart_desc_returns(self, node: Element) -> None: pass def visit_desc_parameterlist(self, node: Element) -> None: - self.body.append(' (') + open_punct, _ = node.brackets # type: ignore[attr-defined] + self.body.append(f' {open_punct}') self.first_param = 1 def depart_desc_parameterlist(self, node: Element) -> None: - self.body.append(')') + _, close_punct = node.brackets # type: ignore[attr-defined] + self.body.append(close_punct) def visit_desc_type_parameter_list(self, node: Element) -> None: self.body.append(' [') diff --git a/sphinx/writers/text.py b/sphinx/writers/text.py index 1f14781fc19..8a38632d373 100644 --- a/sphinx/writers/text.py +++ b/sphinx/writers/text.py @@ -659,7 +659,10 @@ def _depart_sig_parameter_list(self, node: Element) -> None: self.add_text(sig_close_paren) def visit_desc_parameterlist(self, node: Element) -> None: - self._visit_sig_parameter_list(node, addnodes.desc_parameter, '(', ')') + open_punct, close_punct = node.brackets # type: ignore[attr-defined] + self._visit_sig_parameter_list( + node, addnodes.desc_parameter, open_punct, close_punct + ) def depart_desc_parameterlist(self, node: Element) -> None: self._depart_sig_parameter_list(node) diff --git a/tests/test_domains/test_domain_py.py b/tests/test_domains/test_domain_py.py index 3ae41b7cd2e..50b699df14c 100644 --- a/tests/test_domains/test_domain_py.py +++ b/tests/test_domains/test_domain_py.py @@ -1819,3 +1819,12 @@ def test_pytype_canonical(app): doctree = restructuredtext.parse(app, text) assert not app.warning.getvalue() + + +def test_subscript_function(app): + text = """ +.. py:function:: f(x: int) -> bool + :subscript: +""" + doctree = restructuredtext.parse(app, text) + assert doctree.astext().strip() == 'f[x: int] -> bool'