diff options
author | Hassan Kibirige <has2k1@gmail.com> | 2017-12-29 20:09:43 -0600 |
---|---|---|
committer | Eric Larson <larson.eric.d@gmail.com> | 2019-04-17 21:21:19 -0400 |
commit | 477ff10470784baa344229fde5880e4e2bffb545 (patch) | |
tree | fc396f488ccd60818db465e314c096ec937627fd | |
parent | c2e8b8f5fea8f0adde207436869cec0841e85262 (diff) | |
download | numpydoc-477ff10470784baa344229fde5880e4e2bffb545.tar.gz |
Add cross-reference links to parameter types
Tokens of the type description that are determined to be "link-worthy"
are enclosed in a new role called `xref_param_type`. This role when
when processed adds a `pending_xref` node to the DOM. If these types
cross-references are not resolved when the build ends, sphinx does
not complain. This forgives errors made when deciding whether tokens
are "link-worthy". And provided text from the type description is not
lost in the processing, the only unwanted outcome is a type link (due
to coincidence) when none was desired.
-rw-r--r-- | doc/install.rst | 47 | ||||
-rw-r--r-- | numpydoc/docscrape_sphinx.py | 24 | ||||
-rw-r--r-- | numpydoc/numpydoc.py | 11 | ||||
-rw-r--r-- | numpydoc/tests/test_docscrape.py | 79 | ||||
-rw-r--r-- | numpydoc/tests/test_xref.py | 128 | ||||
-rw-r--r-- | numpydoc/xref.py | 185 |
6 files changed, 469 insertions, 5 deletions
diff --git a/doc/install.rst b/doc/install.rst index 1fa0fde..c6d6209 100644 --- a/doc/install.rst +++ b/doc/install.rst @@ -47,6 +47,53 @@ numpydoc_attributes_as_param_list : bool as the Parameter section. If it's False, the Attributes section will be formatted as the Methods section using an autosummary table. ``True`` by default. +numpydoc_xref_param_type : bool + Whether to create cross-references for the parameter types in the + ``Parameters``, ``Other Parameters``, ``Returns`` and ``Yields`` + sections of the docstring. + ``True`` by default. +numpydoc_xref_aliases : dict + Mappings to fully qualified paths (or correct ReST references) for the + aliases/shortcuts used when specifying the types of parameters. + The keys should not have any spaces. Together with the ``intersphinx`` + extension, you can map to links in any documentation. + The default is an empty ``dict``. + + If you have the following ``intersphinx`` namespace configuration:: + + intersphinx_mapping = { + 'python': ('https://docs.python.org/3/', None), + 'numpy': ('https://docs.scipy.org/doc/numpy', None), + } + + A useful ``dict`` may look like the following:: + + numpydoc_xref_aliases = { + # python + 'sequence': ':term:`python:sequence`', + 'iterable': ':term:`python:iterable`', + 'string': 'str', + # numpy + 'array': 'numpy.ndarray', + 'dtype': 'numpy.dtype', + 'ndarray': 'numpy.ndarray', + 'matrix': 'numpy.matrix', + 'array-like': ':term:`numpy:array_like`', + 'array_like': ':term:`numpy:array_like`', + } + + This option depends on the ``numpydoc_xref_param_type`` option + being ``True``. + +numpydoc_xref_ignore : set + Words not to cross-reference. Most likely, these are common words + used in parameter type descriptions that may be confused for + classes of the same name. For example:: + + numpydoc_xref_ignore = {'type', 'optional', 'default'} + + The default is an empty set. + numpydoc_edit_link : bool .. deprecated:: edit your HTML template instead diff --git a/numpydoc/docscrape_sphinx.py b/numpydoc/docscrape_sphinx.py index 5f7843b..942c78f 100644 --- a/numpydoc/docscrape_sphinx.py +++ b/numpydoc/docscrape_sphinx.py @@ -17,6 +17,7 @@ import sphinx from sphinx.jinja2glue import BuiltinTemplateLoader from .docscrape import NumpyDocString, FunctionDoc, ClassDoc +from .xref import make_xref_param_type if sys.version_info[0] >= 3: sixu = lambda s: s @@ -37,6 +38,9 @@ class SphinxDocString(NumpyDocString): self.use_blockquotes = config.get('use_blockquotes', False) self.class_members_toctree = config.get('class_members_toctree', True) self.attributes_as_param_list = config.get('attributes_as_param_list', True) + self.xref_param_type = config.get('xref_param_type', False) + self.xref_aliases = config.get('xref_aliases', dict()) + self.xref_ignore = config.get('xref_ignore', set()) self.template = config.get('template', None) if self.template is None: template_dirs = [os.path.join(os.path.dirname(__file__), 'templates')] @@ -79,11 +83,17 @@ class SphinxDocString(NumpyDocString): out += self._str_field_list(name) out += [''] for param in self[name]: + param_type = param.type + if param_type and self.xref_param_type: + param_type = make_xref_param_type( + param_type, + self.xref_aliases, + self.xref_ignore) if param.name: out += self._str_indent([named_fmt % (param.name.strip(), - param.type)]) + param_type)]) else: - out += self._str_indent([unnamed_fmt % param.type.strip()]) + out += self._str_indent([unnamed_fmt % param_type.strip()]) if not param.desc: out += self._str_indent(['..'], 8) else: @@ -213,8 +223,14 @@ class SphinxDocString(NumpyDocString): parts = [] if display_param: parts.append(display_param) - if param.type: - parts.append(param.type) + param_type = param.type + if param_type: + if self.xref_param_type: + param_type = make_xref_param_type( + param_type, + self.xref_aliases, + self.xref_ignore) + parts.append(param_type) out += self._str_indent([' : '.join(parts)]) if desc and self.use_blockquotes: diff --git a/numpydoc/numpydoc.py b/numpydoc/numpydoc.py index f6f262c..b8e993f 100644 --- a/numpydoc/numpydoc.py +++ b/numpydoc/numpydoc.py @@ -37,6 +37,7 @@ if sphinx.__version__ < '1.0.1': raise RuntimeError("Sphinx 1.0.1 or newer is required") from .docscrape_sphinx import get_doc_object +from .xref import xref_param_type_role from . import __version__ if sys.version_info[0] >= 3: @@ -154,7 +155,11 @@ def mangle_docstrings(app, what, name, obj, options, lines): app.config.numpydoc_show_inherited_class_members, 'class_members_toctree': app.config.numpydoc_class_members_toctree, 'attributes_as_param_list': - app.config.numpydoc_attributes_as_param_list} + app.config.numpydoc_attributes_as_param_list, + 'xref_param_type': app.config.numpydoc_xref_param_type, + 'xref_aliases': app.config.numpydoc_xref_aliases, + 'xref_ignore': app.config.numpydoc_xref_ignore, + } cfg.update(options or {}) u_NL = sixu('\n') @@ -218,6 +223,7 @@ def setup(app, get_doc_object_=get_doc_object): app.setup_extension('sphinx.ext.autosummary') + app.add_role('xref_param_type', xref_param_type_role) app.connect('autodoc-process-docstring', mangle_docstrings) app.connect('autodoc-process-signature', mangle_signature) app.connect('doctree-read', relabel_references) @@ -230,6 +236,9 @@ def setup(app, get_doc_object_=get_doc_object): app.add_config_value('numpydoc_class_members_toctree', True, True) app.add_config_value('numpydoc_citation_re', '[a-z0-9_.-]+', True) app.add_config_value('numpydoc_attributes_as_param_list', True, True) + app.add_config_value('numpydoc_xref_param_type', True, True) + app.add_config_value('numpydoc_xref_aliases', dict(), True) + app.add_config_value('numpydoc_xref_ignore', set(), True) # Extra mangling domains app.add_domain(NumpyPythonDomain) diff --git a/numpydoc/tests/test_docscrape.py b/numpydoc/tests/test_docscrape.py index c6f9d08..2f5a688 100644 --- a/numpydoc/tests/test_docscrape.py +++ b/numpydoc/tests/test_docscrape.py @@ -1423,6 +1423,85 @@ A top section before ''') +xref_doc_txt = """ +Test xref in Parameters, Other Parameters and Returns + +Parameters +---------- +p1 : int + Integer value + +p2 : float, optional + Integer value + +Other Parameters +---------------- +p3 : list[int] + List of integers +p4 : :class:`pandas.DataFrame` + A dataframe +p5 : sequence of int + A sequence + +Returns +------- +out : array + Numerical return value +""" + + +xref_doc_txt_expected = """ +Test xref in Parameters, Other Parameters and Returns + + +:Parameters: + + p1 : :xref_param_type:`int` + Integer value + + p2 : :xref_param_type:`float`, optional + Integer value + +:Returns: + + out : :xref_param_type:`array <numpy.ndarray>` + Numerical return value + + +:Other Parameters: + + p3 : :xref_param_type:`list`\[:xref_param_type:`int`] + List of integers + + p4 : :class:`pandas.DataFrame` + A dataframe + + p5 : :term:`python:sequence` of :xref_param_type:`int` + A sequence +""" + + +def test_xref(): + xref_aliases = { + 'sequence': ':term:`python:sequence`', + 'iterable': ':term:`python:iterable`', + 'array': 'numpy.ndarray', + } + + xref_ignore = {'of', 'default', 'optional'} + + doc = SphinxDocString( + xref_doc_txt, + config=dict( + xref_param_type=True, + xref_aliases=xref_aliases, + xref_ignore=xref_ignore + ) + ) + + line_by_line_compare(str(doc), xref_doc_txt_expected) + + if __name__ == "__main__": import pytest pytest.main() diff --git a/numpydoc/tests/test_xref.py b/numpydoc/tests/test_xref.py new file mode 100644 index 0000000..8786d4b --- /dev/null +++ b/numpydoc/tests/test_xref.py @@ -0,0 +1,128 @@ +# -*- encoding:utf-8 -*- +from __future__ import division, absolute_import, print_function + +from nose.tools import assert_equal +from numpydoc.xref import make_xref_param_type + +xref_aliases = { + # python + 'sequence': ':term:`python:sequence`', + 'iterable': ':term:`python:iterable`', + 'string': 'str', + # numpy + 'array': 'numpy.ndarray', + 'dtype': 'numpy.dtype', + 'ndarray': 'numpy.ndarray', + 'matrix': 'numpy.matrix', + 'array-like': ':term:`numpy:array_like`', + 'array_like': ':term:`numpy:array_like`', +} + +# Comes mainly from numpy +data = """ +(...) array_like, float, optional +(...) :term:`numpy:array_like`, :xref_param_type:`float`, optional + +(2,) ndarray +(2,) :xref_param_type:`ndarray <numpy.ndarray>` + +(...,M,N) array_like +(...,M,N) :term:`numpy:array_like` + +(..., M, N) array_like +(..., :xref_param_type:`M`, :xref_param_type:`N`) :term:`numpy:array_like` + +(float, float), optional +(:xref_param_type:`float`, :xref_param_type:`float`), optional + +1-D array or sequence +1-D :xref_param_type:`array <numpy.ndarray>` or :term:`python:sequence` + +array of str or unicode-like +:xref_param_type:`array <numpy.ndarray>` of :xref_param_type:`str` or unicode-like + +array_like of float +:term:`numpy:array_like` of :xref_param_type:`float` + +bool or callable +:xref_param_type:`bool` or :xref_param_type:`callable` + +int in [0, 255] +:xref_param_type:`int` in [0, 255] + +int or None, optional +:xref_param_type:`int` or :xref_param_type:`None`, optional + +list of str or array_like +:xref_param_type:`list` of :xref_param_type:`str` or :term:`numpy:array_like` + +sequence of array_like +:term:`python:sequence` of :term:`numpy:array_like` + +str or pathlib.Path +:xref_param_type:`str` or :xref_param_type:`pathlib.Path` + +{'', string}, optional +{'', :xref_param_type:`string <str>`}, optional + +{'C', 'F', 'A', or 'K'}, optional +{'C', 'F', 'A', or 'K'}, optional + +{'linear', 'lower', 'higher', 'midpoint', 'nearest'} +{'linear', 'lower', 'higher', 'midpoint', 'nearest'} + +{False, True, 'greedy', 'optimal'} +{:xref_param_type:`False`, :xref_param_type:`True`, 'greedy', 'optimal'} + +{{'begin', 1}, {'end', 0}}, {string, int} +{{'begin', 1}, {'end', 0}}, {:xref_param_type:`string <str>`, :xref_param_type:`int`} + +callable f'(x,*args) +:xref_param_type:`callable` f'(x,*args) + +callable ``fhess(x, *args)``, optional +:xref_param_type:`callable` ``fhess(x, *args)``, optional + +spmatrix (format: ``csr``, ``bsr``, ``dia`` or coo``) +:xref_param_type:`spmatrix` (format: ``csr``, ``bsr``, ``dia`` or coo``) + +:ref:`strftime <strftime-strptime-behavior>` +:ref:`strftime <strftime-strptime-behavior>` + +callable or :ref:`strftime <strftime-strptime-behavior>` +:xref_param_type:`callable` or :ref:`strftime <strftime-strptime-behavior>` + +callable or :ref:`strftime behavior <strftime-strptime-behavior>` +:xref_param_type:`callable` or :ref:`strftime behavior <strftime-strptime-behavior>` + +list(int) +:xref_param_type:`list`\(:xref_param_type:`int`) + +list[int] +:xref_param_type:`list`\[:xref_param_type:`int`] + +dict(str, int) +:xref_param_type:`dict`\(:xref_param_type:`str`, :xref_param_type:`int`) + +dict[str, int] +:xref_param_type:`dict`\[:xref_param_type:`str`, :xref_param_type:`int`] + +tuple(float, float) +:xref_param_type:`tuple`\(:xref_param_type:`float`, :xref_param_type:`float`) + +dict[tuple(str, str), int] +:xref_param_type:`dict`\[:xref_param_type:`tuple`\(:xref_param_type:`str`, :xref_param_type:`str`), :xref_param_type:`int`] +""" # noqa: E501 + +xref_ignore = {'or', 'in', 'of', 'default', 'optional'} + + +def test_make_xref_param_type(): + for s in data.strip().split('\n\n'): + param_type, expected_result = s.split('\n') + result = make_xref_param_type( + param_type, + xref_aliases, + xref_ignore + ) + assert_equal(result, expected_result) diff --git a/numpydoc/xref.py b/numpydoc/xref.py new file mode 100644 index 0000000..5804a7c --- /dev/null +++ b/numpydoc/xref.py @@ -0,0 +1,185 @@ +import re + +from docutils import nodes +from sphinx import addnodes +from sphinx.util.nodes import split_explicit_title + +# When sphinx (including the napoleon extension) parses the parameters +# section of a docstring, it converts the information into field lists. +# Some items in the list are for the parameter type. When the type fields +# are processed, the text is split and some tokens are turned into +# pending_xref nodes. These nodes are responsible for creating links. +# +# numpydoc does not create field lists, so the type information is +# not placed into fields that can be processed to make links. Instead, +# when parsing the type information we identify tokens that are link +# worthy and wrap them around a special role (xref_param_type_role). +# When the role is processed, we create pending_xref nodes which are +# later turned into links. + +# Note: we never split on commas that are not followed by a space +# You risk creating bad rst markup if you do so. + +QUALIFIED_NAME_RE = re.compile( + # e.g int, numpy.array, ~numpy.array, .class_in_current_module + r'^' + r'[~\.]?' + r'[a-zA-Z_]\w*' + r'(?:\.[a-zA-Z_]\w*)*' + r'$' +) + +CONTAINER_SPLIT_RE = re.compile( + # splits dict(str, int) into + # ['dict', '[', 'str', ', ', 'int', ']', ''] + r'(\s*[\[\]\(\)\{\}]\s*|,\s+)' +) + +CONTAINER_SPLIT_REJECT_RE = re.compile( + # Leads to bad markup e.g. + # {int}qualified_name + r'[\]\)\}]\w' +) + +DOUBLE_QUOTE_SPLIT_RE = re.compile( + # splits 'callable ``f(x0, *args)`` or ``f(x0, y0, *args)``' into + # ['callable ', '``f(x0, *args)``', ' or ', '``f(x0, y0, *args)``', ''] + r'(``.+?``)' +) + +ROLE_SPLIT_RE = re.compile( + # splits to preserve ReST roles + r'(:\w+:`.+?(?<!\\)`)' +) + +SINGLE_QUOTE_SPLIT_RE = re.compile( + # splits to preserve quoted expressions roles + r'(`.+?`)' +) + +TEXT_SPLIT_RE = re.compile( + # splits on ' or ', ' | ', ', ' and ' ' + r'(\s+or\s+|\s+\|\s+|,\s+|\s+)' +) + +CONTAINER_CHARS = set('[](){}') + + +def make_xref_param_type(param_type, xref_aliases, xref_ignore): + """ + Enclose str in a role that creates a cross-reference + The role ``xref_param_type`` *may be* added to any token + that looks like type information and no other. The + function tries to be clever and catch type information + in different disguises. + + Parameters + ---------- + param_type : str + text + xref_aliases : dict + Mapping used to resolve common abbreviations and aliases + to fully qualified names that can be cross-referenced. + xref_ignore : set + Words not to cross-reference. + + Returns + ------- + out : str + Text with parts that may be wrapped in a + ``xref_param_type`` role. + """ + if param_type in xref_aliases: + link, title = xref_aliases[param_type], param_type + param_type = link + else: + link = title = param_type + + if QUALIFIED_NAME_RE.match(link) and link not in xref_ignore: + if link != title: + return ':xref_param_type:`%s <%s>`' % (title, link) + else: + return ':xref_param_type:`%s`' % link + + def _split_and_apply_re(s, pattern): + """ + Split string using the regex pattern, + apply main function to the parts that do not match the pattern, + combine the results + """ + results = [] + tokens = pattern.split(s) + n = len(tokens) + if n > 1: + for i, tok in enumerate(tokens): + if pattern.match(tok): + results.append(tok) + else: + res = make_xref_param_type( + tok, xref_aliases, xref_ignore) + # Openning brackets immediated after a role is + # bad markup. Detect that and add backslash. + # :role:`type`( to :role:`type`\( + if res and res[-1] == '`' and i < n-1: + next_char = tokens[i+1][0] + if next_char in '([{': + res += '\\' + results.append(res) + + return ''.join(results) + return s + + # The cases are dealt with in an order the prevents + # conflict. + # Then the strategy is: + # - Identify a pattern we are not interested in + # - split off the pattern + # - re-apply the function to the other parts + # - join the results with the pattern + + # Unsplittable literal + if '``' in param_type: + return _split_and_apply_re(param_type, DOUBLE_QUOTE_SPLIT_RE) + + # Any roles + if ':`' in param_type: + return _split_and_apply_re(param_type, ROLE_SPLIT_RE) + + # Any quoted expressions + if '`' in param_type: + return _split_and_apply_re(param_type, SINGLE_QUOTE_SPLIT_RE) + + # Any sort of bracket '[](){}' + if any(c in CONTAINER_CHARS for c in param_type): + if CONTAINER_SPLIT_REJECT_RE.search(param_type): + return param_type + return _split_and_apply_re(param_type, CONTAINER_SPLIT_RE) + + # Common splitter tokens + return _split_and_apply_re(param_type, TEXT_SPLIT_RE) + + +def xref_param_type_role(role, rawtext, text, lineno, inliner, + options={}, content=[]): + """ + Add a pending_xref for the param_type of a field list + """ + has_title, title, target = split_explicit_title(text) + if has_title: + target = target.lstrip('~') + else: + if target.startswith(('~', '.')): + prefix, target = target[0], target[1:] + if prefix == '.': + env = inliner.document.settings.env + modname = env.ref_context.get('py:module') + target = target[1:] + target = '%s.%s' % (modname, target) + elif prefix == '~': + title = target.split('.')[-1] + + contnode = nodes.Text(title, title) + node = addnodes.pending_xref('', refdomain='py', refexplicit=False, + reftype='class', reftarget=target) + node += contnode + return [node], [] |