Skip to content

Commit a4c64a5

Browse files
committed
add support for pytest-xdist
1 parent e94a40d commit a4c64a5

File tree

4 files changed

+134
-19
lines changed

4 files changed

+134
-19
lines changed

pytest_mpl/plugin.py

Lines changed: 73 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,27 +949,65 @@ 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+
try:
981+
import xdist
982+
is_xdist_controller = xdist.is_xdist_controller(session)
983+
is_xdist_worker = xdist.is_xdist_worker(session)
984+
except ImportError:
985+
is_xdist_controller = False
986+
is_xdist_worker = False
987+
except Exception as e:
988+
if "xdist" not in session.config.option:
989+
is_xdist_controller = False
990+
is_xdist_worker = False
991+
else:
992+
raise e
993+
994+
if is_xdist_controller: # Merge results from workers
995+
uid = config.pytest_mpl_uid
996+
for worker_hashes in self.results_dir.glob(f"generated-hashes-xdist-{uid}-*.json"):
997+
with worker_hashes.open() as f:
998+
self._generated_hash_library.update(json.load(f))
999+
for worker_results in self.results_dir.glob(f"results-xdist-{uid}-*.json"):
1000+
with worker_results.open() as f:
1001+
self._test_results.update(json.load(f))
1002+
9451003
result_hash_library = self.results_dir / (self.results_hash_library_name or "temp.json")
9461004
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
1005+
hash_library_path = self.generate_hash_library_json()
1006+
if self.results_always and not is_xdist_worker: # Make accessible in results directory
9521007
# Use same name as generated
9531008
result_hash_library = self.results_dir / hash_library_path.name
9541009
shutil.copy(hash_library_path, result_hash_library)
955-
elif self.results_always and self.results_hash_library_name:
1010+
elif self.results_always and self.results_hash_library_name and not is_xdist_worker:
9561011
result_hashes = {k: v['result_hash'] for k, v in self._test_results.items()
9571012
if v['result_hash']}
9581013
if len(result_hashes) > 0: # At least one hash comparison test
@@ -964,6 +1019,8 @@ def pytest_unconfigure(self, config):
9641019
if 'json' in self.generate_summary:
9651020
summary = self.generate_summary_json()
9661021
print(f"A JSON report can be found at: {summary}")
1022+
if is_xdist_worker:
1023+
return
9671024
if result_hash_library.exists(): # link to it in the HTML
9681025
kwargs["hash_library"] = result_hash_library.name
9691026
if 'html' in self.generate_summary:

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: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848

4949

5050
def run_subtest(baseline_summary_name, tmp_path, args, summaries=None, xfail=True,
51-
has_result_hashes=False, generating_hashes=False, testing_hashes=False,
51+
has_result_hashes=False, generating_hashes=False, testing_hashes=False, n_xdist_workers=None,
5252
update_baseline=UPDATE_BASELINE, update_summary=UPDATE_SUMMARY):
5353
""" Run pytest (within pytest) and check JSON summary report.
5454
@@ -72,6 +72,9 @@ def run_subtest(baseline_summary_name, tmp_path, args, summaries=None, xfail=Tru
7272
both of `--mpl-hash-library` and `hash_library=` were not.
7373
testing_hashes : bool, optional, default=False
7474
Whether the subtest is comparing hashes and therefore needs baseline hashes generated.
75+
n_xdist_workers : str or int, optional, default=None
76+
Number of xdist workers to use, or "auto" to use all available cores.
77+
None will disable xdist. If pytest-xdist is not installed, this will be ignored.
7578
"""
7679
if update_baseline and update_summary:
7780
raise ValueError("Cannot enable both `update_baseline` and `update_summary`.")
@@ -109,6 +112,15 @@ def run_subtest(baseline_summary_name, tmp_path, args, summaries=None, xfail=Tru
109112
shutil.copy(expected_result_hash_library, baseline_hash_library)
110113
transform_hashes(baseline_hash_library)
111114

115+
try:
116+
import xdist
117+
if n_xdist_workers is None:
118+
pytest_args += ["-p", "no:xdist"]
119+
else:
120+
pytest_args += ["-n", str(n_xdist_workers)]
121+
except ImportError:
122+
pass
123+
112124
# Run the test and record exit status
113125
status = subprocess.call(pytest_args + mpl_args + args)
114126

@@ -206,6 +218,21 @@ def test_html(tmp_path):
206218
assert (tmp_path / 'results' / 'styles.css').exists()
207219

208220

221+
@pytest.mark.parametrize("num_workers", [None, 0, 1, 2])
222+
def test_html_xdist(request, tmp_path, num_workers):
223+
if not request.config.pluginmanager.hasplugin("xdist"):
224+
pytest.skip("Skipping: pytest-xdist is not installed")
225+
run_subtest('test_results_always', tmp_path,
226+
[HASH_LIBRARY_FLAG, BASELINE_IMAGES_FLAG_ABS], summaries=['html'],
227+
has_result_hashes=True, n_xdist_workers=num_workers)
228+
assert (tmp_path / 'results' / 'fig_comparison.html').exists()
229+
assert (tmp_path / 'results' / 'extra.js').exists()
230+
assert (tmp_path / 'results' / 'styles.css').exists()
231+
if num_workers is not None:
232+
assert len(list((tmp_path / 'results').glob('generated-hashes-xdist-*-*.json'))) == 0
233+
assert len(list((tmp_path / 'results').glob('results-xdist-*-*.json'))) == num_workers
234+
235+
209236
def test_html_hashes_only(tmp_path):
210237
run_subtest('test_html_hashes_only', tmp_path,
211238
[HASH_LIBRARY_FLAG, *HASH_COMPARISON_MODE],
@@ -260,6 +287,24 @@ def test_html_generate(tmp_path):
260287
assert (tmp_path / 'results' / 'fig_comparison.html').exists()
261288

262289

290+
@pytest.mark.parametrize("num_workers", [None, 0, 1, 2])
291+
def test_html_generate_xdist(request, tmp_path, num_workers):
292+
# generating hashes and images; no testing
293+
if not request.config.pluginmanager.hasplugin("xdist"):
294+
pytest.skip("Skipping: pytest-xdist is not installed")
295+
run_subtest('test_html_generate', tmp_path,
296+
[rf'--mpl-generate-path={tmp_path}',
297+
rf'--mpl-generate-hash-library={tmp_path / "test_hashes.json"}'],
298+
summaries=['html'], xfail=False, has_result_hashes="test_hashes.json",
299+
generating_hashes=True, n_xdist_workers=num_workers)
300+
assert (tmp_path / 'results' / 'fig_comparison.html').exists()
301+
assert (tmp_path / 'results' / 'extra.js').exists()
302+
assert (tmp_path / 'results' / 'styles.css').exists()
303+
if num_workers is not None:
304+
assert len(list((tmp_path / 'results').glob('generated-hashes-xdist-*-*.json'))) == num_workers
305+
assert len(list((tmp_path / 'results').glob('results-xdist-*-*.json'))) == num_workers
306+
307+
263308
def test_html_generate_images_only(tmp_path):
264309
# generating images; no testing
265310
run_subtest('test_html_generate_images_only', tmp_path,

tox.ini

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ setenv =
1919
changedir = .tmp/{envname}
2020
description = run tests
2121
deps =
22+
pytest-xdist
2223
mpl20: matplotlib==2.0.*
2324
mpl21: matplotlib==2.1.*
2425
mpl22: matplotlib==2.2.*
@@ -58,7 +59,7 @@ commands =
5859
# Make sure the tests pass with and without --mpl
5960
# Use -m so pytest skips "subtests" which always apply --mpl
6061
pytest '{toxinidir}' -m "mpl_image_compare" {posargs}
61-
coverage run --source=pytest_mpl -m pytest '{toxinidir}' --mpl
62+
coverage run --source=pytest_mpl -m pytest '{toxinidir}' -n auto --mpl
6263
coverage xml -o '{toxinidir}{/}coverage.xml'
6364

6465
[testenv:codestyle]

0 commit comments

Comments
 (0)