43
43
44
44
import pytest
45
45
46
+ SUPPORTED_FORMATS = {'html' , 'json' }
47
+
46
48
SHAPE_MISMATCH_ERROR = """Error: Image dimensions did not match.
47
49
Expected shape: {expected_shape}
48
50
{expected_path}
@@ -150,7 +152,8 @@ def pytest_addoption(parser):
150
152
group .addoption ('--mpl-generate-summary' , action = 'store' ,
151
153
help = "Generate a summary report of any failed tests"
152
154
", 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." )
154
157
155
158
results_path_help = "directory for test results, relative to location where py.test is run"
156
159
group .addoption ('--mpl-results-path' , help = results_path_help , action = 'store' )
@@ -281,8 +284,12 @@ def __init__(self,
281
284
self .results_dir = path_is_not_none (results_dir )
282
285
self .hash_library = path_is_not_none (hash_library )
283
286
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." )
286
293
self .generate_summary = generate_summary
287
294
self .results_always = results_always
288
295
@@ -420,13 +427,16 @@ def generate_image_hash(self, item, fig):
420
427
close_mpl_figure (fig )
421
428
return out
422
429
423
- def compare_image_to_baseline (self , item , fig , result_dir ):
430
+ def compare_image_to_baseline (self , item , fig , result_dir , summary = None ):
424
431
"""
425
432
Compare a test image to a baseline image.
426
433
"""
427
434
from matplotlib .image import imread
428
435
from matplotlib .testing .compare import compare_images
429
436
437
+ if summary is None :
438
+ summary = {}
439
+
430
440
compare = self .get_compare (item )
431
441
tolerance = compare .kwargs .get ('tolerance' , 2 )
432
442
savefig_kwargs = compare .kwargs .get ('savefig_kwargs' , {})
@@ -435,40 +445,68 @@ def compare_image_to_baseline(self, item, fig, result_dir):
435
445
436
446
test_image = (result_dir / "result.png" ).absolute ()
437
447
fig .savefig (str (test_image ), ** savefig_kwargs )
448
+ summary ['result_image' ] = '%EXISTS%'
438
449
439
450
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
445
459
446
460
# setuptools may put the baseline images in non-accessible places,
447
461
# copy to our tmpdir to be sure to keep them in case of failure
448
462
baseline_image = (result_dir / "baseline.png" ).absolute ()
449
463
shutil .copyfile (baseline_image_ref , baseline_image )
464
+ summary ['baseline_image' ] = '%EXISTS%'
450
465
451
466
# Compare image size ourselves since the Matplotlib
452
467
# exception is a bit cryptic in this case and doesn't show
453
468
# the filenames
454
469
expected_shape = imread (str (baseline_image )).shape [:2 ]
455
470
actual_shape = imread (str (test_image )).shape [:2 ]
456
471
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
461
479
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
463
499
464
500
def load_hash_library (self , library_path ):
465
501
with open (str (library_path )) as fp :
466
502
return json .load (fp )
467
503
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 ):
469
505
new_test = False
470
506
hash_comparison_pass = False
471
507
baseline_image_path = None
508
+ if summary is None :
509
+ summary = {}
472
510
473
511
compare = self .get_compare (item )
474
512
savefig_kwargs = compare .kwargs .get ('savefig_kwargs' , {})
@@ -483,23 +521,33 @@ def compare_image_to_hash_library(self, item, fig, result_dir):
483
521
hash_name = self .generate_test_name (item )
484
522
485
523
test_hash = self .generate_image_hash (item , fig )
524
+ summary ['result_hash' ] = test_hash
486
525
487
526
if hash_name not in hash_library :
488
527
new_test = True
528
+ summary ['status' ] = 'failed'
489
529
error_message = (f"Hash for test '{ hash_name } ' not found in { hash_library_filename } . "
490
530
f"Generated hash is { test_hash } ." )
531
+ summary ['status_msg' ] = error_message
532
+ else :
533
+ summary ['baseline_hash' ] = hash_library [hash_name ]
491
534
492
535
# Save the figure for later summary (will be removed later if not needed)
493
536
test_image = (result_dir / "result.png" ).absolute ()
494
537
fig .savefig (str (test_image ), ** savefig_kwargs )
538
+ summary ['result_image' ] = '%EXISTS%'
495
539
496
540
if not new_test :
497
541
if test_hash == hash_library [hash_name ]:
498
542
hash_comparison_pass = True
543
+ summary ['status' ] = 'passed'
544
+ summary ['status_msg' ] = 'Test hash matches baseline hash.'
499
545
else :
500
546
error_message = (f"Hash { test_hash } doesn't match hash "
501
547
f"{ hash_library [hash_name ]} in library "
502
548
f"{ hash_library_filename } for test { hash_name } ." )
549
+ summary ['status' ] = 'failed'
550
+ summary ['status_msg' ] = 'Test hash does not match baseline hash.'
503
551
504
552
# If the compare has only been specified with hash and not baseline
505
553
# 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):
509
557
# If this is not a new test try and get the baseline image.
510
558
if not new_test :
511
559
baseline_error = None
560
+ baseline_summary = {}
512
561
# Ignore Errors here as it's possible the reference image dosen't exist yet.
513
562
try :
514
563
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):
517
566
baseline_image = None
518
567
# Get the baseline and generate a diff image, always so that
519
568
# --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 )
521
571
except Exception as e :
522
572
baseline_image = None
523
573
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 )
524
576
525
577
# If the hash comparison passes then return
526
578
if hash_comparison_pass :
@@ -530,8 +582,12 @@ def compare_image_to_hash_library(self, item, fig, result_dir):
530
582
error_message += f"\n Unable to find baseline image for { item } ."
531
583
if baseline_error :
532
584
error_message += f"\n { baseline_error } "
585
+ summary ['status' ] = 'failed'
586
+ summary ['status_msg' ] = error_message
533
587
return error_message
534
588
589
+ summary ['baseline_image' ] = '%EXISTS%'
590
+
535
591
# Override the tolerance (if not explicitly set) to 0 as the hashes are not forgiving
536
592
tolerance = compare .kwargs .get ('tolerance' , None )
537
593
if not tolerance :
@@ -540,7 +596,10 @@ def compare_image_to_hash_library(self, item, fig, result_dir):
540
596
comparison_error = (baseline_comparison or
541
597
"\n However, the comparison to the baseline image succeeded." )
542
598
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
544
603
545
604
def pytest_runtest_setup (self , item ): # noqa
546
605
@@ -583,40 +642,60 @@ def item_function_wrapper(*args, **kwargs):
583
642
584
643
test_name = self .generate_test_name (item )
585
644
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
+
586
657
# What we do now depends on whether we are generating the
587
658
# reference images or simply running the test.
588
659
if self .generate_dir is not None :
660
+ summary ['status' ] = 'skipped'
661
+ summary ['status_msg' ] = 'Skipped test, since generating image.'
589
662
self .generate_baseline_image (item , fig )
590
663
if self .generate_hash_library is None :
664
+ self ._test_results [str (pathify (test_name ))] = summary
591
665
pytest .skip ("Skipping test, since generating image." )
592
666
593
667
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
595
671
596
672
# Only test figures if not generating images
597
673
if self .generate_dir is None :
598
674
result_dir = self .make_test_results_dir (item )
599
675
600
676
# Compare to hash library
601
677
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 )
603
679
604
680
# Compare against a baseline if specified
605
681
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 )
607
683
608
684
close_mpl_figure (fig )
609
685
610
- self ._test_results [str (pathify (test_name ))] = msg or True
611
-
612
686
if msg is None :
613
687
if not self .results_always :
614
688
shutil .rmtree (result_dir )
689
+ for image_type in ['baseline_image' , 'diff_image' , 'result_image' ]:
690
+ summary [image_type ] = None # image no longer %EXISTS%
615
691
else :
692
+ self ._test_results [str (pathify (test_name ))] = summary
616
693
pytest .fail (msg , pytrace = False )
617
694
618
695
close_mpl_figure (fig )
619
696
697
+ self ._test_results [str (pathify (test_name ))] = summary
698
+
620
699
if item .cls is not None :
621
700
setattr (item .cls , item .function .__name__ , item_function_wrapper )
622
701
else :
@@ -646,6 +725,12 @@ def generate_summary_html(self, dir_list):
646
725
647
726
return html_file
648
727
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
+
649
734
def pytest_unconfigure (self , config ):
650
735
"""
651
736
Save out the hash library at the end of the run.
@@ -656,12 +741,28 @@ def pytest_unconfigure(self, config):
656
741
with open (hash_library_path , "w" ) as fp :
657
742
json .dump (self ._generated_hash_library , fp , indent = 2 )
658
743
659
- if self .generate_summary and self . generate_summary . lower () == 'html' :
744
+ if self .generate_summary :
660
745
# Generate a list of test directories
661
746
dir_list = [p .relative_to (self .results_dir )
662
747
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 } " )
665
766
666
767
667
768
class FigureCloser :
0 commit comments