Skip to content

Commit 3f3991a

Browse files
committed
Refactored image_comparison decorator
It is now function based, what removes the need of pytest workaround. On pytest it uses `mark.parametrize` instead of a generator, what makes collection phase lightning fast and significantly reduces number of generators usages and corresponding deprecation warnings. (generators are deprecated since pytest 3.0 and will be removed in 4.0) Note: There is a bug in Nose related to `GeneratorExit` exception handling in `setup` fixture of a function. It is workarounded in `image_comparison` decorator, but you will see this line in such case in the log: `RuntimeError: generator ignored GeneratorExit`
1 parent e8ef748 commit 3f3991a

File tree

3 files changed

+165
-124
lines changed

3 files changed

+165
-124
lines changed

conftest.py

-7
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
matplotlib.use('agg')
1111

1212
from matplotlib import default_test_modules
13-
from matplotlib.testing.decorators import ImageComparisonTest
1413

1514

1615
IGNORED_TESTS = {
@@ -86,12 +85,6 @@ def pytest_ignore_collect(path, config):
8685

8786
def pytest_pycollect_makeitem(collector, name, obj):
8887
if inspect.isclass(obj):
89-
if issubclass(obj, ImageComparisonTest):
90-
# Workaround `image_compare` decorator as it returns class
91-
# instead of function and this confuses pytest because it crawls
92-
# original names and sees 'test_*', but not 'Test*' in that case
93-
return pytest.Class(name, parent=collector)
94-
9588
if is_nose_class(obj) and not issubclass(obj, unittest.TestCase):
9689
# Workaround unittest-like setup/teardown names in pure classes
9790
setup = getattr(obj, 'setUp', None)

lib/matplotlib/testing/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def getrawcode(obj, trycall=True):
6262

6363
def copy_metadata(src_func, tgt_func):
6464
"""Replicates metadata of the function. Returns target function."""
65-
tgt_func.__dict__ = src_func.__dict__
65+
tgt_func.__dict__.update(src_func.__dict__)
6666
tgt_func.__doc__ = src_func.__doc__
6767
tgt_func.__module__ = src_func.__module__
6868
tgt_func.__name__ = src_func.__name__

lib/matplotlib/testing/decorators.py

+164-116
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,9 @@
2424
from matplotlib import ticker
2525
from matplotlib import pyplot as plt
2626
from matplotlib import ft2font
27-
from matplotlib import rcParams
2827
from matplotlib.testing.compare import comparable_formats, compare_images, \
2928
make_test_filename
30-
from . import copy_metadata, is_called_from_pytest, skip, xfail
29+
from . import copy_metadata, is_called_from_pytest, xfail
3130
from .exceptions import ImageComparisonFailure
3231

3332

@@ -176,98 +175,171 @@ def check_freetype_version(ver):
176175
return found >= ver[0] and found <= ver[1]
177176

178177

179-
class ImageComparisonTest(CleanupTest):
180-
@classmethod
181-
def setup_class(cls):
182-
CleanupTest.setup_class()
178+
def checked_on_freetype_version(required_freetype_version):
179+
if check_freetype_version(required_freetype_version):
180+
return lambda f: f
181+
182+
reason = ("Mismatched version of freetype. "
183+
"Test requires '%s', you have '%s'" %
184+
(required_freetype_version, ft2font.__freetype_version__))
185+
return knownfailureif('indeterminate', msg=reason,
186+
known_exception_class=ImageComparisonFailure)
187+
188+
189+
def remove_ticks_and_titles(figure):
190+
figure.suptitle("")
191+
null_formatter = ticker.NullFormatter()
192+
for ax in figure.get_axes():
193+
ax.set_title("")
194+
ax.xaxis.set_major_formatter(null_formatter)
195+
ax.xaxis.set_minor_formatter(null_formatter)
196+
ax.yaxis.set_major_formatter(null_formatter)
197+
ax.yaxis.set_minor_formatter(null_formatter)
198+
try:
199+
ax.zaxis.set_major_formatter(null_formatter)
200+
ax.zaxis.set_minor_formatter(null_formatter)
201+
except AttributeError:
202+
pass
203+
204+
205+
def raise_on_image_difference(expected, actual, tol):
206+
__tracebackhide__ = True
207+
208+
err = compare_images(expected, actual, tol, in_decorator=True)
209+
210+
if not os.path.exists(expected):
211+
raise ImageComparisonFailure('image does not exist: %s' % expected)
212+
213+
if err:
214+
raise ImageComparisonFailure(
215+
'images not close: %(actual)s vs. %(expected)s '
216+
'(RMS %(rms).3f)' % err)
217+
218+
219+
def xfail_if_format_is_uncomparable(extension):
220+
will_fail = extension not in comparable_formats()
221+
if will_fail:
222+
fail_msg = 'Cannot compare %s files on this system' % extension
223+
else:
224+
fail_msg = 'No failure expected'
225+
226+
return knownfailureif(will_fail, fail_msg,
227+
known_exception_class=ImageComparisonFailure)
228+
229+
230+
def mark_xfail_if_format_is_uncomparable(extension):
231+
will_fail = extension not in comparable_formats()
232+
if will_fail:
233+
fail_msg = 'Cannot compare %s files on this system' % extension
234+
import pytest
235+
return pytest.mark.xfail(extension, reason=fail_msg, strict=False,
236+
raises=ImageComparisonFailure)
237+
else:
238+
return extension
239+
240+
241+
class ImageComparisonDecorator(CleanupTest):
242+
def __init__(self, baseline_images, extensions, tol,
243+
freetype_version, remove_text, savefig_kwargs, style):
244+
self.func = self.baseline_dir = self.result_dir = None
245+
self.baseline_images = baseline_images
246+
self.extensions = extensions
247+
self.tol = tol
248+
self.freetype_version = freetype_version
249+
self.remove_text = remove_text
250+
self.savefig_kwargs = savefig_kwargs
251+
self.style = style
252+
253+
def setup(self):
254+
func = self.func
255+
self.setup_class()
183256
try:
184-
matplotlib.style.use(cls._style)
257+
matplotlib.style.use(self.style)
185258
matplotlib.testing.set_font_settings_for_testing()
186-
cls._func()
259+
func()
260+
assert len(plt.get_fignums()) == len(self.baseline_images), (
261+
'Figures and baseline_images count are not the same'
262+
' (`%s`)' % getattr(func, '__qualname__', func.__name__))
187263
except:
188264
# Restore original settings before raising errors during the update.
189-
CleanupTest.teardown_class()
265+
self.teardown_class()
190266
raise
191267

192-
@classmethod
193-
def teardown_class(cls):
194-
CleanupTest.teardown_class()
195-
196-
@staticmethod
197-
def remove_text(figure):
198-
figure.suptitle("")
199-
for ax in figure.get_axes():
200-
ax.set_title("")
201-
ax.xaxis.set_major_formatter(ticker.NullFormatter())
202-
ax.xaxis.set_minor_formatter(ticker.NullFormatter())
203-
ax.yaxis.set_major_formatter(ticker.NullFormatter())
204-
ax.yaxis.set_minor_formatter(ticker.NullFormatter())
205-
try:
206-
ax.zaxis.set_major_formatter(ticker.NullFormatter())
207-
ax.zaxis.set_minor_formatter(ticker.NullFormatter())
208-
except AttributeError:
209-
pass
268+
def teardown(self):
269+
self.teardown_class()
270+
271+
def copy_baseline(self, baseline, extension):
272+
baseline_path = os.path.join(self.baseline_dir, baseline)
273+
orig_expected_fname = baseline_path + '.' + extension
274+
if extension == 'eps' and not os.path.exists(orig_expected_fname):
275+
orig_expected_fname = baseline_path + '.pdf'
276+
expected_fname = make_test_filename(os.path.join(
277+
self.result_dir, os.path.basename(orig_expected_fname)), 'expected')
278+
actual_fname = os.path.join(self.result_dir, baseline) + '.' + extension
279+
if os.path.exists(orig_expected_fname):
280+
shutil.copyfile(orig_expected_fname, expected_fname)
281+
else:
282+
xfail("Do not have baseline image {0} because this "
283+
"file does not exist: {1}".format(expected_fname,
284+
orig_expected_fname))
285+
return expected_fname, actual_fname
286+
287+
def compare(self, idx, baseline, extension):
288+
__tracebackhide__ = True
289+
if self.baseline_dir is None:
290+
self.baseline_dir, self.result_dir = _image_directories(self.func)
291+
expected_fname, actual_fname = self.copy_baseline(baseline, extension)
292+
fignum = plt.get_fignums()[idx]
293+
fig = plt.figure(fignum)
294+
if self.remove_text:
295+
remove_ticks_and_titles(fig)
296+
fig.savefig(actual_fname, **self.savefig_kwargs)
297+
raise_on_image_difference(expected_fname, actual_fname, self.tol)
298+
299+
def nose_runner(self):
300+
func = self.compare
301+
func = checked_on_freetype_version(self.freetype_version)(func)
302+
funcs = {extension: xfail_if_format_is_uncomparable(extension)(func)
303+
for extension in self.extensions}
304+
for idx, baseline in enumerate(self.baseline_images):
305+
for extension in self.extensions:
306+
yield funcs[extension], idx, baseline, extension
307+
308+
def pytest_runner(self):
309+
from pytest import mark
310+
311+
extensions = map(mark_xfail_if_format_is_uncomparable, self.extensions)
312+
313+
@mark.parametrize("extension", extensions)
314+
@mark.parametrize("idx,baseline", enumerate(self.baseline_images))
315+
@checked_on_freetype_version(self.freetype_version)
316+
def wrapper(idx, baseline, extension):
317+
__tracebackhide__ = True
318+
self.compare(idx, baseline, extension)
319+
320+
# sadly we cannot use fixture here because of visibility problems
321+
# and for for obvious reason avoid `nose.tools.with_setup`
322+
wrapper.setup, wrapper.teardown = self.setup, self.teardown
323+
324+
return wrapper
325+
326+
def __call__(self, func):
327+
self.func = func
328+
if is_called_from_pytest():
329+
return copy_metadata(func, self.pytest_runner())
330+
else:
331+
import nose.tools
210332

211-
def test(self):
212-
baseline_dir, result_dir = _image_directories(self._func)
213-
214-
for fignum, baseline in zip(plt.get_fignums(), self._baseline_images):
215-
for extension in self._extensions:
216-
will_fail = not extension in comparable_formats()
217-
if will_fail:
218-
fail_msg = 'Cannot compare %s files on this system' % extension
219-
else:
220-
fail_msg = 'No failure expected'
221-
222-
orig_expected_fname = os.path.join(baseline_dir, baseline) + '.' + extension
223-
if extension == 'eps' and not os.path.exists(orig_expected_fname):
224-
orig_expected_fname = os.path.join(baseline_dir, baseline) + '.pdf'
225-
expected_fname = make_test_filename(os.path.join(
226-
result_dir, os.path.basename(orig_expected_fname)), 'expected')
227-
actual_fname = os.path.join(result_dir, baseline) + '.' + extension
228-
if os.path.exists(orig_expected_fname):
229-
shutil.copyfile(orig_expected_fname, expected_fname)
230-
else:
231-
will_fail = True
232-
fail_msg = (
233-
"Do not have baseline image {0} because this "
234-
"file does not exist: {1}".format(
235-
expected_fname,
236-
orig_expected_fname
237-
)
238-
)
239-
240-
@knownfailureif(
241-
will_fail, fail_msg,
242-
known_exception_class=ImageComparisonFailure)
243-
def do_test(fignum, actual_fname, expected_fname):
244-
figure = plt.figure(fignum)
245-
246-
if self._remove_text:
247-
self.remove_text(figure)
248-
249-
figure.savefig(actual_fname, **self._savefig_kwarg)
250-
251-
err = compare_images(expected_fname, actual_fname,
252-
self._tol, in_decorator=True)
253-
254-
try:
255-
if not os.path.exists(expected_fname):
256-
raise ImageComparisonFailure(
257-
'image does not exist: %s' % expected_fname)
258-
259-
if err:
260-
raise ImageComparisonFailure(
261-
'images not close: %(actual)s vs. %(expected)s '
262-
'(RMS %(rms).3f)'%err)
263-
except ImageComparisonFailure:
264-
if not check_freetype_version(self._freetype_version):
265-
xfail(
266-
"Mismatched version of freetype. Test requires '%s', you have '%s'" %
267-
(self._freetype_version, ft2font.__freetype_version__))
268-
raise
269-
270-
yield do_test, fignum, actual_fname, expected_fname
333+
@nose.tools.with_setup(self.setup, self.teardown)
334+
def runner_wrapper():
335+
try:
336+
for case in self.nose_runner():
337+
yield case
338+
except GeneratorExit:
339+
# nose bug...
340+
self.teardown()
341+
342+
return copy_metadata(func, runner_wrapper)
271343

272344

273345
def image_comparison(baseline_images=None, extensions=None, tol=0,
@@ -326,35 +398,11 @@ def image_comparison(baseline_images=None, extensions=None, tol=0,
326398
#default no kwargs to savefig
327399
savefig_kwarg = dict()
328400

329-
def compare_images_decorator(func):
330-
# We want to run the setup function (the actual test function
331-
# that generates the figure objects) only once for each type
332-
# of output file. The only way to achieve this with nose
333-
# appears to be to create a test class with "setup_class" and
334-
# "teardown_class" methods. Creating a class instance doesn't
335-
# work, so we use type() to actually create a class and fill
336-
# it with the appropriate methods.
337-
name = func.__name__
338-
# For nose 1.0, we need to rename the test function to
339-
# something without the word "test", or it will be run as
340-
# well, outside of the context of our image comparison test
341-
# generator.
342-
func = staticmethod(func)
343-
func.__get__(1).__name__ = str('_private')
344-
new_class = type(
345-
name,
346-
(ImageComparisonTest,),
347-
{'_func': func,
348-
'_baseline_images': baseline_images,
349-
'_extensions': extensions,
350-
'_tol': tol,
351-
'_freetype_version': freetype_version,
352-
'_remove_text': remove_text,
353-
'_savefig_kwarg': savefig_kwarg,
354-
'_style': style})
355-
356-
return new_class
357-
return compare_images_decorator
401+
return ImageComparisonDecorator(
402+
baseline_images=baseline_images, extensions=extensions, tol=tol,
403+
freetype_version=freetype_version, remove_text=remove_text,
404+
savefig_kwargs=savefig_kwarg, style=style)
405+
358406

359407
def _image_directories(func):
360408
"""

0 commit comments

Comments
 (0)