Skip to content

Commit

Permalink
Add partial support for PEP 695 syntax (sphinx-doc#11438)
Browse files Browse the repository at this point in the history
  • Loading branch information
picnixz committed May 28, 2023
1 parent d3c91f9 commit 2de93dc
Show file tree
Hide file tree
Showing 8 changed files with 148 additions and 13 deletions.
3 changes: 3 additions & 0 deletions CHANGES
Expand Up @@ -18,6 +18,9 @@ Deprecated
Features added
--------------

* #11438: Add support to the :rst:dir:`py:class` and :rst:dir:`py:function`
directives for PEP 695 (generic classes and functions declarations).
Patch by Bénédikt Tran.
* #11415: Add a checksum to JavaScript and CSS asset URIs included within
generated HTML, using the CRC32 algorithm.
* :meth:`~sphinx.application.Sphinx.require_sphinx` now allows the version
Expand Down
11 changes: 10 additions & 1 deletion sphinx/addnodes.py
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
54 changes: 52 additions & 2 deletions sphinx/domains/python.py
Expand Up @@ -39,10 +39,11 @@
logger = logging.getLogger(__name__)


# REs for Python signatures
# REs for Python signatures (supports PEP 695)
py_sig_re = re.compile(
r'''^ ([\w.]*\.)? # class name(s)
(\w+) \s* # thing name
(?: \[\s*(.*)\s*])? # optional: generics (PEP 695)
(?: \(\s*(.*)\s*\) # optional: arguments
(?:\s* -> \s* (.*))? # return annotation
)? $ # and nothing more
Expand Down Expand Up @@ -257,6 +258,48 @@ def _unparse_pep_604_annotation(node: ast.Subscript) -> list[Node]:
return [type_to_xref(annotation, env)]


def _parse_tplist(
tplist: str, env: BuildEnvironment | None = None,
multi_line_parameter_list: bool = False,
) -> addnodes.desc_tparameterlist:
"""Parse a list of type parameters according to PEP 695."""
tparams = addnodes.desc_tparameterlist(tplist)
tparams['multi_line_parameter_list'] = multi_line_parameter_list
sig = signature_from_str('(%s)' % tplist)
# formal parameter names are interpreted as type parameter names and
# type annotations are interpreted as type parameter bounds
for tparam in sig.parameters.values():
node = addnodes.desc_parameter()
if tparam.kind == tparam.VAR_POSITIONAL:
node += addnodes.desc_sig_operator('', '*')
node += addnodes.desc_sig_name('', tparam.name)
elif tparam.kind == tparam.VAR_KEYWORD:
node += addnodes.desc_sig_operator('', '**')
node += addnodes.desc_sig_name('', tparam.name)
else:
node += addnodes.desc_sig_name('', tparam.name)
if tparam.annotation is not tparam.empty:
type_bound = _parse_annotation(tparam.annotation, env)
if not type_bound:
continue

node += addnodes.desc_sig_punctuation('', ':')
node += addnodes.desc_sig_space()

type_bound_expr = addnodes.desc_sig_name('', '', *type_bound) # type: ignore

# add delimiters around type bounds written as e.g., "(T1, T2)"
if tparam.annotation.startswith('(') and tparam.annotation.endswith(')'):
node += addnodes.desc_sig_punctuation('', '(')
node += type_bound_expr
node += addnodes.desc_sig_punctuation('', ')')
else:
node += type_bound_expr

tparams += node
return tparams


def _parse_arglist(
arglist: str, env: BuildEnvironment | None = None, multi_line_parameter_list: bool = False,
) -> addnodes.desc_parameterlist:
Expand Down Expand Up @@ -514,7 +557,7 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]
m = py_sig_re.match(sig)
if m is None:
raise ValueError
prefix, name, arglist, retann = m.groups()
prefix, name, tplist, arglist, retann = m.groups()

# determine module and class name (if applicable), as well as full name
modname = self.options.get('module', self.env.ref_context.get('py:module'))
Expand Down Expand Up @@ -570,6 +613,13 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]
signode += addnodes.desc_addname(nodetext, nodetext)

signode += addnodes.desc_name(name, name)

if tplist:
try:
signode += _parse_tplist(tplist, self.env, multi_line_parameter_list)
except SyntaxError:
pass

if arglist:
try:
signode += _parse_arglist(arglist, self.env, multi_line_parameter_list)
Expand Down
4 changes: 2 additions & 2 deletions sphinx/writers/html5.py
Expand Up @@ -149,7 +149,7 @@ def depart_desc_returns(self, node: Element) -> None:
self.body.append('</span></span>')

def visit_desc_parameterlist(self, node: Element) -> None:
self.body.append('<span class="sig-paren">(</span>')
self.body.append(f'<span class="sig-paren">{node.list_left_delim}</span>')
self.is_first_param = True
self.optional_param_level = 0
self.params_left_at_level = 0
Expand All @@ -170,7 +170,7 @@ def visit_desc_parameterlist(self, node: Element) -> None:
def depart_desc_parameterlist(self, node: Element) -> None:
if node.get('multi_line_parameter_list'):
self.body.append('</dl>\n\n')
self.body.append('<span class="sig-paren">)</span>')
self.body.append(f'<span class="sig-paren">{node.list_right_delim}</span>')

# If required parameters are still to come, then put the comma after
# the parameter. Otherwise, put the comma before. This ensures that
Expand Down
4 changes: 2 additions & 2 deletions sphinx/writers/manpage.py
Expand Up @@ -184,11 +184,11 @@ def depart_desc_returns(self, node: Element) -> None:
pass

def visit_desc_parameterlist(self, node: Element) -> None:
self.body.append('(')
self.body.append(node.list_left_delim)
self.first_param = 1

def depart_desc_parameterlist(self, node: Element) -> None:
self.body.append(')')
self.body.append(node.list_right_delim)

def visit_desc_parameter(self, node: Element) -> None:
if not self.first_param:
Expand Down
4 changes: 2 additions & 2 deletions sphinx/writers/texinfo.py
Expand Up @@ -1462,11 +1462,11 @@ def depart_desc_returns(self, node: Element) -> None:
pass

def visit_desc_parameterlist(self, node: Element) -> None:
self.body.append(' (')
self.body.append(f' {node.list_left_delim}')
self.first_param = 1

def depart_desc_parameterlist(self, node: Element) -> None:
self.body.append(')')
self.body.append(node.list_right_delim)

def visit_desc_parameter(self, node: Element) -> None:
if not self.first_param:
Expand Down
4 changes: 2 additions & 2 deletions sphinx/writers/text.py
Expand Up @@ -593,7 +593,7 @@ def depart_desc_returns(self, node: Element) -> None:
pass

def visit_desc_parameterlist(self, node: Element) -> None:
self.add_text('(')
self.add_text(node.list_left_delim)
self.is_first_param = True
self.optional_param_level = 0
self.params_left_at_level = 0
Expand All @@ -609,7 +609,7 @@ def visit_desc_parameterlist(self, node: Element) -> None:
self.param_separator = self.param_separator.rstrip()

def depart_desc_parameterlist(self, node: Element) -> None:
self.add_text(')')
self.add_text(node.list_right_delim)

def visit_desc_parameter(self, node: Element) -> None:
on_separate_line = self.multi_line_parameter_list
Expand Down
77 changes: 75 additions & 2 deletions tests/test_domain_py.py
Expand Up @@ -17,6 +17,7 @@
desc_optional,
desc_parameter,
desc_parameterlist,
desc_tparameterlist,
desc_returns,
desc_sig_keyword,
desc_sig_literal_number,
Expand Down Expand Up @@ -45,7 +46,7 @@ def parse(sig):
m = py_sig_re.match(sig)
if m is None:
raise ValueError
name_prefix, name, arglist, retann = m.groups()
name_prefix, generics, name, arglist, retann = m.groups()
signode = addnodes.desc_signature(sig, '')
_pseudo_parse_arglist(signode, arglist)
return signode.astext()
Expand Down Expand Up @@ -1776,7 +1777,7 @@ def test_module_content_line_number(app):


@pytest.mark.sphinx(freshenv=True, confoverrides={'python_display_short_literal_types': True})
def test_short_literal_types(app):
def test_short_literal_types(app, status):
text = """\
.. py:function:: literal_ints(x: Literal[1, 2, 3] = 1) -> None
.. py:function:: literal_union(x: Union[Literal["a"], Literal["b"], Literal["c"]]) -> None
Expand Down Expand Up @@ -1840,3 +1841,75 @@ def test_short_literal_types(app):
[desc_content, ()],
)],
))

def test_function_pep_695(app):
text = """.. py:function:: func[T: int, U: (int, str), *V, **P]"""
doctree = restructuredtext.parse(app, text)
assert_node(doctree, (
addnodes.index,
[desc, (
[desc_signature, (
[desc_name, 'func'],
[desc_tparameterlist, (
[desc_parameter, (
[desc_sig_name, 'T'],
[desc_sig_punctuation, ':'],
desc_sig_space,
[desc_sig_name, ([pending_xref, 'int'])],
)],
[desc_parameter, (
[desc_sig_name, 'U'],
[desc_sig_punctuation, ':'],
desc_sig_space,
[desc_sig_punctuation, '('],
[desc_sig_name, (
[pending_xref, 'int'],
[desc_sig_punctuation, ','],
desc_sig_space,
[pending_xref, 'str']
)],
[desc_sig_punctuation, ')'],
)],
[desc_parameter, (
[desc_sig_operator, '*'],
[desc_sig_name, 'V']
)],
[desc_parameter, (
[desc_sig_operator, '**'],
[desc_sig_name, 'P']
)]
)],
[desc_parameterlist, ()]
)],
[desc_content, ()])
]
))

def test_class_def_pep_695(app):
# type checkers should reject this but it does not raise a compilation error
text = """.. py:class:: Class[S: Sequence[T], T]"""
doctree = restructuredtext.parse(app, text)
assert_node(doctree, (
addnodes.index,
[desc, (
[desc_signature, (
[desc_annotation, ('class', desc_sig_space)],
[desc_name, 'Class'],
[desc_tparameterlist, (
[desc_parameter, (
[desc_sig_name, 'S'],
[desc_sig_punctuation, ':'],
desc_sig_space,
[desc_sig_name, (
[pending_xref, 'Sequence'],
[desc_sig_punctuation, '['],
[pending_xref, 'T'],
[desc_sig_punctuation, ']'],
)],
)],
[desc_parameter, ([desc_sig_name, 'T'])],
)]
)],
[desc_content, ()])
]
))

0 comments on commit 2de93dc

Please sign in to comment.