diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..4b322d1c --- /dev/null +++ b/conftest.py @@ -0,0 +1,15 @@ +# Configure test collection to skip doctests for modules that depend on sphinx +# when sphinx is not available in the environment. + +try: + import sphinx + + has_sphinx = True +except ImportError: + has_sphinx = False + + +collect_ignore = [] + +if not has_sphinx: + collect_ignore += ["numpydoc/numpydoc.py", "numpydoc/docscrape_sphinx.py"] diff --git a/numpydoc/__init__.py b/numpydoc/__init__.py index 5458b67f..632b7063 100644 --- a/numpydoc/__init__.py +++ b/numpydoc/__init__.py @@ -5,8 +5,15 @@ from ._version import __version__ +# NOTE: Determine whether sphinx is installed with an explicit import. +# If so, define the setup function for registering the numpydoc extension; +# otherwise skip this step. +try: + import sphinx -def setup(app, *args, **kwargs): - from .numpydoc import setup + def setup(app, *args, **kwargs): + from .numpydoc import setup - return setup(app, *args, **kwargs) + return setup(app, *args, **kwargs) +except ModuleNotFoundError: + pass diff --git a/numpydoc/_utils.py b/numpydoc/_utils.py new file mode 100644 index 00000000..cb3d9f95 --- /dev/null +++ b/numpydoc/_utils.py @@ -0,0 +1,13 @@ +import re + + +def _clean_text_signature(sig): + if sig is None: + return None + start_pattern = re.compile(r"^[^(]*\(") + start, end = start_pattern.search(sig).span() + start_sig = sig[start:end] + sig = sig[end:-1] + sig = re.sub(r"^\$(self|module|type)(,\s|$)", "", sig, count=1) + sig = re.sub(r"(^|(?<=,\s))/,\s\*", "*", sig, count=1) + return start_sig + sig + ")" diff --git a/numpydoc/cli.py b/numpydoc/cli.py index 30daa3f5..5fb217bd 100644 --- a/numpydoc/cli.py +++ b/numpydoc/cli.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import List -from .docscrape_sphinx import get_doc_object +from .docscrape import get_doc_object from .hooks import utils, validate_docstrings from .validate import ERROR_MSGS, Validator, validate diff --git a/numpydoc/docscrape.py b/numpydoc/docscrape.py index 26a08259..f468b27a 100644 --- a/numpydoc/docscrape.py +++ b/numpydoc/docscrape.py @@ -574,7 +574,7 @@ def dedent_lines(lines): class FunctionDoc(NumpyDocString): - def __init__(self, func, role="func", doc=None, config=None): + def __init__(self, func, role=None, doc=None, config=None): self._f = func self._role = role # e.g. "func" or "meth" diff --git a/numpydoc/numpydoc.py b/numpydoc/numpydoc.py index 2b9757d7..70a5ed77 100644 --- a/numpydoc/numpydoc.py +++ b/numpydoc/numpydoc.py @@ -34,6 +34,7 @@ from sphinx.util import logging from . import __version__ +from ._utils import _clean_text_signature from .docscrape_sphinx import get_doc_object from .validate import get_validation_checks, validate from .xref import DEFAULT_LINKS @@ -301,18 +302,6 @@ def mangle_signature(app: SphinxApp, what, name, obj, options, sig, retann): return sig, "" -def _clean_text_signature(sig): - if sig is None: - return None - start_pattern = re.compile(r"^[^(]*\(") - start, end = start_pattern.search(sig).span() - start_sig = sig[start:end] - sig = sig[end:-1] - sig = re.sub(r"^\$(self|module|type)(,\s|$)", "", sig, count=1) - sig = re.sub(r"(^|(?<=,\s))/,\s\*", "*", sig, count=1) - return start_sig + sig + ")" - - def setup(app: SphinxApp, get_doc_object_=get_doc_object): if not hasattr(app, "add_config_value"): return None # probably called by nose, better bail out diff --git a/numpydoc/tests/hooks/test_utils.py b/numpydoc/tests/hooks/test_utils.py index 5db63762..918b9045 100644 --- a/numpydoc/tests/hooks/test_utils.py +++ b/numpydoc/tests/hooks/test_utils.py @@ -23,7 +23,11 @@ def test_find_project_root(tmp_path, request, reason_file, files, expected_reaso (tmp_path / reason_file).touch() if files: - expected_dir = Path("/") if expected_reason == "file system root" else tmp_path + if expected_reason == "file system root": + expected_dir = Path(tmp_path.drive + tmp_path.root) + else: + expected_dir = tmp_path + for file in files: (tmp_path / file).touch() else: diff --git a/numpydoc/tests/hooks/test_validate_hook.py b/numpydoc/tests/hooks/test_validate_hook.py index b235313b..c9acad3f 100644 --- a/numpydoc/tests/hooks/test_validate_hook.py +++ b/numpydoc/tests/hooks/test_validate_hook.py @@ -1,6 +1,7 @@ """Test the numpydoc validate pre-commit hook.""" import inspect +import sys from pathlib import Path import pytest @@ -63,6 +64,8 @@ def test_validate_hook(example_module, config, capsys): numpydoc/tests/hooks/example_module.py:30: GL08 The object does not have a docstring """ ) + if sys.platform == "win32": + expected = expected.replace("/", "\\") return_code = run_hook([example_module], config=config) assert return_code == 1 @@ -90,6 +93,8 @@ def test_validate_hook_with_ignore(example_module, capsys): numpydoc/tests/hooks/example_module.py:30: GL08 The object does not have a docstring """ ) + if sys.platform == "win32": + expected = expected.replace("/", "\\") return_code = run_hook([example_module], ignore=["ES01", "SA01", "EX01"]) @@ -133,6 +138,8 @@ def test_validate_hook_with_toml_config(example_module, tmp_path, capsys): numpydoc/tests/hooks/example_module.py:30: GL08 The object does not have a docstring """ ) + if sys.platform == "win32": + expected = expected.replace("/", "\\") return_code = run_hook([example_module], config=tmp_path) assert return_code == 1 @@ -168,6 +175,8 @@ def test_validate_hook_with_setup_cfg(example_module, tmp_path, capsys): numpydoc/tests/hooks/example_module.py:30: GL08 The object does not have a docstring """ ) + if sys.platform == "win32": + expected = expected.replace("/", "\\") return_code = run_hook([example_module], config=tmp_path) assert return_code == 1 @@ -209,6 +218,8 @@ def test_validate_hook_exclude_option_pyproject(example_module, tmp_path, capsys numpydoc/tests/hooks/example_module.py:30: GL08 The object does not have a docstring """ ) + if sys.platform == "win32": + expected = expected.replace("/", "\\") return_code = run_hook([example_module], config=tmp_path) assert return_code == 1 @@ -242,6 +253,8 @@ def test_validate_hook_exclude_option_setup_cfg(example_module, tmp_path, capsys numpydoc/tests/hooks/example_module.py:17: PR07 Parameter "*args" has no description """ ) + if sys.platform == "win32": + expected = expected.replace("/", "\\") return_code = run_hook([example_module], config=tmp_path) assert return_code == 1 diff --git a/numpydoc/tests/test_docscrape.py b/numpydoc/tests/test_docscrape.py index cf7c0de9..6c81f952 100644 --- a/numpydoc/tests/test_docscrape.py +++ b/numpydoc/tests/test_docscrape.py @@ -4,19 +4,10 @@ from collections import namedtuple from copy import deepcopy -import jinja2 import pytest from pytest import warns as assert_warns -from numpydoc.docscrape import ClassDoc, FunctionDoc, NumpyDocString -from numpydoc.docscrape_sphinx import ( - SphinxClassDoc, - SphinxDocString, - SphinxFunctionDoc, - get_doc_object, -) -from numpydoc.numpydoc import update_config -from numpydoc.xref import DEFAULT_LINKS +from numpydoc.docscrape import ClassDoc, FunctionDoc, NumpyDocString, get_doc_object doc_txt = """\ numpy.multivariate_normal(mean, cov, shape=None, spam=None) @@ -338,10 +329,10 @@ def dummy_func(arg): """ with pytest.raises(ValueError, match="Dummy class"): - SphinxClassDoc(Dummy) + ClassDoc(Dummy) with pytest.raises(ValueError, match="dummy_func"): - SphinxFunctionDoc(dummy_func) + FunctionDoc(dummy_func) def test_notes(doc): @@ -570,153 +561,6 @@ def test_no_index_in_str(): ) -def test_sphinx_str(): - sphinx_doc = SphinxDocString(doc_txt) - line_by_line_compare( - str(sphinx_doc), - """ -.. index:: random - single: random;distributions, random;gauss - -Draw values from a multivariate normal distribution with specified -mean and covariance. - -The multivariate normal or Gaussian distribution is a generalisation -of the one-dimensional normal distribution to higher dimensions. - -:Parameters: - - **mean** : (N,) ndarray - Mean of the N-dimensional distribution. - - .. math:: - - (1+2+3)/3 - - **cov** : (N, N) ndarray - Covariance matrix of the distribution. - - **shape** : tuple of ints - Given a shape of, for example, (m,n,k), m*n*k samples are - generated, and packed in an m-by-n-by-k arrangement. Because - each sample is N-dimensional, the output shape is (m,n,k,N). - - **dtype** : data type object, optional (default : float) - The type and size of the data to be returned. - -:Returns: - - **out** : ndarray - The drawn samples, arranged according to `shape`. If the - shape given is (m,n,...), then the shape of `out` is - (m,n,...,N). - - In other words, each entry ``out[i,j,...,:]`` is an N-dimensional - value drawn from the distribution. - - list of str - This is not a real return value. It exists to test - anonymous return values. - - no_description - .. - -:Other Parameters: - - **spam** : parrot - A parrot off its mortal coil. - -:Raises: - - RuntimeError - Some error - -:Warns: - - RuntimeWarning - Some warning - -.. warning:: - - Certain warnings apply. - -.. seealso:: - - :obj:`some`, :obj:`other`, :obj:`funcs` - .. - :obj:`otherfunc` - relationship - :py:meth:`spyder.widgets.mixins.GetHelpMixin.show_object_info` - .. - -.. rubric:: Notes - -Instead of specifying the full covariance matrix, popular -approximations include: - - - Spherical covariance (`cov` is a multiple of the identity matrix) - - Diagonal covariance (`cov` has non-negative elements only on the diagonal) - -This geometrical property can be seen in two dimensions by plotting -generated data-points: - ->>> mean = [0,0] ->>> cov = [[1,0],[0,100]] # diagonal covariance, points lie on x or y-axis - ->>> x,y = multivariate_normal(mean,cov,5000).T ->>> plt.plot(x,y,'x'); plt.axis('equal'); plt.show() - -Note that the covariance matrix must be symmetric and non-negative -definite. - -.. rubric:: References - -.. [1] A. Papoulis, "Probability, Random Variables, and Stochastic - Processes," 3rd ed., McGraw-Hill Companies, 1991 -.. [2] R.O. Duda, P.E. Hart, and D.G. Stork, "Pattern Classification," - 2nd ed., Wiley, 2001. - -.. only:: latex - - [1]_, [2]_ - -.. rubric:: Examples - ->>> mean = (1,2) ->>> cov = [[1,0],[1,0]] ->>> x = multivariate_normal(mean,cov,(3,3)) ->>> print(x.shape) -(3, 3, 2) - -The following is probably true, given that 0.6 is roughly twice the -standard deviation: - ->>> print(list((x[0, 0, :] - mean) < 0.6)) -[True, True] -""", - ) - - -def test_sphinx_yields_str(): - sphinx_doc = SphinxDocString(doc_yields_txt) - line_by_line_compare( - str(sphinx_doc), - """Test generator - -:Yields: - - **a** : int - The number of apples. - - **b** : int - The number of bananas. - - int - The number of unknowns. -""", - ) - - doc2 = NumpyDocString( """ Returns array of indices of the maximum values of along the given axis. @@ -945,13 +789,6 @@ class BadSection: NumpyDocString(doc_text) assert len(record) == 1 - # SphinxClassDoc has _obj.__name__ == "BadSection". Test that this is - # included in the message - msg_match = "Unknown section Nope in the docstring of BadSection" - with pytest.warns(UserWarning, match=msg_match) as record: - SphinxClassDoc(BadSection) - assert len(record) == 1 - doc7 = NumpyDocString( """ @@ -999,18 +836,8 @@ def test_trailing_colon(): assert doc8["Parameters"][0].name == "data" -def test_no_summary(): - str( - SphinxDocString( - """ - Parameters - ----------""" - ) - ) - - def test_unicode(): - doc = SphinxDocString( + doc = NumpyDocString( """ öäöäöäöäöåååå @@ -1032,48 +859,6 @@ def test_unicode(): assert doc["Summary"][0] == "öäöäöäöäöåååå" -def test_plot_examples(): - cfg = dict(use_plots=True) - - doc = SphinxDocString( - """ - Examples - -------- - >>> import matplotlib.pyplot as plt - >>> plt.plot([1,2,3],[4,5,6]) - >>> plt.show() - """, - config=cfg, - ) - assert "plot::" in str(doc), str(doc) - - doc = SphinxDocString( - """ - Examples - -------- - >>> from matplotlib import pyplot as plt - >>> plt.plot([1,2,3],[4,5,6]) - >>> plt.show() - """, - config=cfg, - ) - assert "plot::" in str(doc), str(doc) - - doc = SphinxDocString( - """ - Examples - -------- - .. plot:: - - import matplotlib.pyplot as plt - plt.plot([1,2,3],[4,5,6]) - plt.show() - """, - config=cfg, - ) - assert str(doc).count("plot::") == 1, str(doc) - - def test_class_members(): class Dummy: """ @@ -1095,24 +880,20 @@ def spammity(self): class Ignorable: """local class, to be ignored""" - for cls in (ClassDoc, SphinxClassDoc): - doc = cls(Dummy, config=dict(show_class_members=False)) - assert "Methods" not in str(doc), (cls, str(doc)) - assert "spam" not in str(doc), (cls, str(doc)) - assert "ham" not in str(doc), (cls, str(doc)) - assert "spammity" not in str(doc), (cls, str(doc)) - assert "Spammity index" not in str(doc), (cls, str(doc)) - - doc = cls(Dummy, config=dict(show_class_members=True)) - assert "Methods" in str(doc), (cls, str(doc)) - assert "spam" in str(doc), (cls, str(doc)) - assert "ham" in str(doc), (cls, str(doc)) - assert "spammity" in str(doc), (cls, str(doc)) - - if cls is SphinxClassDoc: - assert ".. autosummary::" in str(doc), str(doc) - else: - assert "Spammity index" in str(doc), str(doc) + doc = ClassDoc(Dummy, config=dict(show_class_members=False)) + assert "Methods" not in str(doc), (ClassDoc, str(doc)) + assert "spam" not in str(doc), (ClassDoc, str(doc)) + assert "ham" not in str(doc), (ClassDoc, str(doc)) + assert "spammity" not in str(doc), (ClassDoc, str(doc)) + assert "Spammity index" not in str(doc), (ClassDoc, str(doc)) + + doc = ClassDoc(Dummy, config=dict(show_class_members=True)) + assert "Methods" in str(doc), (ClassDoc, str(doc)) + assert "spam" in str(doc), (ClassDoc, str(doc)) + assert "ham" in str(doc), (ClassDoc, str(doc)) + assert "spammity" in str(doc), (ClassDoc, str(doc)) + + assert "Spammity index" in str(doc), str(doc) class SubDummy(Dummy): """ @@ -1126,36 +907,29 @@ def ham(self, c, d): def bar(self, a, b): """Bar\n\nNo bar""" - for cls in (ClassDoc, SphinxClassDoc): - doc = cls( - SubDummy, - config=dict(show_class_members=True, show_inherited_class_members=False), - ) - assert "Methods" in str(doc), (cls, str(doc)) - assert "spam" not in str(doc), (cls, str(doc)) - assert "ham" in str(doc), (cls, str(doc)) - assert "bar" in str(doc), (cls, str(doc)) - assert "spammity" not in str(doc), (cls, str(doc)) - - if cls is SphinxClassDoc: - assert ".. autosummary::" in str(doc), str(doc) - else: - assert "Spammity index" not in str(doc), str(doc) - - doc = cls( - SubDummy, - config=dict(show_class_members=True, show_inherited_class_members=True), - ) - assert "Methods" in str(doc), (cls, str(doc)) - assert "spam" in str(doc), (cls, str(doc)) - assert "ham" in str(doc), (cls, str(doc)) - assert "bar" in str(doc), (cls, str(doc)) - assert "spammity" in str(doc), (cls, str(doc)) + doc = ClassDoc( + SubDummy, + config=dict(show_class_members=True, show_inherited_class_members=False), + ) + assert "Methods" in str(doc), (ClassDoc, str(doc)) + assert "spam" not in str(doc), (ClassDoc, str(doc)) + assert "ham" in str(doc), (ClassDoc, str(doc)) + assert "bar" in str(doc), (ClassDoc, str(doc)) + assert "spammity" not in str(doc), (ClassDoc, str(doc)) + + assert "Spammity index" not in str(doc), str(doc) + + doc = ClassDoc( + SubDummy, + config=dict(show_class_members=True, show_inherited_class_members=True), + ) + assert "Methods" in str(doc), (ClassDoc, str(doc)) + assert "spam" in str(doc), (ClassDoc, str(doc)) + assert "ham" in str(doc), (ClassDoc, str(doc)) + assert "bar" in str(doc), (ClassDoc, str(doc)) + assert "spammity" in str(doc), (ClassDoc, str(doc)) - if cls is SphinxClassDoc: - assert ".. autosummary::" in str(doc), str(doc) - else: - assert "Spammity index" in str(doc), str(doc) + assert "Spammity index" in str(doc), str(doc) def test_duplicate_signature(): @@ -1226,226 +1000,6 @@ def test_duplicate_signature(): """ -def test_class_members_doc(): - doc = ClassDoc(None, class_doc_txt) - line_by_line_compare( - str(doc), - """ - Foo - - Parameters - ---------- - f : callable ``f(t, y, *f_args)`` - Aaa. - jac : callable ``jac(t, y, *jac_args)`` - Bbb. - - Attributes - ---------- - t : float - Current time. - y : ndarray - Current variable values. - - * hello - * world - an_attribute : float - The docstring is printed instead - no_docstring : str - But a description - no_docstring2 : str - multiline_sentence - midword_period - no_period - - Methods - ------- - a - b - c - - Other Parameters - ---------------- - another parameter : str - This parameter is less important. - - Notes - ----- - Some notes about the class. - - Examples - -------- - For usage examples, see `ode`. - - """, - ) - - -def test_class_members_doc_sphinx(): - class Foo: - @property - def an_attribute(self): - """Test attribute""" - return - - @property - def no_docstring(self): - return None - - @property - def no_docstring2(self): - return None - - @property - def multiline_sentence(self): - """This is a - sentence. It spans multiple lines.""" - return - - @property - def midword_period(self): - """The sentence for numpy.org.""" - return - - @property - def no_period(self): - """This does not have a period - so we truncate its summary to the first linebreak - - Apparently. - """ - return - - doc = SphinxClassDoc(Foo, class_doc_txt) - line_by_line_compare( - str(doc), - """ - Foo - - :Parameters: - - **f** : callable ``f(t, y, *f_args)`` - Aaa. - - **jac** : callable ``jac(t, y, *jac_args)`` - Bbb. - - :Attributes: - - **t** : float - Current time. - - **y** : ndarray - Current variable values. - - * hello - * world - - :obj:`an_attribute ` : float - Test attribute - - **no_docstring** : str - But a description - - **no_docstring2** : str - .. - - :obj:`multiline_sentence ` - This is a sentence. - - :obj:`midword_period ` - The sentence for numpy.org. - - :obj:`no_period ` - This does not have a period - - .. rubric:: Methods - - ===== ========== - **a** - **b** - **c** - ===== ========== - - :Other Parameters: - - **another parameter** : str - This parameter is less important. - - .. rubric:: Notes - - Some notes about the class. - - .. rubric:: Examples - - For usage examples, see `ode`. - - """, - ) - - -def test_class_attributes_as_member_list(): - class Foo: - """ - Class docstring. - - Attributes - ---------- - an_attribute - Another description that is not used. - - """ - - @property - def an_attribute(self): - """Test attribute""" - return - - attr_doc = """:Attributes: - - :obj:`an_attribute ` - Test attribute""" - - assert attr_doc in str(SphinxClassDoc(Foo)) - assert "Another description" not in str(SphinxClassDoc(Foo)) - - attr_doc2 = """.. rubric:: Attributes - -.. autosummary:: - :toctree: - - an_attribute""" - - cfg = dict(attributes_as_param_list=False) - assert attr_doc2 in str(SphinxClassDoc(Foo, config=cfg)) - assert "Another description" not in str(SphinxClassDoc(Foo, config=cfg)) - - -def test_templated_sections(): - doc = SphinxClassDoc( - None, - class_doc_txt, - config={"template": jinja2.Template("{{examples}}\n{{parameters}}")}, - ) - line_by_line_compare( - str(doc), - """ - .. rubric:: Examples - - For usage examples, see `ode`. - - :Parameters: - - **f** : callable ``f(t, y, *f_args)`` - Aaa. - - **jac** : callable ``jac(t, y, *jac_args)`` - Bbb. - - """, - ) - - def test_nonstandard_property(): # test discovery of a property that does not satisfy isinstace(.., property) @@ -1471,158 +1025,6 @@ class Dummy: assert "test attribute" in str(doc) -def test_args_and_kwargs(): - cfg = dict() - doc = SphinxDocString( - """ - Parameters - ---------- - param1 : int - First parameter - *args : tuple - Arguments - **kwargs : dict - Keyword arguments - """, - config=cfg, - ) - line_by_line_compare( - str(doc), - r""" -:Parameters: - - **param1** : int - First parameter - - **\*args** : tuple - Arguments - - **\*\*kwargs** : dict - Keyword arguments - """, - ) - - -def test_autoclass(): - cfg = dict(show_class_members=True, show_inherited_class_members=True) - doc = SphinxClassDoc( - str, - """ -A top section before - -.. autoclass:: str - """, - config=cfg, - ) - line_by_line_compare( - str(doc), - r""" -A top section before - -.. autoclass:: str - -.. 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`", - } - - class Config: - def __init__(self, a, b): - self.numpydoc_xref_aliases = a - self.numpydoc_xref_aliases_complete = b - # numpydoc.update_config fails if this config option not present - self.numpydoc_validation_checks = set() - self.numpydoc_validation_exclude = set() - self.numpydoc_validation_exclude_files = set() - self.numpydoc_validation_overrides = dict() - - xref_aliases_complete = deepcopy(DEFAULT_LINKS) - for key, val in xref_aliases.items(): - xref_aliases_complete[key] = val - config = Config(xref_aliases, xref_aliases_complete) - 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_complete, - xref_ignore=xref_ignore, - ), - ) - - line_by_line_compare(str(doc), xref_doc_txt_expected) - - def test__error_location_no_name_attr(): """ Ensure that NumpyDocString._error_location doesn't fail when self._obj @@ -1675,9 +1077,9 @@ def test_namedtuple_no_duplicate_attributes(): foo = namedtuple("Foo", ("bar", "baz")) - # Create the SphinxClassDoc object via get_doc_object - sds = get_doc_object(foo) - assert sds["Attributes"] == [] + # Create the ClassDoc object via get_doc_object + nds = get_doc_object(foo) + assert nds["Attributes"] == [] def test_namedtuple_class_docstring(): @@ -1692,9 +1094,9 @@ def test_namedtuple_class_docstring(): class MyFoo(foo): """MyFoo's class docstring""" - # Create the SphinxClassDoc object via get_doc_object - sds = get_doc_object(MyFoo) - assert sds["Summary"] == ["MyFoo's class docstring"] + # Create the ClassDoc object via get_doc_object + nds = get_doc_object(MyFoo) + assert nds["Summary"] == ["MyFoo's class docstring"] # Example dataclass where constructor params are documented explicit. # Parameter names/descriptions should be included in the docstring, but @@ -1714,12 +1116,12 @@ class MyFooWithParams(foo): bar: str baz: str - sds = get_doc_object(MyFooWithParams) - assert "MyFoo's class docstring" in sds["Summary"] - assert len(sds["Attributes"]) == 0 - assert len(sds["Parameters"]) == 2 - assert sds["Parameters"][0].desc[0] == "The bar attribute" - assert sds["Parameters"][1].desc[0] == "The baz attribute" + nds = get_doc_object(MyFooWithParams) + assert "MyFoo's class docstring" in nds["Summary"] + assert len(nds["Attributes"]) == 0 + assert len(nds["Parameters"]) == 2 + assert nds["Parameters"][0].desc[0] == "The bar attribute" + assert nds["Parameters"][1].desc[0] == "The baz attribute" if __name__ == "__main__": diff --git a/numpydoc/tests/test_docscrape_sphinx.py b/numpydoc/tests/test_docscrape_sphinx.py new file mode 100644 index 00000000..357e325e --- /dev/null +++ b/numpydoc/tests/test_docscrape_sphinx.py @@ -0,0 +1,1263 @@ +import pytest + +pytest.importorskip("sphinx") + +import re +import textwrap +import warnings +from collections import namedtuple +from copy import deepcopy + +import jinja2 +from pytest import warns as assert_warns +from test_docscrape import class_doc_txt, doc_sent_txt, doc_txt, doc_yields_txt + +from numpydoc.docscrape_sphinx import ( + SphinxClassDoc, + SphinxDocString, + SphinxFunctionDoc, + get_doc_object, +) +from numpydoc.numpydoc import update_config +from numpydoc.xref import DEFAULT_LINKS + + +@pytest.fixture(params=["", "\n "], ids=["flush", "newline_indented"]) +def doc(request): + return SphinxDocString(request.param + doc_txt) + + +doc_yields = SphinxDocString(doc_yields_txt) +doc_sent = SphinxDocString(doc_sent_txt) + + +def test_signature(doc): + assert doc["Signature"].startswith("numpy.multivariate_normal(") + assert doc["Signature"].endswith("spam=None)") + + +def test_summary(doc): + assert doc["Summary"][0].startswith("Draw values") + assert doc["Summary"][-1].endswith("covariance.") + + +def test_extended_summary(doc): + assert doc["Extended Summary"][0].startswith("The multivariate normal") + + +def test_parameters(doc): + assert len(doc["Parameters"]) == 4 + names = [n for n, _, _ in doc["Parameters"]] + assert all( + a == b for a, b in zip(names, ["mean", "cov", "shape", "dtype"], strict=True) + ) + + arg, arg_type, desc = doc["Parameters"][1] + assert arg_type == "(N, N) ndarray" + assert desc[0].startswith("Covariance matrix") + assert doc["Parameters"][0][-1][-1] == " (1+2+3)/3" + + arg, arg_type, desc = doc["Parameters"][2] + assert arg == "shape" + assert arg_type == "tuple of ints" + assert desc[0].startswith("Given") + assert doc["Parameters"][0][-1][-1] == " (1+2+3)/3" + + arg, arg_type, desc = doc["Parameters"][3] + assert arg == "dtype" + assert arg_type == "data type object, optional (default : float)" + assert desc[0].startswith("The type and size") + + +def test_other_parameters(doc): + assert len(doc["Other Parameters"]) == 1 + assert [n for n, _, _ in doc["Other Parameters"]] == ["spam"] + _arg, arg_type, desc = doc["Other Parameters"][0] + assert arg_type == "parrot" + assert desc[0].startswith("A parrot off its mortal coil") + + +def test_returns(doc): + assert len(doc["Returns"]) == 3 + arg, arg_type, desc = doc["Returns"][0] + assert arg == "out" + assert arg_type == "ndarray" + assert desc[0].startswith("The drawn samples") + assert desc[-1].endswith("distribution.") + + arg, arg_type, desc = doc["Returns"][1] + assert arg == "" + assert arg_type == "list of str" + assert desc[0].startswith("This is not a real") + assert desc[-1].endswith("anonymous return values.") + + arg, arg_type, desc = doc["Returns"][2] + assert arg == "" + assert arg_type == "no_description" + assert not "".join(desc).strip() + + +def test_yields(): + section = doc_yields["Yields"] + assert len(section) == 3 + truth = [ + ("a", "int", "apples."), + ("b", "int", "bananas."), + ("", "int", "unknowns."), + ] + for (arg, arg_type, desc), (arg_, arg_type_, end) in zip( + section, truth, strict=True + ): + assert arg == arg_ + assert arg_type == arg_type_ + assert desc[0].startswith("The number of") + assert desc[0].endswith(end) + + +def test_sent(): + section = doc_sent["Receives"] + assert len(section) == 2 + truth = [("b", "int", "bananas."), ("c", "int", "oranges.")] + for (arg, arg_type, desc), (arg_, arg_type_, end) in zip( + section, truth, strict=True + ): + assert arg == arg_ + assert arg_type == arg_type_ + assert desc[0].startswith("The number of") + assert desc[0].endswith(end) + + +def test_returnyield(): + doc_text = """ +Test having returns and yields. + +Returns +------- +int + The number of apples. + +Yields +------ +a : int + The number of apples. +b : int + The number of bananas. + +""" + doc = SphinxDocString(doc_text) + assert len(doc["Returns"]) == 1 + assert len(doc["Yields"]) == 2 + + +def test_section_twice(): + doc_text = """ +Test having a section Notes twice + +Notes +----- +See the next note for more information + +Notes +----- +That should break... +""" + with pytest.raises(ValueError, match="The section Notes appears twice"): + SphinxDocString(doc_text) + + # if we have a numpydoc object, we know where the error came from + class Dummy: + """ + Dummy class. + + Notes + ----- + First note. + + Notes + ----- + Second note. + + """ + + def spam(self, a, b): + """Spam\n\nSpam spam.""" + + def ham(self, c, d): + """Cheese\n\nNo cheese.""" + + def dummy_func(arg): + """ + Dummy function. + + Notes + ----- + First note. + + Notes + ----- + Second note. + """ + + with pytest.raises(ValueError, match="Dummy class"): + SphinxClassDoc(Dummy) + + with pytest.raises(ValueError, match="dummy_func"): + SphinxFunctionDoc(dummy_func) + + +def test_notes(doc): + assert doc["Notes"][0].startswith("Instead") + assert doc["Notes"][-1].endswith("definite.") + assert len(doc["Notes"]) == 17 + + +def test_references(doc): + assert doc["References"][0].startswith("..") + assert doc["References"][-1].endswith("2001.") + + +def test_examples(doc): + assert doc["Examples"][0].startswith(">>>") + assert doc["Examples"][-1].endswith("True]") + + +def test_index(doc): + assert doc["index"]["default"] == "random" + assert len(doc["index"]) == 2 + assert len(doc["index"]["refguide"]) == 2 + + +def _strip_blank_lines(s): + "Remove leading, trailing and multiple blank lines" + s = re.sub(r"^\s*\n", "", s) + s = re.sub(r"\n\s*$", "", s) + s = re.sub(r"\n\s*\n", r"\n\n", s) + return s + + +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")][: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, strict=True)): + assert aa == bb + + +def test_no_index_in_str(): + assert "index" not in str( + SphinxDocString( + """Test idx + + """ + ) + ) + + assert "index" in str( + SphinxDocString( + """Test idx + + .. index :: random + """ + ) + ) + + assert "index" in str( + SphinxDocString( + """Test idx + + .. index :: + foo + """ + ) + ) + + +def test_sphinx_str(): + sphinx_doc = SphinxDocString(doc_txt) + line_by_line_compare( + str(sphinx_doc), + """ +.. index:: random + single: random;distributions, random;gauss + +Draw values from a multivariate normal distribution with specified +mean and covariance. + +The multivariate normal or Gaussian distribution is a generalisation +of the one-dimensional normal distribution to higher dimensions. + +:Parameters: + + **mean** : (N,) ndarray + Mean of the N-dimensional distribution. + + .. math:: + + (1+2+3)/3 + + **cov** : (N, N) ndarray + Covariance matrix of the distribution. + + **shape** : tuple of ints + Given a shape of, for example, (m,n,k), m*n*k samples are + generated, and packed in an m-by-n-by-k arrangement. Because + each sample is N-dimensional, the output shape is (m,n,k,N). + + **dtype** : data type object, optional (default : float) + The type and size of the data to be returned. + +:Returns: + + **out** : ndarray + The drawn samples, arranged according to `shape`. If the + shape given is (m,n,...), then the shape of `out` is + (m,n,...,N). + + In other words, each entry ``out[i,j,...,:]`` is an N-dimensional + value drawn from the distribution. + + list of str + This is not a real return value. It exists to test + anonymous return values. + + no_description + .. + +:Other Parameters: + + **spam** : parrot + A parrot off its mortal coil. + +:Raises: + + RuntimeError + Some error + +:Warns: + + RuntimeWarning + Some warning + +.. warning:: + + Certain warnings apply. + +.. seealso:: + + :obj:`some`, :obj:`other`, :obj:`funcs` + .. + :obj:`otherfunc` + relationship + :py:meth:`spyder.widgets.mixins.GetHelpMixin.show_object_info` + .. + +.. rubric:: Notes + +Instead of specifying the full covariance matrix, popular +approximations include: + + - Spherical covariance (`cov` is a multiple of the identity matrix) + - Diagonal covariance (`cov` has non-negative elements only on the diagonal) + +This geometrical property can be seen in two dimensions by plotting +generated data-points: + +>>> mean = [0,0] +>>> cov = [[1,0],[0,100]] # diagonal covariance, points lie on x or y-axis + +>>> x,y = multivariate_normal(mean,cov,5000).T +>>> plt.plot(x,y,'x'); plt.axis('equal'); plt.show() + +Note that the covariance matrix must be symmetric and non-negative +definite. + +.. rubric:: References + +.. [1] A. Papoulis, "Probability, Random Variables, and Stochastic + Processes," 3rd ed., McGraw-Hill Companies, 1991 +.. [2] R.O. Duda, P.E. Hart, and D.G. Stork, "Pattern Classification," + 2nd ed., Wiley, 2001. + +.. only:: latex + + [1]_, [2]_ + +.. rubric:: Examples + +>>> mean = (1,2) +>>> cov = [[1,0],[1,0]] +>>> x = multivariate_normal(mean,cov,(3,3)) +>>> print(x.shape) +(3, 3, 2) + +The following is probably true, given that 0.6 is roughly twice the +standard deviation: + +>>> print(list((x[0, 0, :] - mean) < 0.6)) +[True, True] +""", + ) + + +def test_sphinx_yields_str(): + sphinx_doc = SphinxDocString(doc_yields_txt) + line_by_line_compare( + str(sphinx_doc), + """Test generator + +:Yields: + + **a** : int + The number of apples. + + **b** : int + The number of bananas. + + int + The number of unknowns. +""", + ) + + +doc2 = SphinxDocString( + """ + Returns array of indices of the maximum values of along the given axis. + + Parameters + ---------- + a : {array_like} + Array to look in. + axis : {None, integer} + If None, the index is into the flattened array, otherwise along + the specified axis""" +) + + +def test_parameters_without_extended_description(): + assert len(doc2["Parameters"]) == 2 + + +doc4 = SphinxDocString( + """a.conj() + + Return an array with all complex-valued elements conjugated.""" +) + + +def test_empty_extended_summary(): + assert doc4["Extended Summary"] == [] + + +doc5 = SphinxDocString( + """ + a.something() + + Raises + ------ + LinAlgException + If array is singular. + + Warns + ----- + SomeWarning + If needed + """ +) + + +def test_raises(): + assert len(doc5["Raises"]) == 1 + param = doc5["Raises"][0] + assert param.name == "" + assert param.type == "LinAlgException" + assert param.desc == ["If array is singular."] + + +def test_warns(): + assert len(doc5["Warns"]) == 1 + param = doc5["Warns"][0] + assert param.name == "" + assert param.type == "SomeWarning" + assert param.desc == ["If needed"] + + +# see numpydoc/numpydoc #281 +# we want to correctly parse "See Also" both in docstrings both like +# """foo +# and +# """ +# foo +@pytest.mark.parametrize("prefix", ["", "\n "]) +def test_see_also(prefix): + doc6 = SphinxDocString( + prefix + + """z(x,theta) + + See Also + -------- + func_a, func_b, func_c + func_d : some equivalent func + foo.func_e : some other func over + multiple lines + func_f, func_g, :meth:`func_h`, func_j, + func_k + func_f1, func_g1, :meth:`func_h1`, func_j1 + func_f2, func_g2, :meth:`func_h2`, func_j2 : description of multiple + :obj:`baz.obj_q` + :obj:`~baz.obj_r` + :class:`class_j`: fubar + foobar + """ + ) + + assert len(doc6["See Also"]) == 10 + for funcs, desc in doc6["See Also"]: + for func, role in funcs: + if func in ( + "func_a", + "func_b", + "func_c", + "func_f", + "func_g", + "func_h", + "func_j", + "func_k", + "baz.obj_q", + "func_f1", + "func_g1", + "func_h1", + "func_j1", + "~baz.obj_r", + ): + assert not desc, str([func, desc]) + elif func in ("func_f2", "func_g2", "func_h2", "func_j2"): + assert desc, str([func, desc]) + else: + assert desc, str([func, desc]) + + if func == "func_h": + assert role == "meth" + elif func in ("baz.obj_q", "~baz.obj_r"): + assert role == "obj" + elif func == "class_j": + assert role == "class" + elif func in ("func_h1", "func_h2"): + assert role == "meth" + else: + assert role is None, str([func, role]) + + if func == "func_d": + assert desc == ["some equivalent func"] + elif func == "foo.func_e": + assert desc == ["some other func over", "multiple lines"] + elif func == "class_j": + assert desc == ["fubar", "foobar"] + elif func in ("func_f2", "func_g2", "func_h2", "func_j2"): + assert desc == ["description of multiple"], str( + [desc, ["description of multiple"]] + ) + + +def test_see_also_parse_error(): + text = """ + z(x,theta) + + See Also + -------- + :func:`~foo` + """ + with pytest.raises(ValueError, match="See Also entry ':func:`~foo`'"): + SphinxDocString(text) + + +def test_see_also_trailing_comma_warning(): + warnings.filterwarnings("error") + with assert_warns( + Warning, + match="Unexpected comma or period after function list at index 43 of line .*", + ): + SphinxDocString( + """ + z(x,theta) + + See Also + -------- + func_f2, func_g2, :meth:`func_h2`, func_j2, : description of multiple + :class:`class_j`: fubar + foobar + """ + ) + + +def test_unknown_section(): + doc_text = """ +Test having an unknown section + +Mope +---- +This should be ignored and warned about +""" + + class BadSection: + """Class with bad section. + + Nope + ---- + This class has a nope section. + """ + + # SphinxClassDoc has _obj.__name__ == "BadSection". Test that this is + # included in the message + msg_match = "Unknown section Nope in the docstring of BadSection" + with pytest.warns(UserWarning, match=msg_match) as record: + SphinxClassDoc(BadSection) + assert len(record) == 1 + + +doc7 = SphinxDocString( + """ + + Doc starts on second line. + + """ +) + + +def test_empty_first_line(): + assert doc7["Summary"][0].startswith("Doc starts") + + +doc8 = SphinxDocString( + """ + + Parameters with colon and no types: + + Parameters + ---------- + + data : + some stuff, technically invalid + """ +) + + +def test_returns_with_roles_no_names(): + """Make sure colons that are part of sphinx roles are not misinterpreted + as type separator in returns section. See gh-428.""" + docstring = SphinxDocString( + """ + Returns + ------- + str or :class:`NumpyDocString` + """ + ) + expected = "str or :class:`NumpyDocString`" # not "str or : class:... + assert docstring["Returns"][0].type == expected + assert expected in str(docstring) + + +def test_trailing_colon(): + assert doc8["Parameters"][0].name == "data" + + +def test_unicode(): + doc = SphinxDocString( + """ + öäöäöäöäöåååå + + öäöäöäööäååå + + Parameters + ---------- + ååå : äää + ööö + + Returns + ------- + ååå : ööö + äää + + """ + ) + assert isinstance(doc["Summary"][0], str) + assert doc["Summary"][0] == "öäöäöäöäöåååå" + + +def test_plot_examples(): + cfg = dict(use_plots=True) + + doc = SphinxDocString( + """ + Examples + -------- + >>> import matplotlib.pyplot as plt + >>> plt.plot([1,2,3],[4,5,6]) + >>> plt.show() + """, + config=cfg, + ) + assert "plot::" in str(doc), str(doc) + + doc = SphinxDocString( + """ + Examples + -------- + >>> from matplotlib import pyplot as plt + >>> plt.plot([1,2,3],[4,5,6]) + >>> plt.show() + """, + config=cfg, + ) + assert "plot::" in str(doc), str(doc) + + doc = SphinxDocString( + """ + Examples + -------- + .. plot:: + + import matplotlib.pyplot as plt + plt.plot([1,2,3],[4,5,6]) + plt.show() + """, + config=cfg, + ) + assert str(doc).count("plot::") == 1, str(doc) + + +def test_class_members(): + class Dummy: + """ + Dummy class. + + """ + + def spam(self, a, b): + """Spam\n\nSpam spam.""" + + def ham(self, c, d): + """Cheese\n\nNo cheese.""" + + @property + def spammity(self): + """Spammity index""" + return 0.95 + + class Ignorable: + """local class, to be ignored""" + + doc = SphinxClassDoc(Dummy, config=dict(show_class_members=False)) + assert "Methods" not in str(doc), (SphinxClassDoc, str(doc)) + assert "spam" not in str(doc), (SphinxClassDoc, str(doc)) + assert "ham" not in str(doc), (SphinxClassDoc, str(doc)) + assert "spammity" not in str(doc), (SphinxClassDoc, str(doc)) + assert "Spammity index" not in str(doc), (SphinxClassDoc, str(doc)) + + doc = SphinxClassDoc(Dummy, config=dict(show_class_members=True)) + assert "Methods" in str(doc), (SphinxClassDoc, str(doc)) + assert "spam" in str(doc), (SphinxClassDoc, str(doc)) + assert "ham" in str(doc), (SphinxClassDoc, str(doc)) + assert "spammity" in str(doc), (SphinxClassDoc, str(doc)) + + assert ".. autosummary::" in str(doc), str(doc) + + class SubDummy(Dummy): + """ + Subclass of Dummy class. + + """ + + def ham(self, c, d): + """Cheese\n\nNo cheese.\nOverloaded Dummy.ham""" + + def bar(self, a, b): + """Bar\n\nNo bar""" + + doc = SphinxClassDoc( + SubDummy, + config=dict(show_class_members=True, show_inherited_class_members=False), + ) + assert "Methods" in str(doc), (SphinxClassDoc, str(doc)) + assert "spam" not in str(doc), (SphinxClassDoc, str(doc)) + assert "ham" in str(doc), (SphinxClassDoc, str(doc)) + assert "bar" in str(doc), (SphinxClassDoc, str(doc)) + assert "spammity" not in str(doc), (SphinxClassDoc, str(doc)) + + assert ".. autosummary::" in str(doc), str(doc) + + doc = SphinxClassDoc( + SubDummy, + config=dict(show_class_members=True, show_inherited_class_members=True), + ) + assert "Methods" in str(doc), (SphinxClassDoc, str(doc)) + assert "spam" in str(doc), (SphinxClassDoc, str(doc)) + assert "ham" in str(doc), (SphinxClassDoc, str(doc)) + assert "bar" in str(doc), (SphinxClassDoc, str(doc)) + assert "spammity" in str(doc), (SphinxClassDoc, str(doc)) + + assert ".. autosummary::" in str(doc), str(doc) + + +def test_duplicate_signature(): + # Duplicate function signatures occur e.g. in ufuncs, when the + # automatic mechanism adds one, and a more detailed comes from the + # docstring itself. + + doc = SphinxDocString( + """ + z(x1, x2) + + z(a, theta) + """ + ) + + assert doc["Signature"].strip() == "z(a, theta)" + + +def test_class_members_doc_sphinx(): + class Foo: + @property + def an_attribute(self): + """Test attribute""" + return + + @property + def no_docstring(self): + return None + + @property + def no_docstring2(self): + return None + + @property + def multiline_sentence(self): + """This is a + sentence. It spans multiple lines.""" + return + + @property + def midword_period(self): + """The sentence for numpy.org.""" + return + + @property + def no_period(self): + """This does not have a period + so we truncate its summary to the first linebreak + + Apparently. + """ + return + + doc = SphinxClassDoc(Foo, class_doc_txt) + line_by_line_compare( + str(doc), + """ + Foo + + :Parameters: + + **f** : callable ``f(t, y, *f_args)`` + Aaa. + + **jac** : callable ``jac(t, y, *jac_args)`` + Bbb. + + :Attributes: + + **t** : float + Current time. + + **y** : ndarray + Current variable values. + + * hello + * world + + :obj:`an_attribute ` : float + Test attribute + + **no_docstring** : str + But a description + + **no_docstring2** : str + .. + + :obj:`multiline_sentence ` + This is a sentence. + + :obj:`midword_period ` + The sentence for numpy.org. + + :obj:`no_period ` + This does not have a period + + .. rubric:: Methods + + ===== ========== + **a** + **b** + **c** + ===== ========== + + :Other Parameters: + + **another parameter** : str + This parameter is less important. + + .. rubric:: Notes + + Some notes about the class. + + .. rubric:: Examples + + For usage examples, see `ode`. + + """, + ) + + +def test_class_attributes_as_member_list(): + class Foo: + """ + Class docstring. + + Attributes + ---------- + an_attribute + Another description that is not used. + + """ + + @property + def an_attribute(self): + """Test attribute""" + return + + attr_doc = """:Attributes: + + :obj:`an_attribute ` + Test attribute""" + + assert attr_doc in str(SphinxClassDoc(Foo)) + assert "Another description" not in str(SphinxClassDoc(Foo)) + + attr_doc2 = """.. rubric:: Attributes + +.. autosummary:: + :toctree: + + an_attribute""" + + cfg = dict(attributes_as_param_list=False) + assert attr_doc2 in str(SphinxClassDoc(Foo, config=cfg)) + assert "Another description" not in str(SphinxClassDoc(Foo, config=cfg)) + + +def test_templated_sections(): + doc = SphinxClassDoc( + None, + class_doc_txt, + config={"template": jinja2.Template("{{examples}}\n{{parameters}}")}, + ) + line_by_line_compare( + str(doc), + """ + .. rubric:: Examples + + For usage examples, see `ode`. + + :Parameters: + + **f** : callable ``f(t, y, *f_args)`` + Aaa. + + **jac** : callable ``jac(t, y, *jac_args)`` + Bbb. + + """, + ) + + +def test_nonstandard_property(): + # test discovery of a property that does not satisfy isinstace(.., property) + + class SpecialProperty: + def __init__(self, axis=0, doc=""): + self.axis = axis + self.__doc__ = doc + + def __get__(self, obj, type): + if obj is None: + # Only instances have actual _data, not classes + return self + else: + return obj._data.axes[self.axis] + + def __set__(self, obj, value): + obj._set_axis(self.axis, value) + + class Dummy: + attr = SpecialProperty(doc="test attribute") + + doc = get_doc_object(Dummy) + assert "test attribute" in str(doc) + + +def test_args_and_kwargs(): + cfg = dict() + doc = SphinxDocString( + """ + Parameters + ---------- + param1 : int + First parameter + *args : tuple + Arguments + **kwargs : dict + Keyword arguments + """, + config=cfg, + ) + line_by_line_compare( + str(doc), + r""" +:Parameters: + + **param1** : int + First parameter + + **\*args** : tuple + Arguments + + **\*\*kwargs** : dict + Keyword arguments + """, + ) + + +def test_autoclass(): + cfg = dict(show_class_members=True, show_inherited_class_members=True) + doc = SphinxClassDoc( + str, + """ +A top section before + +.. autoclass:: str + """, + config=cfg, + ) + line_by_line_compare( + str(doc), + r""" +A top section before + +.. autoclass:: str + +.. 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`", + } + + class Config: + def __init__(self, a, b): + self.numpydoc_xref_aliases = a + self.numpydoc_xref_aliases_complete = b + # numpydoc.update_config fails if this config option not present + self.numpydoc_validation_checks = set() + self.numpydoc_validation_exclude = set() + self.numpydoc_validation_exclude_files = set() + self.numpydoc_validation_overrides = dict() + + xref_aliases_complete = deepcopy(DEFAULT_LINKS) + for key, val in xref_aliases.items(): + xref_aliases_complete[key] = val + config = Config(xref_aliases, xref_aliases_complete) + 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_complete, + xref_ignore=xref_ignore, + ), + ) + + line_by_line_compare(str(doc), xref_doc_txt_expected) + + +def test__error_location_no_name_attr(): + """ + Ensure that SphinxDocString._error_location doesn't fail when self._obj + does not have a __name__ attr. + + See gh-362 + """ + from collections.abc import Callable + + # Create a Callable that doesn't have a __name__ attribute + class Foo: + def __call__(self): + pass + + foo = Foo() # foo is a Callable, but no a function instance + assert isinstance(foo, Callable) + + # Create an SphinxDocString instance to call the _error_location method + sds = get_doc_object(foo) + + msg = "Potentially wrong underline length.*Foo.*" + with pytest.raises(ValueError, match=msg): + sds._error_location(msg=msg) + + +def test_class_docstring_cached_property(): + """Ensure that properties marked with the `cached_property` decorator + are listed in the Methods section. See gh-432.""" + from functools import cached_property + + class Foo: + _x = [1, 2, 3] + + @cached_property + def val(self): + return self._x + + class_docstring = get_doc_object(Foo) + assert len(class_docstring["Attributes"]) == 1 + assert class_docstring["Attributes"][0].name == "val" + + +def test_namedtuple_no_duplicate_attributes(): + """ + Ensure that attributes of namedtuples are not duplicated in the docstring. + + See gh-257 + """ + from collections import namedtuple + + foo = namedtuple("Foo", ("bar", "baz")) + + # Create the SphinxClassDoc object via get_doc_object + sds = get_doc_object(foo) + assert sds["Attributes"] == [] + + +def test_namedtuple_class_docstring(): + """Ensure that class docstring is preserved when inheriting from namedtuple. + + See gh-257 + """ + from collections import namedtuple + + foo = namedtuple("Foo", ("bar", "baz")) + + class MyFoo(foo): + """MyFoo's class docstring""" + + # Create the SphinxClassDoc object via get_doc_object + sds = get_doc_object(MyFoo) + assert sds["Summary"] == ["MyFoo's class docstring"] + + # Example dataclass where constructor params are documented explicit. + # Parameter names/descriptions should be included in the docstring, but + # should not result in a duplicated `Attributes` section + class MyFooWithParams(foo): + """ + MyFoo's class docstring + + Parameters + ---------- + bar : str + The bar attribute + baz : str + The baz attribute + """ + + bar: str + baz: str + + sds = get_doc_object(MyFooWithParams) + assert "MyFoo's class docstring" in sds["Summary"] + assert len(sds["Attributes"]) == 0 + assert len(sds["Parameters"]) == 2 + assert sds["Parameters"][0].desc[0] == "The bar attribute" + assert sds["Parameters"][1].desc[0] == "The baz attribute" + + +if __name__ == "__main__": + import pytest + + pytest.main() diff --git a/numpydoc/tests/test_full.py b/numpydoc/tests/test_full.py index 9ab241fa..90c431a2 100644 --- a/numpydoc/tests/test_full.py +++ b/numpydoc/tests/test_full.py @@ -1,8 +1,11 @@ +import pytest + +pytest.importorskip("sphinx") + import os.path as op import re import shutil -import pytest from docutils import __version__ as docutils_version from packaging import version from sphinx.application import Sphinx diff --git a/numpydoc/tests/test_numpydoc.py b/numpydoc/tests/test_numpydoc.py index f879d7fb..5d7179a4 100644 --- a/numpydoc/tests/test_numpydoc.py +++ b/numpydoc/tests/test_numpydoc.py @@ -1,15 +1,18 @@ +import pytest + +pytest.importorskip("sphinx") +pytest.importorskip("docutils") + from collections import defaultdict from copy import deepcopy from io import StringIO from pathlib import PosixPath -import pytest from docutils import nodes from sphinx.ext.autodoc import ALL from sphinx.util import logging from numpydoc.numpydoc import ( - _clean_text_signature, clean_backrefs, mangle_docstrings, update_config, @@ -111,40 +114,6 @@ def test_mangle_docstrings_inherited_class_members(): assert "samefile" not in lines -def test_clean_text_signature(): - assert _clean_text_signature(None) is None - assert _clean_text_signature("func($self)") == "func()" - assert ( - _clean_text_signature("func($self, *args, **kwargs)") == "func(*args, **kwargs)" - ) - assert _clean_text_signature("($self)") == "()" - assert _clean_text_signature("()") == "()" - assert _clean_text_signature("func()") == "func()" - assert ( - _clean_text_signature("func($self, /, *args, **kwargs)") - == "func(*args, **kwargs)" - ) - assert ( - _clean_text_signature("func($self, other, /, *args, **kwargs)") - == "func(other, *args, **kwargs)" - ) - assert _clean_text_signature("($module)") == "()" - assert _clean_text_signature("func($type)") == "func()" - assert ( - _clean_text_signature('func($self, foo="hello world")') - == 'func(foo="hello world")' - ) - assert ( - _clean_text_signature("func($self, foo='hello world')") - == "func(foo='hello world')" - ) - assert _clean_text_signature('func(foo="hello world")') == 'func(foo="hello world")' - assert _clean_text_signature('func(foo="$self")') == 'func(foo="$self")' - assert _clean_text_signature('func($self, foo="$self")') == 'func(foo="$self")' - assert _clean_text_signature("func(self, other)") == "func(self, other)" - assert _clean_text_signature("func($self, *args)") == "func(*args)" - - @pytest.fixture def f(): def _function_without_seealso_and_examples(): diff --git a/numpydoc/tests/test_utils.py b/numpydoc/tests/test_utils.py new file mode 100644 index 00000000..80cfe897 --- /dev/null +++ b/numpydoc/tests/test_utils.py @@ -0,0 +1,35 @@ +from numpydoc._utils import _clean_text_signature + + +def test_clean_text_signature(): + assert _clean_text_signature(None) is None + assert _clean_text_signature("func($self)") == "func()" + assert ( + _clean_text_signature("func($self, *args, **kwargs)") == "func(*args, **kwargs)" + ) + assert _clean_text_signature("($self)") == "()" + assert _clean_text_signature("()") == "()" + assert _clean_text_signature("func()") == "func()" + assert ( + _clean_text_signature("func($self, /, *args, **kwargs)") + == "func(*args, **kwargs)" + ) + assert ( + _clean_text_signature("func($self, other, /, *args, **kwargs)") + == "func(other, *args, **kwargs)" + ) + assert _clean_text_signature("($module)") == "()" + assert _clean_text_signature("func($type)") == "func()" + assert ( + _clean_text_signature('func($self, foo="hello world")') + == 'func(foo="hello world")' + ) + assert ( + _clean_text_signature("func($self, foo='hello world')") + == "func(foo='hello world')" + ) + assert _clean_text_signature('func(foo="hello world")') == 'func(foo="hello world")' + assert _clean_text_signature('func(foo="$self")') == 'func(foo="$self")' + assert _clean_text_signature('func($self, foo="$self")') == 'func(foo="$self")' + assert _clean_text_signature("func(self, other)") == "func(self, other)" + assert _clean_text_signature("func($self, *args)") == "func(*args)" diff --git a/pyproject.toml b/pyproject.toml index b8911d85..33a15c15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,6 @@ classifiers = [ 'Topic :: Documentation', ] dependencies = [ - 'sphinx>=6', "tomli>=1.1.0;python_version<'3.11'", ] @@ -42,6 +41,11 @@ file = 'LICENSE.txt' Homepage = 'https://numpydoc.readthedocs.io' Source = 'https://github.com/numpy/numpydoc/' +[project.optional-dependencies] +default = [ + 'sphinx>=6', +] + [dependency-groups] dev = [ 'pre-commit>=3.3',