Repository: SneaksAndData/adapta (language: python)

Issue:
[FEATURE] AWS Clients
**Is your feature request related to a problem? Please describe.**
We are getting an AWS account soon, thus some services will requires AWS API integrations.

**Describe the solution you'd like**
Add `AwsClient` similar to `AzureClient` and related storage clients.




Discussion / hints:

Fix patch:
diff --git a/adapta/security/clients/README.md b/adapta/security/clients/README.md
index c586f881..90fc82ec 100644
--- a/adapta/security/clients/README.md
+++ b/adapta/security/clients/README.md
@@ -1,6 +1,7 @@
 # Authentication Clients for various platforms
 
 Currently supported:
+- AWS
 - HashiCorp Vault
 - Azure
 - Local (for unit tests)
@@ -25,3 +26,19 @@ Note that this gives your client full write access on the account.
 This method requires user to have `Reader and Data Access` IAM role on the **storage account**.
 
 If none of the above options work, the method raises a ValueError.
+
+
+## AWS Client
+
+The AWS client allows you to authenticate with the Amazon Web Services cloud using the static credentials (access key id, secret access key and region).
+
+Before using this client, the caller code should invoke the `initialize_session` method.
+
+
+Currently, only the two types of credentials are supported now:
+- `EnvironmentAwsCredentials`: This credential provider loads credentials from the following environment variables:
+  - PROTEUS__AWS_SECRET_ACCESS_KEY: Secret access key.
+  - PROTEUS__AWS_ACCESS_KEY_ID: Access key id.
+  - PROTEUS__AWS_REGION: AWS region.
+
+- `ExplicitAwsCredentials`: This credential provider accepts the credentials as a constructor arguments.
diff --git a/adapta/security/clients/__init__.py b/adapta/security/clients/__init__.py
index 8dba4f69..54ee0ee0 100644
--- a/adapta/security/clients/__init__.py
+++ b/adapta/security/clients/__init__.py
@@ -52,3 +52,8 @@
     from adapta.security.clients._azure_client import AzureClient
 except ImportError:
     pass
+
+try:
+    from adapta.security.clients.aws import AwsClient
+except ImportError:
+    pass
diff --git a/adapta/security/clients/aws/__init__.py b/adapta/security/clients/aws/__init__.py
new file mode 100644
index 00000000..2ce2504e
--- /dev/null
+++ b/adapta/security/clients/aws/__init__.py
@@ -0,0 +1,20 @@
+"""
+Import index
+"""
+#  Copyright (c) 2023. ECCO Sneaks & Data
+#
+#  Licensed under the Apache License, Version 2.0 (the "License");
+#  you may not use this file except in compliance with the License.
+#  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+#
+
+from adapta.security.clients.aws._aws_client import *
+from adapta.security.clients.aws._aws_credentials import *
diff --git a/adapta/security/clients/aws/_aws_client.py b/adapta/security/clients/aws/_aws_client.py
new file mode 100644
index 00000000..ce47371b
--- /dev/null
+++ b/adapta/security/clients/aws/_aws_client.py
@@ -0,0 +1,94 @@
+"""
+ Amazon Web Services implementation of AuthenticationClient.
+"""
+#  Copyright (c) 2023. ECCO Sneaks & Data
+#
+#  Licensed under the Apache License, Version 2.0 (the "License");
+#  you may not use this file except in compliance with the License.
+#  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+#
+
+from typing import Optional, Dict, final
+
+import boto3
+from pyarrow.filesystem import FileSystem
+
+from adapta.security.clients._base import AuthenticationClient
+from adapta.security.clients.aws._aws_credentials import AccessKeyCredentials, EnvironmentAwsCredentials
+from adapta.storage.models.base import DataPath
+
+
+@final
+class AwsClient(AuthenticationClient):
+    """
+    AWS Credentials provider for various AWS resources.
+    """
+
+    def __init__(self, aws_credentials: Optional[AccessKeyCredentials] = None):
+        self._session = None
+        self._credentials = aws_credentials or EnvironmentAwsCredentials()
+
+    @property
+    def session(self):
+        """
+        Returns configured session (if any)
+        """
+        return self._session
+
+    @classmethod
+    def from_base_client(cls, client: AuthenticationClient) -> Optional["AwsClient"]:
+        """
+         Safe casts AuthenticationClient to AwsClient if type checks out.
+
+        :param client: AuthenticationClient
+        :return: AwsClient or None if type does not check out
+        """
+        return client if isinstance(client, AwsClient) else None
+
+    def get_credentials(self):
+        """
+         Not used in AWS.
+        :return:
+        """
+        raise NotImplementedError("Not implemented in AwsClient")
+
+    def get_access_token(self, scope: Optional[str] = None) -> str:
+        """
+         Not used in AWS.
+        :return:
+        """
+        raise NotImplementedError("Authentication with temporary credentials is not supported yet in AwsClient")
+
+    def connect_storage(self, path: DataPath, set_env: bool = False) -> Optional[Dict]:
+        """
+         Not used in AWS.
+        :return:
+        """
+
+    def connect_account(self):
+        """
+         Not used in AWS.
+        :return:
+        """
+
+    def get_pyarrow_filesystem(self, path: DataPath, connection_options: Optional[Dict[str, str]] = None) -> FileSystem:
+        raise ValueError("Not supported  in AwsClient")
+
+    def initialize_session(self) -> "AwsClient":
+        """
+        Initializes session. Should be called before any operations with client
+        """
+        self._session = boto3.Session(
+            aws_access_key_id=self._credentials.access_key_id,
+            aws_secret_access_key=self._credentials.access_key,
+            region_name=self._credentials.region,
+        )
+        return self
diff --git a/adapta/security/clients/aws/_aws_credentials.py b/adapta/security/clients/aws/_aws_credentials.py
new file mode 100644
index 00000000..6d4f54c1
--- /dev/null
+++ b/adapta/security/clients/aws/_aws_credentials.py
@@ -0,0 +1,94 @@
+"""
+Contains credentials provider for AWS clients
+"""
+#  Copyright (c) 2023. ECCO Sneaks & Data
+#
+#  Licensed under the Apache License, Version 2.0 (the "License");
+#  you may not use this file except in compliance with the License.
+#  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+#
+import os
+from abc import ABC, abstractmethod
+
+
+class AccessKeyCredentials(ABC):
+    """
+    Abstract class that represents credentials for AWS connections
+    """
+
+    @property
+    @abstractmethod
+    def access_key(self) -> str:
+        """AWS account access key"""
+
+    @property
+    @abstractmethod
+    def access_key_id(self) -> str:
+        """AWS account access key id"""
+
+    @property
+    @abstractmethod
+    def region(self) -> str:
+        """AWS region"""
+
+
+class EnvironmentAwsCredentials(AccessKeyCredentials):
+    """
+    Reads credentials from environment variables
+    """
+
+    def __init__(self):
+        if "PROTEUS__AWS_SECRET_ACCESS_KEY" not in os.environ:
+            raise ValueError("PROTEUS__AWS_SECRET_ACCESS_KEY must be set")
+        self._access_key = os.environ["PROTEUS__AWS_SECRET_ACCESS_KEY"]
+
+        if "PROTEUS__AWS_ACCESS_KEY_ID" not in os.environ:
+            raise ValueError("PROTEUS__AWS_ACCESS_KEY_ID must be set")
+        self._access_key_id = os.environ["PROTEUS__AWS_ACCESS_KEY_ID"]
+
+        if "PROTEUS__AWS_REGION" not in os.environ:
+            raise ValueError("PROTEUS__AWS_REGION must be set")
+        self._region = os.environ["PROTEUS__AWS_REGION"]
+
+    @property
+    def access_key(self) -> str:
+        return self._access_key
+
+    @property
+    def access_key_id(self) -> str:
+        return self._access_key_id
+
+    @property
+    def region(self) -> str:
+        return self.region
+
+
+class ExplicitAwsCredentials(AccessKeyCredentials):
+    """
+    Explicitly passed AWS credentials
+    """
+
+    def __init__(self, access_key, access_key_id, region):
+        self._access_key = access_key
+        self._access_key_id = access_key_id
+        self._region = region
+
+    @property
+    def access_key(self) -> str:
+        return self._access_key
+
+    @property
+    def access_key_id(self) -> str:
+        return self._access_key_id
+
+    @property
+    def region(self) -> str:
+        return self._region
diff --git a/adapta/storage/blob/README.md b/adapta/storage/blob/README.md
index d1387738..07a32e54 100644
--- a/adapta/storage/blob/README.md
+++ b/adapta/storage/blob/README.md
@@ -6,6 +6,7 @@ This module contains storage clients for various cloud/hybrid platforms. Base cl
 
 In order to init a storage client, you need a respective authentication provider (`SecurityClient`) and a data path:
 
+### Azure example
 ```python
 import pandas
 from adapta.security.clients import AzureClient
@@ -27,3 +28,26 @@ non_partitioned_parquet_table: pandas.DataFrame = pandas.concat(azure_storage_cl
     filter_predicate=lambda b: b.name.endswith('.parquet')  # Ignore non-parquet files that might be present in a folder
 ))
 ```
+
+### AWS example
+```python
+from adapta.security.clients import AwsClient
+from adapta.storage.models.aws import S3Path
+from adapta.storage.blob.s3_storage_client import S3StorageClient
+from adapta.storage.models.format import DictJsonSerializationFormat
+
+aws_client = AwsClient()
+s3_path = S3Path.from_hdfs_path('s3a://bucket/path/to/my/table')
+
+# init storage client
+s3_client = S3StorageClient(base_client=aws_client)
+
+# Save data to S3
+
+s3_client.save_data_as_blob(
+    data={"data_value": "2"}, blob_path=s3_path, serialization_format=DictJsonSerializationFormat, overwrite=True
+)
+
+# read files from S3
+blobs = s3_client.read_blobs(s3_path, serialization_format=DictJsonSerializationFormat)
+```
diff --git a/adapta/storage/blob/s3_storage_client.py b/adapta/storage/blob/s3_storage_client.py
new file mode 100644
index 00000000..98b3d598
--- /dev/null
+++ b/adapta/storage/blob/s3_storage_client.py
@@ -0,0 +1,141 @@
+"""
+ Storage Client implementation for AWS S3.
+"""
+#  Copyright (c) 2023. ECCO Sneaks & Data
+#
+#  Licensed under the Apache License, Version 2.0 (the "License");
+#  you may not use this file except in compliance with the License.
+#  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+#
+
+from abc import ABC
+from typing import Optional, Callable, Type, Iterator, Dict, TypeVar
+
+from adapta.security.clients import AwsClient
+from adapta.storage.blob.base import StorageClient
+from adapta.storage.exceptions import StorageClientError
+from adapta.storage.models.aws import cast_path
+from adapta.storage.models.base import DataPath
+from adapta.storage.models.format import SerializationFormat
+
+T = TypeVar("T")  # pylint: disable=C0103
+
+
+class S3StorageClient(StorageClient, ABC):
+    """
+    S3 Storage Client.
+    """
+
+    def __init__(self, *, base_client: AwsClient):
+        super().__init__(base_client=base_client)
+        if base_client.session is None:
+            raise ValueError("AwsClient.initialize_session should be called before accessing S3StorageClient")
+        self._s3_resource = base_client.session.resource("s3")
+
+    def get_blob_uri(self, blob_path: DataPath, **kwargs) -> str:
+        """
+        Not implemented in S3 Client
+        """
+        raise NotImplementedError("Not implemented in S3StorageClient")
+
+    def blob_exists(self, blob_path: DataPath) -> bool:
+        """Checks if blob located at blob_path exists
+
+        :param blob_path: Path to blob
+
+        :return: Boolean indicator of blob existence
+        """
+        s3_path = cast_path(blob_path)
+        return any(self._s3_resource.Bucket(s3_path.bucket).objects.filter(Prefix=s3_path.path))
+
+    def save_data_as_blob(
+        self,
+        data: T,
+        blob_path: DataPath,
+        serialization_format: Type[SerializationFormat[T]],
+        metadata: Optional[Dict[str, str]] = None,
+        overwrite: bool = False,
+    ) -> None:
+        """
+         Saves any data with the given serialization format.
+
+        :param data: Data to save.
+        :param blob_path: Blob path in DataPath notation.
+        :param metadata: Optional blob tags or metadata to attach.
+        :param overwrite: whether a blob should be overwritten or an exception thrown if it already exists.
+        :param serialization_format: The serialization format.
+            The type (T) of the serialization format must be compatible with the provided data.
+        :return:
+        """
+        if not overwrite:
+            if self.blob_exists(blob_path=blob_path):
+                raise StorageClientError(
+                    f"Blob already exists at path: {blob_path.path}. Please specify overwrite=True if you want to overwrite it."
+                )
+
+        s3_path = cast_path(blob_path)
+        bytes_ = serialization_format().serialize(data)
+        self._s3_resource.Bucket(s3_path.bucket).put_object(Key=s3_path.path, Body=bytes_)
+
+    def delete_blob(self, blob_path: DataPath) -> None:
+        """
+        Deletes blob at blob_path
+
+        :param blob_path: Blob path as DataPath object
+        """
+        s3_path = cast_path(blob_path)
+        self._s3_resource.Bucket(s3_path.bucket).Object(blob_path.path).delete()
+
+    def list_blobs(
+        self, blob_path: DataPath, filter_predicate: Optional[Callable[[...], bool]] = None
+    ) -> Iterator[DataPath]:
+        """
+        Not implemented in S3 Client
+        """
+        raise NotImplementedError("Not implemented in S3StorageClient")
+
+    def read_blobs(
+        self,
+        blob_path: DataPath,
+        serialization_format: Type[SerializationFormat[T]],
+        filter_predicate: Optional[Callable[[...], bool]] = None,
+    ) -> Iterator[T]:
+        """
+         Reads data under provided path into the given format.
+
+        :param blob_path: Path to blob(s).
+        :param serialization_format: Format to deserialize blobs into.
+        :param filter_predicate: Take only blobs that match a supplied predicate.
+        :return: An iterator over deserialized blobs
+        """
+        s3_path = cast_path(blob_path)
+        for blob in self._s3_resource.Bucket(s3_path.bucket).objects.filter(Prefix=s3_path.path):
+            if filter_predicate is not None and not filter_predicate(blob):
+                continue
+            yield serialization_format().deserialize(blob.get()["Body"].read())
+
+    def download_blobs(
+        self,
+        blob_path: DataPath,
+        local_path: str,
+        threads: Optional[int] = None,
+        filter_predicate: Optional[Callable[[...], bool]] = None,
+    ) -> None:
+        """
+        Not yet implemented in S3 Client
+        """
+        raise NotImplementedError("Not yet implemented in S3StorageClient")
+
+    def copy_blob(self, blob_path: DataPath, target_blob_path: DataPath, doze_period_ms: int) -> None:
+        """
+        Not implemented in S3 Client
+        """
+        raise NotImplementedError("Not implemented in S3StorageClient")
diff --git a/adapta/storage/exceptions.py b/adapta/storage/exceptions.py
new file mode 100644
index 00000000..7fa1a85c
--- /dev/null
+++ b/adapta/storage/exceptions.py
@@ -0,0 +1,23 @@
+"""
+Storage client exceptions
+"""
+
+
+#  Copyright (c) 2023. ECCO Sneaks & Data
+#
+#  Licensed under the Apache License, Version 2.0 (the "License");
+#  you may not use this file except in compliance with the License.
+#  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+#
+
+
+class StorageClientError(Exception):
+    """Base exception for StorageClient implementations"""
diff --git a/adapta/storage/models/aws.py b/adapta/storage/models/aws.py
new file mode 100644
index 00000000..89591f8a
--- /dev/null
+++ b/adapta/storage/models/aws.py
@@ -0,0 +1,86 @@
+"""
+ Models used by AWS when working with storage.
+"""
+#  Copyright (c) 2023. ECCO Sneaks & Data
+#
+#  Licensed under the Apache License, Version 2.0 (the "License");
+#  you may not use this file except in compliance with the License.
+#  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+#
+
+from dataclasses import dataclass
+from urllib.parse import urlparse
+
+from adapta.storage.models.base import DataPath, DataProtocols
+
+
+@dataclass
+class S3Path(DataPath):
+    """
+    Path wrapper for Astra DB.
+    """
+
+    def to_uri(self) -> str:
+        """
+        Not yet implemented in S3Path
+        """
+        raise NotImplementedError
+
+    def base_uri(self) -> str:
+        """
+        Not yet implemented in S3Path
+        """
+        raise NotImplementedError
+
+    @classmethod
+    def from_uri(cls, url: str) -> "DataPath":
+        """
+        Not yet implemented in S3Path
+        """
+        raise NotImplementedError
+
+    bucket: str
+    path: str
+    protocol: str = DataProtocols.S3.value
+
+    @classmethod
+    def from_hdfs_path(cls, hdfs_path: str) -> "S3Path":
+        """
+        Not yet implemented in S3Path
+        """
+        assert hdfs_path.startswith("s3a://"), "HDFS S3 path should start with s3a://"
+        uri = urlparse(hdfs_path)
+        parsed_path = uri.path.split("/")
+        return cls(bucket=uri.netloc, path="/".join(parsed_path[1:]))
+
+    def to_hdfs_path(self) -> str:
+        """
+        Not yet implemented in S3Path
+        """
+        return f"s3a://{self.bucket}/{self.path}"
+
+    def to_delta_rs_path(self) -> str:
+        """
+        Not yet implemented in S3Path
+        """
+        raise NotImplementedError
+
+
+def cast_path(blob_path: DataPath) -> S3Path:
+    """
+     Type cast from DataPath to S3Path
+
+    :param blob_path: DataPath
+    :return: S3Path
+    """
+    assert isinstance(blob_path, S3Path), "Only S3 paths are supported by this client."
+
+    return blob_path
diff --git a/adapta/storage/models/base.py b/adapta/storage/models/base.py
index f526a795..d05e5a2f 100644
--- a/adapta/storage/models/base.py
+++ b/adapta/storage/models/base.py
@@ -30,6 +30,7 @@ class DataProtocols(Enum):
     FILE = "file"
     HIVE = "hive"
     ASTRA = "astra"
+    S3 = "s3"
 
 
 class DataPath(ABC):
diff --git a/poetry.lock b/poetry.lock
index 250e6a66..691468c0 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
 
 [[package]]
 name = "adlfs"
@@ -407,6 +407,47 @@ d = ["aiohttp (>=3.7.4)"]
 jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
 uvloop = ["uvloop (>=0.15.2)"]
 
+[[package]]
+name = "boto3"
+version = "1.29.0"
+description = "The AWS SDK for Python"
+optional = true
+python-versions = ">= 3.7"
+files = [
+    {file = "boto3-1.29.0-py3-none-any.whl", hash = "sha256:91c72fa4848eda9311c273db667946bd9d953285ae8d54b7bbad541b74adc254"},
+    {file = "boto3-1.29.0.tar.gz", hash = "sha256:3e90ea2faa3e9892b9140f857911f9ef0013192a106f50d0ec7b71e8d1afc90a"},
+]
+
+[package.dependencies]
+botocore = ">=1.32.0,<1.33.0"
+jmespath = ">=0.7.1,<2.0.0"
+s3transfer = ">=0.7.0,<0.8.0"
+
+[package.extras]
+crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
+
+[[package]]
+name = "botocore"
+version = "1.32.2"
+description = "Low-level, data-driven core of boto 3."
+optional = true
+python-versions = ">= 3.7"
+files = [
+    {file = "botocore-1.32.2-py3-none-any.whl", hash = "sha256:a68a33193d8cd59e3b2142bff632e562afc02f9c4417e3dcc81a6e1b1f47148e"},
+    {file = "botocore-1.32.2.tar.gz", hash = "sha256:0e231524e9b72169fe0b8d9310f47072c245fb712778e0669f53f264f0e49536"},
+]
+
+[package.dependencies]
+jmespath = ">=0.7.1,<2.0.0"
+python-dateutil = ">=2.1,<3.0.0"
+urllib3 = [
+    {version = ">=1.25.4,<1.27", markers = "python_version < \"3.10\""},
+    {version = ">=1.25.4,<2.1", markers = "python_version >= \"3.10\""},
+]
+
+[package.extras]
+crt = ["awscrt (==0.19.12)"]
+
 [[package]]
 name = "bottleneck"
 version = "1.3.7"
@@ -1389,6 +1430,17 @@ pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"
 plugins = ["setuptools"]
 requirements-deprecated-finder = ["pip-api", "pipreqs"]
 
+[[package]]
+name = "jmespath"
+version = "1.0.1"
+description = "JSON Matching Expressions"
+optional = true
+python-versions = ">=3.7"
+files = [
+    {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"},
+    {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"},
+]
+
 [[package]]
 name = "lazy-object-proxy"
 version = "1.9.0"
@@ -2382,6 +2434,23 @@ requests = ">=2.0.0"
 [package.extras]
 rsa = ["oauthlib[signedtoken] (>=3.0.0)"]
 
+[[package]]
+name = "s3transfer"
+version = "0.7.0"
+description = "An Amazon S3 Transfer Manager"
+optional = true
+python-versions = ">= 3.7"
+files = [
+    {file = "s3transfer-0.7.0-py3-none-any.whl", hash = "sha256:10d6923c6359175f264811ef4bf6161a3156ce8e350e705396a7557d6293c33a"},
+    {file = "s3transfer-0.7.0.tar.gz", hash = "sha256:fd3889a66f5fe17299fe75b82eae6cf722554edca744ca5d5fe308b104883d2e"},
+]
+
+[package.dependencies]
+botocore = ">=1.12.36,<2.0a.0"
+
+[package.extras]
+crt = ["botocore[crt] (>=1.20.29,<2.0a.0)"]
+
 [[package]]
 name = "six"
 version = "1.16.0"
@@ -2504,7 +2573,7 @@ files = [
 ]
 
 [package.dependencies]
-greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\")"}
+greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"}
 
 [package.extras]
 aiomysql = ["aiomysql (>=0.2.0)", "greenlet (!=0.4.17)"]
@@ -2652,32 +2721,32 @@ files = [
 
 [[package]]
 name = "uamqp"
-version = "1.6.5"
+version = "1.6.6"
 description = "AMQP 1.0 Client Library for Python"
 optional = true
 python-versions = ">=3.7"
 files = [
-    {file = "uamqp-1.6.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:88a04a223c2f3a4fa4368f025c540ebad605d523dcc6f990ff8a735191fbcc38"},
-    {file = "uamqp-1.6.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f08051bc65cb297ce5b491c23fbd84f61ef1e43ebaed10f97bfc1edbd02a45ce"},
-    {file = "uamqp-1.6.5-cp310-cp310-win32.whl", hash = "sha256:36f68373857dc61144b5fc2519b7dc9675e2f96a9c9712ec1410a807dae7833a"},
-    {file = "uamqp-1.6.5-cp310-cp310-win_amd64.whl", hash = "sha256:abb830a69b346de1f2530d293d2f77f337002e4ff6518ae8161bf8a25416de8c"},
-    {file = "uamqp-1.6.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d9dcf92b3d2a776ddfc4855ba1268ddb271f529cdcd502a8ef09291866e7430d"},
-    {file = "uamqp-1.6.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8621162dab880ded0ea487922794e0eb71f2077e8ed42740ed6c89663d0bbe58"},
-    {file = "uamqp-1.6.5-cp311-cp311-win32.whl", hash = "sha256:7d74ae8a1e441573b4847e3c3b4f19920c93c4c5a7245b308c2e3fc38babe9cb"},
-    {file = "uamqp-1.6.5-cp311-cp311-win_amd64.whl", hash = "sha256:5a966f054bbe4bb860ba5a1e0cf070008c6fa6e8a46cc313b30a7d89026d32c0"},
-    {file = "uamqp-1.6.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:51d009c5d90267e9aa9f879f78a511c7f17b13a5d56f2a54edd646b620595985"},
-    {file = "uamqp-1.6.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b90a9f61845dec24f40a77a6bf73d6a728e08b5017d7b55a2015e72853da39b"},
-    {file = "uamqp-1.6.5-cp37-cp37m-win32.whl", hash = "sha256:36c7850f725d2f233bd8c5872c348591293d9c5a8ae88991b173dda8cff38784"},
-    {file = "uamqp-1.6.5-cp37-cp37m-win_amd64.whl", hash = "sha256:6eba92ef3ed6ce67a184d21af2cb5cd04bcb3c2b4f534a495a03c88a0db1fb71"},
-    {file = "uamqp-1.6.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9593cb10ab9aafd2a5e398c18a912cb025773fcd084aaf98df17ef5c1ffee723"},
-    {file = "uamqp-1.6.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff12427af7f4d3eb307529c22d0293e51d4b4c790d6b94ecb1b99729ab3f2cea"},
-    {file = "uamqp-1.6.5-cp38-cp38-win32.whl", hash = "sha256:a899a76c91970a6ecd7cf4732b0fd529bca48278aa79cf6cdcb8976a611c1467"},
-    {file = "uamqp-1.6.5-cp38-cp38-win_amd64.whl", hash = "sha256:9137b463cac6d3a22da27024797bd68119d759da45872fa19b172592a68e5eac"},
-    {file = "uamqp-1.6.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c136d30b48c80fdb7098cff66943b46dd90b968f587b4725b6bdb890c531e6b2"},
-    {file = "uamqp-1.6.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60a930b8e6510c2c551fed5746d9d53fd21e93b9e05493dbe91dedd27415927d"},
-    {file = "uamqp-1.6.5-cp39-cp39-win32.whl", hash = "sha256:034ec2be6437dd9c174169e379eefa76116987ffbdba553e5294b5c59bc15efe"},
-    {file = "uamqp-1.6.5-cp39-cp39-win_amd64.whl", hash = "sha256:0504c500e00b90ad168aca8a05c6770407d906b44840cefe9c45a05516f0e8ad"},
-    {file = "uamqp-1.6.5.tar.gz", hash = "sha256:ebe35d7dd6b9a5188fd925fc9b239909fbc8e1d4e0e8d032a82e3335d7c402ec"},
+    {file = "uamqp-1.6.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a609c8ec600f7d02bbdfb3b19fc206bc31957863f254b385ca0e0e0b06db287"},
+    {file = "uamqp-1.6.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc02e4225dd5d7c93f9afd5a3bcdefa926144665223356b4c37999e39ac4609d"},
+    {file = "uamqp-1.6.6-cp310-cp310-win32.whl", hash = "sha256:6c98f4bec8b8f41863a47b07a2adc4924316758e02d8f8c2d4995df98941a89b"},
+    {file = "uamqp-1.6.6-cp310-cp310-win_amd64.whl", hash = "sha256:753a654c8b6be654d8ad95db593b1d43f46ae1dbf86c6b15eb9f7a81607ceeb3"},
+    {file = "uamqp-1.6.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8028e72b82efb13301f953bf30bf0d96de3a6b3d3bb2cbaa701d80e99f672d67"},
+    {file = "uamqp-1.6.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80e80822bc2edf9f4e1f2297078f2f106bcdaaa412be281608b90200cfd1101f"},
+    {file = "uamqp-1.6.6-cp311-cp311-win32.whl", hash = "sha256:309bc4f2083cc6dfabb1ff3b9f53ee72c8900a24331dbcba4c1fe421e82f9f46"},
+    {file = "uamqp-1.6.6-cp311-cp311-win_amd64.whl", hash = "sha256:fa195557f0f50b223cdbc9283623c6169633a8007a6c903e3efaa0419adf9c5c"},
+    {file = "uamqp-1.6.6-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e9925a352436ff1cc975503f97149fe96527bc921b98366a87d66b09412be81d"},
+    {file = "uamqp-1.6.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4cb5e53ca3665ed57dccfbc5750fa5553892d2989130f1b3661df3a6e5fe429"},
+    {file = "uamqp-1.6.6-cp312-cp312-win32.whl", hash = "sha256:f394368d80ae5bad81fc81c5ecb5c5dd7bcc4088f0ac595918ba8f109d7f62a2"},
+    {file = "uamqp-1.6.6-cp312-cp312-win_amd64.whl", hash = "sha256:1001d8e89adf05cef270023c9a957b825b32b36d4ad83db55063d760c6086943"},
+    {file = "uamqp-1.6.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e3498e7552d414768ada3e8972b44c5ff822c8482d7d35d48fa2c60935fc7d93"},
+    {file = "uamqp-1.6.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd124ca0ef998a108411d4d2b7da057cf34fbac3d6ff4ac229776160c75feb24"},
+    {file = "uamqp-1.6.6-cp38-cp38-win32.whl", hash = "sha256:6eae6f57fb69b747a8dc9040acd5a216b5f8fcd9255fb04c8833a3db58ca132e"},
+    {file = "uamqp-1.6.6-cp38-cp38-win_amd64.whl", hash = "sha256:5b8c1eb0a9cd6e19934fa624c6c99312a6a46cf8eefda8b5bb8892ee017aa99a"},
+    {file = "uamqp-1.6.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5ddf588af47f83a19bb1fb9c30b424d2649a181b2e5eff036c8904148d65ffe1"},
+    {file = "uamqp-1.6.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fe448bdd4b8e2d7b99cc68b896417c1855bb2a0116b23901638e42a6c44c7a2"},
+    {file = "uamqp-1.6.6-cp39-cp39-win32.whl", hash = "sha256:2bb1ad0853964d433cd8ecee02ea79a52547177dd823206dc3d79dbbc8f3ad3e"},
+    {file = "uamqp-1.6.6-cp39-cp39-win_amd64.whl", hash = "sha256:2d7401f053a7446954b53f970006c6702fa5077084ecc1e1bcdb9cd11f937420"},
+    {file = "uamqp-1.6.6.tar.gz", hash = "sha256:f5fb23c16f11245e6fe93d33f8be04ba12383ad7e93342eaaff368d11da05f9a"},
 ]
 
 [package.dependencies]
@@ -2881,6 +2950,7 @@ docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.link
 testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
 
 [extras]
+aws = ["boto3"]
 azure = ["adlfs", "azure-identity", "azure-keyvault-secrets", "azure-mgmt-storage", "azure-servicebus", "azure-storage-blob"]
 caching = ["redis"]
 databases = ["SQLAlchemy", "pyodbc"]
@@ -2894,4 +2964,4 @@ trino = ["trino"]
 [metadata]
 lock-version = "2.0"
 python-versions = ">=3.9, <3.12"
-content-hash = "4c1bae6d7b7cc0255419fdadca6da3a3594ea46f76b51eaf60620ced167f0444"
+content-hash = "d258fe6e3fa27501107e3a1e28f51d48e976aec05fe6b3a11372d4657994eca6"
diff --git a/pyproject.toml b/pyproject.toml
index 27743f49..ddd7b6ad 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,6 +29,8 @@ azure-keyvault-secrets = { version = "^4.3", optional = true }
 azure-servicebus = { version = "~7.6", optional = true }
 adlfs = { version = '~2023', optional = true }
 
+boto3 = { version = "1.29.0", optional = true}
+
 datadog = { version = "~0.47.0", optional = true }
 datadog-api-client = { version = "~2.18.0", optional = true }
 
@@ -88,6 +90,10 @@ storage = [
     "cassandra-driver"
 ]
 
+aws = [
+    "boto3"
+]
+
 [tool.poetry.group.dev.dependencies]
 pytest = "^7.1"
 pytest-mock = "~3.6.1"


Test patch:
diff --git a/tests/test_s3_storage_client.py b/tests/test_s3_storage_client.py
new file mode 100644
index 00000000..b8956424
--- /dev/null
+++ b/tests/test_s3_storage_client.py
@@ -0,0 +1,12 @@
+from adapta.storage.models.aws import S3Path
+
+
+def test_from_hdfs_path():
+    path = S3Path.from_hdfs_path("s3a://bucket/nested/key")
+    assert path.bucket == "bucket"
+    assert path.path == "nested/key"
+
+
+def test_to_hdfs_path():
+    path = S3Path("bucket", "nested/key").to_hdfs_path()
+    assert path == "s3a://bucket/nested/key"