From 5409d99ca294a593f208de415c6d5a4b40b5a161 Mon Sep 17 00:00:00 2001 From: Richard Eames Date: Fri, 27 Jun 2014 11:34:00 -0600 Subject: [PATCH 1/3] Added basic implementation of key/bucket versioning --- AUTHORS.md | 1 + moto/s3/models.py | 41 ++++++++++++++++++++++++++++++++--- moto/s3/responses.py | 47 +++++++++++++++++++++++++++++++--------- tests/test_s3/test_s3.py | 35 ++++++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 13 deletions(-) diff --git a/AUTHORS.md b/AUTHORS.md index 90ed0b35..c1bb7ef7 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -20,3 +20,4 @@ Moto is written by Steve Pulec with contributions from: * [Chris St. Pierre](https://github.com/stpierre) * [Frank Mata](https://github.com/matafc) * [Clint Ecker](https://github.com/clintecker) +* [Richard Eames](https://github.com/Naddiseo) diff --git a/moto/s3/models.py b/moto/s3/models.py index 07aaee51..9cf0a384 100644 --- a/moto/s3/models.py +++ b/moto/s3/models.py @@ -14,7 +14,8 @@ UPLOAD_PART_MIN_SIZE = 5242880 class FakeKey(object): - def __init__(self, name, value, storage="STANDARD", etag=None): + + def __init__(self, name, value, storage="STANDARD", etag=None, is_versioned=False, version_id=0): self.name = name self.value = value self.last_modified = datetime.datetime.now() @@ -22,6 +23,8 @@ class FakeKey(object): self._metadata = {} self._expiry = None self._etag = etag + self._version_id = version_id + self._is_versioned = is_versioned def copy(self, new_name=None): r = copy.deepcopy(self) @@ -42,6 +45,10 @@ class FakeKey(object): self.value += value self.last_modified = datetime.datetime.now() self._etag = None # must recalculate etag + if self._is_versioned: + self._version_id += 1 + else: + self._is_versioned = 0 def restore(self, days): self._expiry = datetime.datetime.now() + datetime.timedelta(days) @@ -79,6 +86,10 @@ class FakeKey(object): if self._expiry is not None: rhdr = 'ongoing-request="false", expiry-date="{0}"' r['x-amz-restore'] = rhdr.format(self.expiry_date) + + if self._is_versioned: + r['x-amz-version-id'] = self._version_id + return r @property @@ -137,10 +148,16 @@ class FakeMultipart(object): class FakeBucket(object): + def __init__(self, name): self.name = name self.keys = {} self.multiparts = {} + self.versioning_status = None + + @property + def is_versioned(self): + return self.versioning_status == 'Enabled' class S3Backend(BaseBackend): @@ -171,12 +188,30 @@ class S3Backend(BaseBackend): return self.buckets.pop(bucket_name) return None + def set_bucket_versioning(self, bucket_name, status): + self.buckets[bucket_name].versioning_status = status + + def get_bucket_versioning(self, bucket_name): + return self.buckets[bucket_name].versioning_status + def set_key(self, bucket_name, key_name, value, storage=None, etag=None): key_name = clean_key_name(key_name) bucket = self.buckets[bucket_name] - new_key = FakeKey(name=key_name, value=value, - storage=storage, etag=etag) + + old_key = bucket.keys.get(key_name, None) + if old_key is not None and bucket.is_versioned: + new_version_id = old_key._version_id + 1 + else: + new_version_id = 0 + + new_key = FakeKey( + name=key_name, + value=value, + storage=storage, + etag=etag, + is_versioned=bucket.is_versioned, + version_id=new_version_id) bucket.keys[key_name] = new_key return new_key diff --git a/moto/s3/responses.py b/moto/s3/responses.py index f180a97a..833c25db 100644 --- a/moto/s3/responses.py +++ b/moto/s3/responses.py @@ -48,7 +48,7 @@ class ResponseObject(object): elif method == 'GET': return self._bucket_response_get(bucket_name, querystring, headers) elif method == 'PUT': - return self._bucket_response_put(bucket_name, headers) + return self._bucket_response_put(request, bucket_name, querystring, headers) elif method == 'DELETE': return self._bucket_response_delete(bucket_name, headers) elif method == 'POST': @@ -73,14 +73,17 @@ class ResponseObject(object): return 200, headers, template.render( bucket_name=bucket_name, uploads=multiparts) - + elif 'versioning' in querystring: + versioning = self.backend.get_bucket_versioning(bucket_name) + template = Template(S3_BUCKET_GET_VERSIONING) + return 200, headers, template.render(status=versioning) bucket = self.backend.get_bucket(bucket_name) if bucket: prefix = querystring.get('prefix', [None])[0] delimiter = querystring.get('delimiter', [None])[0] result_keys, result_folders = self.backend.prefix_query(bucket, prefix, delimiter) template = Template(S3_BUCKET_GET_RESPONSE) - return template.render( + return 200, headers, template.render( bucket=bucket, prefix=prefix, delimiter=delimiter, @@ -90,13 +93,22 @@ class ResponseObject(object): else: return 404, headers, "" - def _bucket_response_put(self, bucket_name, headers): - try: - new_bucket = self.backend.create_bucket(bucket_name) - except BucketAlreadyExists: - return 409, headers, "" - template = Template(S3_BUCKET_CREATE_RESPONSE) - return template.render(bucket=new_bucket) + def _bucket_response_put(self, request, bucket_name, querystring, headers): + if 'versioning' in querystring: + ver = re.search('([A-Za-z]+)', request.body) + if ver: + self.backend.set_bucket_versioning(bucket_name, ver.group(1)) + template = Template(S3_BUCKET_VERSIONING) + return template.render(bucket_versioning_status=ver.group(1)) + else: + return 404, headers, "" + else: + try: + new_bucket = self.backend.create_bucket(bucket_name) + except BucketAlreadyExists: + return 409, headers, "" + template = Template(S3_BUCKET_CREATE_RESPONSE) + return 200, headers, template.render(bucket=new_bucket) def _bucket_response_delete(self, bucket_name, headers): removed_bucket = self.backend.delete_bucket(bucket_name) @@ -411,6 +423,21 @@ S3_DELETE_BUCKET_WITH_ITEMS_ERROR = """ sdfgdsfgdsfgdfsdsfgdfs """ +S3_BUCKET_VERSIONING = """ + + {{ bucket_versioning_status }} + +""" + +S3_BUCKET_GET_VERSIONING = """ +{% if status is none %} + +{% else %} + + {{ status }} + +{% endif %} +""" S3_DELETE_KEYS_RESPONSE = """ {% for k in deleted %} diff --git a/tests/test_s3/test_s3.py b/tests/test_s3/test_s3.py index 72066bcf..5a656147 100644 --- a/tests/test_s3/test_s3.py +++ b/tests/test_s3/test_s3.py @@ -508,3 +508,38 @@ def test_restore_key_headers(): key.ongoing_restore.should_not.be.none key.ongoing_restore.should.be.false key.expiry_date.should.equal("Mon, 02 Jan 2012 12:00:00 GMT") + + +@mock_s3 +def test_get_versioning_status(): + conn = boto.connect_s3('the_key', 'the_secret') + bucket = conn.create_bucket('foobar') + d = bucket.get_versioning_status() + d.should.be.empty + + bucket.configure_versioning(versioning=True) + d = bucket.get_versioning_status() + d.shouldnt.be.empty + d.should.have.key('Versioning').being.equal('Enabled') + + bucket.configure_versioning(versioning=False) + d = bucket.get_versioning_status() + d.should.have.key('Versioning').being.equal('Suspended') + + +@mock_s3 +def test_key_version(): + conn = boto.connect_s3('the_key', 'the_secret') + bucket = conn.create_bucket('foobar') + bucket.configure_versioning(versioning=True) + + key = Key(bucket) + key.key = 'the-key' + key.version_id.should.be.none + key.set_contents_from_string('some string') + key.version_id.should.equal('0') + key.set_contents_from_string('some string') + key.version_id.should.equal('1') + + key = bucket.get_key('the-key') + key.version_id.should.equal('1') From e972000bb4aa9aee2bffdc0c5d27bef2ed6a0aab Mon Sep 17 00:00:00 2001 From: Richard Eames Date: Fri, 27 Jun 2014 15:37:51 -0600 Subject: [PATCH 2/3] Keep track of previous versions of keys --- moto/s3/models.py | 4 +- moto/s3/utils.py | 71 ++++++++++++++++++++++++++++++++++ tests/test_s3/test_s3_utils.py | 39 ++++++++++++++++++- 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/moto/s3/models.py b/moto/s3/models.py index 9cf0a384..921f92d7 100644 --- a/moto/s3/models.py +++ b/moto/s3/models.py @@ -7,7 +7,7 @@ import copy from moto.core import BaseBackend from moto.core.utils import iso_8601_datetime, rfc_1123_datetime from .exceptions import BucketAlreadyExists -from .utils import clean_key_name +from .utils import clean_key_name, _VersionedKeyStore UPLOAD_ID_BYTES = 43 UPLOAD_PART_MIN_SIZE = 5242880 @@ -151,7 +151,7 @@ class FakeBucket(object): def __init__(self, name): self.name = name - self.keys = {} + self.keys = _VersionedKeyStore() self.multiparts = {} self.versioning_status = None diff --git a/moto/s3/utils.py b/moto/s3/utils.py index 19b0cfdf..0fecfb2c 100644 --- a/moto/s3/utils.py +++ b/moto/s3/utils.py @@ -1,4 +1,5 @@ import re +import sys import urllib2 import urlparse @@ -25,3 +26,73 @@ def bucket_name_from_url(url): def clean_key_name(key_name): return urllib2.unquote(key_name) + + +class _VersionedKeyStore(dict): + + """ A simplified/modified version of Django's `MultiValueDict` taken from: + https://github.com/django/django/blob/70576740b0bb5289873f5a9a9a4e1a26b2c330e5/django/utils/datastructures.py#L282 + """ + + def __sgetitem__(self, key): + return super(_VersionedKeyStore, self).__getitem__(key) + + def __getitem__(self, key): + return self.__sgetitem__(key)[-1] + + def __setitem__(self, key, value): + try: + current = self.__sgetitem__(key) + current.append(value) + except (KeyError, IndexError): + current = [value] + + super(_VersionedKeyStore, self).__setitem__(key, current) + + def get(self, key, default=None): + try: + return self[key] + except (KeyError, IndexError): + pass + return default + + def getlist(self, key, default=None): + try: + return self.__sgetitem__(key) + except (KeyError, IndexError): + pass + return default + + def setlist(self, key, list_): + if isinstance(list_, tuple): + list_ = list(list_) + elif not isinstance(list_, list): + list_ = [list_] + + super(_VersionedKeyStore, self).__setitem__(key, list_) + + def _iteritems(self): + for key in self: + yield key, self[key] + + def _itervalues(self): + for key in self: + yield self[key] + + def _iterlists(self): + for key in self: + yield key, self.getlist(key) + + items = iteritems = _iteritems + lists = iterlists = _iterlists + values = itervalues = _itervalues + + if sys.version_info[0] < 3: + def items(self): + return list(self.iteritems()) + + def values(self): + return list(self.itervalues()) + + def lists(self): + return list(self.iterlists()) diff --git a/tests/test_s3/test_s3_utils.py b/tests/test_s3/test_s3_utils.py index cb8bd8b8..d2eee840 100644 --- a/tests/test_s3/test_s3_utils.py +++ b/tests/test_s3/test_s3_utils.py @@ -1,5 +1,5 @@ from sure import expect -from moto.s3.utils import bucket_name_from_url +from moto.s3.utils import bucket_name_from_url, _VersionedKeyStore def test_base_url(): @@ -12,3 +12,40 @@ def test_localhost_bucket(): def test_localhost_without_bucket(): expect(bucket_name_from_url('https://www.localhost:5000/def')).should.equal(None) + +def test_versioned_key_store(): + d = _VersionedKeyStore() + + d.should.have.length_of(0) + + d['key'] = [1] + + d.should.have.length_of(1) + + d['key'] = 2 + d.should.have.length_of(1) + + d.should.have.key('key').being.equal(2) + + d.get.when.called_with('key').should.return_value(2) + d.get.when.called_with('badkey').should.return_value(None) + d.get.when.called_with('badkey', 'HELLO').should.return_value('HELLO') + + # Tests key[ + d.shouldnt.have.key('badkey') + d.__getitem__.when.called_with('badkey').should.throw(KeyError) + + d.getlist('key').should.have.length_of(2) + d.getlist('key').should.be.equal([[1], 2]) + d.getlist('badkey').should.be.none + + d.setlist('key', 1) + d.getlist('key').should.be.equal([1]) + + d.setlist('key', (1, 2)) + d.getlist('key').shouldnt.be.equal((1, 2)) + d.getlist('key').should.be.equal([1, 2]) + + d.setlist('key', [[1], [2]]) + d['key'].should.have.length_of(1) + d.getlist('key').should.be.equal([[1], [2]]) From 4cc45c3ac5cefc464e8c0a762c6a68e1c21ef8e1 Mon Sep 17 00:00:00 2001 From: Richard Eames Date: Fri, 27 Jun 2014 16:21:32 -0600 Subject: [PATCH 3/3] Implementation of bucket.list_versions --- moto/s3/models.py | 22 +++++++++++++-- moto/s3/responses.py | 59 +++++++++++++++++++++++++++++++++++++++- tests/test_s3/test_s3.py | 26 ++++++++++++++++++ 3 files changed, 104 insertions(+), 3 deletions(-) diff --git a/moto/s3/models.py b/moto/s3/models.py index 921f92d7..67b40d5e 100644 --- a/moto/s3/models.py +++ b/moto/s3/models.py @@ -3,6 +3,7 @@ import base64 import datetime import hashlib import copy +import itertools from moto.core import BaseBackend from moto.core.utils import iso_8601_datetime, rfc_1123_datetime @@ -194,6 +195,18 @@ class S3Backend(BaseBackend): def get_bucket_versioning(self, bucket_name): return self.buckets[bucket_name].versioning_status + def get_bucket_versions(self, bucket_name, delimiter=None, + encoding_type=None, + key_marker=None, + max_keys=None, + version_id_marker=None): + bucket = self.buckets[bucket_name] + + if any((delimiter, encoding_type, key_marker, version_id_marker)): + raise NotImplementedError( + "Called get_bucket_versions with some of delimiter, encoding_type, key_marker, version_id_marker") + + return itertools.chain(*(l for _, l in bucket.keys.iterlists())) def set_key(self, bucket_name, key_name, value, storage=None, etag=None): key_name = clean_key_name(key_name) @@ -223,11 +236,16 @@ class S3Backend(BaseBackend): key.append_to_value(value) return key - def get_key(self, bucket_name, key_name): + def get_key(self, bucket_name, key_name, version_id=None): key_name = clean_key_name(key_name) bucket = self.get_bucket(bucket_name) if bucket: - return bucket.keys.get(key_name) + if version_id is None: + return bucket.keys.get(key_name) + else: + for key in bucket.keys.getlist(key_name): + if str(key._version_id) == str(version_id): + return key def initiate_multipart(self, bucket_name, key_name): bucket = self.buckets[bucket_name] diff --git a/moto/s3/responses.py b/moto/s3/responses.py index 833c25db..9e657430 100644 --- a/moto/s3/responses.py +++ b/moto/s3/responses.py @@ -77,6 +77,33 @@ class ResponseObject(object): versioning = self.backend.get_bucket_versioning(bucket_name) template = Template(S3_BUCKET_GET_VERSIONING) return 200, headers, template.render(status=versioning) + elif 'versions' in querystring: + delimiter = querystring.get('delimiter', [None])[0] + encoding_type = querystring.get('encoding-type', [None])[0] + key_marker = querystring.get('key-marker', [None])[0] + max_keys = querystring.get('max-keys', [None])[0] + prefix = querystring.get('prefix', [None])[0] + version_id_marker = querystring.get('version-id-marker', [None])[0] + + bucket = self.backend.get_bucket(bucket_name) + versions = self.backend.get_bucket_versions( + bucket_name, + delimiter=delimiter, + encoding_type=encoding_type, + key_marker=key_marker, + max_keys=max_keys, + version_id_marker=version_id_marker + ) + template = Template(S3_BUCKET_GET_VERSIONS) + return 200, headers, template.render( + key_list=versions, + bucket=bucket, + prefix='', + max_keys='', + delimiter='', + is_truncated='false', + ) + bucket = self.backend.get_bucket(bucket_name) if bucket: prefix = querystring.get('prefix', [None])[0] @@ -236,7 +263,9 @@ class ResponseObject(object): count=len(parts), parts=parts ) - key = self.backend.get_key(bucket_name, key_name) + version_id = query.get('versionId', [None])[0] + key = self.backend.get_key( + bucket_name, key_name, version_id=version_id) if key: headers.update(key.metadata) return 200, headers, key.value @@ -424,12 +453,14 @@ S3_DELETE_BUCKET_WITH_ITEMS_ERROR = """ """ S3_BUCKET_VERSIONING = """ + {{ bucket_versioning_status }} """ S3_BUCKET_GET_VERSIONING = """ + {% if status is none %} {% else %} @@ -438,6 +469,32 @@ S3_BUCKET_GET_VERSIONING = """ {% endif %} """ + +S3_BUCKET_GET_VERSIONS = """ + + {{ bucket.name }} + {{ prefix }} + {{ key_marker }} + {{ max_keys }} + {{ is_truncated }} + {% for key in key_list %} + + {{ key.name }} + {{ key._version_id }} + false + {{ key.last_modified_ISO8601 }} + {{ key.etag }} + {{ key.size }} + {{ key.storage_class }} + + 75aa57f09aa0c8caeab4f8c24e99d10f8e7faeebf76c078efc7c6caea54ba06a + webfile + + + {% endfor %} + +""" + S3_DELETE_KEYS_RESPONSE = """ {% for k in deleted %} diff --git a/tests/test_s3/test_s3.py b/tests/test_s3/test_s3.py index 5a656147..8102f33c 100644 --- a/tests/test_s3/test_s3.py +++ b/tests/test_s3/test_s3.py @@ -543,3 +543,29 @@ def test_key_version(): key = bucket.get_key('the-key') key.version_id.should.equal('1') + + +@mock_s3 +def test_list_versions(): + conn = boto.connect_s3('the_key', 'the_secret') + bucket = conn.create_bucket('foobar') + bucket.configure_versioning(versioning=True) + + key = Key(bucket, 'the-key') + key.version_id.should.be.none + key.set_contents_from_string("Version 1") + key.version_id.should.equal('0') + key.set_contents_from_string("Version 2") + key.version_id.should.equal('1') + + versions = list(bucket.list_versions()) + + versions.should.have.length_of(2) + + versions[0].name.should.equal('the-key') + versions[0].version_id.should.equal('0') + versions[0].get_contents_as_string().should.equal("Version 1") + + versions[1].name.should.equal('the-key') + versions[1].version_id.should.equal('1') + versions[1].get_contents_as_string().should.equal("Version 2")