Skip to content

Commit ed8b06e

Browse files
Merge pull request #34 from SenteraLLC/feature/multi-bucket
Feature/multi bucket
2 parents e5e21a0 + bf97625 commit ed8b06e

21 files changed

Lines changed: 1927 additions & 145 deletions

.github/pull_request_template.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
## PR Checklist
44
- [ ] Merged latest master
55
- [ ] Updated version number in `pyproject.toml`.
6+
- [ ] Added tests for new features or bug fixes.
7+
- [ ] Passed all tests
68
- [ ] Update README.md if needed.
79

810
## Breaking Changes

.github/tasks.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
11
## Tasks
2-
- []

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,25 @@ S3MPConfig.set_mirror_root("s3_mirror")
153153
S3MPConfig.assume_role("arn:aws:iam::<account-id>:role/<role-name>")
154154
```
155155

156+
To manage projects that require managing different S3 buckets or different IAM roles, you can create different `MirrorPath` objects with different `bucket_key` and `iam_role_arn` parameters. These will use the appropriate sessions and clients under the hood, so you can easily interact with multiple buckets and roles within the same project if necessary. When no bucket or role is specified, the defaults from `S3MPConfig` are used — if `S3MPConfig.assume_role(...)` was called, that role becomes the default session; otherwise the ambient AWS credentials are used.
157+
158+
```python
159+
from S3MP.mirror_path import MirrorPath
160+
161+
# MirrorPath using the default bucket and default session from S3MPConfig
162+
default_mp = MirrorPath.from_s3_key("path/to/object.jpg")
163+
# MirrorPath using specific bucket and IAM role
164+
custom_mp = MirrorPath.from_s3_key(
165+
"path/to/object.jpg",
166+
bucket_key="custom-bucket",
167+
iam_role_arn="arn:aws:iam::<account-id>:role/<role-name>"
168+
)
169+
# MirrorPath using bucket from s3 url and the default session
170+
# The bucket key will be parsed from the url
171+
url_mp = MirrorPath.from_s3_key("s3://custom-bucket/path/to/object.jpg")
172+
```
173+
174+
156175
## Installation
157176
[uv](https://docs.astral.sh/uv/) is a fast, cross-platform Python package installer and resolver.
158177

S3MP/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""S3 MirrorPath package."""
22

33
from S3MP._version import __version__
4+
from S3MP.global_config import S3Session
45

5-
__all__ = ["__version__"]
6+
__all__ = ["__version__", "S3Session"]

S3MP/async_utils.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,20 @@
1212
async def async_upload_from_mirror(mirror_path: MirrorPath):
1313
"""Asynchronously upload a file from a MirrorPath."""
1414
session = aioboto3.Session()
15+
if mirror_path.iam_role_arn:
16+
async with session.client("sts") as sts_client:
17+
assumed = await sts_client.assume_role(
18+
RoleArn=mirror_path.iam_role_arn,
19+
RoleSessionName="S3MPAsyncUploadSession",
20+
)
21+
creds = assumed["Credentials"]
22+
session = aioboto3.Session(
23+
aws_access_key_id=creds["AccessKeyId"],
24+
aws_secret_access_key=creds["SecretAccessKey"],
25+
aws_session_token=creds["SessionToken"],
26+
)
1527
async with session.resource("s3") as s3_resource:
16-
bucket = await s3_resource.Bucket(S3MPConfig.default_bucket_key)
28+
bucket = s3_resource.Bucket(mirror_path.bucket_key)
1729
await bucket.upload_file(str(mirror_path.local_path), mirror_path.s3_key)
1830

1931

@@ -22,7 +34,7 @@ def upload_from_mirror_thread(
2234
) -> Coroutine:
2335
"""Upload from mirror on a separate thread."""
2436
return asyncio.to_thread(
25-
S3MPConfig.bucket.upload_file,
37+
mirror_path.bucket.upload_file,
2638
str(mirror_path.local_path),
2739
mirror_path.s3_key,
2840
Callback=S3MPConfig.callback,

S3MP/callbacks.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,22 +31,27 @@ def __init__(
3131
"""
3232
if transfer_objs is None:
3333
return
34+
if not isinstance(transfer_objs, list):
35+
transfer_objs = [transfer_objs]
36+
37+
# Fall back to global defaults for non-MirrorPath objects
3438
if resource is None:
3539
resource = S3MPConfig.s3_resource
3640
if bucket_key is None:
3741
bucket_key = S3MPConfig.default_bucket_key
38-
if not isinstance(transfer_objs, list):
39-
transfer_objs = [transfer_objs]
4042

4143
self._total_bytes = 0
4244
for transfer_mapping in transfer_objs:
4345
if is_download:
44-
s3_key = str(
45-
transfer_mapping.s3_key
46-
if isinstance(transfer_mapping, MirrorPath)
47-
else transfer_mapping
48-
)
49-
self._total_bytes += resource.Object(bucket_key, s3_key).content_length
46+
if isinstance(transfer_mapping, MirrorPath):
47+
mp_resource = transfer_mapping.session.s3_resource
48+
mp_bucket_key = transfer_mapping.bucket_key
49+
s3_key = transfer_mapping.s3_key
50+
else:
51+
mp_resource = resource
52+
mp_bucket_key = bucket_key
53+
s3_key = str(transfer_mapping)
54+
self._total_bytes += mp_resource.Object(mp_bucket_key, s3_key).content_length
5055
else:
5156
local_path = (
5257
transfer_mapping.local_path

S3MP/global_config.py

Lines changed: 90 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Set global values for S3MP module."""
22

3+
from __future__ import annotations
4+
35
import tempfile
46
from collections.abc import Callable
57
from configparser import ConfigParser
@@ -13,6 +15,58 @@
1315
from S3MP.types import S3Bucket, S3Client, S3Resource, S3TransferConfig
1416

1517

18+
@dataclass
19+
class S3Session:
20+
"""Holds cached boto3 objects for a single IAM credential context."""
21+
22+
s3_client: S3Client
23+
s3_resource: S3Resource
24+
_bucket_map: dict[str, S3Bucket] | None = None
25+
26+
@staticmethod
27+
def from_role_arn(role_arn: str, boto3_config: Config | None = None) -> S3Session:
28+
"""Create a session by assuming an IAM role."""
29+
sts_client = boto3.client("sts")
30+
assumed_role = sts_client.assume_role(
31+
RoleArn=role_arn, RoleSessionName="S3MPAssumeRoleSession"
32+
)
33+
credentials = assumed_role["Credentials"]
34+
cfg = boto3_config or Config()
35+
return S3Session(
36+
s3_client=boto3.client(
37+
"s3",
38+
aws_access_key_id=credentials["AccessKeyId"],
39+
aws_secret_access_key=credentials["SecretAccessKey"],
40+
aws_session_token=credentials["SessionToken"],
41+
config=cfg,
42+
),
43+
s3_resource=boto3.resource(
44+
"s3",
45+
aws_access_key_id=credentials["AccessKeyId"],
46+
aws_secret_access_key=credentials["SecretAccessKey"],
47+
aws_session_token=credentials["SessionToken"],
48+
config=cfg,
49+
),
50+
)
51+
52+
@staticmethod
53+
def no_role(boto3_config: Config | None = None) -> S3Session:
54+
"""Create a session using no IAM role."""
55+
cfg = boto3_config or Config()
56+
return S3Session(
57+
s3_client=boto3.client("s3", config=cfg),
58+
s3_resource=boto3.resource("s3", config=cfg),
59+
)
60+
61+
def get_bucket(self, bucket_key: str) -> S3Bucket:
62+
"""Get boto3 S3Bucket object."""
63+
if self._bucket_map is None:
64+
self._bucket_map = {}
65+
if bucket_key not in self._bucket_map:
66+
self._bucket_map[bucket_key] = self.s3_resource.Bucket(bucket_key)
67+
return self._bucket_map[bucket_key]
68+
69+
1670
def get_config_file_path() -> Path:
1771
"""Get the location of the config file."""
1872
root_module_folder = Path(__file__).parent.resolve()
@@ -33,10 +87,8 @@ def __call__(cls, *args, **kwargs):
3387
class _S3MPConfigClass(metaclass=Singleton):
3488
"""Singleton class for S3MP globals."""
3589

36-
# Boto3 Objects
37-
_s3_client: S3Client | None = None
38-
_s3_resource: S3Resource | None = None
39-
_bucket: S3Bucket | None = None
90+
# Session registry: maps role ARN -> S3Session (None key = default session)
91+
_session_map: dict[str | None, S3Session] | None = None
4092
_boto3_config: Config | None = None
4193

4294
# Config Items
@@ -50,32 +102,28 @@ class _S3MPConfigClass(metaclass=Singleton):
50102
callback: Callable | None = None
51103
use_async_global_thread_queue: bool = True
52104

53-
def assume_role(self, role_arn: str) -> None:
54-
"""Assume an IAM role and update the S3 client and resource with the new credentials."""
55-
sts_client = boto3.client("sts")
56-
assumed_role = sts_client.assume_role(
57-
RoleArn=role_arn, RoleSessionName="S3MPAssumeRoleSession"
58-
)
59-
credentials = assumed_role["Credentials"]
105+
def get_session(self, role_arn: str | None = None) -> S3Session:
106+
"""Get or create a cached S3Session for the given role ARN.
60107
61-
self._s3_client = boto3.client(
62-
"s3",
63-
aws_access_key_id=credentials["AccessKeyId"],
64-
aws_secret_access_key=credentials["SecretAccessKey"],
65-
aws_session_token=credentials["SessionToken"],
66-
config=self.boto3_config,
67-
)
68-
self._s3_resource = boto3.resource(
69-
"s3",
70-
aws_access_key_id=credentials["AccessKeyId"],
71-
aws_secret_access_key=credentials["SecretAccessKey"],
72-
aws_session_token=credentials["SessionToken"],
73-
config=self.boto3_config,
74-
)
75-
self._iam_role_arn = role_arn
108+
Args:
109+
role_arn: IAM role ARN. None uses the no-role session.
110+
"""
111+
if self._session_map is None:
112+
self._session_map = {}
113+
114+
if role_arn not in self._session_map:
115+
if role_arn is not None:
116+
self._session_map[role_arn] = S3Session.from_role_arn(role_arn, self.boto3_config)
117+
else:
118+
self._session_map[None] = S3Session.no_role(self.boto3_config)
76119

77-
# Clear cached bucket
78-
self._bucket = None
120+
return self._session_map[role_arn]
121+
122+
def assume_role(self, role_arn: str) -> None:
123+
"""Set the default IAM role for the global config."""
124+
self._iam_role_arn = role_arn
125+
# Pre-cache the session for this role
126+
self.get_session(role_arn)
79127

80128
@property
81129
def default_bucket_key(self) -> str:
@@ -89,14 +137,10 @@ def default_bucket_key(self) -> str:
89137
def set_default_bucket_key(self, bucket_key: str) -> None:
90138
"""Set default bucket key."""
91139
self._default_bucket_key = bucket_key
92-
# Clear cached bucket
93-
self._bucket = None
94140

95141
def clear_boto3_cache(self) -> None:
96-
"""Clear cached boto3 client and resource."""
97-
self._s3_client = None
98-
self._s3_resource = None
99-
self._bucket = None
142+
"""Clear cached boto3 sessions, config, and buckets."""
143+
self._session_map = {}
100144
self._boto3_config = None
101145

102146
@property
@@ -123,34 +167,24 @@ def boto3_config(self) -> Config:
123167
return self._boto3_config
124168

125169
@property
126-
def s3_client(self) -> S3Client:
127-
"""Get S3 client."""
128-
if not self._s3_client and self._iam_role_arn:
129-
self.assume_role(self._iam_role_arn)
130-
131-
if not self._s3_client:
132-
self._s3_client = boto3.client("s3", config=self.boto3_config)
170+
def default_session(self) -> S3Session:
171+
"""Get the default session (uses default role if set, otherwise default credentials)."""
172+
return self.get_session(self._iam_role_arn)
133173

134-
return self._s3_client
174+
@property
175+
def s3_client(self) -> S3Client:
176+
"""Get S3 client from the default session."""
177+
return self.default_session.s3_client
135178

136179
@property
137180
def s3_resource(self) -> S3Resource:
138-
"""Get S3 resource."""
139-
if not self._s3_resource and self._iam_role_arn:
140-
self.assume_role(self._iam_role_arn)
141-
142-
if not self._s3_resource:
143-
self._s3_resource = boto3.resource("s3", config=self.boto3_config)
144-
145-
return self._s3_resource
181+
"""Get S3 resource from the default session."""
182+
return self.default_session.s3_resource
146183

147184
def get_bucket(self, bucket_key: str | None = None) -> S3Bucket:
148-
"""Get bucket."""
149-
if bucket_key:
150-
return self.s3_resource.Bucket(bucket_key)
151-
elif self._bucket is None:
152-
self._bucket = self.s3_resource.Bucket(self.default_bucket_key)
153-
return self._bucket
185+
"""Get boto3 S3Bucket object from the default session."""
186+
bucket_key = bucket_key or self.default_bucket_key
187+
return self.default_session.get_bucket(bucket_key)
154188

155189
@property
156190
def bucket(self) -> S3Bucket:

0 commit comments

Comments
 (0)