boto3-refresh-session 1.0.36__py3-none-any.whl → 6.2.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,437 @@
1
+ """This module defines the core building blocks used by `RefreshableSession`
2
+ and method-specific session classes. The intent is to separate registry
3
+ mechanics, credential refresh contracts, and boto3 session behavior so each
4
+ concern is clear and testable.
5
+
6
+ `Registry` is a lightweight class-level registry. Subclasses register
7
+ themselves by key at import time, enabling factory-style lookup without
8
+ hard-coded class references. This is how `RefreshableSession` discovers method
9
+ implementations.
10
+
11
+ `refreshable_session` is a decorator that wraps `__init__` and guarantees a
12
+ `__post_init__` hook runs after `boto3.Session` initialization. This allows
13
+ `BRSSession` to create refreshable credentials only after the boto3 Session is
14
+ set up, avoiding circular and ordering issues. It also prevents double
15
+ wrapping on repeated decoration.
16
+
17
+ `CredentialProvider` is a small abstract class that defines the contract for
18
+ refreshable sessions: implement `_get_credentials` (returns temporary creds)
19
+ and `get_identity` (describes the caller identity). The concrete refresh
20
+ methods (STS, IoT, custom) only need to satisfy this interface.
21
+
22
+ `BRSSession` is the concrete wrapper over `boto3.Session`. It owns refreshable
23
+ credential construction, wiring the botocore session to those credentials, and
24
+ client caching with normalized cache keys. It acts as the base implementation
25
+ for session mechanics.
26
+
27
+ `BaseRefreshableSession` and `BaseIoTRefreshableSession` combine `Registry`,
28
+ `CredentialProvider`, and `BRSSession` to create abstract roots for method
29
+ families. They do not implement credential retrieval themselves, but provide a
30
+ common surface and registration behavior for subclasses like STS or IoT X.509.
31
+ """
32
+
33
+ __all__ = [
34
+ "AWSCRTResponse",
35
+ "BaseIoTRefreshableSession",
36
+ "BaseRefreshableSession",
37
+ "BRSSession",
38
+ "CredentialProvider",
39
+ "Registry",
40
+ "refreshable_session",
41
+ ]
42
+
43
+ from abc import ABC, abstractmethod
44
+ from functools import wraps
45
+ from typing import Any, Callable, ClassVar, Generic, TypeVar, cast
46
+
47
+ from awscrt.http import HttpHeaders
48
+ from boto3.session import Session
49
+ from botocore.client import BaseClient
50
+ from botocore.config import Config
51
+ from botocore.credentials import (
52
+ DeferredRefreshableCredentials,
53
+ RefreshableCredentials,
54
+ )
55
+
56
+ from ..exceptions import BRSError, BRSWarning
57
+ from .cache import ClientCache
58
+ from .typing import (
59
+ Identity,
60
+ IoTAuthenticationMethod,
61
+ Method,
62
+ RefreshMethod,
63
+ RegistryKey,
64
+ TemporaryCredentials,
65
+ )
66
+
67
+
68
+ def _freeze_value(value: Any) -> Any:
69
+ """Recursively freezes a value for use in cache keys.
70
+
71
+ Parameters
72
+ ----------
73
+ value : Any
74
+ The value to freeze.
75
+ """
76
+
77
+ if isinstance(value, dict):
78
+ return tuple(
79
+ sorted((key, _freeze_value(val)) for key, val in value.items())
80
+ )
81
+
82
+ # checking for list, tuple, or set just to be safe
83
+ if isinstance(value, (list, tuple, set)):
84
+ return tuple(sorted(_freeze_value(item) for item in value))
85
+ return value
86
+
87
+
88
+ def _config_cache_key(config: Config | None) -> Any:
89
+ """Generates a cache key for a botocore.config.Config object.
90
+
91
+ Parameters
92
+ ----------
93
+ config : Config | None
94
+ The Config object to generate a cache key for.
95
+ """
96
+
97
+ if config is None:
98
+ return None
99
+
100
+ # checking for user-provided options first
101
+ options = getattr(config, "_user_provided_options", None)
102
+ if options is None:
103
+ # __dict__ is pedantic but stable
104
+ return _freeze_value(getattr(config, "__dict__", {}))
105
+ return _freeze_value(options)
106
+
107
+
108
+ class CredentialProvider(ABC):
109
+ """Defines the abstract surface every refreshable session must expose."""
110
+
111
+ @abstractmethod
112
+ def _get_credentials(self) -> TemporaryCredentials: ...
113
+
114
+ @abstractmethod
115
+ def get_identity(self) -> Identity: ...
116
+
117
+
118
+ class Registry(Generic[RegistryKey]):
119
+ """Gives any hierarchy a class-level registry."""
120
+
121
+ registry: ClassVar[dict[str, type]] = {}
122
+
123
+ def __init_subclass__(cls, *, registry_key: RegistryKey, **kwargs: Any):
124
+ super().__init_subclass__(**kwargs)
125
+
126
+ if registry_key in cls.registry:
127
+ BRSWarning.warn(
128
+ f"{registry_key!r} already registered. Overwriting."
129
+ )
130
+
131
+ if "sentinel" not in registry_key:
132
+ cls.registry[registry_key] = cls
133
+
134
+ @classmethod
135
+ def items(cls) -> dict[str, type]:
136
+ """Typed accessor for introspection / debugging."""
137
+
138
+ return dict(cls.registry)
139
+
140
+
141
+ # defining this here instead of utils to avoid circular imports lol
142
+ T_BRSSession = TypeVar("T_BRSSession", bound="BRSSession")
143
+
144
+ #: Type alias for a generic refreshable session type.
145
+ BRSSessionType = type[T_BRSSession]
146
+
147
+
148
+ def refreshable_session(
149
+ cls: BRSSessionType,
150
+ ) -> BRSSessionType:
151
+ """Wraps cls.__init__ so self.__post_init__ runs after init (if present).
152
+
153
+ In plain English: this is essentially a post-initialization hook.
154
+
155
+ Returns
156
+ -------
157
+ BRSSessionType
158
+ The decorated class.
159
+ """
160
+
161
+ init = getattr(cls, "__init__", None)
162
+
163
+ # synthesize __init__ if undefined in the class
164
+ if init in (None, object.__init__):
165
+
166
+ def __init__(self, *args, **kwargs):
167
+ super(cls, self).__init__(*args, **kwargs)
168
+ post = getattr(self, "__post_init__", None)
169
+ if callable(post) and not getattr(self, "_post_inited", False):
170
+ post()
171
+ setattr(self, "_post_inited", True)
172
+
173
+ cls.__init__ = __init__ # type: ignore[assignment]
174
+ return cls
175
+
176
+ # avoids double wrapping
177
+ if getattr(init, "__post_init_wrapped__", False):
178
+ return cls
179
+
180
+ @wraps(init)
181
+ def wrapper(self, *args, **kwargs):
182
+ init(self, *args, **kwargs)
183
+ post = getattr(self, "__post_init__", None)
184
+ if callable(post) and not getattr(self, "_post_inited", False):
185
+ post()
186
+ setattr(self, "_post_inited", True)
187
+
188
+ wrapper.__post_init_wrapped__ = True # type: ignore[attr-defined]
189
+ cls.__init__ = cast(Callable[..., None], wrapper)
190
+ return cls
191
+
192
+
193
+ class BRSSession(Session):
194
+ """Wrapper for boto3.session.Session.
195
+
196
+ Parameters
197
+ ----------
198
+ refresh_method : RefreshMethod
199
+ The method to use for refreshing temporary credentials.
200
+ defer_refresh : bool, optional
201
+ If ``True`` then temporary credentials are not automatically refreshed
202
+ until they are explicitly needed. If ``False`` then temporary
203
+ credentials refresh immediately upon expiration. It is highly
204
+ recommended that you use ``True``. Default is ``True``.
205
+ advisory_timeout : int, optional
206
+ USE THIS ARGUMENT WITH CAUTION!!!
207
+
208
+ Botocore will attempt to refresh credentials early according to
209
+ this value (in seconds), but will continue using the existing
210
+ credentials if refresh fails. Default is 15 minutes (900 seconds).
211
+ mandatory_timeout : int, optional
212
+ USE THIS ARGUMENT WITH CAUTION!!!
213
+
214
+ Botocore requires a successful refresh before continuing. If
215
+ refresh fails in this window (in seconds), API calls may fail.
216
+ Default is 10 minutes (600 seconds).
217
+ cache_clients : bool, optional
218
+ If ``True`` then clients created by this session will be cached and
219
+ reused for subsequent calls to :meth:`client()` with the same
220
+ parameter signatures. Due to the memory overhead of clients, the
221
+ default is ``True`` in order to protect system resources.
222
+
223
+ Other Parameters
224
+ ----------------
225
+ kwargs : Any
226
+ Optional keyword arguments for initializing boto3.session.Session.
227
+ """
228
+
229
+ def __init__(
230
+ self,
231
+ refresh_method: RefreshMethod,
232
+ defer_refresh: bool | None = None,
233
+ advisory_timeout: int | None = None,
234
+ mandatory_timeout: int | None = None,
235
+ cache_clients: bool | None = None,
236
+ **kwargs,
237
+ ):
238
+ # initializing parameters
239
+ self.refresh_method: RefreshMethod = refresh_method
240
+ self.defer_refresh: bool = defer_refresh is not False
241
+ self.advisory_timeout: int | None = advisory_timeout
242
+ self.mandatory_timeout: int | None = mandatory_timeout
243
+ self.cache_clients: bool | None = cache_clients is not False
244
+
245
+ # initializing Session
246
+ super().__init__(**kwargs)
247
+
248
+ # initializing client cache
249
+ self._client_cache: ClientCache = ClientCache()
250
+
251
+ def __post_init__(self):
252
+ if not self.defer_refresh:
253
+ self._credentials = RefreshableCredentials.create_from_metadata(
254
+ metadata=self._get_credentials(),
255
+ refresh_using=self._get_credentials,
256
+ method=self.refresh_method,
257
+ advisory_timeout=self.advisory_timeout,
258
+ mandatory_timeout=self.mandatory_timeout,
259
+ )
260
+ else:
261
+ self._credentials = DeferredRefreshableCredentials(
262
+ refresh_using=self._get_credentials, method=self.refresh_method
263
+ )
264
+
265
+ # without this, boto3 won't use the refreshed credentials properly in
266
+ # clients and resources, depending on how they were created
267
+ self._session._credentials = self._credentials
268
+
269
+ def client(self, *args, **kwargs) -> BaseClient:
270
+ """Creates a low-level service client by name.
271
+
272
+ Parameters
273
+ ----------
274
+ *args : Any
275
+ Positional arguments for :class:`boto3.session.Session.client`.
276
+ **kwargs : Any
277
+ Keyword arguments for :class:`boto3.session.Session.client`.
278
+
279
+ Returns
280
+ -------
281
+ BaseClient
282
+ A low-level service client.
283
+
284
+ Notes
285
+ -----
286
+ This method overrides the default
287
+ :meth:`boto3.session.Session.client` method. If client caching is
288
+ enabled, it will return a cached client instance for the given
289
+ service and parameters. Else, it will create and return a new client
290
+ instance.
291
+ """
292
+
293
+ # check if caching is enabled
294
+ if self.cache_clients:
295
+ # checking if Config was passed as a positional arg
296
+ _args = [
297
+ _config_cache_key(arg) if isinstance(arg, Config) else arg
298
+ for arg in args
299
+ ]
300
+
301
+ # popping trailing None values from args, preserving None in middle
302
+ while _args and _args[-1] is None:
303
+ _args.pop()
304
+ _args = tuple(_args)
305
+
306
+ # checking if Config was passed as a keyword arg
307
+ _kwargs = kwargs.copy()
308
+ if _kwargs.get("config") is not None:
309
+ _kwargs["config"] = _config_cache_key(_kwargs["config"])
310
+
311
+ # preemptively removing None values from kwargs
312
+ _kwargs = {
313
+ key: value
314
+ for key, value in _kwargs.items()
315
+ if value is not None
316
+ }
317
+
318
+ # creating a unique key for the client cache
319
+ key = (_args, tuple(sorted(_kwargs.items())))
320
+
321
+ # if client exists in cache, return it
322
+ if (_cached_client := self._client_cache.get(key)) is not None:
323
+ return _cached_client
324
+
325
+ # else -- initialize, cache, and return it
326
+ client = super().client(*args, **kwargs)
327
+
328
+ # attempting to cache and return the client
329
+ try:
330
+ self._client_cache[key] = client
331
+ return client
332
+
333
+ # if caching fails, return cached client if possible
334
+ except BRSError:
335
+ return (
336
+ cached
337
+ if (cached := self._client_cache.get(key)) is not None
338
+ else client
339
+ )
340
+
341
+ # return a new client if caching is disabled
342
+ else:
343
+ return super().client(*args, **kwargs)
344
+
345
+ def refreshable_credentials(self) -> TemporaryCredentials:
346
+ """The current temporary AWS security credentials.
347
+
348
+ Returns
349
+ -------
350
+ TemporaryCredentials
351
+ Temporary AWS security credentials containing:
352
+ access_key : str
353
+ AWS access key identifier.
354
+ secret_key : str
355
+ AWS secret access key.
356
+ token : str
357
+ AWS session token.
358
+ expiry_time : str
359
+ Expiration timestamp in ISO 8601 format.
360
+ """
361
+
362
+ creds = (
363
+ self._credentials
364
+ if self._credentials is not None
365
+ else self.get_credentials()
366
+ )
367
+ frozen_creds = creds.get_frozen_credentials()
368
+ return {
369
+ "access_key": frozen_creds.access_key,
370
+ "secret_key": frozen_creds.secret_key,
371
+ "token": frozen_creds.token,
372
+ "expiry_time": creds._expiry_time.isoformat(),
373
+ }
374
+
375
+ @property
376
+ def credentials(self) -> TemporaryCredentials:
377
+ """The current temporary AWS security credentials."""
378
+
379
+ return self.refreshable_credentials()
380
+
381
+
382
+ class BaseRefreshableSession(
383
+ Registry[Method],
384
+ CredentialProvider,
385
+ BRSSession,
386
+ registry_key="__sentinel__",
387
+ ):
388
+ """Abstract base class for implementing refreshable AWS sessions.
389
+
390
+ Provides a common interface and factory registration mechanism
391
+ for subclasses that generate temporary credentials using various
392
+ AWS authentication methods (e.g., STS).
393
+
394
+ Subclasses must implement ``_get_credentials()`` and ``get_identity()``.
395
+ They should also register themselves using the ``method=...`` argument
396
+ to ``__init_subclass__``.
397
+
398
+ Parameters
399
+ ----------
400
+ registry : dict[str, type[BaseRefreshableSession]]
401
+ Class-level registry mapping method names to registered session types.
402
+ """
403
+
404
+ def __init__(self, **kwargs):
405
+ super().__init__(**kwargs)
406
+
407
+
408
+ class BaseIoTRefreshableSession(
409
+ Registry[IoTAuthenticationMethod],
410
+ CredentialProvider,
411
+ BRSSession,
412
+ registry_key="__iot_sentinel__",
413
+ ):
414
+ def __init__(self, **kwargs):
415
+ super().__init__(**kwargs)
416
+
417
+
418
+ class AWSCRTResponse:
419
+ """Lightweight response collector for awscrt HTTP."""
420
+
421
+ def __init__(self):
422
+ """Initialize to default for when callbacks are called."""
423
+
424
+ self.status_code = None
425
+ self.headers = None
426
+ self.body = bytearray()
427
+
428
+ def on_response(self, http_stream, status_code, headers, **kwargs):
429
+ """Process awscrt.io response."""
430
+
431
+ self.status_code = status_code
432
+ self.headers = HttpHeaders(headers)
433
+
434
+ def on_body(self, http_stream, chunk, **kwargs):
435
+ """Process awscrt.io body."""
436
+
437
+ self.body.extend(chunk)
@@ -0,0 +1,133 @@
1
+ """Shared type definitions used across refreshable session modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "AssumeRoleParams",
7
+ "CustomCredentialsMethod",
8
+ "CustomCredentialsMethodArgs",
9
+ "Identity",
10
+ "IoTAuthenticationMethod",
11
+ "Method",
12
+ "PKCS11",
13
+ "RefreshMethod",
14
+ "RegistryKey",
15
+ "STSClientParams",
16
+ "TemporaryCredentials",
17
+ "Transport",
18
+ ]
19
+
20
+ from datetime import datetime
21
+ from typing import (
22
+ Any,
23
+ List,
24
+ Literal,
25
+ Mapping,
26
+ Protocol,
27
+ TypeAlias,
28
+ TypedDict,
29
+ TypeVar,
30
+ )
31
+
32
+ try:
33
+ from typing import NotRequired # type: ignore[import]
34
+ except ImportError:
35
+ from typing_extensions import NotRequired
36
+
37
+ #: Type alias for all currently available IoT authentication methods.
38
+ IoTAuthenticationMethod = Literal["x509", "__iot_sentinel__"]
39
+
40
+ #: Type alias for all currently available credential refresh methods.
41
+ Method = Literal[
42
+ "custom",
43
+ "iot",
44
+ "sts",
45
+ "__sentinel__",
46
+ "__iot_sentinel__",
47
+ ]
48
+
49
+ #: Type alias for all refresh method names.
50
+ RefreshMethod = Literal[
51
+ "custom",
52
+ "iot-x509",
53
+ "sts-assume-role",
54
+ ]
55
+
56
+ #: Type alias for all currently registered credential refresh methods.
57
+ RegistryKey = TypeVar("RegistryKey", bound=str)
58
+
59
+ #: Type alias for values returned by get_identity
60
+ Identity: TypeAlias = dict[str, Any]
61
+
62
+ #: Type alias for acceptable transports
63
+ Transport: TypeAlias = Literal["x509", "ws"]
64
+
65
+
66
+ class TemporaryCredentials(TypedDict):
67
+ """Temporary IAM credentials."""
68
+
69
+ access_key: str
70
+ secret_key: str
71
+ token: str
72
+ expiry_time: datetime | str
73
+
74
+
75
+ class _CustomCredentialsMethod(Protocol):
76
+ def __call__(self, **kwargs: Any) -> TemporaryCredentials: ...
77
+
78
+
79
+ #: Type alias for custom credential retrieval methods.
80
+ CustomCredentialsMethod: TypeAlias = _CustomCredentialsMethod
81
+
82
+ #: Type alias for custom credential method arguments.
83
+ CustomCredentialsMethodArgs: TypeAlias = Mapping[str, Any]
84
+
85
+
86
+ class Tag(TypedDict):
87
+ Key: str
88
+ Value: str
89
+
90
+
91
+ class PolicyDescriptorType(TypedDict):
92
+ arn: str
93
+
94
+
95
+ class ProvidedContext(TypedDict):
96
+ ProviderArn: str
97
+ ContextAssertion: str
98
+
99
+
100
+ class AssumeRoleParams(TypedDict):
101
+ RoleArn: str
102
+ RoleSessionName: str
103
+ PolicyArns: NotRequired[List[PolicyDescriptorType]]
104
+ Policy: NotRequired[str]
105
+ DurationSeconds: NotRequired[int]
106
+ ExternalId: NotRequired[str]
107
+ SerialNumber: NotRequired[str]
108
+ TokenCode: NotRequired[str]
109
+ Tags: NotRequired[List[Tag]]
110
+ TransitiveTagKeys: NotRequired[List[str]]
111
+ SourceIdentity: NotRequired[str]
112
+ ProvidedContexts: NotRequired[List[ProvidedContext]]
113
+
114
+
115
+ class STSClientParams(TypedDict):
116
+ region_name: NotRequired[str]
117
+ api_version: NotRequired[str]
118
+ use_ssl: NotRequired[bool]
119
+ verify: NotRequired[bool | str]
120
+ endpoint_url: NotRequired[str]
121
+ aws_access_key_id: NotRequired[str]
122
+ aws_secret_access_key: NotRequired[str]
123
+ aws_session_token: NotRequired[str]
124
+ config: NotRequired[Any]
125
+ aws_account_id: NotRequired[str]
126
+
127
+
128
+ class PKCS11(TypedDict):
129
+ pkcs11_lib: str
130
+ user_pin: NotRequired[str]
131
+ slot_id: NotRequired[int]
132
+ token_label: NotRequired[str | None]
133
+ private_key_label: NotRequired[str | None]