Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions sqlalchemy_imageattach/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,25 +195,28 @@ def __init__(self, get_current_object, repr_string=None):
self.repr_string = repr_string

def put_file(self, file, object_type, object_id, width, height,
mimetype, reproducible):
mimetype, reproducible, created_at=None):
self.get_current_object().put_file(
file, object_type, object_id, width, height,
mimetype, reproducible
mimetype, reproducible, created_at
)

def delete_file(self, object_type, object_id, width, height, mimetype):
def delete_file(self, object_type, object_id, width, height, mimetype,
created_at=None):
self.get_current_object().delete_file(
object_type, object_id, width, height, mimetype
object_type, object_id, width, height, mimetype, created_at
)

def get_file(self, object_type, object_id, width, height, mimetype):
def get_file(self, object_type, object_id, width, height, mimetype,
created_at=None):
return self.get_current_object().get_file(
object_type, object_id, width, height, mimetype
object_type, object_id, width, height, mimetype, created_at
)

def get_url(self, object_type, object_id, width, height, mimetype):
def get_url(self, object_type, object_id, width, height, mimetype,
created_at=None):
return self.get_current_object().get_url(
object_type, object_id, width, height, mimetype
object_type, object_id, width, height, mimetype, created_at
)

def __eq__(self, other):
Expand Down
42 changes: 34 additions & 8 deletions sqlalchemy_imageattach/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,20 @@ class Store(object):

"""

@staticmethod
def created_tag(created_at):
"""Default datetime to string converter for created_at in image.

:param created_at: the created_at of an image.
:type created_at: :class:`datetime.datetime
:return: string representation of created_at
"""
if created_at is None:
return '' # backward compatibility
return created_at.strftime('%Y%m%d%H%M%S%f')

def put_file(self, file, object_type, object_id, width, height, mimetype,
reproducible):
reproducible, created_at=None):
"""Puts the ``file`` of the image.

:param file: the image file to put
Expand All @@ -49,6 +61,8 @@ def put_file(self, file, object_type, object_id, width, height, mimetype,
``False`` if it cannot be reproduced
e.g. original images
:type reproducible: :class:`bool`
:param created_at: the created_at of the image to put
:type created_at: :class:`datetime.datetime`

.. note::

Expand All @@ -61,7 +75,8 @@ def put_file(self, file, object_type, object_id, width, height, mimetype,
"""
raise NotImplementedError('put_file() has to be implemented')

def delete_file(self, object_type, object_id, width, height, mimetype):
def delete_file(self, object_type, object_id, width, height, mimetype,
created_at=None):
"""Deletes all reproducible files related to the image.
It doesn't raise any exception even if there's no such file.

Expand All @@ -77,11 +92,14 @@ def delete_file(self, object_type, object_id, width, height, mimetype):
:param mimetype: the mimetype of the image to delete
e.g. ``'image/jpeg'``
:type mimetype: :class:`basestring`
:param created_at: the created_at of the image to delete
:type created_at: :class:`datetime.datetime`

"""
raise NotImplementedError('delete_file() has to be implemented')

def get_file(self, object_type, object_id, width, height, mimetype):
def get_file(self, object_type, object_id, width, height, mimetype,
created_at=None):
"""Gets the file-like object of the given criteria.

:param object_type: the object type of the image to find
Expand All @@ -96,6 +114,8 @@ def get_file(self, object_type, object_id, width, height, mimetype):
:param mimetype: the mimetype of the image to find
e.g. ``'image/jpeg'``
:type mimetype: :class:`basestring`
:param created_at: the created_at of the image to find
:type created_at: :class:`datetime.datetime`
:returns: the file of the image
:rtype: file-like object, :class:`file`
:raises exceptions.IOError: when such file doesn't exist
Expand All @@ -111,7 +131,8 @@ def get_file(self, object_type, object_id, width, height, mimetype):
"""
raise NotImplementedError('get_file() has to be implemented')

def get_url(self, object_type, object_id, width, height, mimetype):
def get_url(self, object_type, object_id, width, height, mimetype,
created_at=None):
"""Gets the file-like object of the given criteria.

:param object_type: the object type of the image to find
Expand All @@ -126,6 +147,8 @@ def get_url(self, object_type, object_id, width, height, mimetype):
:param mimetype: the mimetype of the image to find
e.g. ``'image/jpeg'``
:type mimetype: :class:`basestring`
:param created_at: the created_at of the image to find
:type created_at: :class:`datetime.datetime`
:returns: the url locating the image
:rtype: :class:`basestring`

Expand Down Expand Up @@ -162,7 +185,7 @@ def store(self, image, file):
'implements read() method, not ' + repr(file))
self.put_file(file, image.object_type, image.object_id,
image.width, image.height, image.mimetype,
not image.original)
not image.original, image.created_at)

def delete(self, image):
"""Delete the file of the given ``image``.
Expand All @@ -176,7 +199,8 @@ def delete(self, image):
raise TypeError('image must be a sqlalchemy_imageattach.entity.'
'Image instance, not ' + repr(image))
self.delete_file(image.object_type, image.object_id,
image.width, image.height, image.mimetype)
image.width, image.height, image.mimetype,
image.created_at)

def open(self, image, use_seek=False):
"""Opens the file-like object of the given ``image``.
Expand Down Expand Up @@ -231,7 +255,8 @@ def open(self, image, use_seek=False):
raise TypeError('image.object_id must be integer, not ' +
repr(image.object_id))
f = self.get_file(image.object_type, image.object_id,
image.width, image.height, image.mimetype)
image.width, image.height, image.mimetype,
image.created_at)
for method in 'read', 'readline', 'readlines':
if not callable(getattr(f, method, None)):
raise TypeError(
Expand Down Expand Up @@ -267,7 +292,8 @@ def locate(self, image):
raise TypeError('image must be a sqlalchemy_imageattach.entity.'
'Image instance, not ' + repr(image))
url = self.get_url(image.object_type, image.object_id,
image.width, image.height, image.mimetype)
image.width, image.height, image.mimetype,
image.created_at)
if '?' in url:
fmt = '{0}&_ts={1}'
else:
Expand Down
25 changes: 16 additions & 9 deletions sqlalchemy_imageattach/stores/fs.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,19 +56,24 @@ class BaseFileSystemStore(Store):

"""

def __init__(self, path):
def __init__(self, path, unique_url=False):
self.path = path
self.unique_url = unique_url

def get_path(self, object_type, object_id, width, height, mimetype):
def get_path(self, object_type, object_id, width, height, mimetype,
created_at=None):
id_segment_a = str(object_id % 1000)
id_segment_b = str(object_id // 1000)
created_tag = self.created_tag(created_at) if self.unique_url else ''
suffix = guess_extension(mimetype)
filename = '{0}.{1}x{2}{3}'.format(object_id, width, height, suffix)
filename = '{0}{1}.{2}x{3}{4}'.format(object_id, created_tag,
width, height, suffix)
return object_type, id_segment_a, id_segment_b, filename

def put_file(self, file, object_type, object_id, width, height, mimetype,
reproducible):
path = self.get_path(object_type, object_id, width, height, mimetype)
reproducible, created_at=None):
path = self.get_path(object_type, object_id, width, height, mimetype,
created_at)
for i in range(len(path)):
d = os.path.join(self.path, *path[:i])
if not os.path.isdir(d):
Expand Down Expand Up @@ -104,8 +109,8 @@ class FileSystemStore(BaseFileSystemStore):

"""

def __init__(self, path, base_url):
super(FileSystemStore, self).__init__(path)
def __init__(self, path, base_url, unique_url=False):
super(FileSystemStore, self).__init__(path, unique_url=unique_url)
if not base_url.endswith('/'):
base_url += '/'
self.base_url = base_url
Expand Down Expand Up @@ -174,10 +179,12 @@ class HttpExposedFileSystemStore(BaseFileSystemStore):

"""

def __init__(self, path, prefix='__images__', host_url_getter=None):
def __init__(self, path, prefix='__images__', host_url_getter=None,
unique_url=False):
if not (callable(host_url_getter) or host_url_getter is None):
raise TypeError('host_url_getter must be callable')
super(HttpExposedFileSystemStore, self).__init__(path)
super(HttpExposedFileSystemStore, self).__init__(path,
unique_url=unique_url)
if prefix.startswith('/'):
prefix = prefix[1:]
if prefix.endswith('/'):
Expand Down
25 changes: 16 additions & 9 deletions sqlalchemy_imageattach/stores/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,15 @@ class S3Store(Store):
public_base_url = None

def __init__(self, bucket, access_key=None, secret_key=None,
max_age=DEFAULT_MAX_AGE, prefix='', public_base_url=None):
max_age=DEFAULT_MAX_AGE, prefix='', public_base_url=None,
unique_url=False):
self.bucket = bucket
self.access_key = access_key
self.secret_key = secret_key
self.base_url = BASE_URL_FORMAT.format(bucket)
self.max_age = max_age
self.prefix = prefix.strip()
self.unique_url = unique_url
if self.prefix.endswith('/'):
self.prefix = self.prefix.rstrip('/')
if public_base_url is None:
Expand All @@ -184,9 +186,11 @@ def __init__(self, bucket, access_key=None, secret_key=None,
else:
self.public_base_url = public_base_url

def get_key(self, object_type, object_id, width, height, mimetype):
key = '{0}/{1}/{2}x{3}{4}'.format(
object_type, object_id, width, height,
def get_key(self, object_type, object_id, width, height, mimetype,
created_at):
created_tag = self.created_tag(created_at) if self.unique_url else ''
key = '{0}/{1}{2}/{3}x{4}{5}'.format(
object_type, object_id, created_tag, width, height,
guess_extension(mimetype)
)
if self.prefix:
Expand Down Expand Up @@ -247,8 +251,9 @@ def upload_file(self, url, data, content_type, rrs, acl='public-read'):
break

def put_file(self, file, object_type, object_id, width, height, mimetype,
reproducible):
url = self.get_s3_url(object_type, object_id, width, height, mimetype)
reproducible, created_at):
url = self.get_s3_url(object_type, object_id, width, height, mimetype,
created_at)
self.upload_file(url, file.read(), mimetype, rrs=reproducible)

def delete_file(self, *args, **kwargs):
Expand Down Expand Up @@ -304,13 +309,15 @@ class S3SandboxStore(Store):

def __init__(self, underlying, overriding,
access_key=None, secret_key=None, max_age=DEFAULT_MAX_AGE,
underlying_prefix='', overriding_prefix=''):
underlying_prefix='', overriding_prefix='', unique_url=False):
self.underlying = S3Store(underlying,
access_key=access_key, secret_key=secret_key,
max_age=max_age, prefix=underlying_prefix)
max_age=max_age, prefix=underlying_prefix,
unique_url=unique_url)
self.overriding = S3Store(overriding,
access_key=access_key, secret_key=secret_key,
max_age=max_age, prefix=overriding_prefix)
max_age=max_age, prefix=overriding_prefix,
unique_url=unique_url)

def get_file(self, *args, **kwargs):
try:
Expand Down
5 changes: 3 additions & 2 deletions tests/migration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ def __init__(self):
self.files = {}

def put_file(self, file, object_type, object_id, width, height, mimetype,
reproducible):
reproducible, created_at):
key = object_type, object_id, width, height, mimetype
self.files[key] = file.read(), reproducible

def get_file(self, object_type, object_id, width, height, mimetype):
def get_file(self, object_type, object_id, width, height, mimetype,
created_at):
key = object_type, object_id, width, height, mimetype
return io.BytesIO(self.files[key][0])

Expand Down
11 changes: 7 additions & 4 deletions tests/store_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,26 +73,29 @@ def __init__(self):
self.log = []

def put_file(self, file, object_type, object_id, width, height, mimetype,
reproducible):
reproducible, created_at):
self.log.append(
(file.read(), object_type, object_id, width, height,
mimetype, reproducible)
)

def delete_file(self, object_type, object_id, width, height, mimetype):
def delete_file(self, object_type, object_id, width, height, mimetype,
created_at):
self.log = [
log
for log in self.log
if log[1:6] != (object_type, object_id, width, height, mimetype)
]

def get_file(self, object_type, object_id, width, height, mimetype):
def get_file(self, object_type, object_id, width, height, mimetype,
created_at):
for log in self.log:
if log[1:6] == (object_type, object_id, width, height, mimetype):
return io.BytesIO(log[0])
raise IOError()

def get_url(self, object_type, object_id, width, height, mimetype):
def get_url(self, object_type, object_id, width, height, mimetype,
created_at):
hash_ = hash((object_type, object_id, width, height, mimetype))
url = 'http://fakeurl.com/' + hex(hash_)
if object_id == self.INCLUDE_QUERY_FOR_URL:
Expand Down
10 changes: 9 additions & 1 deletion tests/stores/fs_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@

def test_fs_store(tmpdir):
fs_store = FileSystemStore(tmpdir.strpath, 'http://mock/img/')
now = utcnow()
image = TestingImage(thing_id=1234, width=405, height=640,
mimetype='image/jpeg', original=True,
created_at=utcnow())
created_at=now)
image_path = os.path.join(sample_images_dir, 'iu.jpg')
with open(image_path, 'rb') as image_file:
expected_data = image_file.read()
Expand All @@ -35,6 +36,13 @@ def test_fs_store(tmpdir):
fs_store.open(image)
tmpdir.remove()

fs_unique_store = FileSystemStore(tmpdir.strpath, 'http://mock/img/',
unique_url=True)
created_tag = now.strftime('%Y%m%d%H%M%S%f')
expected_url = 'http://mock/img/testing/234/1/1234' + created_tag + \
'.405x640.jpe'
actual_url = fs_unique_store.locate(image)
assert expected_url == re.sub(r'\?.*$', '', actual_url)

remove_query = functools.partial(re.compile(r'\?.*$').sub, '')

Expand Down
12 changes: 8 additions & 4 deletions tests/stores/s3_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,8 @@ def test_s3_sandbox_store(underlying_prefix, overriding_prefix,
with s3.open(under_image) as actual:
actual_data = actual.read()
assert expected_data == actual_data
expected_url = under.get_url('testing', under_id, 405, 640, 'image/jpeg')
expected_url = under.get_url('testing', under_id, 405, 640, 'image/jpeg',
'20150101')
actual_url = s3.locate(under_image)
assert remove_query(expected_url) == remove_query(actual_url)
# Store an image to sandbox store
Expand All @@ -159,14 +160,16 @@ def test_s3_sandbox_store(underlying_prefix, overriding_prefix,
with s3.open(image) as actual:
actual_data = actual.read()
assert expected_data == actual_data
expected_url = over.get_url('testing', over_id, 405, 640, 'image/jpeg')
expected_url = over.get_url('testing', over_id, 405, 640, 'image/jpeg',
'20150101')
actual_url = s3.locate(image)
assert remove_query(expected_url) == remove_query(actual_url)
# Image has to be physically stored into the overriding store
with over.open(image) as actual:
actual_data = actual.read()
assert expected_data == actual_data
expected_url = over.get_url('testing', over_id, 405, 640, 'image/jpeg')
expected_url = over.get_url('testing', over_id, 405, 640, 'image/jpeg',
'20150101')
actual_url = s3.locate(image)
assert remove_query(expected_url) == remove_query(actual_url)
# Images must not be physically stored into the underlying store
Expand All @@ -179,7 +182,8 @@ def test_s3_sandbox_store(underlying_prefix, overriding_prefix,
with under.open(under_image) as actual:
actual_data = actual.read()
assert expected_data == actual_data
expected_url = over.get_url('testing', under_id, 405, 640, 'image/jpeg')
expected_url = over.get_url('testing', under_id, 405, 640, 'image/jpeg',
'20150101')
actual_url = s3.locate(under_image)
assert remove_query(expected_url) == remove_query(actual_url)
# Clean up fixtures
Expand Down