Skip to content

Commit 36ddb00

Browse files
authored
Merge pull request #66 from astropy/fixture-api-prototype
Add the ability to use a fixture instead of decorating tests
2 parents 56809c5 + 45947a1 commit 36ddb00

4 files changed

Lines changed: 245 additions & 84 deletions

File tree

‎CHANGES.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
0.8 (unreleased)
2+
----------------
3+
4+
- Added an ``array_compare`` fixture as an alternative to the
5+
``@pytest.mark.array_compare`` marker. Because it does not replace the test
6+
function, it does not interfere with pytest-run-parallel's detection of
7+
thread-unsafe calls.
8+
19
0.7 (2026-05-02)
210
----------------
311

‎README.rst‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ function returns a plain Numpy array::
6969

7070
@pytest.mark.array_compare
7171
def test_succeeds():
72-
return np.arange(3 * 5 * 4).reshape((3, 5, 4))
72+
return np.arange(3 * 5).reshape((3, 5))
7373

7474
To generate the reference data files, run the tests with the
7575
``--arraydiff-generate-path`` option with the name of the directory
@@ -95,6 +95,27 @@ and the tests will pass if the arrays are the same. If you omit the
9595
``--arraydiff`` option, the tests will run but will only check that the
9696
code runs without checking the output arrays.
9797

98+
Fixture-based usage
99+
-------------------
100+
101+
As an alternative to the marker, you can request the ``array_compare``
102+
fixture and pass the array to its ``check`` method instead of returning
103+
it::
104+
105+
python
106+
import numpy as np
107+
108+
def test_succeeds(array_compare):
109+
array_compare.check(np.arange(3 * 5).reshape((3, 5)))
110+
111+
``array_compare.check`` accepts the same keyword arguments as the marker
112+
(``file_format``, ``atol``, ``rtol``, ``reference_dir``, and so on), and
113+
``--arraydiff``/``--arraydiff-generate-path`` behave identically. Unlike
114+
the marker, the fixture does not replace the test function, so plugins
115+
that introspect the test source keep working -- in particular
116+
``pytest-run-parallel`` can still auto-detect thread-unsafe calls in the
117+
test body.
118+
98119
Options
99120
-------
100121

‎pytest_arraydiff/plugin.py‎

Lines changed: 140 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,8 @@ def pytest_configure(config):
217217
config.pluginmanager.register(ArrayComparison(config,
218218
reference_dir=reference_dir,
219219
generate_dir=generate_dir,
220-
default_format=default_format))
220+
default_format=default_format),
221+
name='arraydiff')
221222
else:
222223
config.pluginmanager.register(ArrayInterceptor(config))
223224

@@ -233,6 +234,100 @@ def generate_test_name(item):
233234
return name
234235

235236

237+
def _compare_array(array, item, options, *, plugin_reference_dir,
238+
generate_dir, default_format):
239+
"""
240+
Compare ``array`` against the reference for ``item``, or, in generate mode,
241+
write it out.
242+
243+
``options`` is a mapping accepting the same keys as the ``array_compare``
244+
marker and the ``array_compare`` fixture's ``check`` method (``file_format``,
245+
``extension``, ``atol``, ``rtol``, ``single_reference``, ``write_kwargs``,
246+
``reference_dir``, ``filename``). This is the shared core used both by the
247+
marker-based API (which captures the test's return value) and the
248+
fixture-based API (where the test passes the array in explicitly).
249+
"""
250+
file_format = options.get('file_format', default_format)
251+
252+
if file_format not in FORMATS:
253+
raise ValueError(f"Unknown format: {file_format}")
254+
255+
extension = options.get('extension', FORMATS[file_format].extension)
256+
257+
atol = options.get('atol', 0.)
258+
rtol = options.get('rtol', 1e-7)
259+
260+
single_reference = options.get('single_reference', False)
261+
262+
write_kwargs = options.get('write_kwargs', {})
263+
264+
reference_dir = options.get('reference_dir', None)
265+
if reference_dir is None:
266+
if plugin_reference_dir is None:
267+
reference_dir = os.path.join(os.path.dirname(item.fspath.strpath), 'reference')
268+
else:
269+
reference_dir = plugin_reference_dir
270+
else:
271+
if not reference_dir.startswith(('http://', 'https://')):
272+
reference_dir = os.path.join(os.path.dirname(item.fspath.strpath), reference_dir)
273+
274+
baseline_remote = reference_dir.startswith('http')
275+
276+
# Find test name to use as the reference filename
277+
filename = options.get('filename', None)
278+
if filename is None:
279+
if single_reference:
280+
filename = item.originalname + '.' + extension
281+
else:
282+
filename = item.name + '.' + extension
283+
filename = filename.replace('[', '_').replace(']', '_')
284+
filename = filename.replace('_.' + extension, '.' + extension)
285+
286+
# What we do now depends on whether we are generating the reference
287+
# files or simply running the test.
288+
if generate_dir is None:
289+
290+
# Save the array
291+
result_dir = tempfile.mkdtemp()
292+
test_array = os.path.abspath(os.path.join(result_dir, filename))
293+
294+
FORMATS[file_format].write(test_array, array, **write_kwargs)
295+
296+
# Find path to baseline array
297+
if baseline_remote:
298+
baseline_file_ref = _download_file(reference_dir + filename)
299+
else:
300+
baseline_file_ref = os.path.abspath(os.path.join(os.path.dirname(item.fspath.strpath), reference_dir, filename))
301+
302+
if not os.path.exists(baseline_file_ref):
303+
raise Exception("""File not found for comparison test
304+
Generated file:
305+
\t{test}
306+
This is expected for new tests.""".format(
307+
test=test_array))
308+
309+
# setuptools may put the baseline arrays in non-accessible places,
310+
# copy to our tmpdir to be sure to keep them in case of failure
311+
baseline_file = os.path.abspath(os.path.join(result_dir, 'reference-' + filename))
312+
shutil.copyfile(baseline_file_ref, baseline_file)
313+
314+
identical, msg = FORMATS[file_format].compare(baseline_file, test_array, atol=atol, rtol=rtol)
315+
316+
if identical:
317+
shutil.rmtree(result_dir)
318+
else:
319+
raise Exception(msg)
320+
321+
else:
322+
323+
if not os.path.exists(generate_dir):
324+
os.makedirs(generate_dir)
325+
326+
FORMATS[file_format].write(os.path.abspath(os.path.join(generate_dir, filename)), array, **write_kwargs)
327+
328+
pytest.skip("Skipping test, since generating data")
329+
330+
236331
def wrap_array_interceptor(plugin, item):
237332
"""
238333
Intercept and store arrays returned by test functions.
@@ -279,95 +374,18 @@ def pytest_runtest_call(self, item):
279374
yield
280375
return
281376

282-
file_format = compare.kwargs.get('file_format', self.default_format)
283-
284-
if file_format not in FORMATS:
285-
raise ValueError(f"Unknown format: {file_format}")
286-
287-
if 'extension' in compare.kwargs:
288-
extension = compare.kwargs['extension']
289-
else:
290-
extension = FORMATS[file_format].extension
291-
292-
atol = compare.kwargs.get('atol', 0.)
293-
rtol = compare.kwargs.get('rtol', 1e-7)
294-
295-
single_reference = compare.kwargs.get('single_reference', False)
296-
297-
write_kwargs = compare.kwargs.get('write_kwargs', {})
298-
299-
reference_dir = compare.kwargs.get('reference_dir', None)
300-
if reference_dir is None:
301-
if self.reference_dir is None:
302-
reference_dir = os.path.join(os.path.dirname(item.fspath.strpath), 'reference')
303-
else:
304-
reference_dir = self.reference_dir
305-
else:
306-
if not reference_dir.startswith(('http://', 'https://')):
307-
reference_dir = os.path.join(os.path.dirname(item.fspath.strpath), reference_dir)
308-
309-
baseline_remote = reference_dir.startswith('http')
310-
311377
yield
378+
312379
test_name = generate_test_name(item)
313380
if test_name not in self.return_value:
314381
# Test function did not complete successfully
315382
return
316383
array = self.return_value[test_name]
317384

318-
# Find test name to use as plot name
319-
filename = compare.kwargs.get('filename', None)
320-
if filename is None:
321-
if single_reference:
322-
filename = item.originalname + '.' + extension
323-
else:
324-
filename = item.name + '.' + extension
325-
filename = filename.replace('[', '_').replace(']', '_')
326-
filename = filename.replace('_.' + extension, '.' + extension)
327-
328-
# What we do now depends on whether we are generating the reference
329-
# files or simply running the test.
330-
if self.generate_dir is None:
331-
332-
# Save the figure
333-
result_dir = tempfile.mkdtemp()
334-
test_array = os.path.abspath(os.path.join(result_dir, filename))
335-
336-
FORMATS[file_format].write(test_array, array, **write_kwargs)
337-
338-
# Find path to baseline array
339-
if baseline_remote:
340-
baseline_file_ref = _download_file(reference_dir + filename)
341-
else:
342-
baseline_file_ref = os.path.abspath(os.path.join(os.path.dirname(item.fspath.strpath), reference_dir, filename))
343-
344-
if not os.path.exists(baseline_file_ref):
345-
raise Exception("""File not found for comparison test
346-
Generated file:
347-
\t{test}
348-
This is expected for new tests.""".format(
349-
test=test_array))
350-
351-
# setuptools may put the baseline arrays in non-accessible places,
352-
# copy to our tmpdir to be sure to keep them in case of failure
353-
baseline_file = os.path.abspath(os.path.join(result_dir, 'reference-' + filename))
354-
shutil.copyfile(baseline_file_ref, baseline_file)
355-
356-
identical, msg = FORMATS[file_format].compare(baseline_file, test_array, atol=atol, rtol=rtol)
357-
358-
if identical:
359-
shutil.rmtree(result_dir)
360-
else:
361-
raise Exception(msg)
362-
363-
else:
364-
365-
if not os.path.exists(self.generate_dir):
366-
os.makedirs(self.generate_dir)
367-
368-
FORMATS[file_format].write(os.path.abspath(os.path.join(self.generate_dir, filename)), array, **write_kwargs)
369-
370-
pytest.skip("Skipping test, since generating data")
385+
_compare_array(array, item, compare.kwargs,
386+
plugin_reference_dir=self.reference_dir,
387+
generate_dir=self.generate_dir,
388+
default_format=self.default_format)
371389

372390

373391
class ArrayInterceptor:
@@ -383,3 +401,42 @@ def __init__(self, config):
383401
def pytest_collection_modifyitems(self, items):
384402
for item in items:
385403
wrap_array_interceptor(self, item)
404+
405+
406+
class ArrayCompareFixture:
407+
"""
408+
Object returned by the ``array_compare`` fixture; call ``check(array,
409+
**kwargs)`` to compare an array, where ``kwargs`` accepts the same options
410+
as the ``@pytest.mark.array_compare`` marker.
411+
412+
Unlike the marker, this never replaces ``item.obj``, so the test function
413+
is collected and run as written and plugins that introspect the test source
414+
keep working (notably pytest-run-parallel's thread-unsafe-call detection).
415+
"""
416+
417+
def __init__(self, request, comparison):
418+
self._request = request
419+
self._comparison = comparison
420+
421+
def check(self, array, **kwargs):
422+
if self._comparison is None:
423+
# Array comparison not requested this run (no --arraydiff); no-op,
424+
# mirroring the marker-based API.
425+
return
426+
_compare_array(array, self._request.node, kwargs,
427+
plugin_reference_dir=self._comparison.reference_dir,
428+
generate_dir=self._comparison.generate_dir,
429+
default_format=self._comparison.default_format)
430+
431+
432+
@pytest.fixture
433+
def array_compare(request):
434+
"""
435+
Fixture alternative to the ``@pytest.mark.array_compare`` marker::
436+
437+
def test_something(array_compare):
438+
array_compare.check(compute(), atol=1e-6)
439+
"""
440+
# 'arraydiff' only resolves when comparison is enabled (see pytest_configure)
441+
comparison = request.config.pluginmanager.get_plugin('arraydiff')
442+
return ArrayCompareFixture(request, comparison)

‎tests/test_pytest_arraydiff.py‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,3 +206,78 @@ def test_parallel_iterations(pytester):
206206
'--parallel-threads=2', '--iterations=3',
207207
)
208208
assert result.ret == 0
209+
210+
211+
# ---------------------------------------------------------------------------
212+
# Fixture-based API (alternative to the @pytest.mark.array_compare marker)
213+
# ---------------------------------------------------------------------------
214+
215+
TEST_FIXTURE = """
216+
import numpy as np
217+
218+
def test_fixture(array_compare):
219+
array_compare.check(np.arange(3 * 5).reshape((3, 5)), file_format='text')
220+
"""
221+
222+
223+
def test_fixture_api(pytester):
224+
"""The array_compare fixture can generate, compare, and no-op."""
225+
pytester.makepyfile(test_fixture=TEST_FIXTURE)
226+
gen_dir = pytester.path / 'reference'
227+
228+
# Generating writes the reference and skips
229+
result = pytester.runpytest_subprocess(f'--arraydiff-generate-path={gen_dir}')
230+
assert result.ret == 0
231+
assert (gen_dir / 'test_fixture.txt').exists()
232+
233+
# With --arraydiff it compares against the generated reference and passes
234+
result = pytester.runpytest_subprocess(
235+
'--arraydiff', f'--arraydiff-reference-path={gen_dir}')
236+
assert result.ret == 0
237+
238+
# Without --arraydiff the fixture is a no-op and the test still passes
239+
result = pytester.runpytest_subprocess()
240+
assert result.ret == 0
241+
242+
243+
TEST_FIXTURE_PARALLEL = """
244+
import threading
245+
import warnings
246+
import numpy as np
247+
248+
_threads = set()
249+
250+
def test_fixture_parallel(array_compare):
251+
# A known thread-unsafe call. pytest-run-parallel auto-detects this by
252+
# reading the test source -- which only works if item.obj was not replaced
253+
# by a wrapper. The fixture API never replaces item.obj, so detection holds
254+
# and this test is run single-threaded; hence only one thread id is seen.
255+
with warnings.catch_warnings():
256+
warnings.simplefilter('ignore')
257+
_threads.add(threading.get_ident())
258+
assert len(_threads) == 1
259+
array_compare.check(np.arange(3 * 5).reshape((3, 5)), file_format='text')
260+
"""
261+
262+
263+
def test_fixture_parallel_detection(pytester):
264+
"""Regression: the fixture must not blind pytest-run-parallel's detection
265+
of thread-unsafe calls (unlike the marker path, which swaps item.obj)."""
266+
pytest.importorskip('pytest_run_parallel')
267+
268+
pytester.makepyfile(test_fp=TEST_FIXTURE_PARALLEL)
269+
gen_dir = pytester.path / 'reference'
270+
271+
result = pytester.runpytest_subprocess(f'--arraydiff-generate-path={gen_dir}')
272+
assert result.ret == 0
273+
274+
# With --mark-warnings-as-unsafe, catch_warnings is unconditionally
275+
# thread-unsafe (otherwise its safety depends on the interpreter). If
276+
# detection works (item.obj intact), the test runs single-threaded and
277+
# passes; if detection were defeated it would run in 2 threads and the
278+
# assert fails.
279+
result = pytester.runpytest_subprocess(
280+
'--arraydiff', f'--arraydiff-reference-path={gen_dir}',
281+
'--parallel-threads=2', '--iterations=3', '--mark-warnings-as-unsafe',
282+
)
283+
assert result.ret == 0

0 commit comments

Comments
 (0)