diff --git a/sqlalchemy_imageattach/context.py b/sqlalchemy_imageattach/context.py index 5b656e0..acb8ba7 100644 --- a/sqlalchemy_imageattach/context.py +++ b/sqlalchemy_imageattach/context.py @@ -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): diff --git a/sqlalchemy_imageattach/store.py b/sqlalchemy_imageattach/store.py index 6e02565..8ebb449 100644 --- a/sqlalchemy_imageattach/store.py +++ b/sqlalchemy_imageattach/store.py @@ -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 @@ -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:: @@ -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. @@ -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 @@ -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 @@ -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 @@ -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` @@ -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``. @@ -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``. @@ -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( @@ -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: diff --git a/sqlalchemy_imageattach/stores/fs.py b/sqlalchemy_imageattach/stores/fs.py index 5b6178f..6ce630c 100644 --- a/sqlalchemy_imageattach/stores/fs.py +++ b/sqlalchemy_imageattach/stores/fs.py @@ -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): @@ -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 @@ -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('/'): diff --git a/sqlalchemy_imageattach/stores/s3.py b/sqlalchemy_imageattach/stores/s3.py index be529d4..cdbbbc6 100644 --- a/sqlalchemy_imageattach/stores/s3.py +++ b/sqlalchemy_imageattach/stores/s3.py @@ -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: @@ -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: @@ -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): @@ -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: diff --git a/tests/migration_test.py b/tests/migration_test.py index 3339b9c..ba51f98 100644 --- a/tests/migration_test.py +++ b/tests/migration_test.py @@ -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]) diff --git a/tests/store_test.py b/tests/store_test.py index 69f3033..5c00f62 100644 --- a/tests/store_test.py +++ b/tests/store_test.py @@ -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: diff --git a/tests/stores/fs_test.py b/tests/stores/fs_test.py index babbc00..2609e1f 100644 --- a/tests/stores/fs_test.py +++ b/tests/stores/fs_test.py @@ -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() @@ -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, '') diff --git a/tests/stores/s3_test.py b/tests/stores/s3_test.py index b11c903..a769c19 100644 --- a/tests/stores/s3_test.py +++ b/tests/stores/s3_test.py @@ -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 @@ -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 @@ -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