artresizer: backend classes part 1: stub classes, version checks

This commit is contained in:
wisp3rwind 2022-03-12 11:23:54 +01:00
parent bac93e1095
commit 8a969e3041
6 changed files with 222 additions and 151 deletions

View file

@ -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

View file

@ -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)

View file

@ -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):

View file

@ -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

View file

@ -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')

View file

@ -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())