Skip to content

Commit

Permalink
Merge 61438c0 into db5d555
Browse files Browse the repository at this point in the history
  • Loading branch information
lelit committed Feb 26, 2018
2 parents db5d555 + 61438c0 commit 3eb7739
Show file tree
Hide file tree
Showing 6 changed files with 262 additions and 5 deletions.
3 changes: 2 additions & 1 deletion requirements-optional.txt
Expand Up @@ -4,4 +4,5 @@ requests
Werkzeug
requests-aws4auth >= 0.9
requests-aliyun >= 0.2.5
paramiko
paramiko
pillow
12 changes: 12 additions & 0 deletions sphinx/api.rst
Expand Up @@ -206,6 +206,12 @@ WandAnalyzer
.. autoclass:: WandAnalyzer


PILAnalyzer
^^^^^^^^^^^

.. autoclass:: PILAnalyzer


Validator
^^^^^^^^^

Expand All @@ -229,6 +235,12 @@ ImageProcessor
.. autoclass:: ImageProcessor


PILImageProcessor
^^^^^^^^^^^^^^^^^

.. autoclass:: PILImageProcessor


exceptions Module
-----------------

Expand Down
20 changes: 20 additions & 0 deletions sqlalchemy_media/optionals.py
Expand Up @@ -59,6 +59,26 @@ def ensure_wand():
raise OptionalPackageRequirementError('wand')


# PIL / Pillow

try:
# noinspection PyPackageRequirements
import PIL
except ImportError: # pragma: no cover
PIL = None


def ensure_pil():
"""
.. warning:: :exc:`.OptionalPackageRequirementError` will be raised if ``Pillow`` is not installed.
"""

if PIL is None: # pragma: no cover
raise OptionalPackageRequirementError('Pillow')


# requests-aws4auth
try:
# noinspection PyPackageRequirements
Expand Down
149 changes: 148 additions & 1 deletion sqlalchemy_media/processors.py
Expand Up @@ -8,7 +8,7 @@
AspectRatioValidationError, AnalyzeError
from sqlalchemy_media.helpers import validate_width_height_ratio
from sqlalchemy_media.descriptors import StreamDescriptor
from sqlalchemy_media.optionals import magic_mime_from_buffer, ensure_wand
from sqlalchemy_media.optionals import magic_mime_from_buffer, ensure_wand, ensure_pil


class Processor(object):
Expand Down Expand Up @@ -167,6 +167,82 @@ def process(self, descriptor: StreamDescriptor, context: dict):
descriptor.prepare_to_read(backend='memory')


class PILAnalyzer(Analyzer):
"""
.. versionadded:: 0.16
Analyze an image using ``PIL`` (actually `Pillow`__).
__ https://pillow.readthedocs.io/en/latest/index.html
.. warning:: Installing ``Pillow`` is required for using this class. otherwise, an
:exc:`.OptionalPackageRequirementError` will be raised.
Use it as follow
.. testcode::
from sqlalchemy import TypeDecorator, Unicode, Column, Integer
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy_media import Image, PILAnalyzer
class ProfileImage(Image):
__pre_processors__ = PILAnalyzer()
Base = declarative_base()
class Member(Base):
__tablename__ = 'person'
id = Column(Integer, primary_key=True)
avatar = Column(ProfileImage.as_mutable(JSONB))
The use it inside :class:`.ContextManager` context:
::
from sqlalchemy_media import ContextManager
session = <....>
with ContextManager(session):
me = Member(avatar=ProfileImage.create_from('donkey.jpg'))
print(me.avatar.width)
print(me.avatar.height)
print(me.avatar.content_type)
.. note:: This object currently selects ``width``, ``height`` and ``content_type`` of the image.
"""

def process(self, descriptor: StreamDescriptor, context: dict):
ensure_pil()
# noinspection PyPackageRequirements
from PIL import Image as PILImage

# This processor requires seekable stream.
descriptor.prepare_to_read(backend='memory')

try:
# noinspection PyUnresolvedReferences
img = PILImage.open(descriptor)
context.update(
width=img.width,
height=img.height,
content_type=PILImage.MIME[img.format]
)

except OSError as e:
raise AnalyzeError(str(e))

# prepare for next processor, calling this method is not bad.
descriptor.prepare_to_read(backend='memory')


class Validator(Processor):
"""
Expand Down Expand Up @@ -451,3 +527,74 @@ def process(self, descriptor: StreamDescriptor, context: dict):

output_buffer.seek(0)
descriptor.replace(output_buffer, position=0, **context)


class PILImageProcessor(Processor):
"""
.. versionadded:: 0.16
Used to re-sampling, resizing, reformatting bitmaps.
.. warning::
- If ``width`` or ``height`` is given with ``crop``, cropping will be processed after the resize.
- If you pass both ``width`` and ``height``, aspect ratio may not be preserved.
"""

def __init__(self, fmt: str = None, width: int = None, height: int = None, crop=None):
self.format = fmt.upper() if fmt else None
self.width = width
self.height = height
self.crop = crop
# self.crop = None if crop is None else {k: v if isinstance(v, str) else str(v) for k, v in crop.items()}

def process(self, descriptor: StreamDescriptor, context: dict):

# Ensuring the PIL package is installed.
ensure_pil()
# noinspection PyPackageRequirements
from PIL import Image as PILImage

# Copy the original info
# generating thumbnail and storing in buffer
# noinspection PyTypeChecker
img = PILImage.open(descriptor)

if self.crop is None and (self.format is None or img.format == self.format) and (
(self.width is None or img.width == self.width) and
(self.height is None or img.height == self.height)):
descriptor.prepare_to_read(backend='memory')
return

if 'length' in context:
del context['length']

# opening the original file
output_buffer = io.BytesIO()
with img:
# Changing format if required.
format = self.format or img.format

# Changing dimension if required.
if self.width or self.height:
width, height, _ = validate_width_height_ratio(self.width, self.height, None)
img.thumbnail((width(img.size) if callable(width) else width,
height(img.size) if callable(height) else height))

# Cropping
if self.crop:
img = img.crop(self.crop)

img.save(output_buffer, format)

context.update(
content_type=PILImage.MIME[format],
width=img.width,
height=img.height,
extension='.' + format.lower()
)

output_buffer.seek(0)
descriptor.replace(output_buffer, position=0, **context)
13 changes: 12 additions & 1 deletion sqlalchemy_media/tests/test_analyzers.py
Expand Up @@ -3,7 +3,7 @@
import io
from os.path import dirname, abspath, join

from sqlalchemy_media.processors import MagicAnalyzer, WandAnalyzer
from sqlalchemy_media.processors import MagicAnalyzer, WandAnalyzer, PILAnalyzer
from sqlalchemy_media.descriptors import AttachableDescriptor


Expand Down Expand Up @@ -47,6 +47,17 @@ def test_wand(self):
'content_type': 'image/jpeg'
})

def test_pil(self):
analyzer = PILAnalyzer()
with AttachableDescriptor(self.cat_jpeg) as d:
ctx = {}
analyzer.process(d, ctx)
self.assertDictEqual(ctx, {
'width': 640,
'height': 480,
'content_type': 'image/jpeg'
})


if __name__ == '__main__': # pragma: no cover
unittest.main()
70 changes: 68 additions & 2 deletions sqlalchemy_media/tests/test_image_processors.py
Expand Up @@ -4,6 +4,7 @@

from sqlalchemy_media.descriptors import AttachableDescriptor
from sqlalchemy_media.processors import ImageProcessor, WandAnalyzer
from sqlalchemy_media.processors import PILImageProcessor, PILAnalyzer


class ImageProcessorTestCase(unittest.TestCase):
Expand All @@ -16,7 +17,7 @@ def setUpClass(cls):
cls.cat_png = join(cls.stuff_path, 'cat.png')
cls.dog_jpg = join(cls.stuff_path, 'dog_213X160.jpg')

def test_resize_reformat(self):
def test_resize_reformat_wand(self):
# guess content types from extension

with AttachableDescriptor(self.cat_png) as d:
Expand Down Expand Up @@ -49,7 +50,40 @@ def test_resize_reformat(self):
ImageProcessor(fmt='jpeg', height=480).process(d, ctx)
self.assertFalse(len(ctx))

def test_crop(self):
def test_resize_reformat_pil(self):
# guess content types from extension

with AttachableDescriptor(self.cat_png) as d:
ctx = dict(
length=100000,
extension='.jpg',
)
PILImageProcessor(fmt='jpeg', width=200).process(d, ctx)

self.assertDictEqual(ctx, {
'content_type': 'image/jpeg',
'width': 200,
'height': 150,
'extension': '.jpeg'
})

with AttachableDescriptor(self.cat_jpeg) as d:
# Checking when not modifying stream.
ctx = dict()
PILImageProcessor().process(d, ctx)
self.assertFalse(len(ctx))

# Checking when not modifying stream.
PILImageProcessor(fmt='jpeg').process(d, ctx)
self.assertFalse(len(ctx))

PILImageProcessor(fmt='jpeg', width=640).process(d, ctx)
self.assertFalse(len(ctx))

PILImageProcessor(fmt='jpeg', height=480).process(d, ctx)
self.assertFalse(len(ctx))

def test_crop_wand(self):
with AttachableDescriptor(self.cat_jpeg) as d:
# Checking when not modifying stream.
ctx = dict()
Expand Down Expand Up @@ -81,6 +115,38 @@ def test_crop(self):
}
)

def test_crop_pil(self):
with AttachableDescriptor(self.cat_jpeg) as d:
# Checking when not modifying stream.
ctx = dict()
PILImageProcessor(crop=(160, 120, 160+320, 120+240)).process(d, ctx)
ctx = dict()
PILAnalyzer().process(d, ctx)
self.assertDictEqual(
ctx,
{
'content_type': 'image/jpeg',
'width': 320,
'height': 240,
}
)

# With integer values
with AttachableDescriptor(self.cat_jpeg) as d:
# Checking when not modifying stream.
ctx = dict()
PILImageProcessor(crop=(0, 0, 100, 480)).process(d, ctx)
ctx = dict()
PILAnalyzer().process(d, ctx)
self.assertDictEqual(
ctx,
{
'content_type': 'image/jpeg',
'width': 100,
'height': 480,
}
)


if __name__ == '__main__': # pragma: no cover
unittest.main()

0 comments on commit 3eb7739

Please sign in to comment.