""" sphinx.domains.python ~~~~~~~~~~~~~~~~~~~~~ The Python domain. :copyright: Copyright 2007-2020 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, 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 RemovedInSphinx40Warning, 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 if False: # For type annotation from typing import Type # for python3.5.1 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'), } ObjectEntry = NamedTuple('ObjectEntry', [('docname', str), ('node_id', str), ('objtype', str)]) ModuleEntry = NamedTuple('ModuleEntry', [('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.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, 6): if isinstance(node, ast.Constant): if node.value is Ellipsis: return [addnodes.desc_sig_punctuation('', "...")] else: return [nodes.Text(node.value)] 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, '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'), rolename='obj', 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) 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 PyModulelevel(PyObject): """ Description of an object on module level (functions, data). """ def run(self) -> List[Node]: for cls in self.__class__.__mro__: if cls.__name__ != 'DirectiveAdapter': warnings.warn('PyModulelevel is deprecated. ' 'Please check the implementation of %s' % cls, RemovedInSphinx40Warning, stacklevel=2) break else: warnings.warn('PyModulelevel is deprecated', RemovedInSphinx40Warning, stacklevel=2) return super().run() def needs_arglist(self) -> bool: return self.objtype == 'function' def get_index_text(self, modname: str, name_cls: Tuple[str, str]) -> str: if self.objtype == 'function': if not modname: return _('%s() (built-in function)') % name_cls[0] return _('%s() (in module %s)') % (name_cls[0], modname) elif self.objtype == 'data': if not modname: return _('%s (built-in variable)') % name_cls[0] return _('%s (in module %s)') % (name_cls[0], modname) else: return '' 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 PyClassmember(PyObject): """ Description of a class member (methods, attributes). """ def run(self) -> List[Node]: for cls in self.__class__.__mro__: if cls.__name__ != 'DirectiveAdapter': warnings.warn('PyClassmember is deprecated. ' 'Please check the implementation of %s' % cls, RemovedInSphinx40Warning, stacklevel=2) break else: warnings.warn('PyClassmember is deprecated', RemovedInSphinx40Warning, stacklevel=2) return super().run() def needs_arglist(self) -> bool: return self.objtype.endswith('method') def get_signature_prefix(self, sig: str) -> str: if self.objtype == 'staticmethod': return 'static ' elif self.objtype == 'classmethod': return 'classmethod ' return '' def get_index_text(self, modname: str, name_cls: Tuple[str, str]) -> str: name, cls = name_cls add_modules = self.env.config.add_module_names if self.objtype == 'method': try: clsname, methname = name.rsplit('.', 1) except ValueError: if modname: return _('%s() (in module %s)') % (name, modname) else: return '%s()' % name if modname and add_modules: return _('%s() (%s.%s method)') % (methname, modname, clsname) else: return _('%s() (%s method)') % (methname, clsname) elif self.objtype == 'staticmethod': try: clsname, methname = name.rsplit('.', 1) except ValueError: if modname: return _('%s() (in module %s)') % (name, modname) else: return '%s()' % name if modname and add_modules: return _('%s() (%s.%s static method)') % (methname, modname, clsname) else: return _('%s() (%s static method)') % (methname, clsname) elif self.objtype == 'classmethod': try: clsname, methname = name.rsplit('.', 1) except ValueError: if modname: return _('%s() (in module %s)') % (name, modname) else: return '%s()' % name if modname: return _('%s() (%s.%s class method)') % (methname, modname, clsname) else: return _('%s() (%s class method)') % (methname, clsname) elif self.objtype == 'attribute': try: clsname, attrname = name.rsplit('.', 1) except ValueError: if modname: return _('%s (in module %s)') % (name, modname) else: return name if modname and add_modules: return _('%s (%s.%s attribute)') % (attrname, modname, clsname) else: return _('%s (%s attribute)') % (attrname, clsname) 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, 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) @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 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': 2, 'parallel_read_safe': True, 'parallel_write_safe': True, }