diff --git a/cubicweb_s3storage/__pkginfo__.py b/cubicweb_s3storage/__pkginfo__.py index 171278ab8a0ae067a228653d0acf2e0ef5e7eece_Y3ViaWN3ZWJfczNzdG9yYWdlL19fcGtnaW5mb19fLnB5..7b94c60170ee823ab83636780b11a2fbfdfd838b_Y3ViaWN3ZWJfczNzdG9yYWdlL19fcGtnaW5mb19fLnB5 100644 --- a/cubicweb_s3storage/__pkginfo__.py +++ b/cubicweb_s3storage/__pkginfo__.py @@ -14,7 +14,11 @@ description = 'A Cubicweb Storage that stores the data on S3' web = 'http://www.cubicweb.org/project/%s' % distname -__depends__ = {'cubicweb': '>= 3.24.9', 'six': '>= 1.4.0'} +__depends__ = { + 'cubicweb': '>= 3.24.9', + 'six': '>= 1.4.0', + 'boto3': None, +} __recommends__ = {} classifiers = [ diff --git a/cubicweb_s3storage/site_cubicweb.py b/cubicweb_s3storage/site_cubicweb.py new file mode 100644 index 0000000000000000000000000000000000000000..7b94c60170ee823ab83636780b11a2fbfdfd838b_Y3ViaWN3ZWJfczNzdG9yYWdlL3NpdGVfY3ViaWN3ZWIucHk= --- /dev/null +++ b/cubicweb_s3storage/site_cubicweb.py @@ -0,0 +1,30 @@ +from rql.utils import register_function +from logilab.database import FunctionDescr + +from cubicweb import Binary + + +class STKEY(FunctionDescr): + """return the S3 key of the bytes attribute stored using the S3 Storage (s3s) + """ + rtype = 'Bytes' + + def update_cb_stack(self, stack): + assert len(stack) == 1 + stack[0] = self.source_execute + + def as_sql(self, backend, args): + raise NotImplementedError( + 'This callback is only available for S3Storage ' + 'managed attribute. Is STKEY() argument S3S managed?') + + def source_execute(self, source, session, value): + s3key = source.binary_to_str(value) + try: + return Binary(s3key) + except OSError as ex: + source.critical("can't read %s: %s", s3key, ex) + return None + + +register_function(STKEY) diff --git a/cubicweb_s3storage/storages.py b/cubicweb_s3storage/storages.py new file mode 100644 index 0000000000000000000000000000000000000000..7b94c60170ee823ab83636780b11a2fbfdfd838b_Y3ViaWN3ZWJfczNzdG9yYWdlL3N0b3JhZ2VzLnB5 --- /dev/null +++ b/cubicweb_s3storage/storages.py @@ -0,0 +1,101 @@ +# copyright 2018 LOGILAB S.A. (Paris, FRANCE), all rights reserved. +# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr +# +# This file is part of CubicWeb. +# +# CubicWeb is free software: you can redistribute it and/or modify it under the +# terms of the GNU Lesser General Public License as published by the Free +# Software Foundation, either version 2.1 of the License, or (at your option) +# any later version. +# +# CubicWeb is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with CubicWeb. If not, see <http://www.gnu.org/licenses/>. + +"""custom storages for S3""" + +import uuid +from logging import getLogger + +from six import PY3 +import boto3 + +from cubicweb import Binary, set_log_methods +from cubicweb.server.sources.storages import Storage + + +class S3Storage(Storage): + is_source_callback = True + + def __init__(self, bucket): + self.s3cnx = boto3.client('s3') + self.bucket = bucket + + def callback(self, source, cnx, value): + """see docstring for prototype, which vary according to is_source_callback + """ + key = source.binary_to_str(value).decode() + try: + data = Binary() + self.s3cnx.download_fileobj(self.bucket, key, data) + return data + except Exception as ex: + source.critical("can't retrive S3 object %s: %s", value, ex) + return None + + def entity_added(self, entity, attr): + """an entity using this storage for attr has been added""" + binary = entity.cw_edited.pop(attr) + if binary is not None: + key = self.get_s3_key(entity, attr) + # bytes storage used to store S3's object key + binary_obj = Binary(key.encode()) + entity.cw_edited.edited_attribute(attr, binary_obj) + self.debug('Upload object to S3') + self.s3cnx.upload_fileobj(binary, self.bucket, key) + self.info('Uploaded object %s.%s to S3', entity.eid, attr) + return binary + + def entity_updated(self, entity, attr): + """an entity using this storage for attr has been updatded""" + return self.entity_added(entity, attr) + + def entity_deleted(self, entity, attr): + """an entity using this storage for attr has been deleted""" + raise NotImplementedError() + + def migrate_entity(self, entity, attribute): + """migrate an entity attribute to the storage""" + raise NotImplementedError() + + def get_s3_key(self, entity, attr): + """Return the S3 key of the S3 object storing the content of attribute attr of + the entity. + + If the given entity has key yet (eg. at entity creation time), a new + key is generated. + + """ + rset = entity._cw.execute( + 'Any stkey(D) WHERE X eid %s, X %s D' % + (entity.eid, attr)) + if rset and rset.rows[0][0]: + key = rset.rows[0][0].getvalue() + if PY3: + key = key.decode() + return key + return self.new_s3_key(entity, attr) + + def new_s3_key(self, entity, attr): + """Generate a new key for given entity attr. + + This implemenation just return a random UUID""" + return str(uuid.uuid1()) + + +set_log_methods(S3Storage, + getLogger('cube.s3storage.storages.s3storage')) diff --git a/test/data/bootstrap_cubes b/test/data/bootstrap_cubes index 171278ab8a0ae067a228653d0acf2e0ef5e7eece_dGVzdC9kYXRhL2Jvb3RzdHJhcF9jdWJlcw==..7b94c60170ee823ab83636780b11a2fbfdfd838b_dGVzdC9kYXRhL2Jvb3RzdHJhcF9jdWJlcw== 100644 --- a/test/data/bootstrap_cubes +++ b/test/data/bootstrap_cubes @@ -1,1 +1,1 @@ -s3storage +s3storage, file diff --git a/test/test_s3storage.py b/test/test_s3storage.py index 171278ab8a0ae067a228653d0acf2e0ef5e7eece_dGVzdC90ZXN0X3Mzc3RvcmFnZS5weQ==..7b94c60170ee823ab83636780b11a2fbfdfd838b_dGVzdC90ZXN0X3Mzc3RvcmFnZS5weQ== 100644 --- a/test/test_s3storage.py +++ b/test/test_s3storage.py @@ -1,16 +1,5 @@ -# copyright 2018 LOGILAB S.A. (Paris, FRANCE), all rights reserved. -# contact http://www.logilab.fr -- mailto:contact@logilab.fr -# -# This program is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the Free -# Software Foundation, either version 2.1 of the License, or (at your option) -# any later version. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more -# details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. +import io +import boto3 +from moto import mock_s3 +from six import PY3 @@ -16,4 +5,8 @@ -"""cubicweb-s3storage automatic tests +from cubicweb.server.sources import storages +from cubicweb.devtools.testlib import CubicWebTC +from cubicweb import Binary + +from cubicweb_s3storage.storages import S3Storage @@ -18,4 +11,5 @@ -uncomment code below if you want to activate automatic test for your cube: +class S3StorageTC(CubicWebTC): + bucket = 'test-bucket' @@ -21,3 +15,14 @@ -.. sourcecode:: python + def setup_database(self): + mock = mock_s3() + mock.start() + s3_cnx = boto3.client('s3') + s3_cnx.create_bucket( + Bucket=self.bucket, + CreateBucketConfiguration={'LocationConstraint': 'eu-west-1'}) + s3_storage = S3Storage(self.bucket) + storages.set_attribute_storage(self.repo, 'File', 'data', s3_storage) + self.s3_storage = s3_storage + self.s3_cnx = s3_cnx + self.s3_mock = mock @@ -23,3 +28,12 @@ - from cubicweb.devtools.testlib import AutomaticWebTest + def tearDown(self): + super(S3StorageTC, self).tearDown() + storages.unset_attribute_storage(self.repo, 'File', 'data') + del self.s3_storage + self.s3_mock.stop() + + def create_file(self, cnx, content=b'the-data'): + return cnx.create_entity('File', data=Binary(content), + data_format=u'text/plain', + data_name=u'foo.pdf') @@ -25,6 +39,12 @@ - class AutomaticWebTest(AutomaticWebTest): - '''provides `to_test_etypes` and/or `list_startup_views` implementation - to limit test scope - ''' + def test_s3key_gen(self): + with self.admin_access.client_cnx() as cnx: + fobj = self.create_file(cnx, b'some content') + cnx.commit() + eid = fobj.eid + k1 = self.s3_storage.get_s3_key(fobj, 'data') + with self.admin_access.client_cnx() as cnx: + fobj = cnx.find('File', eid=eid).one() + k2 = self.s3_storage.get_s3_key(fobj, 'data') + self.assertEqual(k1, k2) @@ -30,5 +50,19 @@ - def to_test_etypes(self): - '''only test views for entities of the returned types''' - return set(('My', 'Cube', 'Entity', 'Types')) + def test_file_create(self): + with self.admin_access.client_cnx() as cnx: + eid = self.create_file(cnx, b'some content').eid + cnx.commit() + + key = cnx.execute('Any STKEY(D) WHERE F is File, F data D, ' + 'F eid %(eid)s', {'eid': eid}).rows[0][0] + key = key.getvalue().decode() + + data = io.BytesIO() + self.s3_cnx.download_fileobj(self.bucket, key, data) + self.assertEqual(data.getvalue(), b'some content') + + def test_file_modify(self): + with self.admin_access.client_cnx() as cnx: + eid = self.create_file(cnx, b'some content').eid + cnx.commit() @@ -34,6 +68,13 @@ - def list_startup_views(self): - '''only test startup views of the returned identifiers''' - return ('some', 'startup', 'views') -""" + key = cnx.execute('Any STKEY(D) WHERE F is File, F data D, ' + 'F eid %(eid)s', {'eid': eid}).rows[0][0] + key = key.getvalue().decode() + with self.admin_access.client_cnx() as cnx: + fobj = cnx.find('File', eid=eid).one() + fobj.cw_set(data=Binary(b'something else')) + cnx.commit() + + data = io.BytesIO() + self.s3_cnx.download_fileobj(self.bucket, key, data) + self.assertEqual(data.getvalue(), b'something else') @@ -39,4 +80,9 @@ -from cubicweb.devtools import testlib - + def test_file_retrieve(self): + binstuff = ''.join(chr(x) for x in range(256)) + if PY3: + binstuff = binstuff.encode() + with self.admin_access.client_cnx() as cnx: + eid = self.create_file(cnx, binstuff).eid + cnx.commit() @@ -42,7 +88,10 @@ -class DefaultTC(testlib.CubicWebTC): - def test_something(self): - self.skipTest('this cube has no test') + with self.admin_access.client_cnx() as cnx: + rset = cnx.execute('Any D WHERE F eid %(eid)s, F data D', + {'eid': eid}) + self.assertTrue(rset) + data = rset.rows[0][0] + self.assertEqual(data.getvalue(), binstuff) if __name__ == '__main__': diff --git a/tox.ini b/tox.ini index 171278ab8a0ae067a228653d0acf2e0ef5e7eece_dG94LmluaQ==..7b94c60170ee823ab83636780b11a2fbfdfd838b_dG94LmluaQ== 100644 --- a/tox.ini +++ b/tox.ini @@ -1,4 +1,4 @@ [tox] -envlist = py27,py34,flake8 +envlist = py27,py3,flake8 [testenv] @@ -3,5 +3,4 @@ [testenv] -sitepackages = true deps = pytest @@ -6,5 +5,7 @@ deps = pytest + moto + cubicweb-file commands = {envpython} -m pytest {posargs:test}