diff options
Diffstat (limited to 'sphinx/domains/python.py')
-rw-r--r-- | sphinx/domains/python.py | 368 |
1 files changed, 278 insertions, 90 deletions
diff --git a/sphinx/domains/python.py b/sphinx/domains/python.py index be6bf34e3..fae1991c7 100644 --- a/sphinx/domains/python.py +++ b/sphinx/domains/python.py @@ -8,8 +8,12 @@ :license: BSD, see LICENSE for details. """ +import builtins +import inspect import re +import typing import warnings +from inspect import Parameter from typing import Any, Dict, Iterable, Iterator, List, Tuple from typing import cast @@ -17,22 +21,22 @@ from docutils import nodes from docutils.nodes import Element, Node from docutils.parsers.rst import directives -from sphinx import addnodes, locale +from sphinx import addnodes from sphinx.addnodes import pending_xref, desc_signature from sphinx.application import Sphinx from sphinx.builders import Builder -from sphinx.deprecation import ( - DeprecatedDict, RemovedInSphinx30Warning, RemovedInSphinx40Warning -) +from sphinx.deprecation import RemovedInSphinx40Warning, RemovedInSphinx50Warning from sphinx.directives import ObjectDescription from sphinx.domains import Domain, ObjType, Index, IndexEntry from sphinx.environment import BuildEnvironment from sphinx.locale import _, __ +from sphinx.pycode.ast import ast, 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.nodes import make_refnode +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: @@ -63,12 +67,107 @@ pairindextypes = { 'builtin': _('built-in function'), } -locale.pairindextypes = DeprecatedDict( - pairindextypes, - 'sphinx.locale.pairindextypes is deprecated. ' - 'Please use sphinx.domains.python.pairindextypes instead.', - RemovedInSphinx30Warning -) + +def _parse_annotation(annotation: str) -> List[Node]: + """Parse type annotation.""" + def make_xref(text: str) -> addnodes.pending_xref: + return pending_xref('', nodes.Text(text), + refdomain='py', reftype='class', reftarget=text) + + 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): + result = [] + for elem in node.elts: + result.extend(unparse(elem)) + result.append(addnodes.desc_sig_punctuation('', ', ')) + result.pop() + return result + else: + raise SyntaxError # unsupported syntax + + try: + tree = ast_parse(annotation) + result = unparse(tree) + for i, node in enumerate(result): + if isinstance(node, nodes.Text): + result[i] = make_xref(str(node)) + return result + except SyntaxError: + return [make_xref(annotation)] + + +def _parse_arglist(arglist: str) -> 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) + 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: @@ -293,14 +392,24 @@ class PyObject(ObjectDescription): signode += addnodes.desc_name(name, name) if arglist: - _pseudo_parse_arglist(signode, arglist) + try: + signode += _parse_arglist(arglist) + 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: - signode += addnodes.desc_returns(retann, retann) + children = _parse_annotation(retann) + signode += addnodes.desc_returns(retann, '', *children) anno = self.options.get('annotation') if anno: @@ -316,21 +425,22 @@ class PyObject(ObjectDescription): signode: desc_signature) -> None: modname = self.options.get('module', self.env.ref_context.get('py:module')) fullname = (modname + '.' if modname else '') + name_cls[0] - # note target - if fullname not in self.state.document.ids: - signode['names'].append(fullname) + 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) - signode['first'] = (not self.names) - self.state.document.note_explicit_target(signode) - domain = cast(PythonDomain, self.env.get_domain('py')) - domain.note_object(fullname, self.objtype, - location=(self.env.docname, self.lineno)) + 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) indextext = self.get_index_text(modname, name_cls) if indextext: - self.indexnode['entries'].append(('single', indextext, - fullname, '', None)) + self.indexnode['entries'].append(('single', indextext, node_id, '', None)) def before_content(self) -> None: """Handle object nesting before content @@ -449,6 +559,23 @@ class PyFunction(PyObject): return _('%s() (built-in function)') % name +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.""" @@ -666,6 +793,22 @@ class PyStaticMethod(PyMethod): 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.""" @@ -708,6 +851,15 @@ 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) + break + else: + warnings.warn('PyDecoratorMixin is deprecated', RemovedInSphinx50Warning) + ret = super().handle_signature(sig, signode) # type: ignore signode.insert(0, addnodes.desc_addname('@', '@')) return ret @@ -716,25 +868,6 @@ class PyDecoratorMixin: return False -class PyDecoratorFunction(PyDecoratorMixin, PyModulelevel): - """ - Directive to mark functions meant to be used as decorators. - """ - def run(self) -> List[Node]: - # a decorator function is a function after all - self.name = 'py:function' - return super().run() - - -class PyDecoratorMethod(PyDecoratorMixin, PyClassmember): - """ - Directive to mark methods meant to be used as decorators. - """ - def run(self) -> List[Node]: - self.name = 'py:method' - return super().run() - - class PyModule(SphinxDirective): """ Directive to mark description of a new module. @@ -760,24 +893,43 @@ class PyModule(SphinxDirective): 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', location=(self.env.docname, self.lineno)) + domain.note_object(modname, 'module', node_id, location=target) - targetnode = nodes.target('', '', ids=['module-' + modname], - ismod=True) - self.state.document.note_explicit_target(targetnode) # the platform and synopsis aren't printed; in fact, they are only # used in the modindex currently - ret.append(targetnode) + ret.append(target) indextext = _('%s (module)') % modname - inode = addnodes.index(entries=[('single', indextext, - 'module-' + modname, '', None)]) + inode = addnodes.index(entries=[('single', 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): """ @@ -823,6 +975,21 @@ class PyXRefRole(XRefRole): 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. @@ -845,7 +1012,7 @@ class PythonModuleIndex(Index): # sort out collapsable modules prev_modname = '' num_toplevels = 0 - for modname, (docname, synopsis, platforms, deprecated) in modules: + for modname, (docname, node_id, synopsis, platforms, deprecated) in modules: if docnames and docname not in docnames: continue @@ -882,8 +1049,7 @@ class PythonModuleIndex(Index): qualifier = _('Deprecated') if deprecated else '' entries.append(IndexEntry(stripped + modname, subtype, docname, - 'module-' + stripped + modname, platforms, - qualifier, synopsis)) + node_id, platforms, qualifier, synopsis)) prev_modname = modname # apply heuristics when to collapse modindex at page load: @@ -947,10 +1113,10 @@ class PythonDomain(Domain): ] @property - def objects(self) -> Dict[str, Tuple[str, str]]: - return self.data.setdefault('objects', {}) # fullname -> docname, objtype + def objects(self) -> Dict[str, Tuple[str, str, str]]: + return self.data.setdefault('objects', {}) # fullname -> docname, node_id, objtype - def note_object(self, name: str, objtype: str, location: Any = None) -> None: + 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 @@ -960,38 +1126,40 @@ class PythonDomain(Domain): logger.warning(__('duplicate object description of %s, ' 'other instance in %s, use :noindex: for one of them'), name, docname, location=location) - self.objects[name] = (self.env.docname, objtype) + self.objects[name] = (self.env.docname, node_id, objtype) @property - def modules(self) -> Dict[str, Tuple[str, str, str, bool]]: - return self.data.setdefault('modules', {}) # modname -> docname, synopsis, platform, deprecated # NOQA + def modules(self) -> Dict[str, Tuple[str, str, str, str, bool]]: + return self.data.setdefault('modules', {}) # modname -> docname, node_id, synopsis, platform, deprecated # NOQA - def note_module(self, name: str, synopsis: str, platform: str, deprecated: bool) -> None: + 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] = (self.env.docname, synopsis, platform, deprecated) + self.modules[name] = (self.env.docname, node_id, synopsis, platform, deprecated) def clear_doc(self, docname: str) -> None: - for fullname, (fn, _l) in list(self.objects.items()): + for fullname, (fn, _x, _x) in list(self.objects.items()): if fn == docname: del self.objects[fullname] - for modname, (fn, _x, _x, _y) in list(self.modules.items()): + for modname, (fn, _x, _x, _x, _y) in list(self.modules.items()): if fn == docname: del self.modules[modname] def merge_domaindata(self, docnames: List[str], otherdata: Dict) -> None: # XXX check duplicates? - for fullname, (fn, objtype) in otherdata['objects'].items(): + for fullname, (fn, node_id, objtype) in otherdata['objects'].items(): if fn in docnames: - self.objects[fullname] = (fn, objtype) + self.objects[fullname] = (fn, node_id, objtype) for modname, data in otherdata['modules'].items(): if data[0] in docnames: self.modules[modname] = data def find_obj(self, env: BuildEnvironment, modname: str, classname: str, - name: str, type: str, searchmode: int = 0) -> List[Tuple[str, Any]]: + name: str, type: str, searchmode: int = 0 + ) -> List[Tuple[str, Tuple[str, str, str]]]: """Find a Python object for "name", perhaps using the given module and/or classname. Returns a list of (name, object entry) tuples. """ @@ -1002,7 +1170,7 @@ class PythonDomain(Domain): if not name: return [] - matches = [] # type: List[Tuple[str, Any]] + matches = [] # type: List[Tuple[str, Tuple[str, str, str]]] newname = None if searchmode == 1: @@ -1013,20 +1181,20 @@ class PythonDomain(Domain): if objtypes is not None: if modname and classname: fullname = modname + '.' + classname + '.' + name - if fullname in self.objects and self.objects[fullname][1] in objtypes: + if fullname in self.objects and self.objects[fullname][2] in objtypes: newname = fullname if not newname: if modname and modname + '.' + name in self.objects and \ - self.objects[modname + '.' + name][1] in objtypes: + self.objects[modname + '.' + name][2] in objtypes: newname = modname + '.' + name - elif name in self.objects and self.objects[name][1] in objtypes: + elif name in self.objects and self.objects[name][2] 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][1] in objtypes] + self.objects[oname][2] in objtypes] else: # NOTE: searching for exact match, object type is not considered if name in self.objects: @@ -1041,14 +1209,6 @@ class PythonDomain(Domain): elif modname and classname and \ modname + '.' + classname + '.' + name in self.objects: newname = modname + '.' + classname + '.' + name - # special case: builtin exceptions have module "exceptions" set - elif type == 'exc' and '.' not in name and \ - 'exceptions.' + name in self.objects: - newname = 'exceptions.' + name - # special case: object methods - elif type in ('func', 'meth') and '.' not in name and \ - 'object.' + name in self.objects: - newname = 'object.' + name if newname is not None: matches.append((newname, self.objects[newname])) return matches @@ -1074,10 +1234,10 @@ class PythonDomain(Domain): type='ref', subtype='python', location=node) name, obj = matches[0] - if obj[1] == 'module': + if obj[2] == 'module': return self._make_module_refnode(builder, fromdocname, name, contnode) else: - return make_refnode(builder, fromdocname, obj[0], name, contnode, name) + 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 @@ -1089,20 +1249,20 @@ class PythonDomain(Domain): # 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[1] == 'module': + 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[1]), - make_refnode(builder, fromdocname, obj[0], name, + 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 - docname, synopsis, platform, deprecated = self.modules[name] + docname, node_id, synopsis, platform, deprecated = self.modules[name] title = name if synopsis: title += ': ' + synopsis @@ -1110,15 +1270,14 @@ class PythonDomain(Domain): title += _(' (deprecated)') if platform: title += ' (' + platform + ')' - return make_refnode(builder, fromdocname, docname, - 'module-' + name, contnode, title) + return make_refnode(builder, fromdocname, docname, node_id, contnode, title) def get_objects(self) -> Iterator[Tuple[str, str, str, str, str, int]]: for modname, info in self.modules.items(): - yield (modname, modname, 'module', info[0], 'module-' + modname, 0) - for refname, (docname, type) in self.objects.items(): + yield (modname, modname, 'module', info[0], info[1], 0) + for refname, (docname, node_id, type) in self.objects.items(): if type != 'module': # modules are already handled - yield (refname, refname, type, docname, refname, 1) + yield (refname, refname, type, docname, node_id, 1) def get_full_qualified_name(self, node: Element) -> str: modname = node.get('py:module') @@ -1130,12 +1289,41 @@ class PythonDomain(Domain): 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') == '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': 1, + 'env_version': 2, 'parallel_read_safe': True, 'parallel_write_safe': True, } |