File: decorators.py

package info (click to toggle)
matplotlib 2.0.0%2Bdfsg1-2
  • links: PTS, VCS
  • area: main
  • in suites: stretch
  • size: 91,640 kB
  • ctags: 29,525
  • sloc: python: 122,697; cpp: 60,806; ansic: 30,799; objc: 2,830; makefile: 224; sh: 85
file content (427 lines) | stat: -rw-r--r-- 16,223 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
from __future__ import (absolute_import, division, print_function,
                        unicode_literals)

import six

import functools
import gc
import inspect
import os
import sys
import shutil
import warnings
import unittest

# Note - don't import nose up here - import it only as needed in functions. This
# allows other functions here to be used by pytest-based testing suites without
# requiring nose to be installed.

import numpy as np

import matplotlib as mpl
import matplotlib.style
import matplotlib.units
import matplotlib.testing
from matplotlib import cbook
from matplotlib import ticker
from matplotlib import pyplot as plt
from matplotlib import ft2font
from matplotlib import rcParams
from matplotlib.testing.noseclasses import KnownFailureTest, \
     KnownFailureDidNotFailTest, ImageComparisonFailure
from matplotlib.testing.compare import comparable_formats, compare_images, \
     make_test_filename


def knownfailureif(fail_condition, msg=None, known_exception_class=None ):
    """

    Assume a will fail if *fail_condition* is True. *fail_condition*
    may also be False or the string 'indeterminate'.

    *msg* is the error message displayed for the test.

    If *known_exception_class* is not None, the failure is only known
    if the exception is an instance of this class. (Default = None)

    """
    # based on numpy.testing.dec.knownfailureif
    if msg is None:
        msg = 'Test known to fail'
    def known_fail_decorator(f):
        # Local import to avoid a hard nose dependency and only incur the
        # import time overhead at actual test-time.
        import nose
        def failer(*args, **kwargs):
            try:
                # Always run the test (to generate images).
                result = f(*args, **kwargs)
            except Exception as err:
                if fail_condition:
                    if known_exception_class is not None:
                        if not isinstance(err,known_exception_class):
                            # This is not the expected exception
                            raise
                    # (Keep the next ultra-long comment so in shows in console.)
                    raise KnownFailureTest(msg) # An error here when running nose means that you don't have the matplotlib.testing.noseclasses:KnownFailure plugin in use.
                else:
                    raise
            if fail_condition and fail_condition != 'indeterminate':
                raise KnownFailureDidNotFailTest(msg)
            return result
        return nose.tools.make_decorator(f)(failer)
    return known_fail_decorator


def _do_cleanup(original_units_registry, original_settings):
    plt.close('all')
    gc.collect()

    mpl.rcParams.clear()
    mpl.rcParams.update(original_settings)
    matplotlib.units.registry.clear()
    matplotlib.units.registry.update(original_units_registry)
    warnings.resetwarnings()  # reset any warning filters set in tests


class CleanupTest(object):
    @classmethod
    def setup_class(cls):
        cls.original_units_registry = matplotlib.units.registry.copy()
        cls.original_settings = mpl.rcParams.copy()
        matplotlib.testing.setup()

    @classmethod
    def teardown_class(cls):
        _do_cleanup(cls.original_units_registry,
                    cls.original_settings)

    def test(self):
        self._func()


class CleanupTestCase(unittest.TestCase):
    '''A wrapper for unittest.TestCase that includes cleanup operations'''
    @classmethod
    def setUpClass(cls):
        import matplotlib.units
        cls.original_units_registry = matplotlib.units.registry.copy()
        cls.original_settings = mpl.rcParams.copy()

    @classmethod
    def tearDownClass(cls):
        _do_cleanup(cls.original_units_registry,
                    cls.original_settings)


def cleanup(style=None):
    """
    A decorator to ensure that any global state is reset before
    running a test.

    Parameters
    ----------
    style : str, optional
        The name of the style to apply.
    """

    # If cleanup is used without arguments, `style` will be a
    # callable, and we pass it directly to the wrapper generator.  If
    # cleanup if called with an argument, it is a string naming a
    # style, and the function will be passed as an argument to what we
    # return.  This is a confusing, but somewhat standard, pattern for
    # writing a decorator with optional arguments.

    def make_cleanup(func):
        if inspect.isgeneratorfunction(func):
            @functools.wraps(func)
            def wrapped_callable(*args, **kwargs):
                original_units_registry = matplotlib.units.registry.copy()
                original_settings = mpl.rcParams.copy()
                matplotlib.style.use(style)
                try:
                    for yielded in func(*args, **kwargs):
                        yield yielded
                finally:
                    _do_cleanup(original_units_registry,
                                original_settings)
        else:
            @functools.wraps(func)
            def wrapped_callable(*args, **kwargs):
                original_units_registry = matplotlib.units.registry.copy()
                original_settings = mpl.rcParams.copy()
                matplotlib.style.use(style)
                try:
                    func(*args, **kwargs)
                finally:
                    _do_cleanup(original_units_registry,
                                original_settings)

        return wrapped_callable

    if isinstance(style, six.string_types):
        return make_cleanup
    else:
        result = make_cleanup(style)
        style = 'classic'
        return result


def check_freetype_version(ver):
    if ver is None:
        return True

    from distutils import version
    if isinstance(ver, six.string_types):
        ver = (ver, ver)
    ver = [version.StrictVersion(x) for x in ver]
    found = version.StrictVersion(ft2font.__freetype_version__)

    return found >= ver[0] and found <= ver[1]


class ImageComparisonTest(CleanupTest):
    @classmethod
    def setup_class(cls):
        CleanupTest.setup_class()
        try:
            matplotlib.style.use(cls._style)
            matplotlib.testing.set_font_settings_for_testing()
            cls._func()
        except:
            # Restore original settings before raising errors during the update.
            CleanupTest.teardown_class()
            raise

    @classmethod
    def teardown_class(cls):
        CleanupTest.teardown_class()

    @staticmethod
    def remove_text(figure):
        figure.suptitle("")
        for ax in figure.get_axes():
            ax.set_title("")
            ax.xaxis.set_major_formatter(ticker.NullFormatter())
            ax.xaxis.set_minor_formatter(ticker.NullFormatter())
            ax.yaxis.set_major_formatter(ticker.NullFormatter())
            ax.yaxis.set_minor_formatter(ticker.NullFormatter())
            try:
                ax.zaxis.set_major_formatter(ticker.NullFormatter())
                ax.zaxis.set_minor_formatter(ticker.NullFormatter())
            except AttributeError:
                pass

    def test(self):
        baseline_dir, result_dir = _image_directories(self._func)
        for fignum, baseline in zip(plt.get_fignums(), self._baseline_images):
            for extension in self._extensions:
                will_fail = not extension in comparable_formats()
                if will_fail:
                    fail_msg = 'Cannot compare %s files on this system' % extension
                else:
                    fail_msg = 'No failure expected'

                orig_expected_fname = os.path.join(baseline_dir, baseline) + '.' + extension
                if extension == 'eps' and not os.path.exists(orig_expected_fname):
                    orig_expected_fname = os.path.join(baseline_dir, baseline) + '.pdf'
                expected_fname = make_test_filename(os.path.join(
                    result_dir, os.path.basename(orig_expected_fname)), 'expected')
                actual_fname = os.path.join(result_dir, baseline) + '.' + extension
                if os.path.exists(orig_expected_fname):
                    shutil.copyfile(orig_expected_fname, expected_fname)
                else:
                    will_fail = True
                    fail_msg = 'Do not have baseline image %s' % expected_fname

                @knownfailureif(
                    will_fail, fail_msg,
                    known_exception_class=ImageComparisonFailure)
                def do_test(fignum, actual_fname, expected_fname):
                    figure = plt.figure(fignum)

                    if self._remove_text:
                        self.remove_text(figure)

                    figure.savefig(actual_fname, **self._savefig_kwarg)

                    err = compare_images(expected_fname, actual_fname,
                                         self._tol, in_decorator=True)

                    try:
                        if not os.path.exists(expected_fname):
                            raise ImageComparisonFailure(
                                'image does not exist: %s' % expected_fname)

                        if err:
                            raise ImageComparisonFailure(
                                'images not close: %(actual)s vs. %(expected)s '
                                '(RMS %(rms).3f)'%err)
                    except ImageComparisonFailure:
                        if not check_freetype_version(self._freetype_version):
                            raise KnownFailureTest(
                                "Mismatched version of freetype.  Test requires '%s', you have '%s'" %
                                (self._freetype_version, ft2font.__freetype_version__))
                        raise

                yield do_test, fignum, actual_fname, expected_fname

def image_comparison(baseline_images=None, extensions=None, tol=0.306,
                     freetype_version=None, remove_text=False,
                     savefig_kwarg=None, style='classic'):
    """
    Compare images generated by the test with those specified in
    *baseline_images*, which must correspond else an
    ImageComparisonFailure exception will be raised.

    Keyword arguments:

      *baseline_images*: list
        A list of strings specifying the names of the images generated
        by calls to :meth:`matplotlib.figure.savefig`.

      *extensions*: [ None | list ]

        If *None*, default to all supported extensions.

        Otherwise, a list of extensions to test. For example ['png','pdf'].

      *tol*: (default 0)
        The RMS threshold above which the test is considered failed.

      *freetype_version*: str or tuple
        The expected freetype version or range of versions for this
        test to pass.

      *remove_text*: bool
        Remove the title and tick text from the figure before
        comparison.  This does not remove other, more deliberate,
        text, such as legends and annotations.

      *savefig_kwarg*: dict
        Optional arguments that are passed to the savefig method.

      *style*: string
        Optional name for the base style to apply to the image
        test. The test itself can also apply additional styles
        if desired. Defaults to the 'classic' style.

    """
    if baseline_images is None:
        raise ValueError('baseline_images must be specified')

    if extensions is None:
        # default extensions to test
        extensions = ['png', 'pdf', 'svg']

    if savefig_kwarg is None:
        #default no kwargs to savefig
        savefig_kwarg = dict()

    def compare_images_decorator(func):
        # We want to run the setup function (the actual test function
        # that generates the figure objects) only once for each type
        # of output file.  The only way to achieve this with nose
        # appears to be to create a test class with "setup_class" and
        # "teardown_class" methods.  Creating a class instance doesn't
        # work, so we use type() to actually create a class and fill
        # it with the appropriate methods.
        name = func.__name__
        # For nose 1.0, we need to rename the test function to
        # something without the word "test", or it will be run as
        # well, outside of the context of our image comparison test
        # generator.
        func = staticmethod(func)
        func.__get__(1).__name__ = str('_private')
        new_class = type(
            name,
            (ImageComparisonTest,),
            {'_func': func,
             '_baseline_images': baseline_images,
             '_extensions': extensions,
             '_tol': tol,
             '_freetype_version': freetype_version,
             '_remove_text': remove_text,
             '_savefig_kwarg': savefig_kwarg,
             '_style': style})

        return new_class
    return compare_images_decorator

def _image_directories(func):
    """
    Compute the baseline and result image directories for testing *func*.
    Create the result directory if it doesn't exist.
    """
    module_name = func.__module__
    if module_name == '__main__':
        # FIXME: this won't work for nested packages in matplotlib.tests
        warnings.warn('test module run as script. guessing baseline image locations')
        script_name = sys.argv[0]
        basedir = os.path.abspath(os.path.dirname(script_name))
        subdir = os.path.splitext(os.path.split(script_name)[1])[0]
    else:
        mods = module_name.split('.')
        if len(mods) >= 3:
            mods.pop(0)
            # mods[0] will be the name of the package being tested (in
            # most cases "matplotlib") However if this is a
            # namespace package pip installed and run via the nose
            # multiprocess plugin or as a specific test this may be
            # missing. See https://github.com/matplotlib/matplotlib/issues/3314
        if mods.pop(0) != 'tests':
            warnings.warn(("Module '%s' does not live in a parent module "
                "named 'tests'. This is probably ok, but we may not be able "
                "to guess the correct subdirectory containing the baseline "
                "images. If things go wrong please make sure that there is "
                "a parent directory named 'tests' and that it contains a "
                "__init__.py file (can be empty).") % module_name)
        subdir = os.path.join(*mods)

        import imp
        def find_dotted_module(module_name, path=None):
            """A version of imp which can handle dots in the module name.
               As for imp.find_module(), the return value is a 3-element
               tuple (file, pathname, description)."""
            res = None
            for sub_mod in module_name.split('.'):
                try:
                    res = file, path, _ = imp.find_module(sub_mod, path)
                    path = [path]
                    if file is not None:
                        file.close()
                except ImportError:
                    # assume namespace package
                    path = list(sys.modules[sub_mod].__path__)
                    res = None, path, None
            return res

        mod_file = find_dotted_module(func.__module__)[1]
        basedir = os.path.dirname(mod_file)

    baseline_dir = os.path.join(basedir, 'baseline_images', subdir)
    result_dir = os.path.abspath(os.path.join('result_images', subdir))

    if not os.path.exists(result_dir):
        cbook.mkdirs(result_dir)

    return baseline_dir, result_dir


def switch_backend(backend):
    # Local import to avoid a hard nose dependency and only incur the
    # import time overhead at actual test-time.
    import nose
    def switch_backend_decorator(func):
        def backend_switcher(*args, **kwargs):
            try:
                prev_backend = mpl.get_backend()
                matplotlib.testing.setup()
                plt.switch_backend(backend)
                result = func(*args, **kwargs)
            finally:
                plt.switch_backend(prev_backend)
            return result

        return nose.tools.make_decorator(func)(backend_switcher)
    return switch_backend_decorator