boto3-refresh-session 2.0.5__py3-none-any.whl → 7.0.1__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.

Potentially problematic release.


This version of boto3-refresh-session might be problematic. Click here for more details.

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