diff --git a/sphinx/domains/python.py b/sphinx/domains/python.py new file mode 100644 index 00000000000..5afe2993a4f --- /dev/null +++ b/sphinx/domains/python.py @@ -0,0 +1,1299 @@ +""" + sphinx.domains.python + ~~~~~~~~~~~~~~~~~~~~~ + + The Python domain. + + :copyright: Copyright 2007-2021 by the Sphinx team, see AUTHORS. + :license: BSD, see LICENSE for details. +""" + +import builtins +import inspect +import re +import sys +import typing +import warnings +from inspect import Parameter +from typing import Any, Dict, Iterable, Iterator, List, NamedTuple, Tuple, Type, cast + +from docutils import nodes +from docutils.nodes import Element, Node +from docutils.parsers.rst import directives + +from sphinx import addnodes +from sphinx.addnodes import desc_signature, pending_xref +from sphinx.application import Sphinx +from sphinx.builders import Builder +from sphinx.deprecation import RemovedInSphinx50Warning +from sphinx.directives import ObjectDescription +from sphinx.domains import Domain, Index, IndexEntry, ObjType +from sphinx.environment import BuildEnvironment +from sphinx.locale import _, __ +from sphinx.pycode.ast import ast +from sphinx.pycode.ast import parse as ast_parse +from sphinx.roles import XRefRole +from sphinx.util import logging +from sphinx.util.docfields import Field, GroupedField, TypedField +from sphinx.util.docutils import SphinxDirective +from sphinx.util.inspect import signature_from_str +from sphinx.util.nodes import make_id, make_refnode +from sphinx.util.typing import 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: arguments + (?:\s* -> \s* (.*))? # return annotation + )? $ # and nothing more + ''', re.VERBOSE) + + +pairindextypes = { + 'module': _('module'), + 'keyword': _('keyword'), + 'operator': _('operator'), + 'object': _('object'), + 'exception': _('exception'), + 'statement': _('statement'), + 'builtin': _('built-in function'), +} + + +class ObjectEntry(NamedTuple): + docname: str + node_id: str + objtype: str + canonical: bool + + +class ModuleEntry(NamedTuple): + docname: str + node_id: str + synopsis: str + platform: str + deprecated: bool + + +def type_to_xref(text: str, env: BuildEnvironment = None) -> addnodes.pending_xref: + """Convert a type string to a cross reference node.""" + if text == 'None': + reftype = 'obj' + else: + reftype = 'class' + + if env: + kwargs = {'py:module': env.ref_context.get('py:module'), + 'py:class': env.ref_context.get('py:class')} + else: + kwargs = {} + + return pending_xref('', nodes.Text(text), + refdomain='py', reftype=reftype, reftarget=text, **kwargs) + + +def _parse_annotation(annotation: str, env: BuildEnvironment = None) -> List[Node]: + """Parse type annotation.""" + def unparse(node: ast.AST) -> List[Node]: + if isinstance(node, ast.Attribute): + return [nodes.Text("%s.%s" % (unparse(node.value)[0], node.attr))] + elif isinstance(node, ast.Constant): # type: ignore + if node.value is Ellipsis: + return [addnodes.desc_sig_punctuation('', "...")] + else: + return [nodes.Text(node.value)] + elif isinstance(node, ast.Expr): + return unparse(node.value) + elif isinstance(node, ast.Index): + return unparse(node.value) + elif isinstance(node, ast.List): + result = [addnodes.desc_sig_punctuation('', '[')] # type: List[Node] + for elem in node.elts: + result.extend(unparse(elem)) + result.append(addnodes.desc_sig_punctuation('', ', ')) + result.pop() + result.append(addnodes.desc_sig_punctuation('', ']')) + return result + elif isinstance(node, ast.Module): + return sum((unparse(e) for e in node.body), []) + elif isinstance(node, ast.Name): + return [nodes.Text(node.id)] + elif isinstance(node, ast.Subscript): + result = unparse(node.value) + result.append(addnodes.desc_sig_punctuation('', '[')) + result.extend(unparse(node.slice)) + result.append(addnodes.desc_sig_punctuation('', ']')) + return result + elif isinstance(node, ast.Tuple): + if node.elts: + result = [] + for elem in node.elts: + result.extend(unparse(elem)) + result.append(addnodes.desc_sig_punctuation('', ', ')) + result.pop() + else: + result = [addnodes.desc_sig_punctuation('', '('), + addnodes.desc_sig_punctuation('', ')')] + + return result + else: + if sys.version_info < (3, 8): + if isinstance(node, ast.Ellipsis): + return [addnodes.desc_sig_punctuation('', "...")] + elif isinstance(node, ast.NameConstant): + return [nodes.Text(node.value)] + + raise SyntaxError # unsupported syntax + + if env is None: + warnings.warn("The env parameter for _parse_annotation becomes required now.", + RemovedInSphinx50Warning, stacklevel=2) + + try: + tree = ast_parse(annotation) + result = unparse(tree) + for i, node in enumerate(result): + if isinstance(node, nodes.Text): + result[i] = type_to_xref(str(node), env) + return result + except SyntaxError: + return [type_to_xref(annotation, env)] + + +def _parse_arglist(arglist: str, env: BuildEnvironment = None) -> addnodes.desc_parameterlist: + """Parse a list of arguments using AST parser""" + params = addnodes.desc_parameterlist(arglist) + sig = signature_from_str('(%s)' % arglist) + last_kind = None + for param in sig.parameters.values(): + if param.kind != param.POSITIONAL_ONLY and last_kind == param.POSITIONAL_ONLY: + # PEP-570: Separator for Positional Only Parameter: / + params += addnodes.desc_parameter('', '', addnodes.desc_sig_operator('', '/')) + if param.kind == param.KEYWORD_ONLY and last_kind in (param.POSITIONAL_OR_KEYWORD, + param.POSITIONAL_ONLY, + None): + # PEP-3102: Separator for Keyword Only Parameter: * + params += addnodes.desc_parameter('', '', addnodes.desc_sig_operator('', '*')) + + node = addnodes.desc_parameter() + if param.kind == param.VAR_POSITIONAL: + node += addnodes.desc_sig_operator('', '*') + node += addnodes.desc_sig_name('', param.name) + elif param.kind == param.VAR_KEYWORD: + node += addnodes.desc_sig_operator('', '**') + node += addnodes.desc_sig_name('', param.name) + else: + node += addnodes.desc_sig_name('', param.name) + + if param.annotation is not param.empty: + children = _parse_annotation(param.annotation, env) + node += addnodes.desc_sig_punctuation('', ':') + node += nodes.Text(' ') + node += addnodes.desc_sig_name('', '', *children) # type: ignore + if param.default is not param.empty: + if param.annotation is not param.empty: + node += nodes.Text(' ') + node += addnodes.desc_sig_operator('', '=') + node += nodes.Text(' ') + else: + node += addnodes.desc_sig_operator('', '=') + node += nodes.inline('', param.default, classes=['default_value'], + support_smartquotes=False) + + params += node + last_kind = param.kind + + if last_kind == Parameter.POSITIONAL_ONLY: + # PEP-570: Separator for Positional Only Parameter: / + params += addnodes.desc_parameter('', '', addnodes.desc_sig_operator('', '/')) + + return params + + +def _pseudo_parse_arglist(signode: desc_signature, arglist: str) -> None: + """"Parse" a list of arguments separated by commas. + + Arguments can have "optional" annotations given by enclosing them in + brackets. Currently, this will split at any comma, even if it's inside a + string literal (e.g. default argument value). + """ + paramlist = addnodes.desc_parameterlist() + stack = [paramlist] # type: List[Element] + try: + for argument in arglist.split(','): + argument = argument.strip() + ends_open = ends_close = 0 + while argument.startswith('['): + stack.append(addnodes.desc_optional()) + stack[-2] += stack[-1] + argument = argument[1:].strip() + while argument.startswith(']'): + stack.pop() + argument = argument[1:].strip() + while argument.endswith(']') and not argument.endswith('[]'): + ends_close += 1 + argument = argument[:-1].strip() + while argument.endswith('['): + ends_open += 1 + argument = argument[:-1].strip() + if argument: + stack[-1] += addnodes.desc_parameter(argument, argument) + while ends_open: + stack.append(addnodes.desc_optional()) + stack[-2] += stack[-1] + ends_open -= 1 + while ends_close: + stack.pop() + ends_close -= 1 + if len(stack) != 1: + raise IndexError + except IndexError: + # if there are too few or too many elements on the stack, just give up + # and treat the whole argument list as one argument, discarding the + # already partially populated paramlist node + paramlist = addnodes.desc_parameterlist() + paramlist += addnodes.desc_parameter(arglist, arglist) + signode += paramlist + else: + signode += paramlist + + +# 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, env: BuildEnvironment = None) -> Node: + result = super().make_xref(rolename, domain, target, # type: ignore + innernode, contnode, env) + result['refspecific'] = True + result['py:module'] = env.ref_context.get('py:module') + result['py:class'] = env.ref_context.get('py:class') + if target.startswith(('.', '~')): + prefix, result['reftarget'] = target[0], target[1:] + if prefix == '.': + text = target[1:] + elif prefix == '~': + text = target.split('.')[-1] + for node in result.traverse(nodes.Text): + node.parent[node.parent.index(node)] = nodes.Text(text) + break + return result + + def make_xrefs(self, rolename: str, domain: str, target: str, + innernode: Type[TextlikeNode] = nodes.emphasis, + contnode: Node = None, env: BuildEnvironment = None) -> List[Node]: + delims = r'(\s*[\[\]\(\),](?:\s*or\s)?\s*|\s+or\s+)' + delims_re = re.compile(delims) + sub_targets = re.split(delims, target) + + split_contnode = bool(contnode and contnode.astext() == target) + + results = [] + for sub_target in filter(None, sub_targets): + if split_contnode: + contnode = nodes.Text(sub_target) + + if delims_re.match(sub_target): + results.append(contnode or innernode(sub_target, sub_target)) + else: + results.append(self.make_xref(rolename, domain, sub_target, + innernode, contnode, env)) + + return results + + +class PyField(PyXrefMixin, Field): + def make_xref(self, rolename: str, domain: str, target: str, + innernode: Type[TextlikeNode] = nodes.emphasis, + contnode: Node = None, env: BuildEnvironment = None) -> Node: + if rolename == 'class' and target == 'None': + # None is not a type, so use obj role instead. + rolename = 'obj' + + return super().make_xref(rolename, domain, target, innernode, contnode, env) + + +class PyGroupedField(PyXrefMixin, GroupedField): + pass + + +class PyTypedField(PyXrefMixin, TypedField): + def make_xref(self, rolename: str, domain: str, target: str, + innernode: Type[TextlikeNode] = nodes.emphasis, + contnode: Node = None, env: BuildEnvironment = None) -> Node: + if rolename == 'class' and target == 'None': + # None is not a type, so use obj role instead. + rolename = 'obj' + + return super().make_xref(rolename, domain, target, innernode, contnode, env) + + +class PyObject(ObjectDescription): + """ + Description of a general Python object. + + :cvar allow_nesting: Class is an object that allows for nested namespaces + :vartype allow_nesting: bool + """ + option_spec = { + 'noindex': directives.flag, + 'noindexentry': 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) -> str: + """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, 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 + + sig_prefix = self.get_signature_prefix(sig) + if sig_prefix: + signode += addnodes.desc_annotation(sig_prefix, sig_prefix) + + if prefix: + signode += addnodes.desc_addname(prefix, prefix) + elif add_module and self.env.config.add_module_names: + if modname and modname != 'exceptions': + # exceptions are a special case, since they are documented in the + # 'exceptions' module. + nodetext = modname + '.' + signode += addnodes.desc_addname(nodetext, nodetext) + + signode += addnodes.desc_name(name, name) + if arglist: + try: + signode += _parse_arglist(arglist, self.env) + except SyntaxError: + # fallback to parse arglist original parser. + # it supports to represent optional arguments (ex. "func(foo [, bar])") + _pseudo_parse_arglist(signode, arglist) + except NotImplementedError as exc: + logger.warning("could not parse arglist (%r): %s", arglist, exc, + location=signode) + _pseudo_parse_arglist(signode, arglist) + 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, ' ' + anno) + + return fullname, prefix + + def get_index_text(self, modname: str, name: Tuple[str, str]) -> str: + """Return the text for the index entry of the object.""" + raise NotImplementedError('must be implemented in subclasses') + + 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) + + # Assign old styled node_id(fullname) not to break old hyperlinks (if possible) + # Note: Will removed in Sphinx-5.0 (RemovedInSphinx50Warning) + if node_id != fullname and fullname not in self.state.document.ids: + signode['ids'].append(fullname) + + self.state.document.note_explicit_target(signode) + + domain = cast(PythonDomain, self.env.get_domain('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, canonical=True, + location=signode) + + if 'noindexentry' 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: + try: + classes.pop() + except IndexError: + pass + 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') + + +class PyFunction(PyObject): + """Description of a function.""" + + option_spec = PyObject.option_spec.copy() + option_spec.update({ + 'async': directives.flag, + }) + + def get_signature_prefix(self, sig: str) -> str: + if 'async' in self.options: + return 'async ' + else: + return '' + + def needs_arglist(self) -> bool: + return True + + def add_target_and_index(self, name_cls: Tuple[str, str], sig: str, + signode: desc_signature) -> None: + super().add_target_and_index(name_cls, sig, signode) + if 'noindexentry' not in self.options: + modname = self.options.get('module', self.env.ref_context.get('py:module')) + node_id = signode['ids'][0] + + name, cls = name_cls + if modname: + text = _('%s() (in module %s)') % (name, modname) + self.indexnode['entries'].append(('single', text, node_id, '', None)) + else: + text = '%s; %s()' % (pairindextypes['builtin'], name) + self.indexnode['entries'].append(('pair', text, node_id, '', None)) + + def get_index_text(self, modname: str, name_cls: Tuple[str, str]) -> str: + # add index in own add_target_and_index() instead. + return None + + +class PyDecoratorFunction(PyFunction): + """Description of a decorator.""" + + def run(self) -> List[Node]: + # a decorator function is a function after all + self.name = 'py:function' + return super().run() + + def handle_signature(self, sig: str, signode: desc_signature) -> Tuple[str, str]: + ret = super().handle_signature(sig, signode) + signode.insert(0, addnodes.desc_addname('@', '@')) + return ret + + def needs_arglist(self) -> bool: + return False + + +class PyVariable(PyObject): + """Description of a variable.""" + + option_spec = PyObject.option_spec.copy() + option_spec.update({ + 'type': directives.unchanged, + 'value': directives.unchanged, + }) + + def handle_signature(self, sig: str, signode: desc_signature) -> Tuple[str, str]: + fullname, prefix = super().handle_signature(sig, signode) + + typ = self.options.get('type') + if typ: + annotations = _parse_annotation(typ, self.env) + signode += addnodes.desc_annotation(typ, '', nodes.Text(': '), *annotations) + + value = self.options.get('value') + if value: + signode += addnodes.desc_annotation(value, ' = ' + value) + + return fullname, prefix + + def get_index_text(self, modname: str, name_cls: Tuple[str, str]) -> str: + name, cls = name_cls + if modname: + return _('%s (in module %s)') % (name, modname) + else: + return _('%s (built-in variable)') % name + + +class PyClasslike(PyObject): + """ + Description of a class-like object (classes, interfaces, exceptions). + """ + + option_spec = PyObject.option_spec.copy() + option_spec.update({ + 'final': directives.flag, + }) + + allow_nesting = True + + def get_signature_prefix(self, sig: str) -> str: + if 'final' in self.options: + return 'final %s ' % self.objtype + else: + return '%s ' % self.objtype + + def get_index_text(self, modname: str, name_cls: Tuple[str, str]) -> str: + if self.objtype == 'class': + if not modname: + return _('%s (built-in class)') % name_cls[0] + return _('%s (class in %s)') % (name_cls[0], modname) + elif self.objtype == 'exception': + return name_cls[0] + else: + return '' + + +class PyMethod(PyObject): + """Description of a method.""" + + option_spec = PyObject.option_spec.copy() + option_spec.update({ + 'abstractmethod': directives.flag, + 'async': directives.flag, + 'classmethod': directives.flag, + 'final': directives.flag, + 'property': directives.flag, + 'staticmethod': directives.flag, + }) + + def needs_arglist(self) -> bool: + if 'property' in self.options: + return False + else: + return True + + def get_signature_prefix(self, sig: str) -> str: + prefix = [] + if 'final' in self.options: + prefix.append('final') + if 'abstractmethod' in self.options: + prefix.append('abstract') + if 'async' in self.options: + prefix.append('async') + if 'classmethod' in self.options: + prefix.append('classmethod') + if 'property' in self.options: + prefix.append('property') + if 'staticmethod' in self.options: + prefix.append('static') + + if prefix: + return ' '.join(prefix) + ' ' + else: + return '' + + def get_index_text(self, modname: str, name_cls: Tuple[str, str]) -> str: + name, cls = name_cls + try: + clsname, methname = name.rsplit('.', 1) + if modname and self.env.config.add_module_names: + clsname = '.'.join([modname, clsname]) + except ValueError: + if modname: + return _('%s() (in module %s)') % (name, modname) + else: + return '%s()' % name + + if 'classmethod' in self.options: + return _('%s() (%s class method)') % (methname, clsname) + elif 'property' in self.options: + return _('%s() (%s property)') % (methname, clsname) + elif 'staticmethod' in self.options: + return _('%s() (%s static method)') % (methname, clsname) + else: + return _('%s() (%s method)') % (methname, clsname) + + +class PyClassMethod(PyMethod): + """Description of a classmethod.""" + + option_spec = PyObject.option_spec.copy() + + def run(self) -> List[Node]: + self.name = 'py:method' + self.options['classmethod'] = True + + return super().run() + + +class PyStaticMethod(PyMethod): + """Description of a staticmethod.""" + + option_spec = PyObject.option_spec.copy() + + def run(self) -> List[Node]: + self.name = 'py:method' + self.options['staticmethod'] = True + + return super().run() + + +class PyDecoratorMethod(PyMethod): + """Description of a decoratormethod.""" + + def run(self) -> List[Node]: + self.name = 'py:method' + return super().run() + + def handle_signature(self, sig: str, signode: desc_signature) -> Tuple[str, str]: + ret = super().handle_signature(sig, signode) + signode.insert(0, addnodes.desc_addname('@', '@')) + return ret + + def needs_arglist(self) -> bool: + return False + + +class PyAttribute(PyObject): + """Description of an attribute.""" + + option_spec = PyObject.option_spec.copy() + option_spec.update({ + 'type': directives.unchanged, + 'value': directives.unchanged, + }) + + def handle_signature(self, sig: str, signode: desc_signature) -> Tuple[str, str]: + fullname, prefix = super().handle_signature(sig, signode) + + typ = self.options.get('type') + if typ: + annotations = _parse_annotation(typ, self.env) + signode += addnodes.desc_annotation(typ, '', nodes.Text(': '), *annotations) + + value = self.options.get('value') + if value: + signode += addnodes.desc_annotation(value, ' = ' + value) + + return fullname, prefix + + def get_index_text(self, modname: str, name_cls: Tuple[str, str]) -> str: + name, cls = name_cls + try: + clsname, attrname = name.rsplit('.', 1) + if modname and self.env.config.add_module_names: + clsname = '.'.join([modname, clsname]) + except ValueError: + if modname: + return _('%s (in module %s)') % (name, modname) + else: + return name + + return _('%s (%s attribute)') % (attrname, clsname) + + +class PyDecoratorMixin: + """ + Mixin for decorator directives. + """ + def handle_signature(self, sig: str, signode: desc_signature) -> Tuple[str, str]: + for cls in self.__class__.__mro__: + if cls.__name__ != 'DirectiveAdapter': + warnings.warn('PyDecoratorMixin is deprecated. ' + 'Please check the implementation of %s' % cls, + RemovedInSphinx50Warning, stacklevel=2) + break + else: + warnings.warn('PyDecoratorMixin is deprecated', + RemovedInSphinx50Warning, stacklevel=2) + + ret = super().handle_signature(sig, signode) # type: ignore + signode.insert(0, addnodes.desc_addname('@', '@')) + return ret + + def needs_arglist(self) -> bool: + return False + + +class PyModule(SphinxDirective): + """ + Directive to mark description of a new module. + """ + + has_content = False + required_arguments = 1 + optional_arguments = 0 + final_argument_whitespace = False + option_spec = { + 'platform': lambda x: x, + 'synopsis': lambda x: x, + 'noindex': directives.flag, + 'deprecated': directives.flag, + } + + def run(self) -> List[Node]: + domain = cast(PythonDomain, self.env.get_domain('py')) + + modname = self.arguments[0].strip() + noindex = 'noindex' in self.options + self.env.ref_context['py:module'] = modname + ret = [] # type: List[Node] + if not noindex: + # note module to the domain + node_id = make_id(self.env, self.state.document, 'module', modname) + target = nodes.target('', '', ids=[node_id], ismod=True) + self.set_source_info(target) + + # Assign old styled node_id not to break old hyperlinks (if possible) + # Note: Will removed in Sphinx-5.0 (RemovedInSphinx50Warning) + old_node_id = self.make_old_id(modname) + if node_id != old_node_id and old_node_id not in self.state.document.ids: + target['ids'].append(old_node_id) + + self.state.document.note_explicit_target(target) + + domain.note_module(modname, + node_id, + self.options.get('synopsis', ''), + self.options.get('platform', ''), + 'deprecated' in self.options) + domain.note_object(modname, 'module', node_id, location=target) + + # the platform and synopsis aren't printed; in fact, they are only + # used in the modindex currently + ret.append(target) + indextext = '%s; %s' % (pairindextypes['module'], modname) + inode = addnodes.index(entries=[('pair', indextext, node_id, '', None)]) + ret.append(inode) + return ret + + def make_old_id(self, name: str) -> str: + """Generate old styled node_id. + + Old styled node_id is incompatible with docutils' node_id. + It can contain dots and hyphens. + + .. note:: Old styled node_id was mainly used until Sphinx-3.0. + """ + return 'module-%s' % name + + +class PyCurrentModule(SphinxDirective): + """ + This directive is just to tell Sphinx that we're documenting + stuff in module foo, but links to module foo won't lead here. + """ + + has_content = False + required_arguments = 1 + optional_arguments = 0 + final_argument_whitespace = False + option_spec = {} # type: Dict + + def run(self) -> List[Node]: + modname = self.arguments[0].strip() + if modname == 'None': + self.env.ref_context.pop('py:module', None) + else: + self.env.ref_context['py:module'] = modname + return [] + + +class PyXRefRole(XRefRole): + def process_link(self, env: BuildEnvironment, refnode: Element, + has_explicit_title: bool, title: str, target: str) -> Tuple[str, str]: + refnode['py:module'] = env.ref_context.get('py:module') + refnode['py:class'] = env.ref_context.get('py:class') + if not has_explicit_title: + title = title.lstrip('.') # only has a meaning for the target + target = target.lstrip('~') # only has a meaning for the title + # if the first character is a tilde, don't display the module/class + # parts of the contents + if title[0:1] == '~': + title = title[1:] + dot = title.rfind('.') + if dot != -1: + title = title[dot + 1:] + # if the first character is a dot, search more specific namespaces first + # else search builtins first + if target[0:1] == '.': + target = target[1:] + refnode['refspecific'] = True + return title, target + + +def filter_meta_fields(app: Sphinx, domain: str, objtype: str, content: Element) -> None: + """Filter ``:meta:`` field from its docstring.""" + if domain != 'py': + return + + for node in content: + if isinstance(node, nodes.field_list): + fields = cast(List[nodes.field], node) + for field in fields: + field_name = cast(nodes.field_body, field[0]).astext().strip() + if field_name == 'meta' or field_name.startswith('meta '): + node.remove(field) + break + + +class PythonModuleIndex(Index): + """ + Index subclass to provide the Python module index. + """ + + name = 'modindex' + localname = _('Python Module Index') + shortname = _('modules') + + def generate(self, docnames: Iterable[str] = None + ) -> Tuple[List[Tuple[str, List[IndexEntry]]], bool]: + content = {} # type: Dict[str, List[IndexEntry]] + # list of prefixes to ignore + ignores = None # type: List[str] + ignores = self.domain.env.config['modindex_common_prefix'] # type: ignore + ignores = sorted(ignores, key=len, reverse=True) + # list of all modules, sorted by module name + modules = sorted(self.domain.data['modules'].items(), + key=lambda x: x[0].lower()) + # sort out collapsable modules + prev_modname = '' + num_toplevels = 0 + for modname, (docname, node_id, synopsis, platforms, deprecated) in modules: + if docnames and docname not in docnames: + continue + + for ignore in ignores: + if modname.startswith(ignore): + modname = modname[len(ignore):] + stripped = ignore + break + else: + stripped = '' + + # we stripped the whole module name? + if not modname: + modname, stripped = stripped, '' + + entries = content.setdefault(modname[0].lower(), []) + + package = modname.split('.')[0] + if package != modname: + # it's a submodule + if prev_modname == package: + # first submodule - make parent a group head + if entries: + last = entries[-1] + entries[-1] = IndexEntry(last[0], 1, last[2], last[3], + last[4], last[5], last[6]) + elif not prev_modname.startswith(package): + # submodule without parent in list, add dummy entry + entries.append(IndexEntry(stripped + package, 1, '', '', '', '', '')) + subtype = 2 + else: + num_toplevels += 1 + subtype = 0 + + qualifier = _('Deprecated') if deprecated else '' + entries.append(IndexEntry(stripped + modname, subtype, docname, + node_id, platforms, qualifier, synopsis)) + prev_modname = modname + + # apply heuristics when to collapse modindex at page load: + # only collapse if number of toplevel modules is larger than + # number of submodules + collapse = len(modules) - num_toplevels < num_toplevels + + # sort by first letter + sorted_content = sorted(content.items()) + + return sorted_content, collapse + + +class PythonDomain(Domain): + """Python language domain.""" + name = 'py' + label = 'Python' + object_types = { + 'function': ObjType(_('function'), 'func', 'obj'), + 'data': ObjType(_('data'), 'data', 'obj'), + 'class': ObjType(_('class'), 'class', 'exc', 'obj'), + 'exception': ObjType(_('exception'), 'exc', 'class', 'obj'), + 'method': ObjType(_('method'), 'meth', 'obj'), + 'classmethod': ObjType(_('class method'), 'meth', 'obj'), + 'staticmethod': ObjType(_('static method'), 'meth', 'obj'), + 'attribute': ObjType(_('attribute'), 'attr', 'obj'), + 'module': ObjType(_('module'), 'mod', 'obj'), + } # type: Dict[str, ObjType] + + directives = { + 'function': PyFunction, + 'data': PyVariable, + 'class': PyClasslike, + 'exception': PyClasslike, + 'method': PyMethod, + 'classmethod': PyClassMethod, + 'staticmethod': PyStaticMethod, + 'attribute': PyAttribute, + 'module': PyModule, + 'currentmodule': PyCurrentModule, + 'decorator': PyDecoratorFunction, + 'decoratormethod': PyDecoratorMethod, + } + roles = { + 'data': PyXRefRole(), + 'exc': PyXRefRole(), + 'func': PyXRefRole(fix_parens=True), + 'class': PyXRefRole(), + 'const': PyXRefRole(), + 'attr': PyXRefRole(), + 'meth': PyXRefRole(fix_parens=True), + 'mod': PyXRefRole(), + 'obj': PyXRefRole(), + } + initial_data = { + 'objects': {}, # fullname -> docname, objtype + 'modules': {}, # modname -> docname, synopsis, platform, deprecated + } # type: Dict[str, Dict[str, Tuple[Any]]] + indices = [ + PythonModuleIndex, + ] + + @property + def objects(self) -> Dict[str, ObjectEntry]: + return self.data.setdefault('objects', {}) # fullname -> ObjectEntry + + def note_object(self, name: str, objtype: str, node_id: str, + canonical: bool = False, location: Any = None) -> None: + """Note a python object for cross reference. + + .. versionadded:: 2.1 + """ + if name in self.objects: + other = self.objects[name] + logger.warning(__('duplicate object description of %s, ' + 'other instance in %s, use :noindex: for one of them'), + name, other.docname, location=location) + self.objects[name] = ObjectEntry(self.env.docname, node_id, objtype, canonical) + + @property + def modules(self) -> Dict[str, ModuleEntry]: + return self.data.setdefault('modules', {}) # modname -> ModuleEntry + + def note_module(self, name: str, node_id: str, synopsis: str, + platform: str, deprecated: bool) -> None: + """Note a python module for cross reference. + + .. versionadded:: 2.1 + """ + self.modules[name] = ModuleEntry(self.env.docname, node_id, + synopsis, platform, deprecated) + + def clear_doc(self, docname: str) -> None: + for fullname, obj in list(self.objects.items()): + if obj.docname == docname: + del self.objects[fullname] + for modname, mod in list(self.modules.items()): + if mod.docname == docname: + del self.modules[modname] + + def merge_domaindata(self, docnames: List[str], otherdata: Dict) -> None: + # XXX check duplicates? + for fullname, obj in otherdata['objects'].items(): + if obj.docname in docnames: + self.objects[fullname] = obj + for modname, mod in otherdata['modules'].items(): + if mod.docname in docnames: + self.modules[modname] = mod + + def find_obj(self, env: BuildEnvironment, modname: str, classname: str, + name: str, type: str, searchmode: int = 0 + ) -> List[Tuple[str, ObjectEntry]]: + """Find a Python object for "name", perhaps using the given module + and/or classname. Returns a list of (name, object entry) tuples. + """ + # skip parens + if name[-2:] == '()': + name = name[:-2] + + if not name: + return [] + + matches = [] # type: List[Tuple[str, ObjectEntry]] + + newname = None + if searchmode == 1: + if type is None: + objtypes = list(self.object_types) + else: + objtypes = self.objtypes_for_role(type) + if objtypes is not None: + if modname and classname: + fullname = modname + '.' + classname + '.' + name + if fullname in self.objects and self.objects[fullname].objtype in objtypes: + newname = fullname + if not newname: + if modname and modname + '.' + name in self.objects and \ + self.objects[modname + '.' + name].objtype in objtypes: + newname = modname + '.' + name + elif name in self.objects and self.objects[name].objtype in objtypes: + newname = name + else: + # "fuzzy" searching mode + searchname = '.' + name + matches = [(oname, self.objects[oname]) for oname in self.objects + if oname.endswith(searchname) and + self.objects[oname].objtype in objtypes] + else: + # NOTE: searching for exact match, object type is not considered + if name in self.objects: + newname = name + elif type == 'mod': + # only exact matches allowed for modules + return [] + elif classname and classname + '.' + name in self.objects: + newname = classname + '.' + name + elif modname and modname + '.' + name in self.objects: + newname = modname + '.' + name + elif modname and classname and \ + modname + '.' + classname + '.' + name in self.objects: + newname = modname + '.' + classname + '.' + name + if newname is not None: + matches.append((newname, self.objects[newname])) + return matches + + def resolve_xref(self, env: BuildEnvironment, fromdocname: str, builder: Builder, + type: str, target: str, node: pending_xref, contnode: Element + ) -> Element: + modname = node.get('py:module') + clsname = node.get('py:class') + searchmode = 1 if node.hasattr('refspecific') else 0 + matches = self.find_obj(env, modname, clsname, target, + type, searchmode) + + if not matches and type == 'attr': + # fallback to meth (for property) + matches = self.find_obj(env, modname, clsname, target, 'meth', searchmode) + + if not matches: + return None + elif len(matches) > 1: + logger.warning(__('more than one target found for cross-reference %r: %s'), + target, ', '.join(match[0] for match in matches), + type='ref', subtype='python', location=node) + name, obj = matches[0] + + if obj[2] == 'module': + return self._make_module_refnode(builder, fromdocname, name, contnode) + else: + return make_refnode(builder, fromdocname, obj[0], obj[1], contnode, name) + + def resolve_any_xref(self, env: BuildEnvironment, fromdocname: str, builder: Builder, + target: str, node: pending_xref, contnode: Element + ) -> List[Tuple[str, Element]]: + modname = node.get('py:module') + clsname = node.get('py:class') + results = [] # type: List[Tuple[str, Element]] + + # always search in "refspecific" mode with the :any: role + matches = self.find_obj(env, modname, clsname, target, None, 1) + for name, obj in matches: + if obj[2] == 'module': + results.append(('py:mod', + self._make_module_refnode(builder, fromdocname, + name, contnode))) + else: + results.append(('py:' + self.role_for_objtype(obj[2]), + make_refnode(builder, fromdocname, obj[0], obj[1], + contnode, name))) + return results + + def _make_module_refnode(self, builder: Builder, fromdocname: str, name: str, + contnode: Node) -> Element: + # get additional info for modules + module = self.modules[name] + title = name + if module.synopsis: + title += ': ' + module.synopsis + if module.deprecated: + title += _(' (deprecated)') + if module.platform: + title += ' (' + module.platform + ')' + return make_refnode(builder, fromdocname, module.docname, module.node_id, + contnode, title) + + def get_objects(self) -> Iterator[Tuple[str, str, str, str, str, int]]: + for modname, mod in self.modules.items(): + yield (modname, modname, 'module', mod.docname, mod.node_id, 0) + for refname, obj in self.objects.items(): + if obj.objtype != 'module': # modules are already handled + if obj.canonical: + # canonical names are not full-text searchable. + yield (refname, refname, obj.objtype, obj.docname, obj.node_id, -1) + else: + yield (refname, refname, obj.objtype, obj.docname, obj.node_id, 1) + + def get_full_qualified_name(self, node: Element) -> str: + modname = node.get('py:module') + clsname = node.get('py:class') + target = node.get('reftarget') + if target is None: + return None + else: + return '.'.join(filter(None, [modname, clsname, target])) + + +def builtin_resolver(app: Sphinx, env: BuildEnvironment, + node: pending_xref, contnode: Element) -> Element: + """Do not emit nitpicky warnings for built-in types.""" + def istyping(s: str) -> bool: + if s.startswith('typing.'): + s = s.split('.', 1)[1] + + return s in typing.__all__ # type: ignore + + if node.get('refdomain') != 'py': + return None + elif node.get('reftype') in ('class', 'obj') and node.get('reftarget') == 'None': + return contnode + elif node.get('reftype') in ('class', 'exc'): + reftarget = node.get('reftarget') + if inspect.isclass(getattr(builtins, reftarget, None)): + # built-in class + return contnode + elif istyping(reftarget): + # typing class + return contnode + + return None + + +def setup(app: Sphinx) -> Dict[str, Any]: + app.setup_extension('sphinx.directives') + + app.add_domain(PythonDomain) + app.connect('object-description-transform', filter_meta_fields) + app.connect('missing-reference', builtin_resolver, priority=900) + + return { + 'version': 'builtin', + 'env_version': 3, + 'parallel_read_safe': True, + 'parallel_write_safe': True, + }