You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
426 lines
17 KiB
426 lines
17 KiB
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import re
|
|
from typing import TYPE_CHECKING, ClassVar
|
|
|
|
from docutils import nodes
|
|
from docutils.parsers.rst import directives
|
|
|
|
from sphinx import addnodes
|
|
from sphinx.addnodes import desc_signature, pending_xref, pending_xref_condition
|
|
from sphinx.directives import ObjectDescription
|
|
from sphinx.domains.python._annotations import (
|
|
_parse_annotation,
|
|
_parse_arglist,
|
|
_parse_type_list,
|
|
_pseudo_parse_arglist,
|
|
parse_reftarget,
|
|
)
|
|
from sphinx.locale import _
|
|
from sphinx.util import logging
|
|
from sphinx.util.docfields import Field, GroupedField, TypedField
|
|
from sphinx.util.nodes import (
|
|
make_id,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
from docutils.nodes import Node
|
|
from docutils.parsers.rst.states import Inliner
|
|
|
|
from sphinx.environment import BuildEnvironment
|
|
from sphinx.util.typing import OptionSpec, TextlikeNode
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# REs for Python signatures
|
|
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
|
|
''', re.VERBOSE)
|
|
|
|
|
|
# This override allows our inline type specifiers to behave like :class: link
|
|
# when it comes to handling "." and "~" prefixes.
|
|
class PyXrefMixin:
|
|
def make_xref(
|
|
self,
|
|
rolename: str,
|
|
domain: str,
|
|
target: str,
|
|
innernode: type[TextlikeNode] = nodes.emphasis,
|
|
contnode: Node | None = None,
|
|
env: BuildEnvironment | None = None,
|
|
inliner: Inliner | None = None,
|
|
location: Node | None = None,
|
|
) -> Node:
|
|
# we use inliner=None to make sure we get the old behaviour with a single
|
|
# pending_xref node
|
|
result = super().make_xref(rolename, domain, target, # type: ignore[misc]
|
|
innernode, contnode,
|
|
env, inliner=None, location=None)
|
|
if isinstance(result, pending_xref):
|
|
assert env is not None
|
|
result['refspecific'] = True
|
|
result['py:module'] = env.ref_context.get('py:module')
|
|
result['py:class'] = env.ref_context.get('py:class')
|
|
|
|
reftype, reftarget, reftitle, _ = parse_reftarget(target)
|
|
if reftarget != reftitle:
|
|
result['reftype'] = reftype
|
|
result['reftarget'] = reftarget
|
|
|
|
result.clear()
|
|
result += innernode(reftitle, reftitle) # type: ignore[call-arg]
|
|
elif env.config.python_use_unqualified_type_names:
|
|
children = result.children
|
|
result.clear()
|
|
|
|
shortname = target.split('.')[-1]
|
|
textnode = innernode('', shortname) # type: ignore[call-arg]
|
|
contnodes = [pending_xref_condition('', '', textnode, condition='resolved'),
|
|
pending_xref_condition('', '', *children, condition='*')]
|
|
result.extend(contnodes)
|
|
|
|
return result
|
|
|
|
def make_xrefs(
|
|
self,
|
|
rolename: str,
|
|
domain: str,
|
|
target: str,
|
|
innernode: type[TextlikeNode] = nodes.emphasis,
|
|
contnode: Node | None = None,
|
|
env: BuildEnvironment | None = None,
|
|
inliner: Inliner | None = None,
|
|
location: Node | None = None,
|
|
) -> list[Node]:
|
|
delims = r'(\s*[\[\]\(\),](?:\s*o[rf]\s)?\s*|\s+o[rf]\s+|\s*\|\s*|\.\.\.)'
|
|
delims_re = re.compile(delims)
|
|
sub_targets = re.split(delims, target)
|
|
|
|
split_contnode = bool(contnode and contnode.astext() == target)
|
|
|
|
in_literal = False
|
|
results = []
|
|
for sub_target in filter(None, sub_targets):
|
|
if split_contnode:
|
|
contnode = nodes.Text(sub_target)
|
|
|
|
if in_literal or delims_re.match(sub_target):
|
|
results.append(contnode or innernode(sub_target, sub_target)) # type: ignore[call-arg]
|
|
else:
|
|
results.append(self.make_xref(rolename, domain, sub_target,
|
|
innernode, contnode, env, inliner, location))
|
|
|
|
if sub_target in ('Literal', 'typing.Literal', '~typing.Literal'):
|
|
in_literal = True
|
|
|
|
return results
|
|
|
|
|
|
class PyField(PyXrefMixin, Field):
|
|
pass
|
|
|
|
|
|
class PyGroupedField(PyXrefMixin, GroupedField):
|
|
pass
|
|
|
|
|
|
class PyTypedField(PyXrefMixin, TypedField):
|
|
pass
|
|
|
|
|
|
class PyObject(ObjectDescription[tuple[str, str]]):
|
|
"""
|
|
Description of a general Python object.
|
|
|
|
:cvar allow_nesting: Class is an object that allows for nested namespaces
|
|
:vartype allow_nesting: bool
|
|
"""
|
|
|
|
option_spec: ClassVar[OptionSpec] = {
|
|
'no-index': directives.flag,
|
|
'no-index-entry': directives.flag,
|
|
'no-contents-entry': directives.flag,
|
|
'no-typesetting': directives.flag,
|
|
'noindex': directives.flag,
|
|
'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,
|
|
}
|
|
|
|
doc_field_types = [
|
|
PyTypedField('parameter', label=_('Parameters'),
|
|
names=('param', 'parameter', 'arg', 'argument',
|
|
'keyword', 'kwarg', 'kwparam'),
|
|
typerolename='class', typenames=('paramtype', 'type'),
|
|
can_collapse=True),
|
|
PyTypedField('variable', label=_('Variables'),
|
|
names=('var', 'ivar', 'cvar'),
|
|
typerolename='class', typenames=('vartype',),
|
|
can_collapse=True),
|
|
PyGroupedField('exceptions', label=_('Raises'), rolename='exc',
|
|
names=('raises', 'raise', 'exception', 'except'),
|
|
can_collapse=True),
|
|
Field('returnvalue', label=_('Returns'), has_arg=False,
|
|
names=('returns', 'return')),
|
|
PyField('returntype', label=_('Return type'), has_arg=False,
|
|
names=('rtype',), bodyrolename='class'),
|
|
]
|
|
|
|
allow_nesting = False
|
|
|
|
def get_signature_prefix(self, sig: str) -> list[nodes.Node]:
|
|
"""May return a prefix to put before the object name in the
|
|
signature.
|
|
"""
|
|
return []
|
|
|
|
def needs_arglist(self) -> bool:
|
|
"""May return true if an empty argument list is to be generated even if
|
|
the document contains none.
|
|
"""
|
|
return False
|
|
|
|
def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]:
|
|
"""Transform a Python signature into RST nodes.
|
|
|
|
Return (fully qualified name of the thing, classname if any).
|
|
|
|
If inside a class, the current class name is handled intelligently:
|
|
* it is stripped from the displayed name if present
|
|
* it is added to the full name (return value) if not present
|
|
"""
|
|
m = py_sig_re.match(sig)
|
|
if m is None:
|
|
raise ValueError
|
|
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'))
|
|
classname = self.env.ref_context.get('py:class')
|
|
if classname:
|
|
add_module = False
|
|
if prefix and (prefix == classname or
|
|
prefix.startswith(classname + ".")):
|
|
fullname = prefix + name
|
|
# class name is given again in the signature
|
|
prefix = prefix[len(classname):].lstrip('.')
|
|
elif prefix:
|
|
# class name is given in the signature, but different
|
|
# (shouldn't happen)
|
|
fullname = classname + '.' + prefix + name
|
|
else:
|
|
# class name is not given in the signature
|
|
fullname = classname + '.' + name
|
|
else:
|
|
add_module = True
|
|
if prefix:
|
|
classname = prefix.rstrip('.')
|
|
fullname = prefix + name
|
|
else:
|
|
classname = ''
|
|
fullname = name
|
|
|
|
signode['module'] = modname
|
|
signode['class'] = classname
|
|
signode['fullname'] = fullname
|
|
|
|
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 (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)
|
|
if sig_prefix:
|
|
if type(sig_prefix) is str:
|
|
msg = ("Python directive method get_signature_prefix()"
|
|
" must return a list of nodes."
|
|
f" Return value was '{sig_prefix}'.")
|
|
raise TypeError(msg)
|
|
signode += addnodes.desc_annotation(str(sig_prefix), '', *sig_prefix)
|
|
|
|
if prefix:
|
|
signode += addnodes.desc_addname(prefix, prefix)
|
|
elif modname and add_module and self.env.config.add_module_names:
|
|
nodetext = modname + '.'
|
|
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
|
|
# (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:
|
|
# 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)
|
|
else:
|
|
if self.needs_arglist():
|
|
# for callables, add an empty parameter list
|
|
signode += addnodes.desc_parameterlist()
|
|
|
|
if retann:
|
|
children = _parse_annotation(retann, self.env)
|
|
signode += addnodes.desc_returns(retann, '', *children)
|
|
|
|
anno = self.options.get('annotation')
|
|
if anno:
|
|
signode += addnodes.desc_annotation(' ' + anno, '',
|
|
addnodes.desc_sig_space(),
|
|
nodes.Text(anno))
|
|
|
|
return fullname, prefix
|
|
|
|
def _object_hierarchy_parts(self, sig_node: desc_signature) -> tuple[str, ...]:
|
|
if 'fullname' not in sig_node:
|
|
return ()
|
|
modname = sig_node.get('module')
|
|
fullname = sig_node['fullname']
|
|
|
|
if modname:
|
|
return (modname, *fullname.split('.'))
|
|
else:
|
|
return tuple(fullname.split('.'))
|
|
|
|
def get_index_text(self, modname: str, name: tuple[str, str]) -> str:
|
|
"""Return the text for the index entry of the object."""
|
|
msg = 'must be implemented in subclasses'
|
|
raise NotImplementedError(msg)
|
|
|
|
def add_target_and_index(self, name_cls: tuple[str, str], sig: str,
|
|
signode: desc_signature) -> None:
|
|
modname = self.options.get('module', self.env.ref_context.get('py:module'))
|
|
fullname = (modname + '.' if modname else '') + name_cls[0]
|
|
node_id = make_id(self.env, self.state.document, '', fullname)
|
|
signode['ids'].append(node_id)
|
|
self.state.document.note_explicit_target(signode)
|
|
|
|
domain = self.env.domains['py']
|
|
domain.note_object(fullname, self.objtype, node_id, location=signode)
|
|
|
|
canonical_name = self.options.get('canonical')
|
|
if canonical_name:
|
|
domain.note_object(canonical_name, self.objtype, node_id, aliased=True,
|
|
location=signode)
|
|
|
|
if 'no-index-entry' not in self.options:
|
|
indextext = self.get_index_text(modname, name_cls)
|
|
if indextext:
|
|
self.indexnode['entries'].append(('single', indextext, node_id, '', None))
|
|
|
|
def before_content(self) -> None:
|
|
"""Handle object nesting before content
|
|
|
|
:py:class:`PyObject` represents Python language constructs. For
|
|
constructs that are nestable, such as a Python classes, this method will
|
|
build up a stack of the nesting hierarchy so that it can be later
|
|
de-nested correctly, in :py:meth:`after_content`.
|
|
|
|
For constructs that aren't nestable, the stack is bypassed, and instead
|
|
only the most recent object is tracked. This object prefix name will be
|
|
removed with :py:meth:`after_content`.
|
|
"""
|
|
prefix = None
|
|
if self.names:
|
|
# fullname and name_prefix come from the `handle_signature` method.
|
|
# fullname represents the full object name that is constructed using
|
|
# object nesting and explicit prefixes. `name_prefix` is the
|
|
# explicit prefix given in a signature
|
|
(fullname, name_prefix) = self.names[-1]
|
|
if self.allow_nesting:
|
|
prefix = fullname
|
|
elif name_prefix:
|
|
prefix = name_prefix.strip('.')
|
|
if prefix:
|
|
self.env.ref_context['py:class'] = prefix
|
|
if self.allow_nesting:
|
|
classes = self.env.ref_context.setdefault('py:classes', [])
|
|
classes.append(prefix)
|
|
if 'module' in self.options:
|
|
modules = self.env.ref_context.setdefault('py:modules', [])
|
|
modules.append(self.env.ref_context.get('py:module'))
|
|
self.env.ref_context['py:module'] = self.options['module']
|
|
|
|
def after_content(self) -> None:
|
|
"""Handle object de-nesting after content
|
|
|
|
If this class is a nestable object, removing the last nested class prefix
|
|
ends further nesting in the object.
|
|
|
|
If this class is not a nestable object, the list of classes should not
|
|
be altered as we didn't affect the nesting levels in
|
|
:py:meth:`before_content`.
|
|
"""
|
|
classes = self.env.ref_context.setdefault('py:classes', [])
|
|
if self.allow_nesting:
|
|
with contextlib.suppress(IndexError):
|
|
classes.pop()
|
|
|
|
self.env.ref_context['py:class'] = (classes[-1] if len(classes) > 0
|
|
else None)
|
|
if 'module' in self.options:
|
|
modules = self.env.ref_context.setdefault('py:modules', [])
|
|
if modules:
|
|
self.env.ref_context['py:module'] = modules.pop()
|
|
else:
|
|
self.env.ref_context.pop('py:module')
|
|
|
|
def _toc_entry_name(self, sig_node: desc_signature) -> str:
|
|
if not sig_node.get('_toc_parts'):
|
|
return ''
|
|
|
|
config = self.env.app.config
|
|
objtype = sig_node.parent.get('objtype')
|
|
if config.add_function_parentheses and objtype in {'function', 'method'}:
|
|
parens = '()'
|
|
else:
|
|
parens = ''
|
|
*parents, name = sig_node['_toc_parts']
|
|
if config.toc_object_entries_show_parents == 'domain':
|
|
return sig_node.get('fullname', name) + parens
|
|
if config.toc_object_entries_show_parents == 'hide':
|
|
return name + parens
|
|
if config.toc_object_entries_show_parents == 'all':
|
|
return '.'.join([*parents, name + parens])
|
|
return ''
|
|
|