Skip to content

Commit a335f50

Browse files
Merge pull request #242 from ConorMacBride/xdist-support
Support generating summary reports when using `pytest-xdist`
2 parents cbd1590 + d9ded2d commit a335f50

File tree

4 files changed

+186
-27
lines changed

4 files changed

+186
-27
lines changed

pytest_mpl/plugin.py

Lines changed: 67 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import io
3232
import os
3333
import json
34+
import uuid
3435
import shutil
3536
import hashlib
3637
import logging
@@ -216,6 +217,12 @@ def pytest_addoption(parser):
216217
parser.addini(option, help=msg)
217218

218219

220+
class XdistPlugin:
221+
def pytest_configure_node(self, node):
222+
node.workerinput["pytest_mpl_uid"] = node.config.pytest_mpl_uid
223+
node.workerinput["pytest_mpl_results_dir"] = node.config.pytest_mpl_results_dir
224+
225+
219226
def pytest_configure(config):
220227

221228
config.addinivalue_line(
@@ -288,12 +295,20 @@ def get_cli_or_ini(name, default=None):
288295
if not _hash_library_from_cli:
289296
hash_library = os.path.abspath(hash_library)
290297

298+
if not hasattr(config, "workerinput"):
299+
uid = uuid.uuid4().hex
300+
results_dir_path = results_dir or tempfile.mkdtemp()
301+
config.pytest_mpl_uid = uid
302+
config.pytest_mpl_results_dir = results_dir_path
303+
304+
if config.pluginmanager.hasplugin("xdist"):
305+
config.pluginmanager.register(XdistPlugin(), name="pytest_mpl_xdist_plugin")
306+
291307
plugin = ImageComparison(
292308
config,
293309
baseline_dir=baseline_dir,
294310
baseline_relative_dir=baseline_relative_dir,
295311
generate_dir=generate_dir,
296-
results_dir=results_dir,
297312
hash_library=hash_library,
298313
generate_hash_library=generate_hash_lib,
299314
generate_summary=generate_summary,
@@ -356,7 +371,6 @@ def __init__(
356371
baseline_dir=None,
357372
baseline_relative_dir=None,
358373
generate_dir=None,
359-
results_dir=None,
360374
hash_library=None,
361375
generate_hash_library=None,
362376
generate_summary=None,
@@ -372,7 +386,7 @@ def __init__(
372386
self.baseline_dir = baseline_dir
373387
self.baseline_relative_dir = path_is_not_none(baseline_relative_dir)
374388
self.generate_dir = path_is_not_none(generate_dir)
375-
self.results_dir = path_is_not_none(results_dir)
389+
self.results_dir = None
376390
self.hash_library = path_is_not_none(hash_library)
377391
self._hash_library_from_cli = _hash_library_from_cli # for backwards compatibility
378392
self.generate_hash_library = path_is_not_none(generate_hash_library)
@@ -394,11 +408,6 @@ def __init__(
394408
self.deterministic = deterministic
395409
self.default_backend = default_backend
396410

397-
# Generate the containing dir for all test results
398-
if not self.results_dir:
399-
self.results_dir = Path(tempfile.mkdtemp(dir=self.results_dir))
400-
self.results_dir.mkdir(parents=True, exist_ok=True)
401-
402411
# Decide what to call the downloadable results hash library
403412
if self.hash_library is not None:
404413
self.results_hash_library_name = self.hash_library.name
@@ -411,6 +420,14 @@ def __init__(
411420
self._test_stats = None
412421
self.return_value = {}
413422

423+
def pytest_sessionstart(self, session):
424+
config = session.config
425+
if hasattr(config, "workerinput"):
426+
config.pytest_mpl_uid = config.workerinput["pytest_mpl_uid"]
427+
config.pytest_mpl_results_dir = config.workerinput["pytest_mpl_results_dir"]
428+
self.results_dir = Path(config.pytest_mpl_results_dir)
429+
self.results_dir.mkdir(parents=True, exist_ok=True)
430+
414431
def get_logger(self):
415432
# configure a separate logger for this pluggin which is independent
416433
# of the options that are configured for pytest or for the code that
@@ -932,34 +949,68 @@ def pytest_runtest_call(self, item): # noqa
932949
result._result = None
933950
result._excinfo = (type(e), e, e.__traceback__)
934951

952+
def generate_hash_library_json(self):
953+
if hasattr(self.config, "workerinput"):
954+
uid = self.config.pytest_mpl_uid
955+
worker_id = os.environ.get("PYTEST_XDIST_WORKER")
956+
json_file = self.results_dir / f"generated-hashes-xdist-{uid}-{worker_id}.json"
957+
else:
958+
json_file = Path(self.config.rootdir) / self.generate_hash_library
959+
json_file.parent.mkdir(parents=True, exist_ok=True)
960+
with open(json_file, 'w') as f:
961+
json.dump(self._generated_hash_library, f, indent=2)
962+
return json_file
963+
935964
def generate_summary_json(self):
936-
json_file = self.results_dir / 'results.json'
965+
filename = "results.json"
966+
if hasattr(self.config, "workerinput"):
967+
uid = self.config.pytest_mpl_uid
968+
worker_id = os.environ.get("PYTEST_XDIST_WORKER")
969+
filename = f"results-xdist-{uid}-{worker_id}.json"
970+
json_file = self.results_dir / filename
937971
with open(json_file, 'w') as f:
938972
json.dump(self._test_results, f, indent=2)
939973
return json_file
940974

941-
def pytest_unconfigure(self, config):
975+
def pytest_sessionfinish(self, session):
942976
"""
943977
Save out the hash library at the end of the run.
944978
"""
979+
config = session.config
980+
is_xdist_worker = hasattr(config, "workerinput")
981+
is_xdist_controller = (
982+
config.pluginmanager.hasplugin("xdist")
983+
and not is_xdist_worker
984+
and getattr(config.option, "dist", "") != "no"
985+
)
986+
987+
if is_xdist_controller: # Merge results from workers
988+
uid = config.pytest_mpl_uid
989+
for worker_hashes in self.results_dir.glob(f"generated-hashes-xdist-{uid}-*.json"):
990+
with worker_hashes.open() as f:
991+
self._generated_hash_library.update(json.load(f))
992+
for worker_results in self.results_dir.glob(f"results-xdist-{uid}-*.json"):
993+
with worker_results.open() as f:
994+
self._test_results.update(json.load(f))
995+
945996
result_hash_library = self.results_dir / (self.results_hash_library_name or "temp.json")
946997
if self.generate_hash_library is not None:
947-
hash_library_path = Path(config.rootdir) / self.generate_hash_library
948-
hash_library_path.parent.mkdir(parents=True, exist_ok=True)
949-
with open(hash_library_path, "w") as fp:
950-
json.dump(self._generated_hash_library, fp, indent=2)
951-
if self.results_always: # Make accessible in results directory
998+
hash_library_path = self.generate_hash_library_json()
999+
if self.results_always and not is_xdist_worker: # Make accessible in results directory
9521000
# Use same name as generated
9531001
result_hash_library = self.results_dir / hash_library_path.name
9541002
shutil.copy(hash_library_path, result_hash_library)
955-
elif self.results_always and self.results_hash_library_name:
1003+
elif self.results_always and self.results_hash_library_name and not is_xdist_worker:
9561004
result_hashes = {k: v['result_hash'] for k, v in self._test_results.items()
9571005
if v['result_hash']}
9581006
if len(result_hashes) > 0: # At least one hash comparison test
9591007
with open(result_hash_library, "w") as fp:
9601008
json.dump(result_hashes, fp, indent=2)
9611009

9621010
if self.generate_summary:
1011+
if is_xdist_worker:
1012+
self.generate_summary_json()
1013+
return
9631014
kwargs = {}
9641015
if 'json' in self.generate_summary:
9651016
summary = self.generate_summary_json()

tests/subtests/helpers.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import os
21
import re
32
import json
43
from pathlib import Path
@@ -8,6 +7,8 @@
87
__all__ = ['diff_summary', 'assert_existence', 'patch_summary', 'apply_regex',
98
'remove_specific_hashes', 'transform_hashes', 'transform_images']
109

10+
MIN_EXPECTED_ITEMS = 20 # Rough minimum number of items in a summary to be valid
11+
1112

1213
class MatchError(Exception):
1314
pass
@@ -39,15 +40,26 @@ def diff_summary(baseline, result, baseline_hash_library=None, result_hash_libra
3940
# Load "correct" baseline hashes
4041
with open(baseline_hash_library, 'r') as f:
4142
baseline_hash_library = json.load(f)
43+
if len(baseline_hash_library.keys()) < MIN_EXPECTED_ITEMS:
44+
raise ValueError(f"baseline_hash_library only has {len(baseline_hash_library.keys())} items")
4245
else:
4346
baseline_hash_library = {}
4447
if result_hash_library and result_hash_library.exists():
4548
# Load "correct" result hashes
4649
with open(result_hash_library, 'r') as f:
4750
result_hash_library = json.load(f)
51+
if len(result_hash_library.keys()) < MIN_EXPECTED_ITEMS:
52+
raise ValueError(f"result_hash_library only has {len(result_hash_library.keys())} items")
4853
else:
4954
result_hash_library = {}
5055

56+
b = baseline.get("a", baseline)
57+
if len(b.keys()) < MIN_EXPECTED_ITEMS:
58+
raise ValueError(f"baseline only has {len(b.keys())} items {b}")
59+
r = result.get("a", result)
60+
if len(r.keys()) < MIN_EXPECTED_ITEMS:
61+
raise ValueError(f"result only has {len(r.keys())} items {r}")
62+
5163
# Get test names
5264
baseline_tests = set(baseline.keys())
5365
result_tests = set(result.keys())

tests/subtests/test_subtest.py

Lines changed: 104 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,19 @@
4747
]
4848

4949

50+
def xdist_args(n_workers):
51+
try:
52+
import xdist
53+
if n_workers is None:
54+
return ["-p", "no:xdist"]
55+
else:
56+
return ["-n", str(n_workers)]
57+
except ImportError:
58+
return []
59+
60+
5061
def run_subtest(baseline_summary_name, tmp_path, args, summaries=None, xfail=True,
51-
has_result_hashes=False, generating_hashes=False, testing_hashes=False,
62+
has_result_hashes=False, generating_hashes=False, testing_hashes=False, n_xdist_workers=None,
5263
update_baseline=UPDATE_BASELINE, update_summary=UPDATE_SUMMARY):
5364
""" Run pytest (within pytest) and check JSON summary report.
5465
@@ -72,6 +83,9 @@ def run_subtest(baseline_summary_name, tmp_path, args, summaries=None, xfail=Tru
7283
both of `--mpl-hash-library` and `hash_library=` were not.
7384
testing_hashes : bool, optional, default=False
7485
Whether the subtest is comparing hashes and therefore needs baseline hashes generated.
86+
n_xdist_workers : str or int, optional, default=None
87+
Number of xdist workers to use, or "auto" to use all available cores.
88+
None will disable xdist. If pytest-xdist is not installed, this will be ignored.
7589
"""
7690
if update_baseline and update_summary:
7791
raise ValueError("Cannot enable both `update_baseline` and `update_summary`.")
@@ -109,6 +123,8 @@ def run_subtest(baseline_summary_name, tmp_path, args, summaries=None, xfail=Tru
109123
shutil.copy(expected_result_hash_library, baseline_hash_library)
110124
transform_hashes(baseline_hash_library)
111125

126+
pytest_args.extend(xdist_args(n_xdist_workers))
127+
112128
# Run the test and record exit status
113129
status = subprocess.call(pytest_args + mpl_args + args)
114130

@@ -201,23 +217,50 @@ def test_html(tmp_path):
201217
run_subtest('test_results_always', tmp_path,
202218
[HASH_LIBRARY_FLAG, BASELINE_IMAGES_FLAG_ABS], summaries=['html'],
203219
has_result_hashes=True)
204-
assert (tmp_path / 'results' / 'fig_comparison.html').exists()
220+
html_path = tmp_path / 'results' / 'fig_comparison.html'
221+
assert html_path.exists()
222+
assert html_path.stat().st_size > 200_000
223+
assert "Baseline image differs" in html_path.read_text()
224+
assert (tmp_path / 'results' / 'extra.js').exists()
225+
assert (tmp_path / 'results' / 'styles.css').exists()
226+
227+
228+
@pytest.mark.parametrize("num_workers", [None, 0, 1, 2])
229+
def test_html_xdist(request, tmp_path, num_workers):
230+
if not request.config.pluginmanager.hasplugin("xdist"):
231+
pytest.skip("Skipping: pytest-xdist is not installed")
232+
run_subtest('test_results_always', tmp_path,
233+
[HASH_LIBRARY_FLAG, BASELINE_IMAGES_FLAG_ABS], summaries=['html'],
234+
has_result_hashes=True, n_xdist_workers=num_workers)
235+
html_path = tmp_path / 'results' / 'fig_comparison.html'
236+
assert html_path.exists()
237+
assert html_path.stat().st_size > 200_000
238+
assert "Baseline image differs" in html_path.read_text()
205239
assert (tmp_path / 'results' / 'extra.js').exists()
206240
assert (tmp_path / 'results' / 'styles.css').exists()
241+
if num_workers is not None:
242+
assert len(list((tmp_path / 'results').glob('generated-hashes-xdist-*-*.json'))) == 0
243+
assert len(list((tmp_path / 'results').glob('results-xdist-*-*.json'))) == num_workers
207244

208245

209246
def test_html_hashes_only(tmp_path):
210247
run_subtest('test_html_hashes_only', tmp_path,
211248
[HASH_LIBRARY_FLAG, *HASH_COMPARISON_MODE],
212249
summaries=['html'], has_result_hashes=True)
213-
assert (tmp_path / 'results' / 'fig_comparison.html').exists()
250+
html_path = tmp_path / 'results' / 'fig_comparison.html'
251+
assert html_path.exists()
252+
assert html_path.stat().st_size > 100_000
253+
assert "Baseline hash differs" in html_path.read_text()
214254
assert (tmp_path / 'results' / 'extra.js').exists()
215255
assert (tmp_path / 'results' / 'styles.css').exists()
216256

217257

218258
def test_html_images_only(tmp_path):
219259
run_subtest('test_html_images_only', tmp_path, [*IMAGE_COMPARISON_MODE], summaries=['html'])
220-
assert (tmp_path / 'results' / 'fig_comparison.html').exists()
260+
html_path = tmp_path / 'results' / 'fig_comparison.html'
261+
assert html_path.exists()
262+
assert html_path.stat().st_size > 200_000
263+
assert "Baseline image differs" in html_path.read_text()
221264
assert (tmp_path / 'results' / 'extra.js').exists()
222265
assert (tmp_path / 'results' / 'styles.css').exists()
223266

@@ -226,7 +269,10 @@ def test_basic_html(tmp_path):
226269
run_subtest('test_results_always', tmp_path,
227270
[HASH_LIBRARY_FLAG, *BASELINE_IMAGES_FLAG_REL], summaries=['basic-html'],
228271
has_result_hashes=True)
229-
assert (tmp_path / 'results' / 'fig_comparison_basic.html').exists()
272+
html_path = tmp_path / 'results' / 'fig_comparison_basic.html'
273+
assert html_path.exists()
274+
assert html_path.stat().st_size > 20_000
275+
assert "hash comparison, although" in html_path.read_text()
230276

231277

232278
def test_generate(tmp_path):
@@ -257,23 +303,53 @@ def test_html_generate(tmp_path):
257303
rf'--mpl-generate-hash-library={tmp_path / "test_hashes.json"}'],
258304
summaries=['html'], xfail=False, has_result_hashes="test_hashes.json",
259305
generating_hashes=True)
260-
assert (tmp_path / 'results' / 'fig_comparison.html').exists()
306+
html_path = tmp_path / 'results' / 'fig_comparison.html'
307+
assert html_path.exists()
308+
assert html_path.stat().st_size > 100_000
309+
assert "Baseline image was generated" in html_path.read_text()
310+
311+
312+
@pytest.mark.parametrize("num_workers", [None, 0, 1, 2])
313+
def test_html_generate_xdist(request, tmp_path, num_workers):
314+
# generating hashes and images; no testing
315+
if not request.config.pluginmanager.hasplugin("xdist"):
316+
pytest.skip("Skipping: pytest-xdist is not installed")
317+
run_subtest('test_html_generate', tmp_path,
318+
[rf'--mpl-generate-path={tmp_path}',
319+
rf'--mpl-generate-hash-library={tmp_path / "test_hashes.json"}'],
320+
summaries=['html'], xfail=False, has_result_hashes="test_hashes.json",
321+
generating_hashes=True, n_xdist_workers=num_workers)
322+
html_path = tmp_path / 'results' / 'fig_comparison.html'
323+
assert html_path.exists()
324+
assert html_path.stat().st_size > 100_000
325+
assert "Baseline image was generated" in html_path.read_text()
326+
assert (tmp_path / 'results' / 'extra.js').exists()
327+
assert (tmp_path / 'results' / 'styles.css').exists()
328+
if num_workers is not None:
329+
assert len(list((tmp_path / 'results').glob('generated-hashes-xdist-*-*.json'))) == num_workers
330+
assert len(list((tmp_path / 'results').glob('results-xdist-*-*.json'))) == num_workers
261331

262332

263333
def test_html_generate_images_only(tmp_path):
264334
# generating images; no testing
265335
run_subtest('test_html_generate_images_only', tmp_path,
266336
[rf'--mpl-generate-path={tmp_path}', *IMAGE_COMPARISON_MODE],
267337
summaries=['html'], xfail=False)
268-
assert (tmp_path / 'results' / 'fig_comparison.html').exists()
338+
html_path = tmp_path / 'results' / 'fig_comparison.html'
339+
assert html_path.exists()
340+
assert html_path.stat().st_size > 100_000
341+
assert "Baseline image was generated" in html_path.read_text()
269342

270343

271344
def test_html_generate_hashes_only(tmp_path):
272345
# generating hashes; testing images
273346
run_subtest('test_html_generate_hashes_only', tmp_path,
274347
[rf'--mpl-generate-hash-library={tmp_path / "test_hashes.json"}'],
275348
summaries=['html'], has_result_hashes="test_hashes.json", generating_hashes=True)
276-
assert (tmp_path / 'results' / 'fig_comparison.html').exists()
349+
html_path = tmp_path / 'results' / 'fig_comparison.html'
350+
assert html_path.exists()
351+
assert html_path.stat().st_size > 200_000
352+
assert "Baseline hash was generated" in html_path.read_text()
277353

278354

279355
def test_html_run_generate_hashes_only(tmp_path):
@@ -282,9 +358,28 @@ def test_html_run_generate_hashes_only(tmp_path):
282358
[rf'--mpl-generate-hash-library={tmp_path / "test_hashes.json"}',
283359
HASH_LIBRARY_FLAG, *HASH_COMPARISON_MODE],
284360
summaries=['html'], has_result_hashes="test_hashes.json")
285-
assert (tmp_path / 'results' / 'fig_comparison.html').exists()
361+
html_path = tmp_path / 'results' / 'fig_comparison.html'
362+
assert html_path.exists()
363+
assert html_path.stat().st_size > 100_000
364+
assert "Baseline hash differs" in html_path.read_text()
286365

287366

288367
# Run a hybrid mode test last so if generating hash libraries, it includes all the hashes.
289368
def test_hybrid(tmp_path):
290369
run_subtest('test_hybrid', tmp_path, [HASH_LIBRARY_FLAG, BASELINE_IMAGES_FLAG_ABS], testing_hashes=True)
370+
371+
372+
@pytest.mark.parametrize("num_workers", [None, 0, 1, 2])
373+
def test_html_no_json(tmp_path, num_workers):
374+
# Previous tests require JSON summary to be generated to function correctly.
375+
# This test ensures HTML summary generation works without JSON summary.
376+
results_path = tmp_path / 'results'
377+
results_path.mkdir()
378+
mpl_args = ['--mpl', rf'--mpl-results-path={results_path.as_posix()}',
379+
'--mpl-generate-summary=html', *xdist_args(num_workers)]
380+
subprocess.call([sys.executable, '-m', 'pytest', str(TEST_FILE), *mpl_args])
381+
assert not (tmp_path / 'results' / 'results.json').exists()
382+
html_path = tmp_path / 'results' / 'fig_comparison.html'
383+
assert html_path.exists()
384+
assert html_path.stat().st_size > 200_000
385+
assert "Baseline image differs" in html_path.read_text()

0 commit comments

Comments
 (0)