Aluode/PerceptionLabPortable
0
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 