4343
4444import pytest
4545
46+ SUPPORTED_FORMATS = {'html' , 'json' }
47+
4648SHAPE_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"\n Unable 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 "\n However, 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
667768class FigureCloser :
0 commit comments