Skip to content

Commit

Permalink
feat: implement mock for S3Tables service (#8470)
Browse files Browse the repository at this point in the history
  • Loading branch information
felixscherz authored Jan 27, 2025
1 parent 786a8ad commit 4f565fb
Show file tree
Hide file tree
Showing 14 changed files with 1,565 additions and 5 deletions.
45 changes: 45 additions & 0 deletions docs/docs/services/s3tables.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
.. _implementedservice_s3tables:

.. |start-h3| raw:: html

<h3>

.. |end-h3| raw:: html

</h3>

========
s3tables
========

.. autoclass:: moto.s3tables.models.S3TablesBackend

|start-h3| Implemented features for this service |end-h3|

- [X] create_namespace
- [X] create_table
- [X] create_table_bucket
- [X] delete_namespace
- [X] delete_table
- [X] delete_table_bucket
- [ ] delete_table_bucket_policy
- [ ] delete_table_policy
- [X] get_namespace
- [X] get_table
- [X] get_table_bucket
- [ ] get_table_bucket_maintenance_configuration
- [ ] get_table_bucket_policy
- [ ] get_table_maintenance_configuration
- [ ] get_table_maintenance_job_status
- [ ] get_table_metadata_location
- [ ] get_table_policy
- [X] list_namespaces
- [X] list_table_buckets
- [X] list_tables
- [ ] put_table_bucket_maintenance_configuration
- [ ] put_table_bucket_policy
- [ ] put_table_maintenance_configuration
- [ ] put_table_policy
- [X] rename_table
- [X] update_table_metadata_location

5 changes: 3 additions & 2 deletions moto/backend_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,17 +160,18 @@
"route53resolver",
re.compile("https?://route53resolver\\.(.+)\\.amazonaws\\.com"),
),
("s3", re.compile("https?://s3(?!-control)(.*)\\.amazonaws.com")),
("s3", re.compile("https?://s3(?!(-control|tables))(.*)\\.amazonaws.com")),
(
"s3",
re.compile(
"https?://(?P<bucket_name>[a-zA-Z0-9\\-_.]*)\\.?s3(?!-control)(.*)\\.amazonaws.com"
"https?://(?P<bucket_name>[a-zA-Z0-9\\-_.]*)\\.?s3(?!(-control|tables))(.*)\\.amazonaws.com"
),
),
(
"s3control",
re.compile("https?://([0-9]+)\\.s3-control\\.(.+)\\.amazonaws\\.com"),
),
("s3tables", re.compile("https?://s3tables\\.(.+)\\.amazonaws\\.com")),
("sagemaker", re.compile("https?://api\\.sagemaker\\.(.+)\\.amazonaws.com")),
(
"sagemakermetrics",
Expand Down
4 changes: 4 additions & 0 deletions moto/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@
from moto.route53resolver.models import Route53ResolverBackend
from moto.s3.models import S3Backend
from moto.s3control.models import S3ControlBackend
from moto.s3tables.models import S3TablesBackend
from moto.sagemaker.models import SageMakerModelBackend
from moto.sagemakermetrics.models import SageMakerMetricsBackend
from moto.sagemakerruntime.models import SageMakerRuntimeBackend
Expand Down Expand Up @@ -307,6 +308,7 @@ def get_service_from_url(url: str) -> Optional[str]:
"Literal['s3']",
"Literal['s3bucket_path']",
"Literal['s3control']",
"Literal['s3tables']",
"Literal['sagemaker']",
"Literal['sagemaker-metrics']",
"Literal['sagemaker-runtime']",
Expand Down Expand Up @@ -753,6 +755,8 @@ def get_backend(
) -> "BackendDict[WorkSpacesWebBackend]": ...
@overload
def get_backend(name: "Literal['xray']") -> "BackendDict[XRayBackend]": ...
@overload
def get_backend(name: "Literal['s3tables']") -> "BackendDict[S3TablesBackend]": ...


def get_backend(name: SERVICE_NAMES) -> "BackendDict[SERVICE_BACKEND]":
Expand Down
10 changes: 10 additions & 0 deletions moto/s3/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -635,3 +635,13 @@ def __init__(self) -> None:
"DaysMustProvidedExceptForSelectRequest",
"`Days` must be provided except for select requests",
)


class MethodNotAllowed(S3ClientError):
code = 405

def __init__(self) -> None:
super().__init__(
"MethodNotAllowed",
"The specified method is not allowed against this resource.",
)
40 changes: 39 additions & 1 deletion moto/s3/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
InvalidTagError,
InvalidTargetBucketForLogging,
MalformedXML,
MethodNotAllowed,
MissingBucket,
MissingKey,
NoSuchPublicAccessBlockConfiguration,
Expand Down Expand Up @@ -1646,6 +1647,9 @@ def default_retention(self) -> str:
return now.strftime("%Y-%m-%dT%H:%M:%SZ")


class FakeTableStorageBucket(FakeBucket): ...


class S3Backend(BaseBackend, CloudWatchMetricProvider):
"""
Custom S3 endpoints are supported, if you are using a S3-compatible storage solution like Ceph.
Expand Down Expand Up @@ -1711,6 +1715,7 @@ def test_my_custom_endpoint():
def __init__(self, region_name: str, account_id: str):
super().__init__(region_name, account_id)
self.buckets: Dict[str, FakeBucket] = {}
self.table_buckets: Dict[str, FakeTableStorageBucket] = {}
self.tagger = TaggingService()
self._pagination_tokens: Dict[str, str] = {}

Expand All @@ -1719,7 +1724,9 @@ def reset(self) -> None:
# Ensure that these TemporaryFile-objects are closed, and leave no filehandles open
#
# First, check all known buckets/keys
for bucket in self.buckets.values():
for bucket in itertools.chain(
self.buckets.values(), self.table_buckets.values()
):
for key in bucket.keys.values(): # type: ignore
if isinstance(key, FakeKey):
key.dispose()
Expand Down Expand Up @@ -1876,13 +1883,25 @@ def create_bucket(self, bucket_name: str, region_name: str) -> FakeBucket:

return new_bucket

def create_table_storage_bucket(self, region_name: str) -> FakeTableStorageBucket:
# every s3 table is assigned a unique s3 bucket with a random name
bucket_name = f"{str(random.uuid4())}--table-s3"
new_bucket = FakeTableStorageBucket(
name=bucket_name, account_id=self.account_id, region_name=region_name
)
self.table_buckets[bucket_name] = new_bucket
return new_bucket

def list_buckets(self) -> List[FakeBucket]:
return list(self.buckets.values())

def get_bucket(self, bucket_name: str) -> FakeBucket:
if bucket_name in self.buckets:
return self.buckets[bucket_name]

if bucket_name in self.table_buckets:
return self.table_buckets[bucket_name]

if bucket_name in s3_backends.bucket_accounts:
if not s3_allow_crossdomain_access():
raise AccessDeniedByLock
Expand All @@ -1903,6 +1922,19 @@ def delete_bucket(self, bucket_name: str) -> Optional[FakeBucket]:
s3_backends.bucket_accounts.pop(bucket_name, None)
return self.buckets.pop(bucket_name)

def delete_table_storage_bucket(self, bucket_name: str) -> Optional[FakeBucket]:
bucket = self.get_bucket(bucket_name)
assert isinstance(bucket, FakeTableStorageBucket)
# table storage buckets can be deleted while not empty
if bucket.keys:
for key in bucket.keys.values(): # type: ignore
if isinstance(key, FakeKey):
key.dispose()
for part in bucket.multiparts.values():
part.dispose()
s3_backends.bucket_accounts.pop(bucket_name, None)
return self.table_buckets.pop(bucket_name)

def get_bucket_accelerate_configuration(self, bucket_name: str) -> Optional[str]:
bucket = self.get_bucket(bucket_name)
return bucket.accelerate_configuration
Expand Down Expand Up @@ -2649,6 +2681,8 @@ def list_objects(
MOTO_S3_DEFAULT_MAX_KEYS=5
"""
if isinstance(bucket, FakeTableStorageBucket):
raise MethodNotAllowed()
key_results = set()
folder_results = set()
if prefix:
Expand Down Expand Up @@ -2707,6 +2741,8 @@ def list_objects_v2(
MOTO_S3_DEFAULT_MAX_KEYS=5
"""
if isinstance(bucket, FakeTableStorageBucket):
raise MethodNotAllowed()
result_keys, result_folders, _, _ = self.list_objects(
bucket, prefix, delimiter, marker=None, max_keys=None
)
Expand Down Expand Up @@ -2789,6 +2825,8 @@ def delete_object(
bypass: bool = False,
) -> Tuple[bool, Dict[str, Any]]:
bucket = self.get_bucket(bucket_name)
if isinstance(bucket, FakeTableStorageBucket):
raise MethodNotAllowed()

response_meta = {}
delete_key = bucket.keys.get(key_name)
Expand Down
4 changes: 2 additions & 2 deletions moto/s3/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

# Catch s3.amazonaws.com, but not s3-control.amazonaws.com
url_bases = [
r"https?://s3(?!-control)(.*)\.amazonaws.com",
r"https?://(?P<bucket_name>[a-zA-Z0-9\-_.]*)\.?s3(?!-control)(.*)\.amazonaws.com",
r"https?://s3(?!(-control|tables))(.*)\.amazonaws.com",
r"https?://(?P<bucket_name>[a-zA-Z0-9\-_.]*)\.?s3(?!(-control|tables))(.*)\.amazonaws.com",
]

url_bases.extend(settings.get_s3_custom_endpoints())
Expand Down
1 change: 1 addition & 0 deletions moto/s3tables/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .models import s3tables_backends # noqa: F401
101 changes: 101 additions & 0 deletions moto/s3tables/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Exceptions raised by the s3tables service."""

from moto.core.exceptions import JsonRESTError


class BadRequestException(JsonRESTError):
code = 400

def __init__(self, message: str) -> None:
super().__init__("BadRequestException", message)


class InvalidContinuationToken(BadRequestException):
msg = "The continuation token is not valid."

def __init__(self) -> None:
super().__init__(self.msg)


class InvalidTableBucketName(BadRequestException):
msg = "The specified bucket name is not valid."

def __init__(self) -> None:
super().__init__(self.msg)


class InvalidTableName(BadRequestException):
template = "1 validation error detected: Value '%s' at 'name' failed to satisfy constraint: Member must satisfy regular expression pattern: [0-9a-z_]*"

def __init__(self, name: str) -> None:
super().__init__(self.template.format(name))


class InvalidNamespaceName(BadRequestException):
msg = "The specified namespace name is not valid."

def __init__(self) -> None:
super().__init__(self.msg)


class InvalidMetadataLocation(BadRequestException):
msg = "The specified metadata location is not valid."

def __init__(self) -> None:
super().__init__(self.msg)


class NothingToRename(BadRequestException):
msg = "Neither a new namespace name nor a new table name is specified."

def __init__(self) -> None:
super().__init__(self.msg)


class NotFoundException(JsonRESTError):
code = 404

def __init__(self, message: str) -> None:
super().__init__("NotFoundException", message)


class NamespaceDoesNotExist(NotFoundException):
msg = "The specified namespace does not exist."

def __init__(self) -> None:
super().__init__(self.msg)


class DestinationNamespaceDoesNotExist(NotFoundException):
msg = "The specified destination namespace does not exist."

def __init__(self) -> None:
super().__init__(self.msg)


class TableDoesNotExist(NotFoundException):
msg = "The specified table does not exist."

def __init__(self) -> None:
super().__init__(self.msg)


class ConflictException(JsonRESTError):
code = 409

def __init__(self, message: str) -> None:
super().__init__("ConflictException", message)


class VersionTokenMismatch(ConflictException):
msg = "Provided version token does not match the table version token."

def __init__(self) -> None:
super().__init__(self.msg)


class TableAlreadyExists(ConflictException):
msg = "A table with an identical name already exists in the namespace."

def __init__(self) -> None:
super().__init__(self.msg)
Loading

0 comments on commit 4f565fb

Please sign in to comment.