File: check_doctest_names.py

package info (click to toggle)
python-pyvista 0.46.4-4
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 176,968 kB
  • sloc: python: 94,346; sh: 216; makefile: 70
file content (213 lines) | stat: -rw-r--r-- 6,815 bytes parent folder | download
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)