diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index 7cfa6b69e..4369dccd8 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -61,7 +61,98 @@ def temp_file_for(path): return bytestring_path(f.name) -def pil_resize(artresizer, maxwidth, path_in, path_out=None, quality=0, +class LocalBackendNotAvailableError(Exception): + pass + + +_NOT_AVAILABLE = object() + + +class LocalBackend: + @classmethod + def available(cls): + try: + cls.version() + return True + except LocalBackendNotAvailableError: + return False + + +class IMBackend(LocalBackend): + NAME="ImageMagick" + ID=IMAGEMAGICK + _version = None + _legacy = None + + @classmethod + def version(cls): + """Obtain and cache ImageMagick version. + + Raises `LocalBackendNotAvailableError` if not available. + """ + if cls._version is None: + for cmd_name, legacy in (('magick', False), ('convert', True)): + try: + out = util.command_output([cmd_name, "--version"]).stdout + except (subprocess.CalledProcessError, OSError) as exc: + log.debug('ImageMagick version check failed: {}', exc) + cls._version = _NOT_AVAILABLE + else: + if b'imagemagick' in out.lower(): + pattern = br".+ (\d+)\.(\d+)\.(\d+).*" + match = re.search(pattern, out) + if match: + cls._version = (int(match.group(1)), + int(match.group(2)), + int(match.group(3))) + cls._legacy = legacy + + if cls._version is _NOT_AVAILABLE: + raise LocalBackendNotAvailableError() + else: + return cls._version + + def __init__(self): + """Initialize a wrapper around ImageMagick for local image operations. + + Stores the ImageMagick version and legacy flag. If ImageMagick is not + available, raise an Exception. + """ + self.version() + + # Use ImageMagick's magick binary when it's available. + # If it's not, fall back to the older, separate convert + # and identify commands. + if self._legacy: + self.convert_cmd = ['convert'] + self.identify_cmd = ['identify'] + self.compare_cmd = ['compare'] + else: + self.convert_cmd = ['magick'] + self.identify_cmd = ['magick', 'identify'] + self.compare_cmd = ['magick', 'compare'] + + +class PILBackend(LocalBackend): + NAME="PIL" + ID=PIL + + @classmethod + def version(cls): + try: + __import__('PIL', fromlist=['Image']) + except ImportError: + raise LocalBackendNotAvailableError() + + def __init__(self): + """Initialize a wrapper around PIL for local image operations. + + If PIL is not available, raise an Exception. + """ + self.version() + + +def pil_resize(backend, maxwidth, path_in, path_out=None, quality=0, max_filesize=0): """Resize using Python Imaging Library (PIL). Return the output path of resized image. @@ -120,7 +211,7 @@ def pil_resize(artresizer, maxwidth, path_in, path_out=None, quality=0, return path_in -def im_resize(artresizer, maxwidth, path_in, path_out=None, quality=0, +def im_resize(backend, maxwidth, path_in, path_out=None, quality=0, max_filesize=0): """Resize using ImageMagick. @@ -136,7 +227,7 @@ def im_resize(artresizer, maxwidth, path_in, path_out=None, quality=0, # with regards to the height. # ImageMagick already seems to default to no interlace, but we include it # here for the sake of explicitness. - cmd = artresizer.im_convert_cmd + [ + cmd = backend.convert_cmd + [ syspath(path_in, prefix=False), '-resize', f'{maxwidth}x>', '-interlace', 'none', @@ -168,7 +259,7 @@ BACKEND_FUNCS = { } -def pil_getsize(artresizer, path_in): +def pil_getsize(backend, path_in): from PIL import Image try: @@ -180,8 +271,8 @@ def pil_getsize(artresizer, path_in): return None -def im_getsize(artresizer, path_in): - cmd = artresizer.im_identify_cmd + \ +def im_getsize(backend, path_in): + cmd = backend.identify_cmd + \ ['-format', '%w %h', syspath(path_in, prefix=False)] try: @@ -207,7 +298,7 @@ BACKEND_GET_SIZE = { } -def pil_deinterlace(artresizer, path_in, path_out=None): +def pil_deinterlace(backend, path_in, path_out=None): path_out = path_out or temp_file_for(path_in) from PIL import Image @@ -219,10 +310,10 @@ def pil_deinterlace(artresizer, path_in, path_out=None): return path_in -def im_deinterlace(artresizer, path_in, path_out=None): +def im_deinterlace(backend, path_in, path_out=None): path_out = path_out or temp_file_for(path_in) - cmd = artresizer.im_convert_cmd + [ + cmd = backend.convert_cmd + [ syspath(path_in, prefix=False), '-interlace', 'none', syspath(path_out, prefix=False), @@ -241,8 +332,8 @@ DEINTERLACE_FUNCS = { } -def im_get_format(artresizer, filepath): - cmd = artresizer.im_identify_cmd + [ +def im_get_format(backend, filepath): + cmd = backend.identify_cmd + [ '-format', '%[magick]', syspath(filepath) ] @@ -253,7 +344,7 @@ def im_get_format(artresizer, filepath): return None -def pil_get_format(artresizer, filepath): +def pil_get_format(backend, filepath): from PIL import Image, UnidentifiedImageError try: @@ -270,8 +361,8 @@ BACKEND_GET_FORMAT = { } -def im_convert_format(artresizer, source, target, deinterlaced): - cmd = artresizer.im_convert_cmd + [ +def im_convert_format(backend, source, target, deinterlaced): + cmd = backend.convert_cmd + [ syspath(source), *(["-interlace", "none"] if deinterlaced else []), syspath(target), @@ -288,7 +379,7 @@ def im_convert_format(artresizer, source, target, deinterlaced): return source -def pil_convert_format(artresizer, source, target, deinterlaced): +def pil_convert_format(backend, source, target, deinterlaced): from PIL import Image, UnidentifiedImageError try: @@ -307,7 +398,7 @@ BACKEND_CONVERT_IMAGE_FORMAT = { } -def im_compare(artresizer, im1, im2, compare_threshold): +def im_compare(backend, im1, im2, compare_threshold): is_windows = platform.system() == "Windows" # Converting images to grayscale tends to minimize the weight @@ -315,11 +406,11 @@ def im_compare(artresizer, im1, im2, compare_threshold): # to grayscale and then pipe them into the `compare` command. # On Windows, ImageMagick doesn't support the magic \\?\ prefix # on paths, so we pass `prefix=False` to `syspath`. - convert_cmd = artresizer.im_convert_cmd + [ + convert_cmd = backend.convert_cmd + [ syspath(im2, prefix=False), syspath(im1, prefix=False), '-colorspace', 'gray', 'MIFF:-' ] - compare_cmd = artresizer.im_compare_cmd + [ + compare_cmd = backend.compare_cmd + [ '-metric', 'PHASH', '-', 'null:', ] log.debug('comparing images with pipeline {} | {}', @@ -373,7 +464,7 @@ def im_compare(artresizer, im1, im2, compare_threshold): return phash_diff <= compare_threshold -def pil_compare(artresizer, im1, im2, compare_threshold): +def pil_compare(backend, im1, im2, compare_threshold): # It is an error to call this when ArtResizer.can_compare is not True. raise NotImplementedError() @@ -409,22 +500,11 @@ class ArtResizer(metaclass=Shareable): def __init__(self): """Create a resizer object with an inferred method. """ - self.method = self._check_method() - log.debug("artresizer: method is {0}", self.method) - - # Use ImageMagick's magick binary when it's available. If it's - # not, fall back to the older, separate convert and identify - # commands. - if self.method[0] == IMAGEMAGICK: - self.im_legacy = self.method[2] - if self.im_legacy: - self.im_convert_cmd = ['convert'] - self.im_identify_cmd = ['identify'] - self.im_compare_cmd = ['compare'] - else: - self.im_convert_cmd = ['magick'] - self.im_identify_cmd = ['magick', 'identify'] - self.im_compare_cmd = ['magick', 'compare'] + self.local_method = self._check_method() + if self.local_method is None: + log.debug(f"artresizer: method is WEBPROXY") + else: + log.debug(f"artresizer: method is {self.local_method.NAME}") def resize( self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0 @@ -435,8 +515,8 @@ class ArtResizer(metaclass=Shareable): For WEBPROXY, returns `path_in` unmodified. """ if self.local: - func = BACKEND_FUNCS[self.method[0]] - return func(self, maxwidth, path_in, path_out, + func = BACKEND_FUNCS[self.local_method] + return func(self.local_method, maxwidth, path_in, path_out, quality=quality, max_filesize=max_filesize) else: # Handled by `proxy_url` already. @@ -448,8 +528,8 @@ class ArtResizer(metaclass=Shareable): Only available locally. """ if self.local: - func = DEINTERLACE_FUNCS[self.method[0]] - return func(self, path_in, path_out) + func = DEINTERLACE_FUNCS[self.local_method.ID] + return func(self.local_method, path_in, path_out) else: # FIXME: Should probably issue a warning? return path_in @@ -470,7 +550,7 @@ class ArtResizer(metaclass=Shareable): """A boolean indicating whether the resizing method is performed locally (i.e., PIL or ImageMagick). """ - return self.method[0] in BACKEND_FUNCS + return self.local_method is not None def get_size(self, path_in): """Return the size of an image file as an int couple (width, height) @@ -479,11 +559,11 @@ class ArtResizer(metaclass=Shareable): Only available locally. """ if self.local: - func = BACKEND_GET_SIZE[self.method[0]] - return func(self, path_in) + func = BACKEND_GET_SIZE[self.local_method.ID] + return func(self.local_method, path_in) else: # FIXME: Should probably issue a warning? - return None + return path_in def get_format(self, path_in): """Returns the format of the image as a string. @@ -491,8 +571,8 @@ class ArtResizer(metaclass=Shareable): Only available locally. """ if self.local: - func = BACKEND_GET_FORMAT[self.method[0]] - return func(self, path_in) + func = BACKEND_GET_FORMAT[self.local_method.ID] + return func(self.local_method, path_in) else: # FIXME: Should probably issue a warning? return None @@ -503,7 +583,7 @@ class ArtResizer(metaclass=Shareable): Only available locally. """ - if not self.local: + if self.local: # FIXME: Should probably issue a warning? return path_in @@ -515,13 +595,13 @@ class ArtResizer(metaclass=Shareable): fname, ext = os.path.splitext(path_in) path_new = fname + b'.' + new_format.encode('utf8') - func = BACKEND_CONVERT_IMAGE_FORMAT[self.method[0]] + func = BACKEND_CONVERT_IMAGE_FORMAT[self.local_method.ID] # allows the exception to propagate, while still making sure a changed # file path was removed result_path = path_in try: - result_path = func(self, path_in, path_new, deinterlaced) + result_path = func(self.local_method, path_in, path_new, deinterlaced) finally: if result_path != path_in: os.unlink(path_in) @@ -531,7 +611,11 @@ class ArtResizer(metaclass=Shareable): def can_compare(self): """A boolean indicating whether image comparison is available""" - return self.method[0] == IMAGEMAGICK and self.method[1] > (6, 8, 7) + return ( + self.local + and self.local_method.ID == IMAGEMAGICK + and self.local_method.version() > (6, 8, 7) + ) def compare(self, im1, im2, compare_threshold): """Return a boolean indicating whether two images are similar. @@ -539,65 +623,23 @@ class ArtResizer(metaclass=Shareable): Only available locally. """ if self.local: - func = BACKEND_COMPARE[self.method[0]] - return func(self, im1, im2, compare_threshold) + func = BACKEND_COMPARE[self.local_method.ID] + return func(self.local_method, im1, im2, compare_threshold) else: # FIXME: Should probably issue a warning? return None @staticmethod def _check_method(): - """Return a tuple indicating an available method and its version. + """Search availabe methods. - The result has at least two elements: - - The method, eitehr WEBPROXY, PIL, or IMAGEMAGICK. - - The version. - - If the method is IMAGEMAGICK, there is also a third element: a - bool flag indicating whether to use the `magick` binary or - legacy single-purpose executables (`convert`, `identify`, etc.) + If a local backend is availabe, return an instance of the backend + class. Otherwise, when fallback to the web proxy is requird, return + None. """ - version = get_im_version() - if version: - version, legacy = version - return IMAGEMAGICK, version, legacy - - version = get_pil_version() - if version: - return PIL, version - - return WEBPROXY, (0) - - -def get_im_version(): - """Get the ImageMagick version and legacy flag as a pair. Or return - None if ImageMagick is not available. - """ - for cmd_name, legacy in ((['magick'], False), (['convert'], True)): - cmd = cmd_name + ['--version'] - try: - out = util.command_output(cmd).stdout - except (subprocess.CalledProcessError, OSError) as exc: - log.debug('ImageMagick version check failed: {}', exc) - else: - if b'imagemagick' in out.lower(): - pattern = br".+ (\d+)\.(\d+)\.(\d+).*" - match = re.search(pattern, out) - if match: - version = (int(match.group(1)), - int(match.group(2)), - int(match.group(3))) - return version, legacy + return IMBackend() + return PILBackend() + except LocalBackendNotAvailableError: + return None - return None - - -def get_pil_version(): - """Get the PIL/Pillow version, or None if it is unavailable. - """ - try: - __import__('PIL', fromlist=['Image']) - return (0,) - except ImportError: - return None diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index 6bd9cbac6..e3cf6e6a2 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -32,7 +32,7 @@ from xdg import BaseDirectory from beets.plugins import BeetsPlugin from beets.ui import Subcommand, decargs from beets import util -from beets.util.artresizer import ArtResizer, get_im_version, get_pil_version +from beets.util.artresizer import ArtResizer, IMBackend, PILBackend BASE_DIR = os.path.join(BaseDirectory.xdg_cache_home, "thumbnails") @@ -90,14 +90,18 @@ class ThumbnailsPlugin(BeetsPlugin): if not os.path.exists(dir): os.makedirs(dir) - if get_im_version(): + # FIXME: Should we have our own backend instance? + self.backend = ArtResizer.shared.local_method + if isinstance(self.backend, IMBackend): self.write_metadata = write_metadata_im - tool = "IM" - else: - assert get_pil_version() # since we're local + elif isinstance(self.backend, PILBackend): self.write_metadata = write_metadata_pil - tool = "PIL" - self._log.debug("using {0} to write metadata", tool) + else: + # since we're local + raise RuntimeError( + f"Thumbnails: Unexpected ArtResizer backend {self.backend!r}." + ) + self._log.debug(f"using {self.backend.NAME} to write metadata") uri_getter = GioURI() if not uri_getter.available: @@ -171,7 +175,7 @@ class ThumbnailsPlugin(BeetsPlugin): metadata = {"Thumb::URI": self.get_uri(album.artpath), "Thumb::MTime": str(mtime)} try: - self.write_metadata(image_path, metadata) + self.write_metadata(self.backend, image_path, metadata) except Exception: self._log.exception("could not write metadata to {0}", util.displayable_path(image_path)) @@ -188,16 +192,16 @@ class ThumbnailsPlugin(BeetsPlugin): self._log.debug("Wrote file {0}", util.displayable_path(outfilename)) -def write_metadata_im(file, metadata): +def write_metadata_im(im_backend, file, metadata): """Enrich the file metadata with `metadata` dict thanks to IM.""" - command = ['convert', file] + \ + command = im_backend.convert_cmd + [file] + \ list(chain.from_iterable(('-set', k, v) for k, v in metadata.items())) + [file] util.command_output(command) return True -def write_metadata_pil(file, metadata): +def write_metadata_pil(pil_backend, file, metadata): """Enrich the file metadata with `metadata` dict thanks to PIL.""" from PIL import Image, PngImagePlugin im = Image.open(file) diff --git a/test/test_art.py b/test/test_art.py index 498c4cedc..b32285e70 100644 --- a/test/test_art.py +++ b/test/test_art.py @@ -31,7 +31,7 @@ from beets import library from beets import importer from beets import logging from beets import util -from beets.util.artresizer import ArtResizer, WEBPROXY +from beets.util.artresizer import ArtResizer import confuse @@ -787,7 +787,7 @@ class ArtForAlbumTest(UseThePlugin): """Skip the test if the art resizer doesn't have ImageMagick or PIL (so comparisons and measurements are unavailable). """ - if ArtResizer.shared.method[0] == WEBPROXY: + if not ArtResizer.shared.local: self.skipTest("ArtResizer has no local imaging backend available") def test_respect_minwidth(self): diff --git a/test/test_art_resize.py b/test/test_art_resize.py index 4600bab77..9d3be19e7 100644 --- a/test/test_art_resize.py +++ b/test/test_art_resize.py @@ -22,16 +22,34 @@ from test import _common from test.helper import TestHelper from beets.util import command_output, syspath from beets.util.artresizer import ( + IMBackend, + PILBackend, pil_resize, im_resize, - get_im_version, - get_pil_version, pil_deinterlace, im_deinterlace, - ArtResizer, ) +class DummyIMBackend(IMBackend): + """An `IMBackend` which pretends that ImageMagick is available, and has + a sufficiently recent version to support image comparison. + """ + def __init__(self): + self.version = (7, 0, 0) + self.legacy = False + self.convert_cmd = ['magick'] + self.identify_cmd = ['magick', 'identify'] + self.compare_cmd = ['magick', 'compare'] + + +class DummyPILBackend(PILBackend): + """An `PILBackend` which pretends that PIL is available. + """ + def __init__(self): + pass + + class ArtResizerFileSizeTest(_common.TestCase, TestHelper): """Unittest test case for Art Resizer to a specific filesize.""" @@ -46,11 +64,11 @@ class ArtResizerFileSizeTest(_common.TestCase, TestHelper): """Called after each test, unloading all plugins.""" self.teardown_beets() - def _test_img_resize(self, resize_func): + def _test_img_resize(self, backend, resize_func): """Test resizing based on file size, given a resize_func.""" # Check quality setting unaffected by new parameter im_95_qual = resize_func( - ArtResizer.shared, + backend, 225, self.IMG_225x225, quality=95, @@ -61,7 +79,7 @@ class ArtResizerFileSizeTest(_common.TestCase, TestHelper): # Attempt a lower filesize with same quality im_a = resize_func( - ArtResizer.shared, + backend, 225, self.IMG_225x225, quality=95, @@ -74,7 +92,7 @@ class ArtResizerFileSizeTest(_common.TestCase, TestHelper): # Attempt with lower initial quality im_75_qual = resize_func( - ArtResizer.shared, + backend, 225, self.IMG_225x225, quality=75, @@ -83,7 +101,7 @@ class ArtResizerFileSizeTest(_common.TestCase, TestHelper): self.assertExists(im_75_qual) im_b = resize_func( - ArtResizer.shared, + backend, 225, self.IMG_225x225, quality=95, @@ -94,37 +112,38 @@ class ArtResizerFileSizeTest(_common.TestCase, TestHelper): self.assertLess(os.stat(syspath(im_b)).st_size, os.stat(syspath(im_75_qual)).st_size) - @unittest.skipUnless(get_pil_version(), "PIL not available") + @unittest.skipUnless(PILBackend.available(), "PIL not available") def test_pil_file_resize(self): """Test PIL resize function is lowering file size.""" - self._test_img_resize(pil_resize) + self._test_img_resize(PILBackend(), pil_resize) - @unittest.skipUnless(get_im_version(), "ImageMagick not available") + @unittest.skipUnless(IMBackend.available(), "ImageMagick not available") def test_im_file_resize(self): """Test IM resize function is lowering file size.""" - self._test_img_resize(im_resize) + self._test_img_resize(IMBackend(), im_resize) - @unittest.skipUnless(get_pil_version(), "PIL not available") + @unittest.skipUnless(PILBackend.available(), "PIL not available") def test_pil_file_deinterlace(self): """Test PIL deinterlace function. Check if pil_deinterlace function returns images that are non-progressive """ - path = pil_deinterlace(ArtResizer.shared, self.IMG_225x225) + path = pil_deinterlace(PILBackend(), self.IMG_225x225) from PIL import Image with Image.open(path) as img: self.assertFalse('progression' in img.info) - @unittest.skipUnless(get_im_version(), "ImageMagick not available") + @unittest.skipUnless(IMBackend.available(), "ImageMagick not available") def test_im_file_deinterlace(self): """Test ImageMagick deinterlace function. Check if im_deinterlace function returns images that are non-progressive. """ - path = im_deinterlace(ArtResizer.shared, self.IMG_225x225) - cmd = ArtResizer.shared.im_identify_cmd + [ + im = IMBackend() + path = im_deinterlace(im, self.IMG_225x225) + cmd = im.identify_cmd + [ '-format', '%[interlace]', syspath(path, prefix=False), ] out = command_output(cmd).stdout diff --git a/test/test_embedart.py b/test/test_embedart.py index 0fed08f98..b3f61babe 100644 --- a/test/test_embedart.py +++ b/test/test_embedart.py @@ -21,6 +21,7 @@ import unittest from test import _common from test.helper import TestHelper +from test.test_art_resize import DummyIMBackend from mediafile import MediaFile from beets import config, logging, ui @@ -220,9 +221,8 @@ class DummyArtResizer(ArtResizer): """An `ArtResizer` which pretends that ImageMagick is available, and has a sufficiently recent version to support image comparison. """ - @staticmethod - def _check_method(): - return artresizer.IMAGEMAGICK, (7, 0, 0), True + def __init__(self): + self.local_method = DummyIMBackend() @patch('beets.util.artresizer.subprocess') diff --git a/test/test_thumbnails.py b/test/test_thumbnails.py index e8ab21d72..376969e93 100644 --- a/test/test_thumbnails.py +++ b/test/test_thumbnails.py @@ -20,6 +20,7 @@ from shutil import rmtree import unittest from test.helper import TestHelper +from test.test_art_resize import DummyIMBackend, DummyPILBackend from beets.util import bytestring_path from beetsplug.thumbnails import (ThumbnailsPlugin, NORMAL_DIR, LARGE_DIR, @@ -37,18 +38,21 @@ class ThumbnailsTest(unittest.TestCase, TestHelper): @patch('beetsplug.thumbnails.util') def test_write_metadata_im(self, mock_util): metadata = {"a": "A", "b": "B"} - write_metadata_im("foo", metadata) + im = DummyIMBackend() + write_metadata_im(im, "foo", metadata) try: - command = "convert foo -set a A -set b B foo".split(' ') + command = im.convert_cmd + "foo -set a A -set b B foo".split() mock_util.command_output.assert_called_once_with(command) except AssertionError: - command = "convert foo -set b B -set a A foo".split(' ') + command = im.convert_cmd + "foo -set b B -set a A foo".split() mock_util.command_output.assert_called_once_with(command) @patch('beetsplug.thumbnails.ThumbnailsPlugin._check_local_ok') @patch('beetsplug.thumbnails.os.stat') def test_add_tags(self, mock_stat, _): plugin = ThumbnailsPlugin() + # backend is not set due to _check_local_ok being mocked + plugin.backend = "DummyBackend" plugin.write_metadata = Mock() plugin.get_uri = Mock(side_effect={b"/path/to/cover": "COVER_URI"}.__getitem__) @@ -59,24 +63,26 @@ class ThumbnailsTest(unittest.TestCase, TestHelper): metadata = {"Thumb::URI": "COVER_URI", "Thumb::MTime": "12345"} - plugin.write_metadata.assert_called_once_with(b"/path/to/thumbnail", - metadata) + plugin.write_metadata.assert_called_once_with( + plugin.backend, + b"/path/to/thumbnail", + metadata, + ) mock_stat.assert_called_once_with(album.artpath) @patch('beetsplug.thumbnails.os') @patch('beetsplug.thumbnails.ArtResizer') - @patch('beetsplug.thumbnails.get_im_version') - @patch('beetsplug.thumbnails.get_pil_version') @patch('beetsplug.thumbnails.GioURI') - def test_check_local_ok(self, mock_giouri, mock_pil, mock_im, - mock_artresizer, mock_os): + def test_check_local_ok(self, mock_giouri, mock_artresizer, mock_os): # test local resizing capability mock_artresizer.shared.local = False + mock_artresizer.shared.local_method = None plugin = ThumbnailsPlugin() self.assertFalse(plugin._check_local_ok()) # test dirs creation mock_artresizer.shared.local = True + mock_artresizer.shared.local_method = DummyIMBackend() def exists(path): if path == NORMAL_DIR: @@ -91,18 +97,18 @@ class ThumbnailsTest(unittest.TestCase, TestHelper): # test metadata writer function mock_os.path.exists = lambda _: True - mock_pil.return_value = False - mock_im.return_value = False - with self.assertRaises(AssertionError): + + mock_artresizer.shared.local = True + mock_artresizer.shared.local_method = None + with self.assertRaises(RuntimeError): ThumbnailsPlugin() - mock_pil.return_value = True + mock_artresizer.shared.local = True + mock_artresizer.shared.local_method = DummyPILBackend() self.assertEqual(ThumbnailsPlugin().write_metadata, write_metadata_pil) - mock_im.return_value = True - self.assertEqual(ThumbnailsPlugin().write_metadata, write_metadata_im) - - mock_pil.return_value = False + mock_artresizer.shared.local = True + mock_artresizer.shared.local_method = DummyIMBackend() self.assertEqual(ThumbnailsPlugin().write_metadata, write_metadata_im) self.assertTrue(ThumbnailsPlugin()._check_local_ok())