diff --git a/.github/workflows/ci_workflows.yml b/.github/workflows/ci_workflows.yml index 0844a3d..7627b3b 100644 --- a/.github/workflows/ci_workflows.yml +++ b/.github/workflows/ci_workflows.yml @@ -30,6 +30,9 @@ jobs: - windows: py312-test-pytest74 - linux: py313-test-pytest83 - linux: py313-test-pytest90 + - linux: py313-test-parallel + - linux: py313t-test + - linux: py313t-test-parallel - linux: py313-test-devdeps publish: needs: tests diff --git a/pytest_arraydiff/plugin.py b/pytest_arraydiff/plugin.py index 0801570..5a46f24 100755 --- a/pytest_arraydiff/plugin.py +++ b/pytest_arraydiff/plugin.py @@ -240,12 +240,18 @@ def wrap_array_interceptor(plugin, item): # Only intercept array on marked array tests if item.get_closest_marker('array_compare') is not None: + # Guard against wrapping more than once (e.g. when pytest-run-parallel + # runs the same item multiple times). + if getattr(item.obj, '_arraydiff_wrapped', False): + return + # Use the full test name as a key to ensure correct array is being retrieved test_name = generate_test_name(item) def array_interceptor(store, obj): def wrapper(*args, **kwargs): store.return_value[test_name] = obj(*args, **kwargs) + wrapper._arraydiff_wrapped = True return wrapper item.obj = array_interceptor(plugin, item.obj) @@ -260,6 +266,10 @@ def __init__(self, config, reference_dir=None, generate_dir=None, default_format self.default_format = default_format self.return_value = {} + def pytest_collection_modifyitems(self, items): + for item in items: + wrap_array_interceptor(self, item) + @pytest.hookimpl(hookwrapper=True) def pytest_runtest_call(self, item): @@ -298,8 +308,6 @@ def pytest_runtest_call(self, item): baseline_remote = reference_dir.startswith('http') - # Run test and get array object - wrap_array_interceptor(self, item) yield test_name = generate_test_name(item) if test_name not in self.return_value: @@ -372,11 +380,11 @@ def __init__(self, config): self.config = config self.return_value = {} - @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_call(self, item): - - if item.get_closest_marker('array_compare') is not None: + def pytest_collection_modifyitems(self, items): + for item in items: wrap_array_interceptor(self, item) + @pytest.hookimpl(hookwrapper=True) + def pytest_runtest_call(self, item): yield return diff --git a/setup.cfg b/setup.cfg index 1740775..b651341 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,11 +35,15 @@ install_requires = pytest>=5.0 numpy -# tables limitation is until 3.9.3 is out as that supports ARM OSX. +# tables limitation is until 3.9.3 is out as that supports ARM OSX, and +# tables wheels are not yet available for free-threaded CPython. Since +# PEP 508 has no marker for that, tables is split into its own extra and +# tox only installs it on the non-free-threaded envs. [options.extras_require] test = astropy pandas +test_hdf5 = tables;platform_machine!='arm64' [options.entry_points] diff --git a/tests/test_pytest_arraydiff.py b/tests/test_pytest_arraydiff.py index 11a191b..90fccdb 100644 --- a/tests/test_pytest_arraydiff.py +++ b/tests/test_pytest_arraydiff.py @@ -177,3 +177,40 @@ def test_single_reference(self, spam): def test_nofile(): pass + + +TEST_PARALLEL = """ +import pytest +import numpy as np +from astropy.io import fits +@pytest.mark.array_compare(file_format='fits') +def test_parallel(): + return fits.PrimaryHDU(np.arange(3 * 5).reshape((3, 5)).astype(np.int64)) +""" + + +def test_parallel_iterations(): + """Regression test: arraydiff should work with pytest-run-parallel.""" + pytest.importorskip('pytest_run_parallel') + + tmpdir = tempfile.mkdtemp() + test_file = os.path.join(tmpdir, 'test.py') + with open(test_file, 'w') as f: + f.write(TEST_PARALLEL) + + gen_dir = os.path.join(tmpdir, 'reference') + + # Generate the reference file first + code = subprocess.call( + ['pytest', f'--arraydiff-generate-path={gen_dir}', test_file], + timeout=30, + ) + assert code == 0 + + # Now run with --arraydiff and multiple iterations + code = subprocess.call( + ['pytest', '--arraydiff', f'--arraydiff-reference-path={gen_dir}', + '--parallel-threads=2', '--iterations=3', test_file], + timeout=30, + ) + assert code == 0 diff --git a/tox.ini b/tox.ini index 11a12cc..a25e74b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,14 @@ [tox] envlist = py{39,310,311,312,313,314}-test{,-pytestoldest,-pytest52,-pytest53,-pytest60,-pytest61,-pytest62,-pytest70,-pytest71,-pytest72,-pytest73,-pytest74,-devdeps} + py{313t,314t}-test{,-parallel} codestyle isolated_build = true [testenv] changedir = .tmp/{envname} setenv = + py313t,py314t: PYTHON_GIL = 0 devdeps: PIP_EXTRA_INDEX_URL = https://pypi.anaconda.org/astropy/simple https://pypi.anaconda.org/liberfa/simple https://pypi.anaconda.org/scientific-python-nightly-wheels/simple description = run tests deps = @@ -24,6 +26,7 @@ deps = pytest80: pytest==8.0.* pytest83: pytest==8.3.* pytest90: pytest==9.0.* + parallel: pytest-run-parallel devdeps: git+https://github.com/pytest-dev/pytest#egg=pytest devdeps: numpy>=0.0.dev0 devdeps: pandas>=0.0.dev0 @@ -31,6 +34,11 @@ deps = devdeps: astropy>=0.0.dev0 extras = test + # tables (PyTables) is needed for the pandas/HDF5 file_format, but + # has no free-threaded wheels and its sdist requires libhdf5-dev, + # which CI does not install. Skip the HDF5 extras on free-threaded + # envs. + !py313t-!py314t: test_hdf5 commands = # Force numpy-dev after something in the stack downgrades it devdeps: python -m pip install --pre --upgrade --extra-index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple numpy