kopia lustrzana https://github.com/wagtail/wagtail
Merge branch 'img-proc-backends' of https://github.com/spapas/wagtail into spapas-img-proc-backends
commit
1a28b572c2
wagtail/wagtailimages
1
setup.py
1
setup.py
|
@ -48,6 +48,7 @@ setup(
|
|||
"Pillow>=2.3.0",
|
||||
"beautifulsoup4>=4.3.2",
|
||||
"lxml>=3.3.0",
|
||||
'Unidecode>=0.04.14',
|
||||
"BeautifulSoup==3.2.1", # django-compressor gets confused if we have lxml but not BS3 installed
|
||||
],
|
||||
zip_safe=False,
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
# Backend loading
|
||||
# Based on the Django cache framework and wagtailsearch
|
||||
# https://github.com/django/django/blob/5d263dee304fdaf95e18d2f0619d6925984a7f02/django/core/cache/__init__.py
|
||||
|
||||
from importlib import import_module
|
||||
from django.utils import six
|
||||
import sys
|
||||
from django.conf import settings
|
||||
|
||||
from base import InvalidImageBackendError
|
||||
|
||||
# Pinched from django 1.7 source code.
|
||||
# TODO: Replace this with "from django.utils.module_loading import import_string"
|
||||
# when django 1.7 is released
|
||||
# TODO: This is not DRY - should be imported from a utils module
|
||||
def import_string(dotted_path):
|
||||
"""
|
||||
Import a dotted module path and return the attribute/class designated by the
|
||||
last name in the path. Raise ImportError if the import failed.
|
||||
"""
|
||||
try:
|
||||
module_path, class_name = dotted_path.rsplit('.', 1)
|
||||
except ValueError:
|
||||
msg = "%s doesn't look like a module path" % dotted_path
|
||||
six.reraise(ImportError, ImportError(msg), sys.exc_info()[2])
|
||||
|
||||
module = import_module(module_path)
|
||||
|
||||
try:
|
||||
return getattr(module, class_name)
|
||||
except AttributeError:
|
||||
msg = 'Module "%s" does not define a "%s" attribute/class' % (
|
||||
dotted_path, class_name)
|
||||
six.reraise(ImportError, ImportError(msg), sys.exc_info()[2])
|
||||
|
||||
|
||||
def get_image_backend(backend='default', **kwargs):
|
||||
# Get configuration
|
||||
default_conf = {
|
||||
'default': {
|
||||
'BACKEND': 'wagtail.wagtailimages.backends.pillow.PillowBackend',
|
||||
},
|
||||
}
|
||||
WAGTAILIMAGES_BACKENDS = getattr(
|
||||
settings, 'WAGTAILIMAGES_BACKENDS', default_conf)
|
||||
|
||||
# Try to find the backend
|
||||
try:
|
||||
# Try to get the WAGTAILIMAGES_BACKENDS entry for the given backend name first
|
||||
conf = WAGTAILIMAGES_BACKENDS[backend]
|
||||
except KeyError:
|
||||
try:
|
||||
# Trying to import the given backend, in case it's a dotted path
|
||||
import_string(backend)
|
||||
except ImportError as e:
|
||||
raise InvalidImageBackendError("Could not find backend '%s': %s" % (
|
||||
backend, e))
|
||||
params = kwargs
|
||||
else:
|
||||
# Backend is a conf entry
|
||||
params = conf.copy()
|
||||
params.update(kwargs)
|
||||
backend = params.pop('BACKEND')
|
||||
|
||||
# Try to import the backend
|
||||
try:
|
||||
backend_cls = import_string(backend)
|
||||
except ImportError as e:
|
||||
raise InvalidImageBackendError("Could not find backend '%s': %s" % (
|
||||
backend, e))
|
||||
|
||||
# Create backend
|
||||
return backend_cls(params)
|
|
@ -0,0 +1,132 @@
|
|||
from django.db import models
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
class InvalidImageBackendError(ImproperlyConfigured):
|
||||
pass
|
||||
|
||||
|
||||
class BaseImageBackend(object):
|
||||
def __init__(self, params):
|
||||
pass
|
||||
|
||||
def open_image(self, input_file):
|
||||
"""
|
||||
Open an image and return the backend specific image object to pass
|
||||
to other methods. The object return has to have a size attribute
|
||||
which is a tuple with the width and height of the image and a format
|
||||
attribute with the format of the image.
|
||||
"""
|
||||
raise NotImplementedError('subclasses of BaseImageBackend must provide an resize() method')
|
||||
|
||||
|
||||
def save_image(self, image, output):
|
||||
"""
|
||||
Save the image to the output
|
||||
"""
|
||||
raise NotImplementedError('subclasses of BaseImageBackend must provide an resize() method')
|
||||
|
||||
def resize(self, image, size):
|
||||
"""
|
||||
resize image to the requested size, using highest quality settings
|
||||
(antialiasing enabled, converting to true colour if required)
|
||||
"""
|
||||
raise NotImplementedError('subclasses of BaseImageBackend must provide an resize() method')
|
||||
|
||||
|
||||
def crop_to_centre(self, image, size):
|
||||
raise NotImplementedError('subclasses of BaseImageBackend must provide an crop_to_centre() method')
|
||||
|
||||
|
||||
def resize_to_max(self, image, size):
|
||||
"""
|
||||
Resize image down to fit within the given dimensions, preserving aspect ratio.
|
||||
Will leave image unchanged if it's already within those dimensions.
|
||||
"""
|
||||
(original_width, original_height) = image.size
|
||||
(target_width, target_height) = size
|
||||
|
||||
if original_width <= target_width and original_height <= target_height:
|
||||
return image
|
||||
|
||||
# scale factor if we were to downsize the image to fit the target width
|
||||
horz_scale = float(target_width) / original_width
|
||||
# scale factor if we were to downsize the image to fit the target height
|
||||
vert_scale = float(target_height) / original_height
|
||||
|
||||
# choose whichever of these gives a smaller image
|
||||
if horz_scale < vert_scale:
|
||||
final_size = (target_width, int(original_height * horz_scale))
|
||||
else:
|
||||
final_size = (int(original_width * vert_scale), target_height)
|
||||
|
||||
return self.resize(image, final_size)
|
||||
|
||||
|
||||
|
||||
def resize_to_min(self, image, size):
|
||||
"""
|
||||
Resize image down to cover the given dimensions, preserving aspect ratio.
|
||||
Will leave image unchanged if width or height is already within those limits.
|
||||
"""
|
||||
(original_width, original_height) = image.size
|
||||
(target_width, target_height) = size
|
||||
|
||||
if original_width <= target_width or original_height <= target_height:
|
||||
return image
|
||||
|
||||
# scale factor if we were to downsize the image to fit the target width
|
||||
horz_scale = float(target_width) / original_width
|
||||
# scale factor if we were to downsize the image to fit the target height
|
||||
vert_scale = float(target_height) / original_height
|
||||
|
||||
# choose whichever of these gives a larger image
|
||||
if horz_scale > vert_scale:
|
||||
final_size = (target_width, int(original_height * horz_scale))
|
||||
else:
|
||||
final_size = (int(original_width * vert_scale), target_height)
|
||||
|
||||
return self.resize(image, final_size)
|
||||
|
||||
|
||||
def resize_to_width(self, image, target_width):
|
||||
"""
|
||||
Resize image down to the given width, preserving aspect ratio.
|
||||
Will leave image unchanged if it's already within that width.
|
||||
"""
|
||||
(original_width, original_height) = image.size
|
||||
|
||||
if original_width <= target_width:
|
||||
return image
|
||||
|
||||
scale = float(target_width) / original_width
|
||||
|
||||
final_size = (target_width, int(original_height * scale))
|
||||
|
||||
return self.resize(image, final_size)
|
||||
|
||||
|
||||
def resize_to_height(self, image, target_height):
|
||||
"""
|
||||
Resize image down to the given height, preserving aspect ratio.
|
||||
Will leave image unchanged if it's already within that height.
|
||||
"""
|
||||
(original_width, original_height) = image.size
|
||||
|
||||
if original_height <= target_height:
|
||||
return image
|
||||
|
||||
scale = float(target_height) / original_height
|
||||
|
||||
final_size = (int(original_width * scale), target_height)
|
||||
|
||||
return self.resize(image, final_size)
|
||||
|
||||
|
||||
def resize_to_fill(self, image, size):
|
||||
"""
|
||||
Resize down and crop image to fill the given dimensions. Most suitable for thumbnails.
|
||||
(The final image will match the requested size, unless one or the other dimension is
|
||||
already smaller than the target size)
|
||||
"""
|
||||
resized_image = self.resize_to_min(image, size)
|
||||
return self.crop_to_centre(resized_image, size)
|
|
@ -0,0 +1,41 @@
|
|||
from django.db import models
|
||||
|
||||
from wagtail.wagtailsearch.backends.base import BaseSearch
|
||||
from wagtail.wagtailsearch.indexed import Indexed
|
||||
from base import BaseImageBackend
|
||||
import PIL.Image
|
||||
|
||||
class PillowBackend(BaseImageBackend):
|
||||
def __init__(self, params):
|
||||
super(PillowBackend, self).__init__(params)
|
||||
|
||||
def open_image(self, input_file):
|
||||
image = PIL.Image.open(input_file)
|
||||
return image
|
||||
|
||||
def save_image(self, image, output, format):
|
||||
image.save(output, format)
|
||||
|
||||
|
||||
def resize(self, image, size):
|
||||
if image.mode in ['1', 'P']:
|
||||
image = image.convert('RGB')
|
||||
return image.resize(size, PIL.Image.ANTIALIAS)
|
||||
|
||||
|
||||
def crop_to_centre(self, image, size):
|
||||
(original_width, original_height) = image.size
|
||||
(target_width, target_height) = size
|
||||
|
||||
# final dimensions should not exceed original dimensions
|
||||
final_width = min(original_width, target_width)
|
||||
final_height = min(original_height, target_height)
|
||||
|
||||
if final_width == original_width and final_height == original_height:
|
||||
return image
|
||||
|
||||
left = (original_width - final_width) / 2
|
||||
top = (original_height - final_height) / 2
|
||||
return image.crop(
|
||||
(left, top, left + final_width, top + final_height)
|
||||
)
|
|
@ -0,0 +1,42 @@
|
|||
from __future__ import absolute_import
|
||||
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from .base import BaseImageBackend
|
||||
|
||||
from wand.image import Image
|
||||
|
||||
class WandBackend(BaseImageBackend):
|
||||
def __init__(self, params):
|
||||
super(WandBackend, self).__init__(params)
|
||||
|
||||
def open_image(self, input_file):
|
||||
image = Image(file=input_file)
|
||||
return image
|
||||
|
||||
def save_image(self, image, output, format):
|
||||
image.format = format
|
||||
image.save(file=output)
|
||||
|
||||
def resize(self, image, size):
|
||||
image.resize(size[0], size[1])
|
||||
return image
|
||||
|
||||
|
||||
def crop_to_centre(self, image, size):
|
||||
(original_width, original_height) = image.size
|
||||
(target_width, target_height) = size
|
||||
|
||||
# final dimensions should not exceed original dimensions
|
||||
final_width = min(original_width, target_width)
|
||||
final_height = min(original_height, target_height)
|
||||
|
||||
if final_width == original_width and final_height == original_height:
|
||||
return image
|
||||
|
||||
left = (original_width - final_width) / 2
|
||||
top = (original_height - final_height) / 2
|
||||
image.crop(
|
||||
left=left, top=top, right=left + final_width, bottom=top + final_height
|
||||
)
|
||||
return image
|
|
@ -1,124 +0,0 @@
|
|||
from PIL import Image
|
||||
|
||||
|
||||
def resize(image, size):
|
||||
"""
|
||||
resize image to the requested size, using highest quality settings
|
||||
(antialiasing enabled, converting to true colour if required)
|
||||
"""
|
||||
if image.mode in ['1', 'P']:
|
||||
image = image.convert('RGB')
|
||||
|
||||
return image.resize(size, Image.ANTIALIAS)
|
||||
|
||||
|
||||
def crop_to_centre(image, size):
|
||||
(original_width, original_height) = image.size
|
||||
(target_width, target_height) = size
|
||||
|
||||
# final dimensions should not exceed original dimensions
|
||||
final_width = min(original_width, target_width)
|
||||
final_height = min(original_height, target_height)
|
||||
|
||||
if final_width == original_width and final_height == original_height:
|
||||
return image
|
||||
|
||||
left = (original_width - final_width) / 2
|
||||
top = (original_height - final_height) / 2
|
||||
return image.crop(
|
||||
(left, top, left + final_width, top + final_height)
|
||||
)
|
||||
|
||||
|
||||
def resize_to_max(image, size):
|
||||
"""
|
||||
Resize image down to fit within the given dimensions, preserving aspect ratio.
|
||||
Will leave image unchanged if it's already within those dimensions.
|
||||
"""
|
||||
(original_width, original_height) = image.size
|
||||
(target_width, target_height) = size
|
||||
|
||||
if original_width <= target_width and original_height <= target_height:
|
||||
return image
|
||||
|
||||
# scale factor if we were to downsize the image to fit the target width
|
||||
horz_scale = float(target_width) / original_width
|
||||
# scale factor if we were to downsize the image to fit the target height
|
||||
vert_scale = float(target_height) / original_height
|
||||
|
||||
# choose whichever of these gives a smaller image
|
||||
if horz_scale < vert_scale:
|
||||
final_size = (target_width, int(original_height * horz_scale))
|
||||
else:
|
||||
final_size = (int(original_width * vert_scale), target_height)
|
||||
|
||||
return resize(image, final_size)
|
||||
|
||||
|
||||
def resize_to_min(image, size):
|
||||
"""
|
||||
Resize image down to cover the given dimensions, preserving aspect ratio.
|
||||
Will leave image unchanged if width or height is already within those limits.
|
||||
"""
|
||||
(original_width, original_height) = image.size
|
||||
(target_width, target_height) = size
|
||||
|
||||
if original_width <= target_width or original_height <= target_height:
|
||||
return image
|
||||
|
||||
# scale factor if we were to downsize the image to fit the target width
|
||||
horz_scale = float(target_width) / original_width
|
||||
# scale factor if we were to downsize the image to fit the target height
|
||||
vert_scale = float(target_height) / original_height
|
||||
|
||||
# choose whichever of these gives a larger image
|
||||
if horz_scale > vert_scale:
|
||||
final_size = (target_width, int(original_height * horz_scale))
|
||||
else:
|
||||
final_size = (int(original_width * vert_scale), target_height)
|
||||
|
||||
return resize(image, final_size)
|
||||
|
||||
|
||||
def resize_to_width(image, target_width):
|
||||
"""
|
||||
Resize image down to the given width, preserving aspect ratio.
|
||||
Will leave image unchanged if it's already within that width.
|
||||
"""
|
||||
(original_width, original_height) = image.size
|
||||
|
||||
if original_width <= target_width:
|
||||
return image
|
||||
|
||||
scale = float(target_width) / original_width
|
||||
|
||||
final_size = (target_width, int(original_height * scale))
|
||||
|
||||
return resize(image, final_size)
|
||||
|
||||
|
||||
def resize_to_height(image, target_height):
|
||||
"""
|
||||
Resize image down to the given height, preserving aspect ratio.
|
||||
Will leave image unchanged if it's already within that height.
|
||||
"""
|
||||
(original_width, original_height) = image.size
|
||||
|
||||
if original_height <= target_height:
|
||||
return image
|
||||
|
||||
scale = float(target_height) / original_height
|
||||
|
||||
final_size = (int(original_width * scale), target_height)
|
||||
|
||||
return resize(image, final_size)
|
||||
|
||||
|
||||
def resize_to_fill(image, size):
|
||||
"""
|
||||
Resize down and crop image to fill the given dimensions. Most suitable for thumbnails.
|
||||
(The final image will match the requested size, unless one or the other dimension is
|
||||
already smaller than the target size)
|
||||
"""
|
||||
resized_image = resize_to_min(image, size)
|
||||
return crop_to_centre(resized_image, size)
|
|
@ -1,7 +1,6 @@
|
|||
import StringIO
|
||||
import os.path
|
||||
|
||||
import PIL.Image
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
from django.core.files import File
|
||||
|
@ -14,9 +13,10 @@ from django.utils.html import escape
|
|||
from django.conf import settings
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from wagtail.wagtailadmin.taggable import TagSearchable
|
||||
from wagtail.wagtailimages import image_ops
|
||||
from unidecode import unidecode
|
||||
|
||||
from wagtail.wagtailadmin.taggable import TagSearchable
|
||||
from wagtail.wagtailimages.backends import get_image_backend
|
||||
|
||||
class AbstractImage(models.Model, TagSearchable):
|
||||
title = models.CharField(max_length=255, verbose_name=_('Title') )
|
||||
|
@ -25,8 +25,9 @@ class AbstractImage(models.Model, TagSearchable):
|
|||
folder_name = 'original_images'
|
||||
filename = self.file.field.storage.get_valid_name(filename)
|
||||
|
||||
# do a unidecode in the filename and then
|
||||
# replace non-ascii characters in filename with _ , to sidestep issues with filesystem encoding
|
||||
filename = "".join((i if ord(i) < 128 else '_') for i in filename)
|
||||
filename = "".join((i if ord(i) < 128 else '_') for i in unidecode(filename))
|
||||
|
||||
while len(os.path.join(folder_name, filename)) >= 95:
|
||||
prefix, dot, extension = filename.rpartition('.')
|
||||
|
@ -68,7 +69,11 @@ class AbstractImage(models.Model, TagSearchable):
|
|||
rendition = self.renditions.get(filter=filter)
|
||||
except ObjectDoesNotExist:
|
||||
file_field = self.file
|
||||
generated_image_file = filter.process_image(file_field.file)
|
||||
|
||||
# If we have a backend attribute then pass it to process
|
||||
# image - else pass 'default'
|
||||
backend_name = getattr(self, 'backend', 'default')
|
||||
generated_image_file = filter.process_image(file_field.file, backend_name=backend_name)
|
||||
|
||||
rendition, created = self.renditions.get_or_create(
|
||||
filter=filter, defaults={'file': generated_image_file})
|
||||
|
@ -143,11 +148,11 @@ class Filter(models.Model):
|
|||
spec = models.CharField(max_length=255, db_index=True)
|
||||
|
||||
OPERATION_NAMES = {
|
||||
'max': image_ops.resize_to_max,
|
||||
'min': image_ops.resize_to_min,
|
||||
'width': image_ops.resize_to_width,
|
||||
'height': image_ops.resize_to_height,
|
||||
'fill': image_ops.resize_to_fill,
|
||||
'max': 'resize_to_max',
|
||||
'min': 'resize_to_min',
|
||||
'width': 'resize_to_width',
|
||||
'height': 'resize_to_height',
|
||||
'fill': 'resize_to_fill',
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
@ -156,12 +161,12 @@ class Filter(models.Model):
|
|||
|
||||
def _parse_spec_string(self):
|
||||
# parse the spec string, which is formatted as (method)-(arg),
|
||||
# and save the results to self.method and self.method_arg
|
||||
# and save the results to self.method_name and self.method_arg
|
||||
try:
|
||||
(method_name, method_arg_string) = self.spec.split('-')
|
||||
self.method = Filter.OPERATION_NAMES[method_name]
|
||||
(method_name_simple, method_arg_string) = self.spec.split('-')
|
||||
self.method_name = Filter.OPERATION_NAMES[method_name_simple]
|
||||
|
||||
if method_name in ('max', 'min', 'fill'):
|
||||
if method_name_simple in ('max', 'min', 'fill'):
|
||||
# method_arg_string is in the form 640x480
|
||||
(width, height) = [int(i) for i in method_arg_string.split('x')]
|
||||
self.method_arg = (width, height)
|
||||
|
@ -172,24 +177,30 @@ class Filter(models.Model):
|
|||
except (ValueError, KeyError):
|
||||
raise ValueError("Invalid image filter spec: %r" % self.spec)
|
||||
|
||||
def process_image(self, input_file):
|
||||
def process_image(self, input_file, backend_name='default'):
|
||||
"""
|
||||
Given an input image file as a django.core.files.File object,
|
||||
generate an output image with this filter applied, returning it
|
||||
as another django.core.files.File object
|
||||
"""
|
||||
|
||||
backend = get_image_backend(backend_name)
|
||||
|
||||
if not self.method:
|
||||
self._parse_spec_string()
|
||||
|
||||
input_file.open()
|
||||
image = PIL.Image.open(input_file)
|
||||
|
||||
# If file is closed, open it
|
||||
input_file.open('rb')
|
||||
image = backend.open_image(input_file)
|
||||
file_format = image.format
|
||||
|
||||
# perform the resize operation
|
||||
image = self.method(image, self.method_arg)
|
||||
method = getattr(backend, self.method_name)
|
||||
|
||||
image = method(image, self.method_arg)
|
||||
|
||||
output = StringIO.StringIO()
|
||||
image.save(output, file_format)
|
||||
backend.save_image(image, output, file_format)
|
||||
|
||||
|
||||
# generate new filename derived from old one, inserting the filter spec string before the extension
|
||||
input_filename_parts = os.path.basename(input_file.name).split('.')
|
||||
|
@ -199,7 +210,7 @@ class Filter(models.Model):
|
|||
output_filename = '.'.join(output_filename_parts)
|
||||
|
||||
output_file = File(output, name=output_filename)
|
||||
input_file.close()
|
||||
|
||||
|
||||
return output_file
|
||||
|
||||
|
|
|
@ -8,6 +8,8 @@ from wagtail.tests.utils import login
|
|||
from wagtail.wagtailimages.models import get_image_model
|
||||
from wagtail.wagtailimages.templatetags import image_tags
|
||||
|
||||
from wagtail.wagtailimages.backends import get_image_backend
|
||||
from wagtail.wagtailimages.backends.pillow import PillowBackend
|
||||
|
||||
def get_test_image_file():
|
||||
from StringIO import StringIO
|
||||
|
@ -78,9 +80,14 @@ class TestRenditions(TestCase):
|
|||
file=get_test_image_file(),
|
||||
)
|
||||
|
||||
def test_default_backend(self):
|
||||
# default backend should be pillow
|
||||
backend = get_image_backend()
|
||||
self.assertTrue(isinstance(backend, PillowBackend))
|
||||
|
||||
def test_minification(self):
|
||||
rendition = self.image.get_rendition('width-400')
|
||||
|
||||
|
||||
# Check size
|
||||
self.assertEqual(rendition.width, 400)
|
||||
self.assertEqual(rendition.height, 300)
|
||||
|
@ -92,6 +99,7 @@ class TestRenditions(TestCase):
|
|||
self.assertEqual(rendition.width, 100)
|
||||
self.assertEqual(rendition.height, 75)
|
||||
|
||||
|
||||
def test_resize_to_min(self):
|
||||
rendition = self.image.get_rendition('min-120x120')
|
||||
|
||||
|
@ -106,7 +114,46 @@ class TestRenditions(TestCase):
|
|||
|
||||
# Check that they are the same object
|
||||
self.assertEqual(first_rendition, second_rendition)
|
||||
|
||||
|
||||
class TestRenditionsWand(TestCase):
|
||||
def setUp(self):
|
||||
# Create an image for running tests on
|
||||
self.image = Image.objects.create(
|
||||
title="Test image",
|
||||
file=get_test_image_file(),
|
||||
)
|
||||
self.image.backend = 'wagtail.wagtailimages.backends.wand.WandBackend'
|
||||
|
||||
def test_minification(self):
|
||||
rendition = self.image.get_rendition('width-400')
|
||||
|
||||
# Check size
|
||||
self.assertEqual(rendition.width, 400)
|
||||
self.assertEqual(rendition.height, 300)
|
||||
|
||||
def test_resize_to_max(self):
|
||||
rendition = self.image.get_rendition('max-100x100')
|
||||
|
||||
# Check size
|
||||
self.assertEqual(rendition.width, 100)
|
||||
self.assertEqual(rendition.height, 75)
|
||||
|
||||
def test_resize_to_min(self):
|
||||
rendition = self.image.get_rendition('min-120x120')
|
||||
|
||||
# Check size
|
||||
self.assertEqual(rendition.width, 160)
|
||||
self.assertEqual(rendition.height, 120)
|
||||
|
||||
def test_cache(self):
|
||||
# Get two renditions with the same filter
|
||||
first_rendition = self.image.get_rendition('width-400')
|
||||
second_rendition = self.image.get_rendition('width-400')
|
||||
|
||||
# Check that they are the same object
|
||||
self.assertEqual(first_rendition, second_rendition)
|
||||
|
||||
|
||||
class TestImageTag(TestCase):
|
||||
def setUp(self):
|
||||
|
|
Ładowanie…
Reference in New Issue