Skip to content

Commit f8efcfc

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 e2a629f commit f8efcfc

File tree

3 files changed

+169
-125
lines changed

3 files changed

+169
-125
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

+168-117
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,99 +175,175 @@ 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 = skipif(self.style != 'classic',
302+
reason='temporarily disabled until 2.0 tag')(func)
303+
func = checked_on_freetype_version(self.freetype_version)(func)
304+
funcs = {extension: xfail_if_format_is_uncomparable(extension)(func)
305+
for extension in self.extensions}
306+
for idx, baseline in enumerate(self.baseline_images):
307+
for extension in self.extensions:
308+
yield funcs[extension], idx, baseline, extension
309+
310+
def pytest_runner(self):
311+
from pytest import mark
312+
313+
extensions = map(mark_xfail_if_format_is_uncomparable, self.extensions)
314+
315+
@mark.skipif(self.style != 'classic',
316+
reason='temporarily disabled until 2.0 tag')
317+
@mark.parametrize("extension", extensions)
318+
@mark.parametrize("idx,baseline", enumerate(self.baseline_images))
319+
@checked_on_freetype_version(self.freetype_version)
320+
def wrapper(idx, baseline, extension):
321+
__tracebackhide__ = True
322+
self.compare(idx, baseline, extension)
323+
324+
# sadly we cannot use fixture here because of visibility problems
325+
# and for for obvious reason avoid `nose.tools.with_setup`
326+
wrapper.setup, wrapper.teardown = self.setup, self.teardown
327+
328+
return wrapper
329+
330+
def __call__(self, func):
331+
self.func = func
332+
if is_called_from_pytest():
333+
return copy_metadata(func, self.pytest_runner())
334+
else:
335+
import nose.tools
210336

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

273348

274349
def image_comparison(baseline_images=None, extensions=None, tol=0,
@@ -327,35 +402,11 @@ def image_comparison(baseline_images=None, extensions=None, tol=0,
327402
#default no kwargs to savefig
328403
savefig_kwarg = dict()
329404

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

360411
def _image_directories(func):
361412
"""

0 commit comments

Comments
 (0)