diff --git a/moto/s3/models.py b/moto/s3/models.py
index b824c4dbf..c1a4fb04d 100644
--- a/moto/s3/models.py
+++ b/moto/s3/models.py
@@ -11,7 +11,7 @@ import six
from bisect import insort
from moto.core import BaseBackend, BaseModel
from moto.core.utils import iso_8601_datetime_with_milliseconds, rfc_1123_datetime
-from .exceptions import BucketAlreadyExists, MissingBucket, InvalidPart, EntityTooSmall
+from .exceptions import BucketAlreadyExists, MissingBucket, InvalidPart, EntityTooSmall, MissingKey
from .utils import clean_key_name, _VersionedKeyStore
UPLOAD_ID_BYTES = 43
@@ -43,6 +43,7 @@ class FakeKey(BaseModel):
self._etag = etag
self._version_id = version_id
self._is_versioned = is_versioned
+ self._tagging = FakeTagging()
@property
def version_id(self):
@@ -59,6 +60,9 @@ class FakeKey(BaseModel):
self._metadata = {}
self._metadata.update(metadata)
+ def set_tagging(self, tagging):
+ self._tagging = tagging
+
def set_storage_class(self, storage_class):
self._storage_class = storage_class
@@ -103,6 +107,10 @@ class FakeKey(BaseModel):
def metadata(self):
return self._metadata
+ @property
+ def tagging(self):
+ return self._tagging
+
@property
def response_dict(self):
res = {
@@ -253,6 +261,25 @@ def get_canned_acl(acl):
return FakeAcl(grants=grants)
+class FakeTagging(BaseModel):
+
+ def __init__(self, tag_set=None):
+ self.tag_set = tag_set or FakeTagSet()
+
+
+class FakeTagSet(BaseModel):
+
+ def __init__(self, tags=None):
+ self.tags = tags or []
+
+
+class FakeTag(BaseModel):
+
+ def __init__(self, key, value=None):
+ self.key = key
+ self.value = value
+
+
class LifecycleRule(BaseModel):
def __init__(self, id=None, prefix=None, status=None, expiration_days=None,
@@ -475,6 +502,13 @@ class S3Backend(BaseBackend):
else:
return None
+ def set_key_tagging(self, bucket_name, key_name, tagging):
+ key = self.get_key(bucket_name, key_name)
+ if key is None:
+ raise MissingKey(key_name)
+ key.set_tagging(tagging)
+ return key
+
def initiate_multipart(self, bucket_name, key_name, metadata):
bucket = self.get_bucket(bucket_name)
new_multipart = FakeMultipart(key_name, metadata)
diff --git a/moto/s3/responses.py b/moto/s3/responses.py
index 3b349d864..a1d5757c8 100644
--- a/moto/s3/responses.py
+++ b/moto/s3/responses.py
@@ -14,7 +14,7 @@ from moto.s3bucket_path.utils import bucket_name_from_url as bucketpath_bucket_n
from .exceptions import BucketAlreadyExists, S3ClientError, MissingKey, InvalidPartOrder
-from .models import s3_backend, get_canned_acl, FakeGrantee, FakeGrant, FakeAcl, FakeKey
+from .models import s3_backend, get_canned_acl, FakeGrantee, FakeGrant, FakeAcl, FakeKey, FakeTagging, FakeTagSet, FakeTag
from .utils import bucket_name_from_url, metadata_from_headers
from xml.dom import minidom
@@ -520,6 +520,9 @@ class ResponseObject(_TemplateEnvironmentMixin):
if 'acl' in query:
template = self.response_template(S3_OBJECT_ACL_RESPONSE)
return 200, response_headers, template.render(obj=key)
+ if 'tagging' in query:
+ template = self.response_template(S3_OBJECT_TAGGING_RESPONSE)
+ return 200, response_headers, template.render(obj=key)
response_headers.update(key.metadata)
response_headers.update(key.response_dict)
@@ -556,6 +559,7 @@ class ResponseObject(_TemplateEnvironmentMixin):
storage_class = request.headers.get('x-amz-storage-class', 'STANDARD')
acl = self._acl_from_headers(request.headers)
+ tagging = self._tagging_from_headers(request.headers)
if 'acl' in query:
key = self.backend.get_key(bucket_name, key_name)
@@ -563,6 +567,11 @@ class ResponseObject(_TemplateEnvironmentMixin):
key.set_acl(acl)
return 200, response_headers, ""
+ if 'tagging' in query:
+ tagging = self._tagging_from_xml(body)
+ self.backend.set_key_tagging(bucket_name, key_name, tagging)
+ return 200, response_headers, ""
+
if 'x-amz-copy-source' in request.headers:
# Copy key
src_key_parsed = urlparse(request.headers.get("x-amz-copy-source"))
@@ -596,6 +605,7 @@ class ResponseObject(_TemplateEnvironmentMixin):
new_key.set_metadata(metadata)
new_key.set_acl(acl)
new_key.website_redirect_location = request.headers.get('x-amz-website-redirect-location')
+ new_key.set_tagging(tagging)
template = self.response_template(S3_OBJECT_RESPONSE)
response_headers.update(new_key.response_dict)
@@ -655,6 +665,30 @@ class ResponseObject(_TemplateEnvironmentMixin):
else:
return None
+ def _tagging_from_headers(self, headers):
+ if headers.get('x-amz-tagging'):
+ parsed_header = parse_qs(headers['x-amz-tagging'], keep_blank_values=True)
+ tags = []
+ for tag in parsed_header.items():
+ tags.append(FakeTag(tag[0], tag[1][0]))
+
+ tag_set = FakeTagSet(tags)
+ tagging = FakeTagging(tag_set)
+ return tagging
+ else:
+ return FakeTagging()
+
+ def _tagging_from_xml(self, xml):
+ parsed_xml = xmltodict.parse(xml)
+
+ tags = []
+ for tag in parsed_xml['Tagging']['TagSet']['Tag']:
+ tags.append(FakeTag(tag['Key'], tag['Value']))
+
+ tag_set = FakeTagSet(tags)
+ tagging = FakeTagging(tag_set)
+ return tagging
+
def _key_response_delete(self, bucket_name, query, key_name, headers):
if query.get('uploadId'):
upload_id = query['uploadId'][0]
@@ -968,6 +1002,19 @@ S3_OBJECT_ACL_RESPONSE = """
"""
+S3_OBJECT_TAGGING_RESPONSE = """\
+
+
+
+ {% for tag in obj.tagging.tag_set.tags %}
+
+ {{ tag.key }}
+ {{ tag.value }}
+
+ {% endfor %}
+
+"""
+
S3_OBJECT_COPY_RESPONSE = """\
{{ key.etag }}
diff --git a/tests/test_s3/__init__.py b/tests/test_s3/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/test_s3/test_s3.py b/tests/test_s3/test_s3.py
index 1cb00d4be..6e6b999ce 100644
--- a/tests/test_s3/test_s3.py
+++ b/tests/test_s3/test_s3.py
@@ -1340,6 +1340,98 @@ def test_boto3_multipart_etag():
resp['ETag'].should.equal(EXPECTED_ETAG)
+@mock_s3
+def test_boto3_put_object_with_tagging():
+ s3 = boto3.client('s3', region_name='us-east-1')
+ bucket_name = 'mybucket'
+ key = 'key-with-tags'
+ s3.create_bucket(Bucket=bucket_name)
+
+ s3.put_object(
+ Bucket=bucket_name,
+ Key=key,
+ Body='test',
+ Tagging='foo=bar',
+ )
+
+ resp = s3.get_object_tagging(Bucket=bucket_name, Key=key)
+
+ resp['TagSet'].should.contain({'Key': 'foo', 'Value': 'bar'})
+
+
+@mock_s3
+def test_boto3_put_object_tagging():
+ s3 = boto3.client('s3', region_name='us-east-1')
+ bucket_name = 'mybucket'
+ key = 'key-with-tags'
+ s3.create_bucket(Bucket=bucket_name)
+
+ with assert_raises(ClientError) as err:
+ s3.put_object_tagging(
+ Bucket=bucket_name,
+ Key=key,
+ Tagging={'TagSet': [
+ {'Key': 'item1', 'Value': 'foo'},
+ {'Key': 'item2', 'Value': 'bar'},
+ ]}
+ )
+
+ e = err.exception
+ e.response['Error'].should.equal({
+ 'Code': 'NoSuchKey',
+ 'Message': 'The specified key does not exist.',
+ 'RequestID': '7a62c49f-347e-4fc4-9331-6e8eEXAMPLE',
+ })
+
+ s3.put_object(
+ Bucket=bucket_name,
+ Key=key,
+ Body='test'
+ )
+
+ resp = s3.put_object_tagging(
+ Bucket=bucket_name,
+ Key=key,
+ Tagging={'TagSet': [
+ {'Key': 'item1', 'Value': 'foo'},
+ {'Key': 'item2', 'Value': 'bar'},
+ ]}
+ )
+
+ resp['ResponseMetadata']['HTTPStatusCode'].should.equal(200)
+
+
+@mock_s3
+def test_boto3_get_object_tagging():
+ s3 = boto3.client('s3', region_name='us-east-1')
+ bucket_name = 'mybucket'
+ key = 'key-with-tags'
+ s3.create_bucket(Bucket=bucket_name)
+
+ s3.put_object(
+ Bucket=bucket_name,
+ Key=key,
+ Body='test'
+ )
+
+ resp = s3.get_object_tagging(Bucket=bucket_name, Key=key)
+ resp['TagSet'].should.have.length_of(0)
+
+ resp = s3.put_object_tagging(
+ Bucket=bucket_name,
+ Key=key,
+ Tagging={'TagSet': [
+ {'Key': 'item1', 'Value': 'foo'},
+ {'Key': 'item2', 'Value': 'bar'},
+ ]}
+ )
+ resp = s3.get_object_tagging(Bucket=bucket_name, Key=key)
+
+ resp['TagSet'].should.have.length_of(2)
+ resp['TagSet'].should.contain({'Key': 'item1', 'Value': 'foo'})
+ resp['TagSet'].should.contain({'Key': 'item2', 'Value': 'bar'})
+
+
@mock_s3
def test_boto3_list_object_versions():
s3 = boto3.client('s3', region_name='us-east-1')