CoolFace
Apppublic

Aluode/PerceptionLabPortable

sourceHugging Faceupdated 9mo agoView on Hugging Face
0likes
conftest.py553 linesDownload Raw Back to scipy
1# Pytest customization
2import json
3import os
4import warnings
5import tempfile
6from contextlib import contextmanager
7
8import numpy as np
9import numpy.testing as npt
10import pytest
11import hypothesis
12
13from scipy._lib._fpumode import get_fpu_mode
14from scipy._lib._testutils import FPUModeChangeWarning
15from scipy._lib._array_api import SCIPY_ARRAY_API, SCIPY_DEVICE
16from scipy._lib import _pep440
17
18try:
19    from scipy_doctest.conftest import dt_config
20    HAVE_SCPDT = True
21except ModuleNotFoundError:
22    HAVE_SCPDT = False
23
24try:
25    import pytest_run_parallel  # noqa:F401
26    PARALLEL_RUN_AVAILABLE = True
27except Exception:
28    PARALLEL_RUN_AVAILABLE = False
29
30
31def pytest_configure(config):
32    config.addinivalue_line("markers",
33        "slow: Tests that are very slow.")
34    config.addinivalue_line("markers",
35        "xslow: mark test as extremely slow (not run unless explicitly requested)")
36    config.addinivalue_line("markers",
37        "xfail_on_32bit: mark test as failing on 32-bit platforms")
38    try:
39        import pytest_timeout  # noqa:F401
40    except Exception:
41        config.addinivalue_line(
42            "markers", 'timeout: mark a test for a non-default timeout')
43    try:
44        # This is a more reliable test of whether pytest_fail_slow is installed
45        # When I uninstalled it, `import pytest_fail_slow` didn't fail!
46        from pytest_fail_slow import parse_duration  # type: ignore[import-not-found] # noqa:F401,E501
47    except Exception:
48        config.addinivalue_line(
49            "markers", 'fail_slow: mark a test for a non-default timeout failure')
50    config.addinivalue_line("markers",
51        "skip_xp_backends(backends, reason=None, np_only=False, cpu_only=False, "
52        "exceptions=None): "
53        "mark the desired skip configuration for the `skip_xp_backends` fixture.")
54    config.addinivalue_line("markers",
55        "xfail_xp_backends(backends, reason=None, np_only=False, cpu_only=False, "
56        "exceptions=None): "
57        "mark the desired xfail configuration for the `xfail_xp_backends` fixture.")
58    if not PARALLEL_RUN_AVAILABLE:
59        config.addinivalue_line(
60            'markers',
61            'parallel_threads(n): run the given test function in parallel '
62            'using `n` threads.')
63        config.addinivalue_line(
64            "markers",
65            "thread_unsafe: mark the test function as single-threaded",
66        )
67        config.addinivalue_line(
68            "markers",
69            "iterations(n): run the given test function `n` times in each thread",
70        )
71
72
73def pytest_runtest_setup(item):
74    mark = item.get_closest_marker("xslow")
75    if mark is not None:
76        try:
77            v = int(os.environ.get('SCIPY_XSLOW', '0'))
78        except ValueError:
79            v = False
80        if not v:
81            pytest.skip("very slow test; "
82                        "set environment variable SCIPY_XSLOW=1 to run it")
83    mark = item.get_closest_marker("xfail_on_32bit")
84    if mark is not None and np.intp(0).itemsize < 8:
85        pytest.xfail(f'Fails on our 32-bit test platform(s): {mark.args[0]}')
86
87    # Older versions of threadpoolctl have an issue that may lead to this
88    # warning being emitted, see gh-14441
89    with npt.suppress_warnings() as sup:
90        sup.filter(pytest.PytestUnraisableExceptionWarning)
91
92        try:
93            from threadpoolctl import threadpool_limits
94
95            HAS_THREADPOOLCTL = True
96        except Exception:  # observed in gh-14441: (ImportError, AttributeError)
97            # Optional dependency only. All exceptions are caught, for robustness
98            HAS_THREADPOOLCTL = False
99
100        if HAS_THREADPOOLCTL:
101            # Set the number of openmp threads based on the number of workers
102            # xdist is using to prevent oversubscription. Simplified version of what
103            # sklearn does (it can rely on threadpoolctl and its builtin OpenMP helper
104            # functions)
105            try:
106                xdist_worker_count = int(os.environ['PYTEST_XDIST_WORKER_COUNT'])
107            except KeyError:
108                # raises when pytest-xdist is not installed
109                return
110
111            if not os.getenv('OMP_NUM_THREADS'):
112                max_openmp_threads = os.cpu_count() // 2  # use nr of physical cores
113                threads_per_worker = max(max_openmp_threads // xdist_worker_count, 1)
114                try:
115                    threadpool_limits(threads_per_worker, user_api='blas')
116                except Exception:
117                    # May raise AttributeError for older versions of OpenBLAS.
118                    # Catch any error for robustness.
119                    return
120
121
122@pytest.fixture(scope="function", autouse=True)
123def check_fpu_mode(request):
124    """
125    Check FPU mode was not changed during the test.
126    """
127    old_mode = get_fpu_mode()
128    yield
129    new_mode = get_fpu_mode()
130
131    if old_mode != new_mode:
132        warnings.warn(f"FPU mode changed from {old_mode:#x} to {new_mode:#x} during "
133                      "the test",
134                      category=FPUModeChangeWarning, stacklevel=0)
135
136
137if not PARALLEL_RUN_AVAILABLE:
138    @pytest.fixture
139    def num_parallel_threads():
140        return 1
141
142
143# Array API backend handling
144xp_available_backends = {'numpy': np}
145
146if SCIPY_ARRAY_API and isinstance(SCIPY_ARRAY_API, str):
147    # fill the dict of backends with available libraries
148    try:
149        import array_api_strict
150        xp_available_backends.update({'array_api_strict': array_api_strict})
151        if _pep440.parse(array_api_strict.__version__) < _pep440.Version('2.0'):
152            raise ImportError("array-api-strict must be >= version 2.0")
153        array_api_strict.set_array_api_strict_flags(
154            api_version='2023.12'
155        )
156    except ImportError:
157        pass
158
159    try:
160        import torch  # type: ignore[import-not-found]
161        xp_available_backends.update({'torch': torch})
162        # can use `mps` or `cpu`
163        torch.set_default_device(SCIPY_DEVICE)
164    except ImportError:
165        pass
166
167    try:
168        import cupy  # type: ignore[import-not-found]
169        xp_available_backends.update({'cupy': cupy})
170    except ImportError:
171        pass
172
173    try:
174        import jax.numpy  # type: ignore[import-not-found]
175        xp_available_backends.update({'jax.numpy': jax.numpy})
176        jax.config.update("jax_enable_x64", True)
177        jax.config.update("jax_default_device", jax.devices(SCIPY_DEVICE)[0])
178    except ImportError:
179        pass
180
181    # by default, use all available backends
182    if SCIPY_ARRAY_API.lower() not in ("1", "true"):
183        SCIPY_ARRAY_API_ = json.loads(SCIPY_ARRAY_API)
184
185        if 'all' in SCIPY_ARRAY_API_:
186            pass  # same as True
187        else:
188            # only select a subset of backend by filtering out the dict
189            try:
190                xp_available_backends = {
191                    backend: xp_available_backends[backend]
192                    for backend in SCIPY_ARRAY_API_
193                }
194            except KeyError:
195                msg = f"'--array-api-backend' must be in {xp_available_backends.keys()}"
196                raise ValueError(msg)
197
198if 'cupy' in xp_available_backends:
199    SCIPY_DEVICE = 'cuda'
200
201array_api_compatible = pytest.mark.parametrize("xp", xp_available_backends.values())
202
203skip_xp_invalid_arg = pytest.mark.skipif(SCIPY_ARRAY_API,
204    reason = ('Test involves masked arrays, object arrays, or other types '
205              'that are not valid input when `SCIPY_ARRAY_API` is used.'))
206
207
208def _backends_kwargs_from_request(request, skip_or_xfail):
209    """A helper for {skip,xfail}_xp_backends"""
210    # do not allow multiple backends
211    args_ = request.keywords[f'{skip_or_xfail}_xp_backends'].args
212    if len(args_) > 1:
213        # np_only / cpu_only has args=(), otherwise it's ('numpy',)
214        # and we do not allow ('numpy', 'cupy')
215        raise ValueError(f"multiple backends: {args_}")
216
217    markers = list(request.node.iter_markers(f'{skip_or_xfail}_xp_backends'))
218    backends = []
219    kwargs = {}
220    for marker in markers:
221        if marker.kwargs.get('np_only'):
222            kwargs['np_only'] = True
223            kwargs['exceptions'] = marker.kwargs.get('exceptions', [])
224        elif marker.kwargs.get('cpu_only'):
225            if not kwargs.get('np_only'):
226                # if np_only is given, it is certainly cpu only
227                kwargs['cpu_only'] = True
228                kwargs['exceptions'] = marker.kwargs.get('exceptions', [])
229
230        # add backends, if any
231        if len(marker.args) > 0:
232            backend = marker.args[0]  # was a tuple, ('numpy',) etc
233            backends.append(backend)
234            kwargs.update(**{backend: marker.kwargs})
235
236    return backends, kwargs
237
238
239@pytest.fixture
240def skip_xp_backends(xp, request):
241    """skip_xp_backends(backend=None, reason=None, np_only=False, cpu_only=False, exceptions=None)
242
243    Skip a decorated test for the provided backend, or skip a category of backends.
244
245    See ``skip_or_xfail_backends`` docstring for details. Note that, contrary to
246    ``skip_or_xfail_backends``, the ``backend`` and ``reason`` arguments are optional
247    single strings: this function only skips a single backend at a time.
248    To skip multiple backends, provide multiple decorators.
249    """  # noqa: E501
250    if "skip_xp_backends" not in request.keywords:
251        return
252
253    backends, kwargs = _backends_kwargs_from_request(request, skip_or_xfail='skip')
254    skip_or_xfail_xp_backends(xp, backends, kwargs, skip_or_xfail='skip')
255
256
257@pytest.fixture
258def xfail_xp_backends(xp, request):
259    """xfail_xp_backends(backend=None, reason=None, np_only=False, cpu_only=False, exceptions=None)
260
261    xfail a decorated test for the provided backend, or xfail a category of backends.
262
263    See ``skip_or_xfail_backends`` docstring for details. Note that, contrary to
264    ``skip_or_xfail_backends``, the ``backend`` and ``reason`` arguments are optional
265    single strings: this function only xfails a single backend at a time.
266    To xfail multiple backends, provide multiple decorators.
267    """  # noqa: E501
268    if "xfail_xp_backends" not in request.keywords:
269        return
270    backends, kwargs = _backends_kwargs_from_request(request, skip_or_xfail='xfail')
271    skip_or_xfail_xp_backends(xp, backends, kwargs, skip_or_xfail='xfail')
272
273
274def skip_or_xfail_xp_backends(xp, backends, kwargs, skip_or_xfail='skip'):
275    """
276    Skip based on the ``skip_xp_backends`` or ``xfail_xp_backends`` marker.
277
278    See the "Support for the array API standard" docs page for usage examples.
279
280    Parameters
281    ----------
282    backends : tuple
283        Backends to skip/xfail, e.g. ``("array_api_strict", "torch")``.
284        These are overriden when ``np_only`` is ``True``, and are not
285        necessary to provide for non-CPU backends when ``cpu_only`` is ``True``.
286        For a custom reason to apply, you should pass a dict ``{'reason': '...'}``
287        to a keyword matching the name of the backend.
288    reason : str, optional
289        A reason for the skip/xfail in the case of ``np_only=True``.
290        If unprovided, a default reason is used. Note that it is not possible
291        to specify a custom reason with ``cpu_only``.
292    np_only : bool, optional
293        When ``True``, the test is skipped/xfailed for all backends other
294        than the default NumPy backend. There is no need to provide
295        any ``backends`` in this case. To specify a reason, pass a
296        value to ``reason``. Default: ``False``.
297    cpu_only : bool, optional
298        When ``True``, the test is skipped/xfailed on non-CPU devices.
299        There is no need to provide any ``backends`` in this case,
300        but any ``backends`` will also be skipped on the CPU.
301        Default: ``False``.
302    exceptions : list, optional
303        A list of exceptions for use with ``cpu_only`` or ``np_only``.
304        This should be provided when delegation is implemented for some,
305        but not all, non-CPU/non-NumPy backends.
306    skip_or_xfail : str
307        ``'skip'`` to skip, ``'xfail'`` to xfail.
308    """
309    skip_or_xfail = getattr(pytest, skip_or_xfail)
310    np_only = kwargs.get("np_only", False)
311    cpu_only = kwargs.get("cpu_only", False)
312    exceptions = kwargs.get("exceptions", [])
313
314    if reasons := kwargs.get("reasons"):
315        raise ValueError(f"provide a single `reason=` kwarg; got {reasons=} instead")
316
317    # input validation
318    if np_only and cpu_only:
319        # np_only is a stricter subset of cpu_only
320        cpu_only = False
321    if exceptions and not (cpu_only or np_only):
322        raise ValueError("`exceptions` is only valid alongside `cpu_only` or `np_only`")
323
324    if np_only:
325        reason = kwargs.get("reason", "do not run with non-NumPy backends.")
326        if not isinstance(reason, str) and len(reason) > 1:
327            raise ValueError("please provide a singleton `reason` "
328                             "when using `np_only`")
329        if xp.__name__ != 'numpy' and xp.__name__ not in exceptions:
330            skip_or_xfail(reason=reason)
331        return
332    if cpu_only:
333        reason = ("no array-agnostic implementation or delegation available "
334                  "for this backend and device")
335        exceptions = [] if exceptions is None else exceptions
336        if SCIPY_ARRAY_API and SCIPY_DEVICE != 'cpu':
337            if xp.__name__ == 'cupy' and 'cupy' not in exceptions:
338                skip_or_xfail(reason=reason)
339            elif xp.__name__ == 'torch' and 'torch' not in exceptions:
340                if 'cpu' not in xp.empty(0).device.type:
341                    skip_or_xfail(reason=reason)
342            elif xp.__name__ == 'jax.numpy' and 'jax.numpy' not in exceptions:
343                for d in xp.empty(0).devices():
344                    if 'cpu' not in d.device_kind:
345                        skip_or_xfail(reason=reason)
346
347    if backends is not None:
348        for i, backend in enumerate(backends):
349            if xp.__name__ == backend:
350                reason = kwargs[backend].get('reason')
351                if not reason:
352                    reason = f"do not run with array API backend: {backend}"
353
354                skip_or_xfail(reason=reason)
355
356
357# Following the approach of NumPy's conftest.py...
358# Use a known and persistent tmpdir for hypothesis' caches, which
359# can be automatically cleared by the OS or user.
360hypothesis.configuration.set_hypothesis_home_dir(
361    os.path.join(tempfile.gettempdir(), ".hypothesis")
362)
363
364# We register two custom profiles for SciPy - for details see
365# https://hypothesis.readthedocs.io/en/latest/settings.html
366# The first is designed for our own CI runs; the latter also
367# forces determinism and is designed for use via scipy.test()
368hypothesis.settings.register_profile(
369    name="nondeterministic", deadline=None, print_blob=True,
370)
371hypothesis.settings.register_profile(
372    name="deterministic",
373    deadline=None, print_blob=True, database=None, derandomize=True,
374    suppress_health_check=list(hypothesis.HealthCheck),
375)
376
377# Profile is currently set by environment variable `SCIPY_HYPOTHESIS_PROFILE`
378# In the future, it would be good to work the choice into dev.py.
379SCIPY_HYPOTHESIS_PROFILE = os.environ.get("SCIPY_HYPOTHESIS_PROFILE",
380                                          "deterministic")
381hypothesis.settings.load_profile(SCIPY_HYPOTHESIS_PROFILE)
382
383
384############################################################################
385# doctesting stuff
386
387if HAVE_SCPDT:
388
389    # FIXME: populate the dict once
390    @contextmanager
391    def warnings_errors_and_rng(test=None):
392        """Temporarily turn (almost) all warnings to errors.
393
394        Filter out known warnings which we allow.
395        """
396        known_warnings = dict()
397
398        # these functions are known to emit "divide by zero" RuntimeWarnings
399        divide_by_zero = [
400            'scipy.linalg.norm', 'scipy.ndimage.center_of_mass',
401        ]
402        for name in divide_by_zero:
403            known_warnings[name] = dict(category=RuntimeWarning,
404                                        message='divide by zero')
405
406        # Deprecated stuff in scipy.signal and elsewhere
407        deprecated = [
408            'scipy.signal.cwt', 'scipy.signal.morlet', 'scipy.signal.morlet2',
409            'scipy.signal.ricker',
410            'scipy.integrate.simpson',
411            'scipy.interpolate.interp2d',
412            'scipy.linalg.kron',
413        ]
414        for name in deprecated:
415            known_warnings[name] = dict(category=DeprecationWarning)
416
417        from scipy import integrate
418        # the functions are known to emit IntegrationWarnings
419        integration_w = ['scipy.special.ellip_normal',
420                         'scipy.special.ellip_harm_2',
421        ]
422        for name in integration_w:
423            known_warnings[name] = dict(category=integrate.IntegrationWarning,
424                                        message='The occurrence of roundoff')
425
426        # scipy.stats deliberately emits UserWarnings sometimes
427        user_w = ['scipy.stats.anderson_ksamp', 'scipy.stats.kurtosistest',
428                  'scipy.stats.normaltest', 'scipy.sparse.linalg.norm']
429        for name in user_w:
430            known_warnings[name] = dict(category=UserWarning)
431
432        # additional one-off warnings to filter
433        dct = {
434            'scipy.sparse.linalg.norm':
435                dict(category=UserWarning, message="Exited at iteration"),
436            # tutorials
437            'linalg.rst':
438                dict(message='the matrix subclass is not',
439                     category=PendingDeprecationWarning),
440            'stats.rst':
441                dict(message='The maximum number of subdivisions',
442                     category=integrate.IntegrationWarning),
443        }
444        known_warnings.update(dct)
445
446        # these legitimately emit warnings in examples
447        legit = set('scipy.signal.normalize')
448
449        # Now, the meat of the matter: filter warnings,
450        # also control the random seed for each doctest.
451
452        # XXX: this matches the refguide-check behavior, but is a tad strange:
453        # makes sure that the seed the old-fashioned np.random* methods is
454        # *NOT* reproducible but the new-style `default_rng()` *IS* repoducible.
455        # Should these two be either both repro or both not repro?
456
457        from scipy._lib._util import _fixed_default_rng
458        import numpy as np
459        with _fixed_default_rng():
460            np.random.seed(None)
461            with warnings.catch_warnings():
462                if test and test.name in known_warnings:
463                    warnings.filterwarnings('ignore',
464                                            **known_warnings[test.name])
465                    yield
466                elif test and test.name in legit:
467                    yield
468                else:
469                    warnings.simplefilter('error', Warning)
470                    yield
471
472    dt_config.user_context_mgr = warnings_errors_and_rng
473    dt_config.skiplist = set([
474        'scipy.linalg.LinAlgError',     # comes from numpy
475        'scipy.fftpack.fftshift',       # fftpack stuff is also from numpy
476        'scipy.fftpack.ifftshift',
477        'scipy.fftpack.fftfreq',
478        'scipy.special.sinc',           # sinc is from numpy
479        'scipy.optimize.show_options',  # does not have much to doctest
480        'scipy.signal.normalize',       # manipulates warnings (XXX temp skip)
481        'scipy.sparse.linalg.norm',     # XXX temp skip
482        # these below test things which inherit from np.ndarray
483        # cross-ref https://github.com/numpy/numpy/issues/28019
484        'scipy.io.matlab.MatlabObject.strides',
485        'scipy.io.matlab.MatlabObject.dtype',
486        'scipy.io.matlab.MatlabOpaque.dtype',
487        'scipy.io.matlab.MatlabOpaque.strides',
488        'scipy.io.matlab.MatlabFunction.strides',
489        'scipy.io.matlab.MatlabFunction.dtype'
490    ])
491
492    # these are affected by NumPy 2.0 scalar repr: rely on string comparison
493    if np.__version__ < "2":
494        dt_config.skiplist.update(set([
495            'scipy.io.hb_read',
496            'scipy.io.hb_write',
497            'scipy.sparse.csgraph.connected_components',
498            'scipy.sparse.csgraph.depth_first_order',
499            'scipy.sparse.csgraph.shortest_path',
500            'scipy.sparse.csgraph.floyd_warshall',
501            'scipy.sparse.csgraph.dijkstra',
502            'scipy.sparse.csgraph.bellman_ford',
503            'scipy.sparse.csgraph.johnson',
504            'scipy.sparse.csgraph.yen',
505            'scipy.sparse.csgraph.breadth_first_order',
506            'scipy.sparse.csgraph.reverse_cuthill_mckee',
507            'scipy.sparse.csgraph.structural_rank',
508            'scipy.sparse.csgraph.construct_dist_matrix',
509            'scipy.sparse.csgraph.reconstruct_path',
510            'scipy.ndimage.value_indices',
511            'scipy.stats.mstats.describe',
512    ]))
513
514    # help pytest collection a bit: these names are either private
515    # (distributions), or just do not need doctesting.
516    dt_config.pytest_extra_ignore = [
517        "scipy.stats.distributions",
518        "scipy.optimize.cython_optimize",
519        "scipy.test",
520        "scipy.show_config",
521        # equivalent to "pytest --ignore=path/to/file"
522        "scipy/special/_precompute",
523        "scipy/interpolate/_interpnd_info.py",
524        "scipy/_lib/array_api_compat",
525        "scipy/_lib/highs",
526        "scipy/_lib/unuran",
527        "scipy/_lib/_gcutils.py",
528        "scipy/_lib/doccer.py",
529        "scipy/_lib/_uarray",
530    ]
531
532    dt_config.pytest_extra_xfail = {
533        # name: reason
534        "ND_regular_grid.rst": "ReST parser limitation",
535        "extrapolation_examples.rst": "ReST parser limitation",
536        "sampling_pinv.rst": "__cinit__ unexpected argument",
537        "sampling_srou.rst": "nan in scalar_power",
538        "probability_distributions.rst": "integration warning",
539    }
540
541    # tutorials
542    dt_config.pseudocode = set(['integrate.nquad(func,'])
543    dt_config.local_resources = {
544        'io.rst': [
545            "octave_a.mat",
546            "octave_cells.mat",
547            "octave_struct.mat"
548        ]
549    }
550
551    dt_config.strict_check = True
552############################################################################
553 
Aluode/PerceptionLabPortable · CoolFace