From 3345ea748eee038ee7c1fc91566578670a118952 Mon Sep 17 00:00:00 2001 From: Leo Singer Date: Sat, 5 Feb 2022 18:21:38 -0500 Subject: [PATCH] Add --doctest-ufunc option to doctest Numpy ufuncs This absorbs the functionality of the pytest-doctest-ufunc package, which was heavily based on pytest-doctestplus to begin with. pytest-doctest-ufunc will be retired. Fixes #123. --- CHANGES.rst | 3 ++ README.rst | 12 ++++-- pytest_doctestplus/plugin.py | 36 +++++++++++++++++- setup.cfg | 1 + tests/conftest.py | 2 + tests/test_doctest_ufunc.py | 42 +++++++++++++++++++++ tests/ufunc_example/_module2.c | 68 ++++++++++++++++++++++++++++++++++ tests/ufunc_example/module1.py | 7 ++++ tests/ufunc_example/module2.py | 1 + tests/ufunc_example/setup.py | 7 ++++ 10 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 tests/test_doctest_ufunc.py create mode 100644 tests/ufunc_example/_module2.c create mode 100644 tests/ufunc_example/module1.py create mode 100644 tests/ufunc_example/module2.py create mode 100644 tests/ufunc_example/setup.py diff --git a/CHANGES.rst b/CHANGES.rst index 738e293..bd0c04d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,9 @@ 0.12.0 (unreleased) =================== +- Add ``--doctest-ufunc`` option to run doctests in docstrings of Numpy ufuncs. + [#123] + 0.11.2 (2021-12-09) =================== diff --git a/README.rst b/README.rst index 6e20052..68c114f 100644 --- a/README.rst +++ b/README.rst @@ -42,6 +42,7 @@ providing the following features: * handling doctests that use remote data in conjunction with the `pytest-remotedata`_ plugin (see `Remote Data`_) * optional inclusion of ``*.rst`` files for doctests (see `Setup and Configuration`_) +* optional inclusion of doctests in docstrings of Numpy ufuncs (see `Setup and Configuration`_) .. _pytest-remotedata: https://github.com/astropy/pytest-remotedata @@ -70,12 +71,15 @@ Usage Setup and Configuration ~~~~~~~~~~~~~~~~~~~~~~~ -This plugin provides two command line options: ``--doctest-plus`` for enabling -the advanced features mentioned above, and ``--doctest-rst`` for including -``*.rst`` files in doctest collection. +This plugin provides three command line options: ``--doctest-plus`` for enabling +the advanced features mentioned above, ``--doctest-rst`` for including +``*.rst`` files in doctest collection, and ``--doctest-ufunc`` for including +docstrings of Numpy ufuncs. This plugin can also be enabled by default by adding ``doctest_plus = enabled`` -to the ``[tool:pytest]`` section of the package's ``setup.cfg`` file. +to the ``[tool:pytest]`` section of the package's ``setup.cfg`` file. Similarly, +the ``doctest_ufunc = enabled`` option is supported to include docstrings of +Numpy ufuncs. The plugin is applied to all directories and files that ``pytest`` collects. This means that configuring ``testpaths`` and ``norecursedirs`` in diff --git a/pytest_doctestplus/plugin.py b/pytest_doctestplus/plugin.py index 319c177..3a26de3 100644 --- a/pytest_doctestplus/plugin.py +++ b/pytest_doctestplus/plugin.py @@ -98,6 +98,12 @@ def pytest_addoption(parser): "This is no longer recommended, use --doctest-glob instead." )) + parser.addoption("--doctest-ufunc", action="store_true", + help=( + "Enable running doctests in docstrings of Numpy ufuncs. " + "Implies usage of doctest-plus." + )) + # Defaults to `atol` parameter from `numpy.allclose`. parser.addoption("--doctest-plus-atol", action="store", help="set the absolute tolerance for float comparison", @@ -129,6 +135,10 @@ def pytest_addoption(parser): "Run the doctests in the rst documentation", default=False) + parser.addini("doctest_ufunc", + "Run doctests in docstrings of Numpy ufuncs", + default=False) + parser.addini("doctest_plus_atol", "set the absolute tolerance for float comparison", default=1e-08) @@ -157,11 +167,26 @@ def get_optionflags(parent): return flag_int +def _is_numpy_ufunc(method): + try: + import numpy as np + except ModuleNotFoundError: + # If Numpy is not installed, then there can't be any ufuncs! + return False + while True: + try: + method = method.__wrapped__ + except AttributeError: + break + return isinstance(method, np.ufunc) + + def pytest_configure(config): doctest_plugin = config.pluginmanager.getplugin('doctest') run_regular_doctest = config.option.doctestmodules and not config.option.doctest_plus + use_ufunc = config.getini('doctest_ufunc') or config.option.doctest_ufunc use_doctest_plus = config.getini( - 'doctest_plus') or config.option.doctest_plus or config.option.doctest_only + 'doctest_plus') or config.option.doctest_plus or config.option.doctest_only or use_ufunc if doctest_plugin is None or run_regular_doctest or not use_doctest_plus: return @@ -238,7 +263,14 @@ def collect(self): runner = doctest.DebugRunner( verbose=False, optionflags=options, checker=OutputChecker()) - for test in finder.find(module): + tests = finder.find(module) + if use_ufunc: + for method in module.__dict__.values(): + if _is_numpy_ufunc(method): + found = finder.find(method, module=module) + tests += found + + for test in tests: if test.examples: # skip empty doctests ignore_warnings_context_needed = False show_warnings_context_needed = False diff --git a/setup.cfg b/setup.cfg index 6fbb291..51b3d03 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,6 +38,7 @@ install_requires = [options.extras_require] test = + numpy pytest-remotedata>=0.3.2 sphinx diff --git a/tests/conftest.py b/tests/conftest.py index 1595c80..30d46cc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,8 @@ import pytest +collect_ignore = ['ufunc_example'] + def _wrap_docstring_in_func(func_name, docstring): template = textwrap.dedent(r""" diff --git a/tests/test_doctest_ufunc.py b/tests/test_doctest_ufunc.py new file mode 100644 index 0000000..b388aee --- /dev/null +++ b/tests/test_doctest_ufunc.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +import sys +import glob + +import pytest + +pytest.importorskip('numpy') + +pytest_plugins = ['pytester'] + + +def test_help_message(testdir): + result = testdir.runpytest( + '--help', + ) + # fnmatch_lines does an assertion internally + result.stdout.fnmatch_lines([ + '*--doctest-ufunc*Enable running doctests in ' + 'docstrings of Numpy ufuncs.', + ]) + + +def test_example(testdir): + # Create and build example module + testdir.copy_example('tests/ufunc_example/_module2.c') + testdir.copy_example('tests/ufunc_example/module1.py') + testdir.copy_example('tests/ufunc_example/module2.py') + testdir.copy_example('tests/ufunc_example/setup.py') + testdir.run(sys.executable, 'setup.py', 'build') + build_dir, = glob.glob(str(testdir.tmpdir / 'build/lib.*')) + + # Run pytest without doctests: 0 tests run + result = testdir.runpytest(build_dir) + result.assert_outcomes(passed=0, failed=0) + + # Run pytest with doctests: 1 test run + result = testdir.runpytest(build_dir, '--doctest-modules') + result.assert_outcomes(passed=1, failed=0) + + # Run pytest with doctests including ufuncs: 2 tests run + result = testdir.runpytest(build_dir, '--doctest-plus', '--doctest-modules', '--doctest-ufunc') + result.assert_outcomes(passed=2, failed=0) diff --git a/tests/ufunc_example/_module2.c b/tests/ufunc_example/_module2.c new file mode 100644 index 0000000..8b42391 --- /dev/null +++ b/tests/ufunc_example/_module2.c @@ -0,0 +1,68 @@ +#define NPY_NO_DEPRECATED_API NPY_1_7_API_VERSION + +#include +#include +#include + + +static double foo_inner(double a, double b) +{ + return a + b; +} + + +static void foo_loop( + char **args, + const npy_intp *dimensions, + const npy_intp *steps, + void *NPY_UNUSED(data) +) { + const npy_intp n = dimensions[0]; + for (npy_intp i = 0; i < n; i ++) + { + *(double *) &args[2][i * steps[2]] = foo_inner( + *(double *) &args[0][i * steps[0]], + *(double *) &args[1][i * steps[1]]); + } +} + + +static PyUFuncGenericFunction foo_loops[] = {foo_loop}; +static char foo_types[] = {NPY_DOUBLE, NPY_DOUBLE, NPY_DOUBLE}; +static void *foo_data[] = {NULL}; +static const char foo_name[] = "foo"; +static const char foo_docstring[] = ">>> foo(1, 2)\n3.0"; + +static PyModuleDef moduledef = { + .m_base = PyModuleDef_HEAD_INIT, + .m_name = "_module2", + .m_size = -1 +}; + + +PyMODINIT_FUNC PyInit__module2(void) +{ + import_array(); + import_ufunc(); + + PyObject *module = PyModule_Create(&moduledef); + if (!module) + return NULL; + + PyObject *obj = PyUFunc_FromFuncAndData( + foo_loops, foo_data, foo_types, 1, 2, 1, PyUFunc_None, foo_name, + foo_docstring, 0); + if (!obj) + { + Py_DECREF(module); + return NULL; + } + if (PyModule_AddObject(module, foo_name, obj) < 0) + { + Py_DECREF(obj); + Py_DECREF(module); + return NULL; + } + + return module; +} diff --git a/tests/ufunc_example/module1.py b/tests/ufunc_example/module1.py new file mode 100644 index 0000000..e343a7a --- /dev/null +++ b/tests/ufunc_example/module1.py @@ -0,0 +1,7 @@ +def foo(): + '''A doctest... + + >>> foo() + 1 + ''' + return 1 diff --git a/tests/ufunc_example/module2.py b/tests/ufunc_example/module2.py new file mode 100644 index 0000000..d2c28f2 --- /dev/null +++ b/tests/ufunc_example/module2.py @@ -0,0 +1 @@ +from _module2 import foo # noqa: F401 diff --git a/tests/ufunc_example/setup.py b/tests/ufunc_example/setup.py new file mode 100644 index 0000000..f32d9f1 --- /dev/null +++ b/tests/ufunc_example/setup.py @@ -0,0 +1,7 @@ +from setuptools import setup, Extension +import numpy as np + +ext = Extension('_module2', ['_module2.c'], + extra_compile_args=['-std=c99'], + include_dirs=[np.get_include()]) +setup(name='example', py_modules=['module1', 'module2'], ext_modules=[ext])