diff --git a/README.rst b/README.rst index 1eedbcd..fb61852 100644 --- a/README.rst +++ b/README.rst @@ -73,6 +73,8 @@ Available operations Operation Pillow Wand OpenCV =================================== ==================== ==================== ==================== ``get_size()`` ✓ ✓ ✓ +``get_frame_count()`` ✓ ✓ ✓** +``get_pixel_count()`` ✓ ✓ ✓ ``resize(size)`` ✓ ✓ ``crop(rect)`` ✓ ✓ ``rotate(angle)`` ✓ ✓ @@ -82,7 +84,7 @@ Operation Pillow Wand Op ``save_as_png(file)`` ✓ ✓ ``save_as_gif(file)`` ✓ ✓ ``has_alpha()`` ✓ ✓ ✓* -``has_animation()`` ✓* ✓ ✓* +``has_animation()`` ✓ ✓ ✓* ``get_pillow_image()`` ✓ ``get_wand_image()`` ✓ ``detect_features()`` ✓ @@ -90,3 +92,4 @@ Operation Pillow Wand Op =================================== ==================== ==================== ==================== \* Always returns ``False`` +\** Always returns ``1`` diff --git a/docs/guide/operations.rst b/docs/guide/operations.rst index 31d399f..6e6b0dd 100644 --- a/docs/guide/operations.rst +++ b/docs/guide/operations.rst @@ -23,6 +23,12 @@ height as a tuple of two integers: # For example, 'i' is a 200x200 pixel image i.get_size() == (200, 200) +For animated GIFs, you can get the number of frames by calling the :meth:`Image.get_frame_count` method: + +.. code-block:: python + + i.get_frame_count() == 34 + Resizing images --------------- diff --git a/docs/installation.rst b/docs/installation.rst index f89b6bd..013f797 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -19,6 +19,3 @@ or Wand. - `Pillow installation `_ - `Wand installation `_ - -Note that Pillow doesn't support animated GIFs and Wand isn't as fast. -Installing both will give best results. diff --git a/docs/reference.rst b/docs/reference.rst index 7c26304..7ae59a6 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -98,6 +98,24 @@ Here's a full list of operations provided by Willow out of the box: width, height = image.get_size() +.. method:: get_frame_count() + + Returns the number of frames in an animated image: + + .. code-block:: python + + number_of_frames = image.get_frame_count() + +.. method:: get_pixel_count() + + Returns the number of pixels in an image. This is just the width, height and number + of frames multiplied together and can be used to work out the amount of memory an + image will used when decompressed: + + .. code-block:: python + + number_of_pixels = image.get_pixel_count() + .. method:: has_alpha Returns ``True`` if the image has an alpha channel. diff --git a/tests/images/animatedgifwithtransparency.gif b/tests/images/animatedgifwithtransparency.gif new file mode 100644 index 0000000..bd5651b Binary files /dev/null and b/tests/images/animatedgifwithtransparency.gif differ diff --git a/tests/test_opencv.py b/tests/test_opencv.py index a52e124..2aa51fb 100644 --- a/tests/test_opencv.py +++ b/tests/test_opencv.py @@ -28,6 +28,14 @@ def test_get_size(self): self.assertEqual(width, 600) self.assertEqual(height, 400) + def test_get_frame_count(self): + frames = self.image.get_frame_count() + self.assertEqual(frames, 1) + + def test_get_pixel_count(self): + pixels = self.image.get_pixel_count() + self.assertEqual(pixels, 200 * 150) + def test_has_alpha(self): has_alpha = self.image.has_alpha() self.assertFalse(has_alpha) diff --git a/tests/test_pillow.py b/tests/test_pillow.py index dbe9af8..6fbc028 100644 --- a/tests/test_pillow.py +++ b/tests/test_pillow.py @@ -21,6 +21,14 @@ def test_get_size(self): self.assertEqual(width, 200) self.assertEqual(height, 150) + def test_get_frame_count(self): + frames = self.image.get_frame_count() + self.assertEqual(frames, 1) + + def test_get_pixel_count(self): + pixels = self.image.get_pixel_count() + self.assertEqual(pixels, 200 * 150) + def test_resize(self): resized_image = self.image.resize((100, 75)) self.assertEqual(resized_image.get_size(), (100, 75)) @@ -119,8 +127,8 @@ def test_save_as_gif_converts_back_to_supported_mode(self): output = io.BytesIO() with open('tests/images/transparent.gif', 'rb') as f: - image = PillowImage.open(GIFImageFile(f)) - image.image = image.image.convert('RGB') + image = PillowImage.open_animated(GIFImageFile(f)) + image.frames[0] = image.frames[0].convert('RGB') image.save_as_gif(output) output.seek(0) @@ -128,6 +136,19 @@ def test_save_as_gif_converts_back_to_supported_mode(self): image = _PIL_Image().open(output) self.assertEqual(image.mode, 'P') + def test_save_as_gif_animated(self): + with open('tests/images/newtons_cradle.gif', 'rb') as f: + image = PillowImage.open_animated(GIFImageFile(f)) + + output = io.BytesIO() + return_value = image.save_as_gif(output) + output.seek(0) + + loaded_image = PillowImage.open_animated(GIFImageFile(output)) + + self.assertTrue(loaded_image.has_animation()) + self.assertEqual(loaded_image.get_frame_count(), 34) + def test_has_alpha(self): has_alpha = self.image.has_alpha() self.assertTrue(has_alpha) @@ -138,7 +159,7 @@ def test_has_animation(self): def test_transparent_gif(self): with open('tests/images/transparent.gif', 'rb') as f: - image = PillowImage.open(GIFImageFile(f)) + image = PillowImage.open_animated(GIFImageFile(f)) self.assertTrue(image.has_alpha()) self.assertFalse(image.has_animation()) @@ -148,7 +169,7 @@ def test_transparent_gif(self): def test_resize_transparent_gif(self): with open('tests/images/transparent.gif', 'rb') as f: - image = PillowImage.open(GIFImageFile(f)) + image = PillowImage.open_animated(GIFImageFile(f)) resized_image = image.resize((100, 75)) @@ -160,7 +181,7 @@ def test_resize_transparent_gif(self): def test_save_transparent_gif(self): with open('tests/images/transparent.gif', 'rb') as f: - image = PillowImage.open(GIFImageFile(f)) + image = PillowImage.open_animated(GIFImageFile(f)) # Save it into memory f = io.BytesIO() @@ -168,7 +189,7 @@ def test_save_transparent_gif(self): # Reload it f.seek(0) - image = PillowImage.open(GIFImageFile(f)) + image = PillowImage.open_animated(GIFImageFile(f)) self.assertTrue(image.has_alpha()) self.assertFalse(image.has_animation()) @@ -176,24 +197,37 @@ def test_save_transparent_gif(self): # Check that the alpha of pixel 1,1 is 0 self.assertEqual(image.image.convert('RGBA').getpixel((1, 1))[3], 0) - @unittest.expectedFailure # Pillow doesn't support animation def test_animated_gif(self): with open('tests/images/newtons_cradle.gif', 'rb') as f: - image = PillowImage.open(GIFImageFile(f)) + image = PillowImage.open_animated(GIFImageFile(f)) - self.assertFalse(image.has_alpha()) + self.assertTrue(image.has_alpha()) self.assertTrue(image.has_animation()) - @unittest.expectedFailure # Pillow doesn't support animation def test_resize_animated_gif(self): with open('tests/images/newtons_cradle.gif', 'rb') as f: - image = PillowImage.open(GIFImageFile(f)) + image = PillowImage.open_animated(GIFImageFile(f)) resized_image = image.resize((100, 75)) - self.assertFalse(resized_image.has_alpha()) + self.assertTrue(resized_image.has_alpha()) self.assertTrue(resized_image.has_animation()) + def test_save_transparent_animated_gif(self): + with open('tests/images/animatedgifwithtransparency.gif', 'rb') as f: + image = PillowImage.open_animated(GIFImageFile(f)) + + # Save it into memory + f = io.BytesIO() + image.save_as_gif(f) + + # Reload it + f.seek(0) + image = PillowImage.open_animated(GIFImageFile(f)) + + self.assertTrue(image.has_alpha()) + self.assertTrue(image.has_animation()) + def test_get_pillow_image(self): pillow_image = self.image.get_pillow_image() diff --git a/tests/test_registry.py b/tests/test_registry.py index 05d10d1..ed50018 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -309,9 +309,14 @@ def setUp(self): self.registry._registered_image_classes.add(self.ImageF) # Add some operations + # foo - reachable on both ImageB and ImageE + # bar - reachable on Image E but unreachable on ImageF + # baz - only unreachable on ImageF self.b_foo = 'b_foo' self.e_foo = 'e_foo' - self.f_unreachable = 'f_unreachable' + self.e_bar = 'e_bar' + self.f_bar = 'f_bar' + self.f_baz = 'f_baz' self.registry._registered_operations = { self.ImageB: { @@ -319,9 +324,12 @@ def setUp(self): }, self.ImageE: { 'foo': self.e_foo, + 'bar': self.e_bar, }, + # An unreachable class self.ImageF: { - 'unreachable': self.f_unreachable, + 'bar': self.f_bar, + 'baz': self.f_baz, } } @@ -374,8 +382,22 @@ def test_find_operation_foo_from_a_all_unavailable(self): self.assertIn("ImageB: missing image library", str(e.exception)) self.assertIn("ImageE: another missing image library", str(e.exception)) - def test_find_operation_unreachable_from_a(self): + def test_find_operation_bar_from_a(self): + # This operation has both a reachable and an unreachable class it could go to + func, image_class, path, cost = self.registry.find_operation(self.ImageA, 'bar') + + self.assertEqual(func, self.e_bar) + self.assertEqual(image_class, self.ImageE) + self.assertEqual(path, [ + (self.conv_a_to_b, self.ImageB), + (self.conv_b_to_d, self.ImageD), + (self.conv_d_to_e, self.ImageE), + ]) + self.assertEqual(cost, 300) + + def test_find_operation_baz_from_a(self): + # This operation is only on the unreachable class so should give exception with self.assertRaises(UnroutableOperationError) as e: - func, image_class, path, cost = self.registry.find_operation(self.ImageA, 'unreachable') + func, image_class, path, cost = self.registry.find_operation(self.ImageA, 'baz') - self.assertEqual(e.exception.args, ("The operation 'unreachable' is available in the image class 'ImageF' but it can't be converted to from 'ImageA'", )) + self.assertEqual(e.exception.args, ("The operation 'baz' is available in the image class 'ImageF' but it can't be converted to from 'ImageA'", )) diff --git a/tests/test_wand.py b/tests/test_wand.py index 835a0c3..a1803cc 100644 --- a/tests/test_wand.py +++ b/tests/test_wand.py @@ -23,6 +23,14 @@ def test_get_size(self): self.assertEqual(width, 200) self.assertEqual(height, 150) + def test_get_frame_count(self): + frames = self.image.get_frame_count() + self.assertEqual(frames, 1) + + def test_get_pixel_count(self): + pixels = self.image.get_pixel_count() + self.assertEqual(pixels, 200 * 150) + def test_resize(self): resized_image = self.image.resize((100, 75)) self.assertEqual(resized_image.get_size(), (100, 75)) @@ -122,6 +130,19 @@ def test_save_as_gif(self): self.assertIsInstance(return_value, GIFImageFile) self.assertEqual(return_value.f, output) + def test_save_as_gif_animated(self): + with open('tests/images/newtons_cradle.gif', 'rb') as f: + image = WandImage.open(GIFImageFile(f)) + + output = io.BytesIO() + return_value = image.save_as_gif(output) + output.seek(0) + + loaded_image = WandImage.open(GIFImageFile(output)) + + self.assertTrue(loaded_image.has_animation()) + self.assertEqual(loaded_image.get_frame_count(), 34) + def test_has_alpha(self): has_alpha = self.image.has_alpha() self.assertTrue(has_alpha) @@ -158,6 +179,10 @@ def test_animated_gif(self): self.assertTrue(image.has_animation()) + self.assertEqual(image.get_size(), (480, 360)) + self.assertEqual(image.get_frame_count(), 34) + self.assertEqual(image.get_pixel_count(), 480 * 360 * 34) + def test_resize_animated_gif(self): with open('tests/images/newtons_cradle.gif', 'rb') as f: image = WandImage.open(GIFImageFile(f)) diff --git a/willow/__init__.py b/willow/__init__.py index 8453032..3138f27 100644 --- a/willow/__init__.py +++ b/willow/__init__.py @@ -14,6 +14,7 @@ def setup(): WebPImageFile, ) from willow.plugins import pillow, wand, opencv + from willow.generic_operations import get_pixel_count registry.register_image_class(JPEGImageFile) registry.register_image_class(PNGImageFile) @@ -28,6 +29,8 @@ def setup(): registry.register_plugin(wand) registry.register_plugin(opencv) + registry.register_generic_operation(['get_size', 'get_frame_count'], 'get_pixel_count', get_pixel_count) + setup() diff --git a/willow/generic_operations.py b/willow/generic_operations.py new file mode 100644 index 0000000..0e0048d --- /dev/null +++ b/willow/generic_operations.py @@ -0,0 +1,8 @@ +# Generic operations are automatically registered on all image models that implement their dependencies + +# Dependencies: get_size, get_frame_count +def get_pixel_count(image): + width, height = image.get_size() + frames = image.get_frame_count() + + return width * height * frames diff --git a/willow/plugins/opencv.py b/willow/plugins/opencv.py index 9be847f..108b97c 100644 --- a/willow/plugins/opencv.py +++ b/willow/plugins/opencv.py @@ -31,6 +31,11 @@ def check(cls): def get_size(self): return self.size + @Image.operation + def get_frame_count(self): + # Animation is not supported by OpenCV + return 1 + @Image.operation def has_alpha(self): # Alpha is not supported by OpenCV diff --git a/willow/plugins/pillow.py b/willow/plugins/pillow.py index 5a68895..7f108a3 100644 --- a/willow/plugins/pillow.py +++ b/willow/plugins/pillow.py @@ -19,9 +19,26 @@ def _PIL_Image(): return PIL.Image +def is_format_supported(image_format): + formats = _PIL_Image().registered_extensions() + return image_format in formats.values() + + +def image_has_alpha(image): + return image.mode in ('RGBA', 'LA') or (image.mode == 'P' and 'transparency' in image.info) + + class PillowImage(Image): - def __init__(self, image): - self.image = image + def __init__(self, image_or_frames): + if isinstance(image_or_frames, _PIL_Image().Image): + self.frames = [image_or_frames] + else: + self.frames = list(image_or_frames) + + @property + def image(self): + # TODO: Deprecation warning + return self.frames[0] @classmethod def check(cls): @@ -29,40 +46,42 @@ def check(cls): @classmethod def is_format_supported(cls, image_format): - formats = _PIL_Image().registered_extensions() - return image_format in formats.values() + return is_format_supported(image_format) @Image.operation def get_size(self): - return self.image.size + return self.frames[0].size + + @Image.operation + def get_frame_count(self): + return len(self.frames) @Image.operation def has_alpha(self): - img = self.image - return img.mode in ('RGBA', 'LA') or (img.mode == 'P' and 'transparency' in img.info) + return any(image_has_alpha(frame) for frame in self.frames) @Image.operation def has_animation(self): - # Animation is not supported by PIL - return False + return self.get_frame_count() > 1 @Image.operation def resize(self, size): - # Convert 1 and P images to RGB to improve resize quality - # (palleted images don't get antialiased or filtered when minified) - if self.image.mode in ['1', 'P']: - if self.has_alpha(): - image = self.image.convert('RGBA') - else: - image = self.image.convert('RGB') - else: - image = self.image + def _resize_frame(frame): + # Convert 1 and P images to RGB to improve resize quality + # (palleted images don't get antialiased or filtered when minified) + if frame.mode in ['1', 'P']: + if image_has_alpha(frame): + frame = frame.convert('RGBA') + else: + frame = frame.convert('RGB') - return PillowImage(image.resize(size, _PIL_Image().ANTIALIAS)) + return frame.resize(size, _PIL_Image().ANTIALIAS) + + return PillowImage([_resize_frame(frame) for frame in self.frames]) @Image.operation def crop(self, rect): - return PillowImage(self.image.crop(rect)) + return PillowImage([frame.crop(rect) for frame in self.frames]) @Image.operation def rotate(self, angle): @@ -94,7 +113,7 @@ def rotate(self, angle): # We call "transpose", as it rotates the image, # updating the height and width, whereas using 'rotate' # only changes the contents of the image. - rotated = self.image.transpose(transpose_code) + rotated = [frame.transpose(transpose_code) for frame in self.frames] return PillowImage(rotated) @@ -108,30 +127,36 @@ def set_background_color_rgb(self, color): if not isinstance(color, (tuple, list)) or not len(color) == 3: raise TypeError("the 'color' argument must be a 3-element tuple or list") - # Convert non-RGB colour formats to RGB - # As we only allow the background color to be passed in as RGB, we - # convert the format of the original image to match. - image = self.image.convert('RGBA') + def _set_frame_background_color_rgb(frame): + # Convert non-RGB colour formats to RGB + # As we only allow the background color to be passed in as RGB, we + # convert the format of the original image to match. + frame = frame.convert('RGBA') - # Generate a new image with background colour and draw existing image on top of it - # The new image must temporarily be RGBA in order for alpha_composite to work - new_image = _PIL_Image().new('RGBA', self.image.size, (color[0], color[1], color[2], 255)) + # Generate a new image with background colour and draw existing image on top of it + # The new image must temporarily be RGBA in order for alpha_composite to work + new_frame = _PIL_Image().new('RGBA', frame.size, (color[0], color[1], color[2], 255)) - if hasattr(new_image, 'alpha_composite'): - new_image.alpha_composite(image) - else: - # Pillow < 4.2.0 fallback - # This method may be slower as the operation generates a new image - new_image = _PIL_Image().alpha_composite(new_image, image) + if hasattr(new_frame, 'alpha_composite'): + new_frame.alpha_composite(frame) + else: + # Pillow < 4.2.0 fallback + # This method may be slower as the operation generates a new image + new_frame = _PIL_Image().alpha_composite(new_frame, frame) - return PillowImage(new_image.convert('RGB')) + return new_frame.convert('RGB') + + return PillowImage([_set_frame_background_color_rgb(frame) for frame in self.frames]) @Image.operation def save_as_jpeg(self, f, quality=85, optimize=False, progressive=False): - if self.image.mode in ['1', 'P']: - image = self.image.convert('RGB') - else: - image = self.image + if self.has_animation(): + pass # TODO: Raise warning + + frame = self.frames[0] + + if frame.mode in ['1', 'P']: + frame = frame.convert('RGB') # Pillow only checks presence of optimize kwarg, not its value kwargs = {} @@ -140,50 +165,70 @@ def save_as_jpeg(self, f, quality=85, optimize=False, progressive=False): if progressive: kwargs['progressive'] = True - image.save(f, 'JPEG', quality=quality, **kwargs) + frame.save(f, 'JPEG', quality=quality, **kwargs) return JPEGImageFile(f) @Image.operation def save_as_png(self, f, optimize=False): + if self.has_animation(): + pass # TODO: Raise warning + + frame = self.frames[0] + # Pillow only checks presence of optimize kwarg, not its value kwargs = {} if optimize: kwargs['optimize'] = True - self.image.save(f, 'PNG', **kwargs) + frame.save(f, 'PNG', **kwargs) return PNGImageFile(f) @Image.operation def save_as_gif(self, f): - image = self.image + frames = self.frames # All gif files use either the L or P mode but we sometimes convert them # to RGB/RGBA to improve the quality of resizing. We must make sure that # they are converted back before saving. - if image.mode not in ['L', 'P']: - image = image.convert('P', palette=_PIL_Image().ADAPTIVE) - - if 'transparency' in image.info: - image.save(f, 'GIF', transparency=image.info['transparency']) + if frames[0].mode not in ['L', 'P']: + frames = [ + frame.convert('P', palette=_PIL_Image().ADAPTIVE) + for frame in frames + ] + + if self.has_animation(): + params = { + 'save_all': True, + 'duration': frames[0].info['duration'], + 'append_images': [frame for frame in frames[1:]] + } else: - image.save(f, 'GIF') + params = {} + + if 'transparency' in frames[0].info: + params['transparency'] = frames[0].info['transparency'] + + frames[0].save(f, 'GIF', **params) return GIFImageFile(f) @Image.operation def save_as_webp(self, f): - self.image.save(f, 'WEBP') + if self.has_animation(): + pass # TODO: Raise warning + + frame = self.frames[0] + + frame.save(f, 'WEBP') return WebPImageFile(f) @Image.operation def auto_orient(self): # JPEG files can be orientated using an EXIF tag. # Make sure this orientation is applied to the data - image = self.image - - if hasattr(image, '_getexif'): + if hasattr(self.frames[0], '_getexif'): try: - exif = image._getexif() + exif = self.frames[0]._getexif() except Exception: # Blanket cover all the ways _getexif can fail in. exif = None @@ -204,19 +249,24 @@ def auto_orient(self): 8: (Image.ROTATE_90,), } - for transpose in ORIENTATION_TO_TRANSPOSE[orientation]: - image = image.transpose(transpose) + def _orient_frame(frame): + for transpose in ORIENTATION_TO_TRANSPOSE[orientation]: + frame = frame.transpose(transpose) + + return frame - return PillowImage(image) + return PillowImage([_orient_frame(frame) for frame in self.frames]) + + return self @Image.operation def get_pillow_image(self): - return self.image + # TODO: Deprecation warning + return self.frames[0] @classmethod @Image.converter_from(JPEGImageFile) @Image.converter_from(PNGImageFile) - @Image.converter_from(GIFImageFile, cost=200) @Image.converter_from(BMPImageFile) @Image.converter_from(TIFFImageFile) @Image.converter_from(WebPImageFile) @@ -227,23 +277,47 @@ def open(cls, image_file): return cls(image) + @classmethod + @Image.converter_from(GIFImageFile) + def open_animated(cls, image_file): + image_file.f.seek(0) + image = _PIL_Image().open(image_file.f) + + frame = image + frames = [] + while frame: + frames.append(frame.copy()) + + try: + image.seek(image.tell() + 1) + except EOFError: + break + + return cls(frames) + @Image.converter_to(RGBImageBuffer) def to_buffer_rgb(self): - image = self.image + if self.has_animation(): + pass # TODO: Raise warning + + frame = self.frames[0] if image.mode != 'RGB': - image = image.convert('RGB') + frame = frame.convert('RGB') - return RGBImageBuffer(image.size, image.tobytes()) + return RGBImageBuffer(frame.size, frame.tobytes()) @Image.converter_to(RGBAImageBuffer) def to_buffer_rgba(self): - image = self.image + if self.has_animation(): + pass # TODO: Raise warning + + frame = self.frames[0] - if image.mode != 'RGBA': - image = image.convert('RGBA') + if frame.mode != 'RGBA': + frame = frame.convert('RGBA') - return RGBAImageBuffer(image.size, image.tobytes()) + return RGBAImageBuffer(frame.size, frame.tobytes()) willow_image_classes = [PillowImage] diff --git a/willow/plugins/wand.py b/willow/plugins/wand.py index 5392a04..fbca1dd 100644 --- a/willow/plugins/wand.py +++ b/willow/plugins/wand.py @@ -58,6 +58,10 @@ def is_format_supported(cls, image_format): def get_size(self): return self.image.size + @Image.operation + def get_frame_count(self): + return len(self.image.sequence) + @Image.operation def has_alpha(self): return self.image.alpha_channel diff --git a/willow/registry.py b/willow/registry.py index 5687228..123d80c 100644 --- a/willow/registry.py +++ b/willow/registry.py @@ -1,6 +1,13 @@ from collections import defaultdict +class OperationNameConflict(Exception): + """ + Raised when an operation is registered that clashes with an existing generic operation's name + """ + pass + + class UnrecognisedOperationError(LookupError): """ Raised when the operation isn't in any of the known image classes. @@ -26,15 +33,29 @@ class UnroutableOperationError(LookupError): class WillowRegistry(object): def __init__(self): + self._used_operation_names = set() self._registered_image_classes = set() self._unavailable_image_classes = dict() self._registered_operations = defaultdict(dict) + self._registered_generic_operations = dict() self._registered_converters = dict() self._registered_converter_costs = dict() def register_operation(self, image_class, operation_name, func): + if operation_name in self._registered_generic_operations: + return OperationNameConflict("A generic operation already exists with the name '{0}'".format(operation_name)) + self._registered_operations[image_class][operation_name] = func + def register_generic_operation(self, dependencies, operation_name, func): + if self.operation_exists(operation_name): + return OperationNameConflict("An operation already exists with the name '{0}'".format(operation_name)) + + self._registered_generic_operations[operation_name] = { + 'dependencies': dependencies, + 'func': func, + } + def register_converter(self, from_image_class, to_image_class, func, cost=None): self._registered_converters[from_image_class, to_image_class] = func @@ -76,6 +97,15 @@ def register_plugin(self, plugin): self.register_converter(converter[0], converter[1], converter[2]) def get_operation(self, image_class, operation_name): + if operation_name in self._registered_generic_operations: + generic_operation = self._registered_generic_operations[operation_name] + + for dependency in generic_operation['dependencies']: + if dependency not in self._registered_operations[image_class]: + raise LookupError("Generic operation dependencies not met") + + return generic_operation['func'] + return self._registered_operations[image_class][operation_name] def operation_exists(self, operation_name): @@ -241,6 +271,9 @@ def find_closest_image_class(self, start, image_classes): for image_class in image_classes: path, cost = self.find_shortest_path(start, image_class) + if path is None: + continue + if current_cost is None or cost < current_cost: current_class = image_class current_cost = cost