1
1
"""
2
2
Base for Parameter providers
3
3
"""
4
+ from __future__ import annotations
4
5
5
6
import base64
6
7
import json
7
8
from abc import ABC , abstractmethod
8
- from collections import namedtuple
9
9
from datetime import datetime , timedelta
10
- from typing import TYPE_CHECKING , Any , Dict , Optional , Tuple , Type , Union
10
+ from typing import (
11
+ TYPE_CHECKING ,
12
+ Any ,
13
+ Callable ,
14
+ Dict ,
15
+ NamedTuple ,
16
+ Optional ,
17
+ Tuple ,
18
+ Type ,
19
+ Union ,
20
+ cast ,
21
+ overload ,
22
+ )
11
23
12
24
import boto3
13
25
from botocore .config import Config
14
26
27
+ from aws_lambda_powertools .utilities .parameters .types import TransformOptions
28
+
15
29
from .exceptions import GetParameterError , TransformParameterError
16
30
17
31
if TYPE_CHECKING :
22
36
23
37
24
38
DEFAULT_MAX_AGE_SECS = 5
25
- ExpirableValue = namedtuple ("ExpirableValue" , ["value" , "ttl" ])
26
39
# These providers will be dynamically initialized on first use of the helper functions
27
40
DEFAULT_PROVIDERS : Dict [str , Any ] = {}
28
41
TRANSFORM_METHOD_JSON = "json"
29
42
TRANSFORM_METHOD_BINARY = "binary"
30
43
SUPPORTED_TRANSFORM_METHODS = [TRANSFORM_METHOD_JSON , TRANSFORM_METHOD_BINARY ]
31
44
ParameterClients = Union ["AppConfigDataClient" , "SecretsManagerClient" , "SSMClient" ]
32
45
46
+ TRANSFORM_METHOD_MAPPING = {
47
+ TRANSFORM_METHOD_JSON : json .loads ,
48
+ TRANSFORM_METHOD_BINARY : base64 .b64decode ,
49
+ ".json" : json .loads ,
50
+ ".binary" : base64 .b64decode ,
51
+ None : lambda x : x ,
52
+ }
53
+
54
+
55
+ class ExpirableValue (NamedTuple ):
56
+ value : str | bytes | Dict [str , Any ]
57
+ ttl : datetime
58
+
33
59
34
60
class BaseProvider (ABC ):
35
61
"""
36
62
Abstract Base Class for Parameter providers
37
63
"""
38
64
39
- store : Any = None
65
+ store : Dict [ Tuple [ str , TransformOptions ], ExpirableValue ]
40
66
41
67
def __init__ (self ):
42
68
"""
43
69
Initialize the base provider
44
70
"""
45
71
46
- self .store = {}
72
+ self .store : Dict [ Tuple [ str , TransformOptions ], ExpirableValue ] = {}
47
73
48
- def _has_not_expired (self , key : Tuple [str , Optional [ str ] ]) -> bool :
74
+ def has_not_expired_in_cache (self , key : Tuple [str , TransformOptions ]) -> bool :
49
75
return key in self .store and self .store [key ].ttl >= datetime .now ()
50
76
51
77
def get (
52
78
self ,
53
79
name : str ,
54
80
max_age : int = DEFAULT_MAX_AGE_SECS ,
55
- transform : Optional [ str ] = None ,
81
+ transform : TransformOptions = None ,
56
82
force_fetch : bool = False ,
57
83
** sdk_options ,
58
84
) -> Optional [Union [str , dict , bytes ]]:
@@ -95,7 +121,7 @@ def get(
95
121
value : Optional [Union [str , bytes , dict ]] = None
96
122
key = (name , transform )
97
123
98
- if not force_fetch and self ._has_not_expired (key ):
124
+ if not force_fetch and self .has_not_expired_in_cache (key ):
99
125
return self .store [key ].value
100
126
101
127
try :
@@ -105,11 +131,11 @@ def get(
105
131
raise GetParameterError (str (exc ))
106
132
107
133
if transform :
108
- if isinstance (value , bytes ):
109
- value = value .decode ("utf-8" )
110
- value = transform_value (value , transform )
134
+ value = transform_value (key = name , value = value , transform = transform , raise_on_transform_error = True )
111
135
112
- self .store [key ] = ExpirableValue (value , datetime .now () + timedelta (seconds = max_age ))
136
+ # NOTE: don't cache None, as they might've been failed transforms and may be corrected
137
+ if value is not None :
138
+ self .store [key ] = ExpirableValue (value , datetime .now () + timedelta (seconds = max_age ))
113
139
114
140
return value
115
141
@@ -124,7 +150,7 @@ def get_multiple(
124
150
self ,
125
151
path : str ,
126
152
max_age : int = DEFAULT_MAX_AGE_SECS ,
127
- transform : Optional [ str ] = None ,
153
+ transform : TransformOptions = None ,
128
154
raise_on_transform_error : bool = False ,
129
155
force_fetch : bool = False ,
130
156
** sdk_options ,
@@ -160,8 +186,8 @@ def get_multiple(
160
186
"""
161
187
key = (path , transform )
162
188
163
- if not force_fetch and self ._has_not_expired (key ):
164
- return self .store [key ].value
189
+ if not force_fetch and self .has_not_expired_in_cache (key ):
190
+ return self .store [key ].value # type: ignore # need to revisit entire typing here
165
191
166
192
try :
167
193
values = self ._get_multiple (path , ** sdk_options )
@@ -170,13 +196,8 @@ def get_multiple(
170
196
raise GetParameterError (str (exc ))
171
197
172
198
if transform :
173
- transformed_values : dict = {}
174
- for (item , value ) in values .items ():
175
- _transform = get_transform_method (item , transform )
176
- if not _transform :
177
- continue
178
- transformed_values [item ] = transform_value (value , _transform , raise_on_transform_error )
179
- values .update (transformed_values )
199
+ values .update (transform_value (values , transform , raise_on_transform_error ))
200
+
180
201
self .store [key ] = ExpirableValue (values , datetime .now () + timedelta (seconds = max_age ))
181
202
182
203
return values
@@ -191,6 +212,12 @@ def _get_multiple(self, path: str, **sdk_options) -> Dict[str, str]:
191
212
def clear_cache (self ):
192
213
self .store .clear ()
193
214
215
+ def add_to_cache (self , key : Tuple [str , TransformOptions ], value : Any , max_age : int ):
216
+ if max_age <= 0 :
217
+ return
218
+
219
+ self .store [key ] = ExpirableValue (value , datetime .now () + timedelta (seconds = max_age ))
220
+
194
221
@staticmethod
195
222
def _build_boto3_client (
196
223
service_name : str ,
@@ -258,57 +285,81 @@ def _build_boto3_resource_client(
258
285
return session .resource (service_name = service_name , config = config , endpoint_url = endpoint_url )
259
286
260
287
261
- def get_transform_method (key : str , transform : Optional [ str ] = None ) -> Optional [ str ]:
288
+ def get_transform_method (value : str , transform : TransformOptions = None ) -> Callable [..., Any ]:
262
289
"""
263
290
Determine the transform method
264
291
265
292
Examples
266
293
-------
267
- >>> get_transform_method("key", "any_other_value")
294
+ >>> get_transform_method("key","any_other_value")
268
295
'any_other_value'
269
- >>> get_transform_method("key.json", "auto")
296
+ >>> get_transform_method("key.json","auto")
270
297
'json'
271
- >>> get_transform_method("key.binary", "auto")
298
+ >>> get_transform_method("key.binary","auto")
272
299
'binary'
273
- >>> get_transform_method("key", "auto")
300
+ >>> get_transform_method("key","auto")
274
301
None
275
- >>> get_transform_method("key", None)
302
+ >>> get_transform_method("key",None)
276
303
None
277
304
278
305
Parameters
279
306
---------
280
- key : str
281
- Only used when the tranform is "auto".
307
+ value : str
308
+ Only used when the transform is "auto".
282
309
transform: str, optional
283
310
Original transform method, only "auto" will try to detect the transform method by the key
284
311
285
312
Returns
286
313
------
287
- Optional[str]:
288
- The transform method either when transform is "auto" then None, "json" or "binary" is returned
289
- or the original transform method
314
+ Callable:
315
+ Transform function could be json.loads, base64.b64decode, or a lambda that echo the str value
290
316
"""
291
- if transform != "auto" :
292
- return transform
317
+ transform_method = TRANSFORM_METHOD_MAPPING .get (transform )
318
+
319
+ if transform == "auto" :
320
+ key_suffix = value .rsplit ("." )[- 1 ]
321
+ transform_method = TRANSFORM_METHOD_MAPPING .get (key_suffix , TRANSFORM_METHOD_MAPPING [None ])
322
+
323
+ return cast (Callable , transform_method ) # https://github.com/python/mypy/issues/10740
324
+
325
+
326
+ @overload
327
+ def transform_value (
328
+ value : Dict [str , Any ],
329
+ transform : TransformOptions ,
330
+ raise_on_transform_error : bool = False ,
331
+ key : str = "" ,
332
+ ) -> Dict [str , Any ]:
333
+ ...
334
+
293
335
294
- for transform_method in SUPPORTED_TRANSFORM_METHODS :
295
- if key .endswith ("." + transform_method ):
296
- return transform_method
297
- return None
336
+ @overload
337
+ def transform_value (
338
+ value : Union [str , bytes , Dict [str , Any ]],
339
+ transform : TransformOptions ,
340
+ raise_on_transform_error : bool = False ,
341
+ key : str = "" ,
342
+ ) -> Optional [Union [str , bytes , Dict [str , Any ]]]:
343
+ ...
298
344
299
345
300
346
def transform_value (
301
- value : str , transform : str , raise_on_transform_error : Optional [bool ] = True
302
- ) -> Optional [Union [dict , bytes ]]:
347
+ value : Union [str , bytes , Dict [str , Any ]],
348
+ transform : TransformOptions ,
349
+ raise_on_transform_error : bool = True ,
350
+ key : str = "" ,
351
+ ) -> Optional [Union [str , bytes , Dict [str , Any ]]]:
303
352
"""
304
- Apply a transform to a value
353
+ Transform a value using one of the available options.
305
354
306
355
Parameters
307
356
---------
308
357
value: str
309
358
Parameter value to transform
310
359
transform: str
311
- Type of transform, supported values are "json" and "binary"
360
+ Type of transform, supported values are "json", "binary", and "auto" based on suffix (.json, .binary)
361
+ key: str
362
+ Parameter key when transform is auto to infer its transform method
312
363
raise_on_transform_error: bool, optional
313
364
Raises an exception if any transform fails, otherwise this will
314
365
return a None value for each transform that failed
@@ -318,18 +369,41 @@ def transform_value(
318
369
TransformParameterError:
319
370
When the parameter value could not be transformed
320
371
"""
372
+ # Maintenance: For v3, we should consider returning the original value for soft transform failures.
373
+
374
+ err_msg = "Unable to transform value using '{transform}' transform: {exc}"
375
+
376
+ if isinstance (value , bytes ):
377
+ value = value .decode ("utf-8" )
378
+
379
+ if isinstance (value , dict ):
380
+ # NOTE: We must handle partial failures when receiving multiple values
381
+ # where one of the keys might fail during transform, e.g. `{"a": "valid", "b": "{"}`
382
+ # expected: `{"a": "valid", "b": None}`
383
+
384
+ transformed_values : Dict [str , Any ] = {}
385
+ for dict_key , dict_value in value .items ():
386
+ transform_method = get_transform_method (value = dict_key , transform = transform )
387
+ try :
388
+ transformed_values [dict_key ] = transform_method (dict_value )
389
+ except Exception as exc :
390
+ if raise_on_transform_error :
391
+ raise TransformParameterError (err_msg .format (transform = transform , exc = exc )) from exc
392
+ transformed_values [dict_key ] = None
393
+ return transformed_values
394
+
395
+ if transform == "auto" :
396
+ # key="a.json", value='{"a": "b"}', or key="a.binary", value="b64_encoded"
397
+ transform_method = get_transform_method (value = key , transform = transform )
398
+ else :
399
+ # value='{"key": "value"}
400
+ transform_method = get_transform_method (value = value , transform = transform )
321
401
322
402
try :
323
- if transform == TRANSFORM_METHOD_JSON :
324
- return json .loads (value )
325
- elif transform == TRANSFORM_METHOD_BINARY :
326
- return base64 .b64decode (value )
327
- else :
328
- raise ValueError (f"Invalid transform type '{ transform } '" )
329
-
403
+ return transform_method (value )
330
404
except Exception as exc :
331
405
if raise_on_transform_error :
332
- raise TransformParameterError (str ( exc ))
406
+ raise TransformParameterError (err_msg . format ( transform = transform , exc = exc )) from exc
333
407
return None
334
408
335
409
0 commit comments