1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213
|
"""A helper script to check names in doctests.
This module is intended to be called from pyvista's root directory with
python tests/check_doctest_names.py
The problem is that pytest doctests (following the standard-library
doctest module) see the module-global namespace. So when a doctest looks
like this:
Examples
--------
>>> import numpy
>>> import pyvista
>>> from pyvista import CellType
>>> offset = np.array([0, 9])
>>> cell0_ids = [8, 0, 1, 2, 3, 4, 5, 6, 7]
>>> cell1_ids = [8, 8, 9, 10, 11, 12, 13, 14, 15]
>>> cells = np.hstack((cell0_ids, cell1_ids))
>>> cell_type = np.array([CellType.HEXAHEDRON, CellType.HEXAHEDRON], np.int8)
there will be a ``NameError`` when the code block is copied into Python
because the ``np`` name is undefined. However, pytest and sphinx test
runs will not catch this, as the ``np`` name is typically also available
in the global namespace of the module where the doctest resides.
In order to fix this, we build a tree of pyvista's public and private
API, using the standard-library doctest module as a doctest parser. We
execute examples with a clean empty namespace to ensure that mistakes
such as the above can be caught.
Note that we don't try to verify that the actual results from each
example are correct; that's still pytest's responsibility. As long
as the examples run without error, this module will be happy.
The implementation is not very robust or smart, it just gets the job
done to find the rare name mistake in our examples.
If you need off-screen plotting, set the ``PYVISTA_OFF_SCREEN``
environmental variable to ``True`` before running the script.
"""
from __future__ import annotations
from argparse import ArgumentParser
from doctest import DocTestFinder
import re
import sys
from textwrap import indent
from types import ModuleType
import pyvista
def discover_modules(entry=pyvista, recurse=True):
"""Discover the submodules present under an entry point.
If ``recurse=True``, search goes all the way into descendants of the
entry point. Only modules are gathered, because within a module
``doctest``'s discovery can work recursively.
Should work for ``pyvista`` as entry, but no promises for its more
general applicability.
Parameters
----------
entry : module, optional
The entry point of the submodule search. Defaults to the main
``pyvista`` module.
recurse : bool, optional
Whether to recurse into submodules.
Returns
-------
modules : dict of modules
A (module name -> module) mapping of submodules under ``entry``.
"""
entry_name = entry.__name__
found_modules = {}
next_entries = {entry}
while next_entries:
next_modules = {}
for ent in next_entries:
for attr_short_name in dir(ent):
attr = getattr(ent, attr_short_name)
if not isinstance(attr, ModuleType):
continue
module_name = attr.__name__
if module_name.startswith(entry_name):
next_modules[module_name] = attr
if not recurse:
return next_modules
# find as-of-yet-undiscovered submodules
next_entries = {
module
for module_name, module in next_modules.items()
if module_name not in found_modules
}
found_modules.update(next_modules)
return found_modules
def check_doctests(modules=None, respect_skips=True, verbose=True):
"""Check whether doctests can be run as-is without errors.
Parameters
----------
modules : dict, optional
(module name -> module) mapping of submodules defined in a
package as returned by ``discover_modules()``. If omitted,
``discover_modules()`` will be called for ``pyvista``.
respect_skips : bool, optional
Whether to ignore doctest examples that contain a DOCTEST:+SKIP
directive.
verbose : bool, optional
Whether to print passes/failures as the testing progresses.
Failures are printed at the end in every case.
Returns
-------
failures : dict of (Exception, str) tuples
An (object name -> (exception raised, failing code)) mapping
of failed doctests under the specified modules.
"""
skip_pattern = re.compile(r'doctest: *\+SKIP')
if modules is None:
modules = discover_modules()
# find and parse all docstrings; this will also remove any duplicates
doctests = {
dt.name: dt
for module_name, module in modules.items()
for dt in DocTestFinder(recurse=True).find(module, globs={})
}
# loop over doctests in alphabetical order for sanity
sorted_names = sorted(doctests)
failures = {}
for dt_name in sorted_names:
dt = doctests[dt_name]
if not dt.examples:
continue
# mock print to suppress output from a few talkative tests
globs = {'print': (lambda *args, **kwargs: ...)} # noqa: ARG005
for iline, example in enumerate(dt.examples, start=1):
if not example.source.strip() or (
respect_skips and skip_pattern.search(example.source)
):
continue
try:
exec(example.source, globs)
except Exception as exc: # noqa: BLE001
if verbose:
print(f'FAILED: {dt.name} -- {exc!r}')
erroring_code = ''.join([example.source for example in dt.examples[:iline]])
failures[dt_name] = exc, erroring_code
break
else:
if verbose:
print(f'PASSED: {dt.name}')
total = len(doctests)
fails = len(failures)
passes = total - fails
print(f'\n{passes} passes and {fails} failures out of {total} total doctests.\n')
if not fails:
return failures
print('List of failures:')
for name, (exc, erroring_code) in failures.items():
print('-' * 60)
print(f'{name}:')
print(indent(erroring_code, ' '))
print(repr(exc))
print('-' * 60)
return failures
if __name__ == '__main__':
parser = ArgumentParser(description='Look for name errors in doctests.')
parser.add_argument(
'-v',
'--verbose',
action='store_true',
help='print passes and failures as tests progress',
)
parser.add_argument(
'--no-respect-skips',
action='store_false',
dest='respect_skips',
help='ignore doctest SKIP directives',
)
args = parser.parse_args()
failures = check_doctests(verbose=args.verbose, respect_skips=args.respect_skips)
if failures:
# raise a red flag for CI
sys.exit(1)
|