Skip to content

Commit db741b0

Browse files
committed
Add JSON output
Signed-off-by: Conor MacBride <[email protected]>
1 parent 81e120a commit db741b0

File tree

1 file changed

+126
-25
lines changed

1 file changed

+126
-25
lines changed

pytest_mpl/plugin.py

Lines changed: 126 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@
4343

4444
import pytest
4545

46+
SUPPORTED_FORMATS = {'html', 'json'}
47+
4648
SHAPE_MISMATCH_ERROR = """Error: Image dimensions did not match.
4749
Expected shape: {expected_shape}
4850
{expected_path}
@@ -150,7 +152,8 @@ def pytest_addoption(parser):
150152
group.addoption('--mpl-generate-summary', action='store',
151153
help="Generate a summary report of any failed tests"
152154
", in --mpl-results-path. The type of the report should be "
153-
"specified, the only format supported at the moment is `html`.")
155+
"specified. Supported types are `html` and `json`. "
156+
"Multiple types can be specified separated by commas.")
154157

155158
results_path_help = "directory for test results, relative to location where py.test is run"
156159
group.addoption('--mpl-results-path', help=results_path_help, action='store')
@@ -281,8 +284,12 @@ def __init__(self,
281284
self.results_dir = path_is_not_none(results_dir)
282285
self.hash_library = path_is_not_none(hash_library)
283286
self.generate_hash_library = path_is_not_none(generate_hash_library)
284-
if generate_summary and generate_summary.lower() not in ("html",):
285-
raise ValueError(f"The mpl summary type '{generate_summary}' is not supported.")
287+
if generate_summary:
288+
generate_summary = {i.lower() for i in generate_summary.split(',')}
289+
unsupported_formats = generate_summary - SUPPORTED_FORMATS
290+
if len(unsupported_formats) > 0:
291+
raise ValueError(f"The mpl summary type(s) '{sorted(unsupported_formats)}' "
292+
"are not supported.")
286293
self.generate_summary = generate_summary
287294
self.results_always = results_always
288295

@@ -420,13 +427,16 @@ def generate_image_hash(self, item, fig):
420427
close_mpl_figure(fig)
421428
return out
422429

423-
def compare_image_to_baseline(self, item, fig, result_dir):
430+
def compare_image_to_baseline(self, item, fig, result_dir, summary=None):
424431
"""
425432
Compare a test image to a baseline image.
426433
"""
427434
from matplotlib.image import imread
428435
from matplotlib.testing.compare import compare_images
429436

437+
if summary is None:
438+
summary = {}
439+
430440
compare = self.get_compare(item)
431441
tolerance = compare.kwargs.get('tolerance', 2)
432442
savefig_kwargs = compare.kwargs.get('savefig_kwargs', {})
@@ -435,40 +445,68 @@ def compare_image_to_baseline(self, item, fig, result_dir):
435445

436446
test_image = (result_dir / "result.png").absolute()
437447
fig.savefig(str(test_image), **savefig_kwargs)
448+
summary['result_image'] = '%EXISTS%'
438449

439450
if not os.path.exists(baseline_image_ref):
440-
return ("Image file not found for comparison test in: \n\t"
441-
f"{self.get_baseline_directory(item)}\n"
442-
"(This is expected for new tests.)\n"
443-
"Generated Image: \n\t"
444-
f"{test_image}")
451+
summary['status'] = 'failed'
452+
error_message = ("Image file not found for comparison test in: \n\t"
453+
f"{self.get_baseline_directory(item)}\n"
454+
"(This is expected for new tests.)\n"
455+
"Generated Image: \n\t"
456+
f"{test_image}")
457+
summary['status_msg'] = error_message
458+
return error_message
445459

446460
# setuptools may put the baseline images in non-accessible places,
447461
# copy to our tmpdir to be sure to keep them in case of failure
448462
baseline_image = (result_dir / "baseline.png").absolute()
449463
shutil.copyfile(baseline_image_ref, baseline_image)
464+
summary['baseline_image'] = '%EXISTS%'
450465

451466
# Compare image size ourselves since the Matplotlib
452467
# exception is a bit cryptic in this case and doesn't show
453468
# the filenames
454469
expected_shape = imread(str(baseline_image)).shape[:2]
455470
actual_shape = imread(str(test_image)).shape[:2]
456471
if expected_shape != actual_shape:
457-
return SHAPE_MISMATCH_ERROR.format(expected_path=baseline_image,
458-
expected_shape=expected_shape,
459-
actual_path=test_image,
460-
actual_shape=actual_shape)
472+
summary['status'] = 'failed'
473+
error_message = SHAPE_MISMATCH_ERROR.format(expected_path=baseline_image,
474+
expected_shape=expected_shape,
475+
actual_path=test_image,
476+
actual_shape=actual_shape)
477+
summary['status_msg'] = error_message
478+
return error_message
461479

462-
return compare_images(str(baseline_image), str(test_image), tol=tolerance)
480+
results = compare_images(str(baseline_image), str(test_image), tol=tolerance, in_decorator=True)
481+
summary['tolerance'] = tolerance
482+
if results is None:
483+
summary['status'] = 'passed'
484+
summary['status_msg'] = 'Image comparison passed.'
485+
return None
486+
else:
487+
summary['status'] = 'failed'
488+
summary['rms'] = results['rms']
489+
summary['diff_image'] = '%EXISTS%'
490+
template = ['Error: Image files did not match.',
491+
'RMS Value: {rms}',
492+
'Expected: \n {expected}',
493+
'Actual: \n {actual}',
494+
'Difference:\n {diff}',
495+
'Tolerance: \n {tol}', ]
496+
error_message = '\n '.join([line.format(**results) for line in template])
497+
summary['status_msg'] = error_message
498+
return error_message
463499

464500
def load_hash_library(self, library_path):
465501
with open(str(library_path)) as fp:
466502
return json.load(fp)
467503

468-
def compare_image_to_hash_library(self, item, fig, result_dir):
504+
def compare_image_to_hash_library(self, item, fig, result_dir, summary=None):
469505
new_test = False
470506
hash_comparison_pass = False
471507
baseline_image_path = None
508+
if summary is None:
509+
summary = {}
472510

473511
compare = self.get_compare(item)
474512
savefig_kwargs = compare.kwargs.get('savefig_kwargs', {})
@@ -483,23 +521,33 @@ def compare_image_to_hash_library(self, item, fig, result_dir):
483521
hash_name = self.generate_test_name(item)
484522

485523
test_hash = self.generate_image_hash(item, fig)
524+
summary['result_hash'] = test_hash
486525

487526
if hash_name not in hash_library:
488527
new_test = True
528+
summary['status'] = 'failed'
489529
error_message = (f"Hash for test '{hash_name}' not found in {hash_library_filename}. "
490530
f"Generated hash is {test_hash}.")
531+
summary['status_msg'] = error_message
532+
else:
533+
summary['baseline_hash'] = hash_library[hash_name]
491534

492535
# Save the figure for later summary (will be removed later if not needed)
493536
test_image = (result_dir / "result.png").absolute()
494537
fig.savefig(str(test_image), **savefig_kwargs)
538+
summary['result_image'] = '%EXISTS%'
495539

496540
if not new_test:
497541
if test_hash == hash_library[hash_name]:
498542
hash_comparison_pass = True
543+
summary['status'] = 'passed'
544+
summary['status_msg'] = 'Test hash matches baseline hash.'
499545
else:
500546
error_message = (f"Hash {test_hash} doesn't match hash "
501547
f"{hash_library[hash_name]} in library "
502548
f"{hash_library_filename} for test {hash_name}.")
549+
summary['status'] = 'failed'
550+
summary['status_msg'] = 'Test hash does not match baseline hash.'
503551

504552
# If the compare has only been specified with hash and not baseline
505553
# dir, don't attempt to find a baseline image at the default path.
@@ -509,6 +557,7 @@ def compare_image_to_hash_library(self, item, fig, result_dir):
509557
# If this is not a new test try and get the baseline image.
510558
if not new_test:
511559
baseline_error = None
560+
baseline_summary = {}
512561
# Ignore Errors here as it's possible the reference image dosen't exist yet.
513562
try:
514563
baseline_image_path = self.obtain_baseline_image(item, result_dir)
@@ -517,10 +566,13 @@ def compare_image_to_hash_library(self, item, fig, result_dir):
517566
baseline_image = None
518567
# Get the baseline and generate a diff image, always so that
519568
# --mpl-results-always can be respected.
520-
baseline_comparison = self.compare_image_to_baseline(item, fig, result_dir)
569+
baseline_comparison = self.compare_image_to_baseline(item, fig, result_dir,
570+
summary=baseline_summary)
521571
except Exception as e:
522572
baseline_image = None
523573
baseline_error = e
574+
for k in ['baseline_image', 'diff_image', 'rms', 'tolerance', 'result_image']:
575+
summary[k] = summary[k] or baseline_summary.get(k)
524576

525577
# If the hash comparison passes then return
526578
if hash_comparison_pass:
@@ -530,8 +582,12 @@ def compare_image_to_hash_library(self, item, fig, result_dir):
530582
error_message += f"\nUnable to find baseline image for {item}."
531583
if baseline_error:
532584
error_message += f"\n{baseline_error}"
585+
summary['status'] = 'failed'
586+
summary['status_msg'] = error_message
533587
return error_message
534588

589+
summary['baseline_image'] = '%EXISTS%'
590+
535591
# Override the tolerance (if not explicitly set) to 0 as the hashes are not forgiving
536592
tolerance = compare.kwargs.get('tolerance', None)
537593
if not tolerance:
@@ -540,7 +596,10 @@ def compare_image_to_hash_library(self, item, fig, result_dir):
540596
comparison_error = (baseline_comparison or
541597
"\nHowever, the comparison to the baseline image succeeded.")
542598

543-
return f"{error_message}\n{comparison_error}"
599+
error_message = f"{error_message}\n{comparison_error}"
600+
summary['status'] = 'failed'
601+
summary['status_msg'] = error_message
602+
return error_message
544603

545604
def pytest_runtest_setup(self, item): # noqa
546605

@@ -583,40 +642,60 @@ def item_function_wrapper(*args, **kwargs):
583642

584643
test_name = self.generate_test_name(item)
585644

645+
summary = {
646+
'status': None,
647+
'status_msg': None,
648+
'baseline_image': None,
649+
'diff_image': None,
650+
'rms': None,
651+
'tolerance': None,
652+
'result_image': None,
653+
'baseline_hash': None,
654+
'result_hash': None,
655+
}
656+
586657
# What we do now depends on whether we are generating the
587658
# reference images or simply running the test.
588659
if self.generate_dir is not None:
660+
summary['status'] = 'skipped'
661+
summary['status_msg'] = 'Skipped test, since generating image.'
589662
self.generate_baseline_image(item, fig)
590663
if self.generate_hash_library is None:
664+
self._test_results[str(pathify(test_name))] = summary
591665
pytest.skip("Skipping test, since generating image.")
592666

593667
if self.generate_hash_library is not None:
594-
self._generated_hash_library[test_name] = self.generate_image_hash(item, fig)
668+
image_hash = self.generate_image_hash(item, fig)
669+
self._generated_hash_library[test_name] = image_hash
670+
summary['result_hash'] = image_hash
595671

596672
# Only test figures if not generating images
597673
if self.generate_dir is None:
598674
result_dir = self.make_test_results_dir(item)
599675

600676
# Compare to hash library
601677
if self.hash_library or compare.kwargs.get('hash_library', None):
602-
msg = self.compare_image_to_hash_library(item, fig, result_dir)
678+
msg = self.compare_image_to_hash_library(item, fig, result_dir, summary=summary)
603679

604680
# Compare against a baseline if specified
605681
else:
606-
msg = self.compare_image_to_baseline(item, fig, result_dir)
682+
msg = self.compare_image_to_baseline(item, fig, result_dir, summary=summary)
607683

608684
close_mpl_figure(fig)
609685

610-
self._test_results[str(pathify(test_name))] = msg or True
611-
612686
if msg is None:
613687
if not self.results_always:
614688
shutil.rmtree(result_dir)
689+
for image_type in ['baseline_image', 'diff_image', 'result_image']:
690+
summary[image_type] = None # image no longer %EXISTS%
615691
else:
692+
self._test_results[str(pathify(test_name))] = summary
616693
pytest.fail(msg, pytrace=False)
617694

618695
close_mpl_figure(fig)
619696

697+
self._test_results[str(pathify(test_name))] = summary
698+
620699
if item.cls is not None:
621700
setattr(item.cls, item.function.__name__, item_function_wrapper)
622701
else:
@@ -646,6 +725,12 @@ def generate_summary_html(self, dir_list):
646725

647726
return html_file
648727

728+
def generate_summary_json(self):
729+
json_file = self.results_dir / 'results.json'
730+
with open(json_file, 'w') as f:
731+
json.dump(self._test_results, f, indent=2)
732+
return json_file
733+
649734
def pytest_unconfigure(self, config):
650735
"""
651736
Save out the hash library at the end of the run.
@@ -656,12 +741,28 @@ def pytest_unconfigure(self, config):
656741
with open(hash_library_path, "w") as fp:
657742
json.dump(self._generated_hash_library, fp, indent=2)
658743

659-
if self.generate_summary and self.generate_summary.lower() == 'html':
744+
if self.generate_summary:
660745
# Generate a list of test directories
661746
dir_list = [p.relative_to(self.results_dir)
662747
for p in self.results_dir.iterdir() if p.is_dir()]
663-
html_summary = self.generate_summary_html(dir_list)
664-
print(f"A summary of the failed tests can be found at: {html_summary}")
748+
749+
# Resolve image paths
750+
for directory in dir_list:
751+
test_name = directory.parts[-1]
752+
for image_type, filename in [
753+
('baseline_image', 'baseline.png'),
754+
('diff_image', 'result-failed-diff.png'),
755+
('result_image', 'result.png'),
756+
]:
757+
if self._test_results[test_name][image_type] == '%EXISTS%':
758+
self._test_results[test_name][image_type] = str(directory / filename)
759+
760+
if 'json' in self.generate_summary:
761+
summary = self.generate_summary_json()
762+
print(f"A JSON report can be found at: {summary}")
763+
if 'html' in self.generate_summary:
764+
summary = self.generate_summary_html(dir_list)
765+
print(f"A summary of the failed tests can be found at: {summary}")
665766

666767

667768
class FigureCloser:

0 commit comments

Comments
 (0)