diff --git a/.gitignore b/.gitignore index 81562fd2..2b54b226 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ *.swo build dist +doc/_build diff --git a/.travis.yml b/.travis.yml index c19e178a..8ffb2bbc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,23 +1,21 @@ # After changing this file, check it on: # http://lint.travis-ci.org/ language: python +dist: xenial sudo: false -env: - - SPHINX_SPEC="Sphinx==1.2.3" - - SPHINX_SPEC="Sphinx" matrix: include: - - python: 3.6 + - python: 3.7 + env: SPHINX_SPEC="==1.2.3" SPHINXOPTS="" + - python: 3.7 - python: 2.7 - env: - - SPHINXOPTS='-W' cache: directories: - $HOME/.cache/pip before_install: - sudo apt-get install texlive texlive-latex-extra latexmk - pip install --upgrade pip setuptools # Upgrade pip and setuptools to get ones with `wheel` support - - pip install pytest numpy matplotlib ${SPHINX_SPEC} + - pip install pytest numpy matplotlib sphinx${SPHINX_SPEC} script: - | python setup.py sdist diff --git a/doc/Makefile b/doc/Makefile index 508376e4..0612444f 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -2,7 +2,7 @@ # # You can set these variables from the command line. -SPHINXOPTS = +SPHINXOPTS = -nWT --keep-going SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build diff --git a/doc/conf.py b/doc/conf.py index ef2788e2..e23939ef 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -82,6 +82,8 @@ release = numpydoc.__version__ version = re.sub(r'(\d+\.\d+)\.\d+(.*)', r'\1\2', numpydoc.__version__) version = re.sub(r'(\.dev\d+).*?$', r'\1', version) +numpydoc_xref_param_type = True +numpydoc_xref_ignore = {'optional', 'type_without_description', 'BadException'} # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -269,5 +271,6 @@ # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = { 'python': ('http://docs.python.org/', None), - 'scikitlearn': ('http://scikit-learn.org/stable/', None), + 'numpy': ('https://www.numpy.org/devdocs', None), + 'sklearn': ('http://scikit-learn.org/stable/', None), } diff --git a/doc/example.py b/doc/example.py index 1bae2d5e..592c2b16 100644 --- a/doc/example.py +++ b/doc/example.py @@ -78,10 +78,10 @@ def foo(var1, var2, long_var_name='hi'): See Also -------- - otherfunc : relationship (optional) - newfunc : Relationship (optional), which could be fairly long, in which - case the line wraps here. - thirdfunc, fourthfunc, fifthfunc + numpy.array : relationship (optional) + numpy.ndarray : Relationship (optional), which could be fairly long, in + which case the line wraps here. + numpy.dot, numpy.linalg.norm, numpy.eye Notes ----- diff --git a/doc/install.rst b/doc/install.rst index 1fa0fde5..2d2c074a 100644 --- a/doc/install.rst +++ b/doc/install.rst @@ -47,6 +47,52 @@ 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. + ``False`` by default. + + .. note:: Depending on the link types, the CSS styles might be different. + consider overridding e.g. ``span.classifier a span.xref`` and + ``span.classifier a code.docutils.literal.notranslate`` + CSS classes to achieve a uniform appearance. + +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), + ... + } + + The default ``numpydoc_xref_aliases`` will supply some common ``Python`` + standard library and ``NumPy`` names for you. Then for your module, a useful + ``dict`` may look like the following (e.g., if you were documenting + :mod:`sklearn.model_selection`):: + + numpydoc_xref_aliases = { + 'LeaveOneOut': 'sklearn.model_selection.LeaveOneOut', + ... + } + + 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 5f7843b2..a4cc71d7 100644 --- a/numpydoc/docscrape_sphinx.py +++ b/numpydoc/docscrape_sphinx.py @@ -17,6 +17,7 @@ from sphinx.jinja2glue import BuiltinTemplateLoader from .docscrape import NumpyDocString, FunctionDoc, ClassDoc +from .xref import make_xref if sys.version_info[0] >= 3: sixu = lambda s: s @@ -37,6 +38,9 @@ def load_config(self, config): 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 @@ def _str_returns(self, name='Returns'): 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, + 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: @@ -158,10 +168,8 @@ def _process_param(self, param, desc, fake_autosummary): prefix = getattr(self, '_name', '') if prefix: - autosum_prefix = '~%s.' % prefix link_prefix = '%s.' % prefix else: - autosum_prefix = '' link_prefix = '' # Referenced object has a docstring @@ -213,8 +221,15 @@ def _str_param_list(self, name, fake_autosummary=False): parts = [] if display_param: parts.append(display_param) - if param.type: - parts.append(param.type) + param_type = param.type + if param_type: + param_type = param.type + if self.xref_param_type: + param_type = make_xref( + 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 f6f262ce..e1b8f263 100644 --- a/numpydoc/numpydoc.py +++ b/numpydoc/numpydoc.py @@ -37,6 +37,7 @@ raise RuntimeError("Sphinx 1.0.1 or newer is required") from .docscrape_sphinx import get_doc_object +from .xref import DEFAULT_LINKS 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.connect('builder-inited', update_config) 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', False, 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) @@ -239,6 +248,14 @@ def setup(app, get_doc_object_=get_doc_object): 'parallel_read_safe': True} return metadata + +def update_config(app): + """Update the configuration with default values.""" + for key, value in DEFAULT_LINKS.items(): + if key not in app.config.numpydoc_xref_aliases: + app.config.numpydoc_xref_aliases[key] = value + + # ------------------------------------------------------------------------------ # Docstring-mangling domains # ------------------------------------------------------------------------------ diff --git a/numpydoc/tests/test_docscrape.py b/numpydoc/tests/test_docscrape.py index c6f9d08a..f6844669 100644 --- a/numpydoc/tests/test_docscrape.py +++ b/numpydoc/tests/test_docscrape.py @@ -1,6 +1,7 @@ # -*- encoding:utf-8 -*- from __future__ import division, absolute_import, print_function +from collections import namedtuple import re import sys import textwrap @@ -8,6 +9,7 @@ import jinja2 +from numpydoc.numpydoc import update_config from numpydoc.docscrape import ( NumpyDocString, FunctionDoc, @@ -382,12 +384,14 @@ def _strip_blank_lines(s): return s -def line_by_line_compare(a, b): +def line_by_line_compare(a, b, n_lines=None): a = textwrap.dedent(a) b = textwrap.dedent(b) - a = [l.rstrip() for l in _strip_blank_lines(a).split('\n')] - b = [l.rstrip() for l in _strip_blank_lines(b).split('\n')] - assert all(x == y for x, y in zip(a, b)), str([[x, y] for x, y in zip(a, b) if x != y]) + a = [l.rstrip() for l in _strip_blank_lines(a).split('\n')][:n_lines] + b = [l.rstrip() for l in _strip_blank_lines(b).split('\n')][:n_lines] + assert len(a) == len(b) + for ii, (aa, bb) in enumerate(zip(a, b)): + assert aa == bb def test_str(): @@ -516,8 +520,7 @@ def test_yield_str(): The number of bananas. int The number of unknowns. - -.. index:: """) +""") def test_receives_str(): @@ -535,8 +538,7 @@ def test_receives_str(): The number of bananas. c : int The number of oranges. - -.. index:: """) +""") def test_no_index_in_str(): @@ -1198,8 +1200,6 @@ def test_class_members_doc(): b c - .. index:: - """) @@ -1420,7 +1420,88 @@ def test_autoclass(): .. rubric:: Methods - ''') + ''', 5) + + +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 = r""" +Test xref in Parameters, Other Parameters and Returns + + +:Parameters: + + **p1** : :class:`python:int` + Integer value + + **p2** : :class:`python:float`, optional + Integer value + +:Returns: + + **out** : :obj:`array ` + Numerical return value + + +:Other Parameters: + + **p3** : :class:`python:list`\[:class:`python:int`] + List of integers + + **p4** : :class:`pandas.DataFrame` + A dataframe + + **p5** : :obj:`python:sequence` of `int` + A sequence +""" + + +def test_xref(): + xref_aliases = { + 'sequence': ':obj:`python:sequence`', + } + config = namedtuple('numpydoc_xref_aliases', + 'numpydoc_xref_aliases')(xref_aliases) + app = namedtuple('config', 'config')(config) + update_config(app) + + 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__": diff --git a/numpydoc/tests/test_numpydoc.py b/numpydoc/tests/test_numpydoc.py index 0fd3c37a..635a271d 100644 --- a/numpydoc/tests/test_numpydoc.py +++ b/numpydoc/tests/test_numpydoc.py @@ -10,6 +10,9 @@ class MockConfig(): numpydoc_show_class_members = True numpydoc_show_inherited_class_members = True numpydoc_class_members_toctree = True + numpydoc_xref_param_type = False + numpydoc_xref_aliases = {} + numpydoc_xref_ignore = set() templates_path = [] numpydoc_edit_link = False numpydoc_citation_re = '[a-z0-9_.-]+' @@ -41,7 +44,7 @@ def test_mangle_docstrings(): doc = mangle_docstrings(MockApp(), 'class', 'str', str, {'members': ['upper']}, lines) assert 'rpartition' not in [x.strip() for x in lines] assert 'upper' in [x.strip() for x in lines] - + lines = s.split('\n') doc = mangle_docstrings(MockApp(), 'class', 'str', str, {'exclude-members': ALL}, lines) assert 'rpartition' not in [x.strip() for x in lines] diff --git a/numpydoc/tests/test_xref.py b/numpydoc/tests/test_xref.py new file mode 100644 index 00000000..6e3170ef --- /dev/null +++ b/numpydoc/tests/test_xref.py @@ -0,0 +1,127 @@ +# -*- encoding:utf-8 -*- +from __future__ import division, absolute_import, print_function + +from numpydoc.xref import make_xref + +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 = r""" +(...) array_like, float, optional +(...) :term:`numpy:array_like`, :obj:`float`, optional + +(2,) ndarray +(2,) :obj:`ndarray ` + +(...,M,N) array_like +(...,M,N) :term:`numpy:array_like` + +(..., M, N) array_like +(..., :obj:`M`, :obj:`N`) :term:`numpy:array_like` + +(float, float), optional +(:obj:`float`, :obj:`float`), optional + +1-D array or sequence +1-D :obj:`array ` or :term:`python:sequence` + +array of str or unicode-like +:obj:`array ` of :obj:`str` or unicode-like + +array_like of float +:term:`numpy:array_like` of :obj:`float` + +bool or callable +:obj:`bool` or :obj:`callable` + +int in [0, 255] +:obj:`int` in [0, 255] + +int or None, optional +:obj:`int` or :obj:`None`, optional + +list of str or array_like +:obj:`list` of :obj:`str` or :term:`numpy:array_like` + +sequence of array_like +:term:`python:sequence` of :term:`numpy:array_like` + +str or pathlib.Path +:obj:`str` or :obj:`pathlib.Path` + +{'', string}, optional +{'', :obj:`string `}, 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'} +{:obj:`False`, :obj:`True`, 'greedy', 'optimal'} + +{{'begin', 1}, {'end', 0}}, {string, int} +{{'begin', 1}, {'end', 0}}, {:obj:`string `, :obj:`int`} + +callable f'(x,*args) +:obj:`callable` f'(x,*args) + +callable ``fhess(x, *args)``, optional +:obj:`callable` ``fhess(x, *args)``, optional + +spmatrix (format: ``csr``, ``bsr``, ``dia`` or coo``) +:obj:`spmatrix` (format: ``csr``, ``bsr``, ``dia`` or coo``) + +:ref:`strftime ` +:ref:`strftime ` + +callable or :ref:`strftime ` +:obj:`callable` or :ref:`strftime ` + +callable or :ref:`strftime behavior ` +:obj:`callable` or :ref:`strftime behavior ` + +list(int) +:obj:`list`\(:obj:`int`) + +list[int] +:obj:`list`\[:obj:`int`] + +dict(str, int) +:obj:`dict`\(:obj:`str`, :obj:`int`) + +dict[str, int] +:obj:`dict`\[:obj:`str`, :obj:`int`] + +tuple(float, float) +:obj:`tuple`\(:obj:`float`, :obj:`float`) + +dict[tuple(str, str), int] +:obj:`dict`\[:obj:`tuple`\(:obj:`str`, :obj:`str`), :obj:`int`] +""" # noqa: E501 + +xref_ignore = {'or', 'in', 'of', 'default', 'optional'} + + +def test_make_xref(): + for s in data.strip().split('\n\n'): + param_type, expected_result = s.split('\n') + result = make_xref( + param_type, + xref_aliases, + xref_ignore + ) + assert result == expected_result diff --git a/numpydoc/xref.py b/numpydoc/xref.py new file mode 100644 index 00000000..65542209 --- /dev/null +++ b/numpydoc/xref.py @@ -0,0 +1,183 @@ +import re + +# 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 :obj: role. + +# 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+:`.+?(?`', + 'boolean': ':ref:`bool `', + 'True': ':data:`python:True`', + 'False': ':data:`python:False`', + 'list': ':class:`python:list`', + 'tuple': ':class:`python:tuple`', + 'str': ':class:`python:str`', + 'string': ':class:`python:str`', + 'dict': ':class:`python:dict`', + 'float': ':class:`python:float`', + 'int': ':class:`python:int`', + 'callable': ':func:`python:callable`', + 'iterable': ':term:`python:iterable`', + 'sequence': ':term:`python:sequence`', + 'contextmanager': ':func:`python:contextlib.contextmanager`', + 'namedtuple': ':func:`python:collections.namedtuple`', + 'generator': ':term:`python:generator`', + # NumPy + 'array': 'numpy.ndarray', + 'ndarray': 'numpy.ndarray', + 'np.ndarray': 'numpy.ndarray', + 'array-like': ':term:`numpy:array_like`', + 'array_like': ':term:`numpy:array_like`', + 'scalar': ':ref:`scalar `', + 'RandomState': 'numpy.random.RandomState', + 'np.random.RandomState': 'numpy.random.RandomState', + 'np.inf': ':data:`numpy.inf`', + 'np.nan': ':data:`numpy.nan`', + 'numpy': ':mod:`numpy`', +} + + +def make_xref(param_type, xref_aliases, xref_ignore): + """Enclose str in a :obj: role. + + 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 + ``:obj:`` 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 ':obj:`%s <%s>`' % (title, link) + else: + return ':obj:`%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( + 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)