11"""Set global values for S3MP module."""
22
3+ from __future__ import annotations
4+
35import tempfile
46from collections .abc import Callable
57from configparser import ConfigParser
1315from 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+
1670def 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):
3387class _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