Repository: getmoto/moto

Issue:
Feature - S3 bucket to support transfer acceleration settings
Implement
* `get_bucket_accelerate_configuration`
* `put_bucket_accelerate_configuration`

Bonus
* If the client connection is opened with `botocore.config.Config(s3={'use_accelerate_endpoint': True}`, then `generate_presigned_url` should return an url with the transfer accelerated endpoint

--> I'll try to give it a go this month


Discussion:
Thanks; setting as a feature request.

Resolving patch:
diff --git a/moto/s3/models.py b/moto/s3/models.py
--- a/moto/s3/models.py
+++ b/moto/s3/models.py
@@ -17,8 +17,11 @@
 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, InvalidBucketName, InvalidPart, \
-    EntityTooSmall, MissingKey, InvalidNotificationDestination, MalformedXML, InvalidStorageClass, DuplicateTagKeys
+from .exceptions import (
+    BucketAlreadyExists, MissingBucket, InvalidBucketName, InvalidPart, InvalidRequest,
+    EntityTooSmall, MissingKey, InvalidNotificationDestination, MalformedXML, InvalidStorageClass,
+    InvalidTargetBucketForLogging, DuplicateTagKeys, CrossLocationLoggingProhibitted
+)
 from .utils import clean_key_name, _VersionedKeyStore
 
 MAX_BUCKET_NAME_LENGTH = 63
@@ -463,6 +466,7 @@ def __init__(self, name, region_name):
         self.cors = []
         self.logging = {}
         self.notification_configuration = None
+        self.accelerate_configuration = None
 
     @property
     def location(self):
@@ -557,7 +561,6 @@ def delete_lifecycle(self):
         self.rules = []
 
     def set_cors(self, rules):
-        from moto.s3.exceptions import InvalidRequest, MalformedXML
         self.cors = []
 
         if len(rules) > 100:
@@ -607,7 +610,6 @@ def set_logging(self, logging_config, bucket_backend):
             self.logging = {}
             return
 
-        from moto.s3.exceptions import InvalidTargetBucketForLogging, CrossLocationLoggingProhibitted
         # Target bucket must exist in the same account (assuming all moto buckets are in the same account):
         if not bucket_backend.buckets.get(logging_config["TargetBucket"]):
             raise InvalidTargetBucketForLogging("The target bucket for logging does not exist.")
@@ -655,6 +657,13 @@ def set_notification_configuration(self, notification_config):
                 if region != self.region_name:
                     raise InvalidNotificationDestination()
 
+    def set_accelerate_configuration(self, accelerate_config):
+        if self.accelerate_configuration is None and accelerate_config == 'Suspended':
+            # Cannot "suspend" a not active acceleration. Leaves it undefined
+            return
+
+        self.accelerate_configuration = accelerate_config
+
     def set_website_configuration(self, website_configuration):
         self.website_configuration = website_configuration
 
@@ -857,6 +866,15 @@ def put_bucket_notification_configuration(self, bucket_name, notification_config
         bucket = self.get_bucket(bucket_name)
         bucket.set_notification_configuration(notification_config)
 
+    def put_bucket_accelerate_configuration(self, bucket_name, accelerate_configuration):
+        if accelerate_configuration not in ['Enabled', 'Suspended']:
+            raise MalformedXML()
+
+        bucket = self.get_bucket(bucket_name)
+        if bucket.name.find('.') != -1:
+            raise InvalidRequest('PutBucketAccelerateConfiguration')
+        bucket.set_accelerate_configuration(accelerate_configuration)
+
     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
old mode 100755
new mode 100644
--- a/moto/s3/responses.py
+++ b/moto/s3/responses.py
@@ -257,6 +257,13 @@ def _bucket_response_get(self, bucket_name, querystring, headers):
                 return 200, {}, ""
             template = self.response_template(S3_GET_BUCKET_NOTIFICATION_CONFIG)
             return template.render(bucket=bucket)
+        elif "accelerate" in querystring:
+            bucket = self.backend.get_bucket(bucket_name)
+            if bucket.accelerate_configuration is None:
+                template = self.response_template(S3_BUCKET_ACCELERATE_NOT_SET)
+                return 200, {}, template.render()
+            template = self.response_template(S3_BUCKET_ACCELERATE)
+            return template.render(bucket=bucket)
 
         elif 'versions' in querystring:
             delimiter = querystring.get('delimiter', [None])[0]
@@ -442,6 +449,15 @@ def _bucket_response_put(self, request, body, region_name, bucket_name, querystr
                 raise MalformedXML()
             except Exception as e:
                 raise e
+        elif "accelerate" in querystring:
+            try:
+                accelerate_status = self._accelerate_config_from_xml(body)
+                self.backend.put_bucket_accelerate_configuration(bucket_name, accelerate_status)
+                return ""
+            except KeyError:
+                raise MalformedXML()
+            except Exception as e:
+                raise e
 
         else:
             if body:
@@ -1034,6 +1050,11 @@ def _notification_config_from_xml(self, xml):
 
         return parsed_xml["NotificationConfiguration"]
 
+    def _accelerate_config_from_xml(self, xml):
+        parsed_xml = xmltodict.parse(xml)
+        config = parsed_xml['AccelerateConfiguration']
+        return config['Status']
+
     def _key_response_delete(self, bucket_name, query, key_name, headers):
         if query.get('uploadId'):
             upload_id = query['uploadId'][0]
@@ -1686,3 +1707,13 @@ def _key_response_post(self, request, body, bucket_name, query, key_name, header
   {% endfor %}
 </NotificationConfiguration>
 """
+
+S3_BUCKET_ACCELERATE = """
+<AccelerateConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
+  <Status>{{ bucket.accelerate_configuration }}</Status>
+</AccelerateConfiguration>
+"""
+
+S3_BUCKET_ACCELERATE_NOT_SET = """
+<AccelerateConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"/>
+"""


Test patch:
diff --git a/tests/test_s3/test_s3.py b/tests/test_s3/test_s3.py
--- a/tests/test_s3/test_s3.py
+++ b/tests/test_s3/test_s3.py
@@ -2820,3 +2820,80 @@ def test_boto3_bucket_name_too_short():
     with assert_raises(ClientError) as exc:
         s3.create_bucket(Bucket='x'*2)
     exc.exception.response['Error']['Code'].should.equal('InvalidBucketName')
+
+@mock_s3
+def test_accelerated_none_when_unspecified():
+    bucket_name = 'some_bucket'
+    s3 = boto3.client('s3')
+    s3.create_bucket(Bucket=bucket_name)
+    resp = s3.get_bucket_accelerate_configuration(Bucket=bucket_name)
+    resp.shouldnt.have.key('Status')
+
+@mock_s3
+def test_can_enable_bucket_acceleration():
+    bucket_name = 'some_bucket'
+    s3 = boto3.client('s3')
+    s3.create_bucket(Bucket=bucket_name)
+    resp = s3.put_bucket_accelerate_configuration(
+        Bucket=bucket_name,
+        AccelerateConfiguration={'Status': 'Enabled'},
+    )
+    resp.keys().should.have.length_of(1)    # Response contains nothing (only HTTP headers)
+    resp = s3.get_bucket_accelerate_configuration(Bucket=bucket_name)
+    resp.should.have.key('Status')
+    resp['Status'].should.equal('Enabled')
+
+@mock_s3
+def test_can_suspend_bucket_acceleration():
+    bucket_name = 'some_bucket'
+    s3 = boto3.client('s3')
+    s3.create_bucket(Bucket=bucket_name)
+    resp = s3.put_bucket_accelerate_configuration(
+        Bucket=bucket_name,
+        AccelerateConfiguration={'Status': 'Enabled'},
+    )
+    resp = s3.put_bucket_accelerate_configuration(
+        Bucket=bucket_name,
+        AccelerateConfiguration={'Status': 'Suspended'},
+    )
+    resp.keys().should.have.length_of(1)    # Response contains nothing (only HTTP headers)
+    resp = s3.get_bucket_accelerate_configuration(Bucket=bucket_name)
+    resp.should.have.key('Status')
+    resp['Status'].should.equal('Suspended')
+
+@mock_s3
+def test_suspending_acceleration_on_not_configured_bucket_does_nothing():
+    bucket_name = 'some_bucket'
+    s3 = boto3.client('s3')
+    s3.create_bucket(Bucket=bucket_name)
+    resp = s3.put_bucket_accelerate_configuration(
+        Bucket=bucket_name,
+        AccelerateConfiguration={'Status': 'Suspended'},
+    )
+    resp.keys().should.have.length_of(1)    # Response contains nothing (only HTTP headers)
+    resp = s3.get_bucket_accelerate_configuration(Bucket=bucket_name)
+    resp.shouldnt.have.key('Status')
+
+@mock_s3
+def test_accelerate_configuration_status_validation():
+    bucket_name = 'some_bucket'
+    s3 = boto3.client('s3')
+    s3.create_bucket(Bucket=bucket_name)
+    with assert_raises(ClientError) as exc:
+        s3.put_bucket_accelerate_configuration(
+            Bucket=bucket_name,
+            AccelerateConfiguration={'Status': 'bad_status'},
+        )
+    exc.exception.response['Error']['Code'].should.equal('MalformedXML')
+
+@mock_s3
+def test_accelerate_configuration_is_not_supported_when_bucket_name_has_dots():
+    bucket_name = 'some.bucket.with.dots'
+    s3 = boto3.client('s3')
+    s3.create_bucket(Bucket=bucket_name)
+    with assert_raises(ClientError) as exc:
+        s3.put_bucket_accelerate_configuration(
+            Bucket=bucket_name,
+            AccelerateConfiguration={'Status': 'Enabled'},
+        )
+    exc.exception.response['Error']['Code'].should.equal('InvalidRequest')