boto3-assist 0.1.0__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.
File without changes
@@ -0,0 +1,173 @@
1
+ """
2
+ Geek Cafe, LLC
3
+ Maintainers: Eric Wilson
4
+ MIT License. See Project Root for the license information.
5
+ """
6
+
7
+ from typing import Any, Optional
8
+
9
+ import boto3
10
+ from aws_lambda_powertools import Logger
11
+ from botocore.config import Config
12
+ from botocore.exceptions import ProfileNotFound
13
+ from boto3_assist.environment_services.environment_variables import EnvironmentVariables
14
+
15
+
16
+ logger = Logger(__name__)
17
+
18
+
19
+ class Boto3SessionManager:
20
+ """Manages Boto3 Sessions"""
21
+
22
+ def __init__(
23
+ self,
24
+ service_name: str,
25
+ *,
26
+ aws_profile: Optional[str] = None,
27
+ aws_region: Optional[str] = None,
28
+ assume_role_arn: Optional[str] = None,
29
+ assume_role_session_name: Optional[str] = None,
30
+ cross_account_role_arn: Optional[str] = None,
31
+ config: Optional[Config] = None,
32
+ aws_endpoint_url: Optional[str] = None,
33
+ aws_access_key_id: Optional[str] = None,
34
+ aws_secret_access_key: Optional[str] = None,
35
+ aws_session_token: Optional[str] = None,
36
+ ):
37
+ self.service_name = service_name
38
+ self.aws_profile = aws_profile
39
+ self.aws_region = aws_region
40
+ self.assume_role_arn = assume_role_arn
41
+ self.assume_role_session_name = assume_role_session_name
42
+ self.config = config
43
+ self.cross_account_role_arn = cross_account_role_arn
44
+ self.endpoint_url = aws_endpoint_url
45
+ self.aws_access_key_id = aws_access_key_id
46
+ self.aws_secret_access_key = aws_secret_access_key
47
+ self.aws_session_token = aws_session_token
48
+ self.__session: Any = None
49
+ self.__client: Any = None
50
+ self.__resource: Any = None
51
+
52
+ self.__setup()
53
+
54
+ def __setup(self):
55
+ """Setup AWS session, client, and resource."""
56
+
57
+ profile = self.aws_profile or EnvironmentVariables.AWS.profile()
58
+ region = self.aws_region or EnvironmentVariables.AWS.region()
59
+ if self.assume_role_arn:
60
+ self.__assume_role()
61
+ else:
62
+ logger.debug("Connecting without assuming a role.")
63
+ self.__session = self.__get_aws_session(profile, region)
64
+
65
+ def __assume_role(self):
66
+ """Assume an AWS IAM role."""
67
+ try:
68
+ logger.debug(f"Assuming role {self.assume_role_arn}")
69
+ sts_client = boto3.client("sts")
70
+ session_name = (
71
+ self.assume_role_session_name
72
+ or f"AssumeRoleSessionFor{self.service_name}"
73
+ )
74
+ if not self.assume_role_arn:
75
+ raise ValueError("assume_role_arn is required")
76
+ assumed_role_response = sts_client.assume_role(
77
+ RoleArn=self.assume_role_arn,
78
+ RoleSessionName=session_name,
79
+ )
80
+ credentials = assumed_role_response["Credentials"]
81
+ self.__session = boto3.Session(
82
+ aws_access_key_id=credentials["AccessKeyId"],
83
+ aws_secret_access_key=credentials["SecretAccessKey"],
84
+ aws_session_token=credentials["SessionToken"],
85
+ )
86
+
87
+ except Exception as e:
88
+ logger.error(f"Error assuming role: {e}")
89
+ raise RuntimeError(f"Failed to assume role {self.assume_role_arn}") from e
90
+
91
+ def __get_aws_session(
92
+ self, aws_profile: Optional[str] = None, aws_region: Optional[str] = None
93
+ ) -> boto3.Session:
94
+ """Get a boto3 session for AWS."""
95
+ logger.debug({"profile": aws_profile, "region": aws_region})
96
+ try:
97
+ self.aws_profile = aws_profile or EnvironmentVariables.AWS.profile()
98
+ self.aws_region = aws_region or EnvironmentVariables.AWS.region()
99
+ tmp_access_key_id = self.aws_access_key_id
100
+ tmp_secret_access_key = self.aws_secret_access_key
101
+ if not EnvironmentVariables.AWS.display_aws_access_key_id():
102
+ tmp_access_key_id = (
103
+ "None" if tmp_access_key_id is None else "***************"
104
+ )
105
+ if not EnvironmentVariables.AWS.display_aws_secret_access_key():
106
+ tmp_secret_access_key = (
107
+ "None" if tmp_secret_access_key is None else "***************"
108
+ )
109
+
110
+ logger.debug(
111
+ {
112
+ "profile": self.aws_profile,
113
+ "region": self.aws_region,
114
+ "aws_access_key_id": tmp_access_key_id,
115
+ "aws_secret_access_key": tmp_secret_access_key,
116
+ "aws_session_token": "*******"
117
+ if self.aws_session_token is not None
118
+ else "",
119
+ }
120
+ )
121
+ logger.debug("Creating boto3 session")
122
+ session = self.__create_boto3_session()
123
+ # if self.aws_profile or self.aws_region
124
+ # else boto3.Session()
125
+
126
+ except Exception as e:
127
+ logger.error(e)
128
+ raise RuntimeError("Failed to create a boto3 session.") from e
129
+
130
+ logger.debug({"session": session})
131
+ return session
132
+
133
+ @property
134
+ def client(self) -> Any:
135
+ """Return the boto3 client connection."""
136
+ if not self.__client:
137
+ self.__client = self.__session.client(
138
+ self.service_name,
139
+ config=self.config,
140
+ endpoint_url=self.endpoint_url,
141
+ )
142
+
143
+ return self.__client
144
+
145
+ @property
146
+ def resource(self) -> Any:
147
+ """Return the boto3 resource connection."""
148
+ if not self.__resource:
149
+ self.__resource = self.__session.resource(
150
+ self.service_name,
151
+ config=self.config,
152
+ endpoint_url=self.endpoint_url,
153
+ )
154
+ return self.__resource
155
+
156
+ def __create_boto3_session(self) -> boto3.Session:
157
+ try:
158
+ session = boto3.Session(
159
+ profile_name=self.aws_profile,
160
+ region_name=self.aws_region,
161
+ aws_access_key_id=self.aws_access_key_id,
162
+ aws_secret_access_key=self.aws_secret_access_key,
163
+ aws_session_token=self.aws_session_token,
164
+ )
165
+ return session
166
+ except ProfileNotFound as e:
167
+ print(
168
+ f"An error occurred setting up the boto3 sessions. Profile not found: {e}"
169
+ )
170
+ raise e
171
+ except Exception as e:
172
+ print(f"An error occurred setting up the boto3 sessions: {e}")
173
+ raise e
@@ -0,0 +1,445 @@
1
+ """
2
+ Geek Cafe, LLC
3
+ Maintainers: Eric Wilson
4
+ MIT License. See Project Root for the license information.
5
+ """
6
+
7
+ import os
8
+ from typing import List, Optional, overload
9
+
10
+ from aws_lambda_powertools import Tracer, Logger
11
+ from boto3.dynamodb.conditions import (
12
+ Key,
13
+ # And,
14
+ # Equals,
15
+ ComparisonCondition,
16
+ ConditionBase,
17
+ )
18
+ from boto3_assist.dynamodb.dynamodb_connection import DynamoDBConnection
19
+ from boto3_assist.dynamodb.dynamodb_helpers import DynamoDBHelpers
20
+ from boto3_assist.dynamodb.dynamodb_model_base import DynamoDBModelBase
21
+ from boto3_assist.utilities.string_utility import StringUtility
22
+
23
+
24
+ logger = Logger()
25
+ tracer = Tracer()
26
+
27
+
28
+ class DynamoDB(DynamoDBConnection):
29
+ """
30
+ DynamoDB. Wrapper for basic DynamoDB Connection and Actions
31
+
32
+ Inherits:
33
+ DynamoDBConnection
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ aws_profile: Optional[str] = None,
40
+ aws_region: Optional[str] = None,
41
+ aws_end_point_url: Optional[str] = None,
42
+ aws_access_key_id: Optional[str] = None,
43
+ aws_secret_access_key: Optional[str] = None,
44
+ ) -> None:
45
+ super().__init__(
46
+ aws_profile=aws_profile,
47
+ aws_region=aws_region,
48
+ aws_end_point_url=aws_end_point_url,
49
+ aws_access_key_id=aws_access_key_id,
50
+ aws_secret_access_key=aws_secret_access_key,
51
+ )
52
+ self.helpers: DynamoDBHelpers = DynamoDBHelpers()
53
+ self.log_dynamodb_item_size = (
54
+ str(os.getenv("LOG_DYNAMODB_ITEM_SIZE", "false")).lower() == "true"
55
+ )
56
+ logger.setLevel(os.getenv("LOG_LEVEL", "INFO"))
57
+
58
+ @tracer.capture_method
59
+ def save(
60
+ self,
61
+ item: dict | DynamoDBModelBase,
62
+ table_name: str,
63
+ source: Optional[str] = None,
64
+ ) -> dict:
65
+ """
66
+ Save an item to the database
67
+ Args:
68
+ item (dict): DynamoDB Dictionay Object or DynamoDBModelBase. Supports the "client" or
69
+ "resource" syntax
70
+ table_name (str): The DyamoDb Table Name
71
+ source (str, optional): The source of the call, used for logging. Defaults to None.
72
+
73
+ Raises:
74
+ e: Any Error Raised
75
+
76
+ Returns:
77
+ dict: The Response from DynamoDB's put_item actions.
78
+ It does not return the saved object, only the response.
79
+ """
80
+ response = None
81
+
82
+ try:
83
+ if not isinstance(item, dict):
84
+ # attemp to convert it
85
+ try:
86
+ item = item.to_resource_dictionary()
87
+ except Exception as e: # pylint: disable=w0718
88
+ logger.exception(e)
89
+ raise ValueError("Unsupported item or module was passed.") from e
90
+
91
+ if isinstance(item, dict):
92
+ self.__log_item_size(item=item)
93
+
94
+ if isinstance(item, dict) and isinstance(next(iter(item.values())), dict):
95
+ # Use boto3.client syntax
96
+ response = self.dynamodb_client.put_item(
97
+ TableName=table_name, Item=item
98
+ )
99
+ else:
100
+ # Use boto3.resource syntax
101
+ table = self.dynamodb_resource.Table(table_name)
102
+ response = table.put_item(Item=item)
103
+
104
+ except Exception as e: # pylint: disable=w0718
105
+ logger.exception(
106
+ {"source": f"{source}", "metric_filter": "put_item", "error": str(e)}
107
+ )
108
+ raise e
109
+
110
+ return response
111
+
112
+ def __log_item_size(self, item: dict):
113
+ if not isinstance(item, dict):
114
+ warning = f"Item is not a dictionary. Type: {type(item).__name__}"
115
+ logger.warning(warning)
116
+ return
117
+
118
+ if self.log_dynamodb_item_size:
119
+ size_bytes: int = StringUtility.get_size_in_bytes(item)
120
+ size_kb: int = StringUtility.get_size_in_kb(item)
121
+ logger.info({"item_size": {"bytes": size_bytes, "kb": f"{size_kb:.2f}kb"}})
122
+
123
+ if size_kb > 390:
124
+ logger.warning(
125
+ {
126
+ "item_size": {
127
+ "bytes": size_bytes,
128
+ "kb": f"{size_kb:.2f}kb",
129
+ },
130
+ "warning": "approaching limit",
131
+ }
132
+ )
133
+
134
+ @overload
135
+ def get(
136
+ self,
137
+ *,
138
+ table_name: str,
139
+ model: DynamoDBModelBase,
140
+ do_projections: bool = False,
141
+ strongly_consistent: bool = False,
142
+ return_consumed_capacity: Optional[str] = None,
143
+ projection_expression: Optional[str] = None,
144
+ expression_attribute_names: Optional[dict] = None,
145
+ source: Optional[str] = None,
146
+ call_type: str = "resource",
147
+ ) -> dict: ...
148
+
149
+ @overload
150
+ def get(
151
+ self,
152
+ key: dict,
153
+ table_name: str,
154
+ *,
155
+ strongly_consistent: bool = False,
156
+ return_consumed_capacity: Optional[str] = None,
157
+ projection_expression: Optional[str] = None,
158
+ expression_attribute_names: Optional[dict] = None,
159
+ source: Optional[str] = None,
160
+ call_type: str = "resource",
161
+ ) -> dict: ...
162
+
163
+ @tracer.capture_method
164
+ def get(
165
+ self,
166
+ key: Optional[dict] = None,
167
+ table_name: Optional[str] = None,
168
+ model: Optional[DynamoDBModelBase] = None,
169
+ do_projections: bool = False,
170
+ strongly_consistent: bool = False,
171
+ return_consumed_capacity: Optional[str] = None,
172
+ projection_expression: Optional[str] = None,
173
+ expression_attribute_names: Optional[dict] = None,
174
+ source: Optional[str] = None,
175
+ call_type: str = "resource",
176
+ ) -> dict:
177
+ """
178
+ Description:
179
+ generic get_item dynamoDb call
180
+ Parameters:
181
+ key: a dictionary object representing the primary key
182
+ model: a model instance of DynamoDBModelBase
183
+ """
184
+
185
+ if model is not None:
186
+ if table_name is None:
187
+ raise ValueError("table_name must be provided when model is used.")
188
+ if key is not None:
189
+ raise ValueError("key cannot be provided when model is used.")
190
+ key = model.indexes.primary.key()
191
+ if do_projections:
192
+ projection_expression = model.projection_expression
193
+ expression_attribute_names = model.projection_expression_attribute_names
194
+ elif key is None or table_name is None:
195
+ raise ValueError("Either 'key' or 'model' must be provided.")
196
+
197
+ response = None
198
+ try:
199
+ kwargs = {
200
+ "ConsistentRead": strongly_consistent,
201
+ "ReturnConsumedCapacity": return_consumed_capacity,
202
+ "ProjectionExpression": projection_expression,
203
+ "ExpressionAttributeNames": expression_attribute_names,
204
+ }
205
+ # only pass in args that aren't none
206
+ valid_kwargs = {k: v for k, v in kwargs.items() if v is not None}
207
+
208
+ if call_type == "resource":
209
+ table = self.dynamodb_resource.Table(table_name)
210
+ response = table.get_item(Key=key, **valid_kwargs)
211
+ elif call_type == "client":
212
+ response = self.dynamodb_client.get_item(
213
+ Key=key, TableName=table_name, **valid_kwargs
214
+ )
215
+ else:
216
+ raise ValueError(
217
+ f"Unknown call_type of {call_type}. Supported call_types [resource | client]"
218
+ )
219
+ except Exception as e: # pylint: disable=w0718
220
+ logger.exception(
221
+ {"source": f"{source}", "metric_filter": "get_item", "error": str(e)}
222
+ )
223
+
224
+ response = {"exception": str(e)}
225
+ if self.raise_on_error:
226
+ raise e
227
+
228
+ return response
229
+
230
+ def update_item(
231
+ self,
232
+ table_name: str,
233
+ key: dict,
234
+ update_expression: str,
235
+ expression_attribute_values: dict,
236
+ ) -> dict:
237
+ """_summary_
238
+
239
+ Args:
240
+ table_name (str): table name
241
+ key (dict): pk or pk and sk (composite key)
242
+ update_expression (str): update expression
243
+ expression_attribute_values (dict): expression attribute values
244
+
245
+ Returns:
246
+ dict: dynamodb response dictionary
247
+ """
248
+ table = self.dynamodb_resource.Table(table_name)
249
+ response = table.update_item(
250
+ Key=key,
251
+ UpdateExpression=update_expression,
252
+ ExpressionAttributeValues=expression_attribute_values,
253
+ )
254
+
255
+ return response
256
+
257
+ def query(
258
+ self,
259
+ key: dict | Key | ConditionBase | ComparisonCondition,
260
+ *,
261
+ index_name: Optional[str] = None,
262
+ ascending: bool = False,
263
+ table_name: Optional[str] = None,
264
+ source: Optional[str] = None,
265
+ strongly_consistent: bool = False,
266
+ projection_expression: Optional[str] = None,
267
+ expression_attribute_names: Optional[dict] = None,
268
+ start_key: Optional[dict] = None,
269
+ limit: Optional[int] = None,
270
+ ) -> dict:
271
+ """
272
+ Run a query and return a list of items
273
+ Args:
274
+ key (Key): _description_
275
+ index_name (str, optional): _description_. Defaults to None.
276
+ ascending (bool, optional): _description_. Defaults to False.
277
+ table_name (str, optional): _description_. Defaults to None.
278
+ source (str, optional): The source of the query. Used for logging. Defaults to None.
279
+
280
+ Returns:
281
+ dict: dynamodb response dictionary
282
+ """
283
+
284
+ logger.debug({"action": "query", "source": source})
285
+
286
+ kwargs: dict = {}
287
+ if index_name:
288
+ kwargs["IndexName"] = f"{index_name}"
289
+ kwargs["TableName"] = f"{table_name}"
290
+ kwargs["KeyConditionExpression"] = key
291
+ kwargs["ScanIndexForward"] = ascending
292
+ kwargs["ConsistentRead"] = strongly_consistent
293
+
294
+ if projection_expression:
295
+ kwargs["ProjectionExpression"] = projection_expression
296
+
297
+ if expression_attribute_names:
298
+ kwargs["ExpressionAttributeNames"] = expression_attribute_names
299
+
300
+ if start_key:
301
+ kwargs["ExclusiveStartKey"] = start_key
302
+
303
+ if limit:
304
+ kwargs["Limit"] = limit
305
+
306
+ if table_name is None:
307
+ raise ValueError("Query failed: table_name must be provided.")
308
+
309
+ table = self.dynamodb_resource.Table(table_name)
310
+ response = table.query(**kwargs)
311
+
312
+ return response
313
+
314
+ @overload
315
+ def delete(self, *, table_name: str, model: DynamoDBModelBase) -> dict:
316
+ pass
317
+
318
+ @overload
319
+ def delete(
320
+ self,
321
+ *,
322
+ table_name: str,
323
+ primary_key: dict,
324
+ ) -> dict:
325
+ pass
326
+
327
+ @tracer.capture_method
328
+ def delete(
329
+ self,
330
+ *,
331
+ primary_key: Optional[dict] = None,
332
+ table_name: Optional[str] = None,
333
+ model: Optional[DynamoDBModelBase] = None,
334
+ ):
335
+ """deletes an item from the database"""
336
+
337
+ if model is not None:
338
+ if table_name is None:
339
+ raise ValueError("table_name must be provided when model is used.")
340
+ if primary_key is not None:
341
+ raise ValueError("primary_key cannot be provided when model is used.")
342
+ primary_key = model.indexes.primary.key()
343
+
344
+ response = None
345
+
346
+ if table_name is None or primary_key is None:
347
+ raise ValueError("table_name and primary_key must be provided.")
348
+
349
+ table = self.dynamodb_resource.Table(table_name)
350
+ response = table.delete_item(Key=primary_key)
351
+
352
+ return response
353
+
354
+ def list_tables(self) -> List[str]:
355
+ """Get a list of tables from the current connection"""
356
+ tables = list(self.dynamodb_resource.tables.all())
357
+ table_list: List[str] = []
358
+ if len(tables) > 0:
359
+ for table in tables:
360
+ table_list.append(table.name)
361
+
362
+ return table_list
363
+
364
+ def query_by_criteria(
365
+ self,
366
+ *,
367
+ model: DynamoDBModelBase,
368
+ table_name: str,
369
+ index_name: str,
370
+ key: dict | Key | ConditionBase | ComparisonCondition,
371
+ start_key: Optional[dict] = None,
372
+ do_projections: bool = False,
373
+ ascending: bool = False,
374
+ strongly_consistent: bool = False,
375
+ ) -> dict:
376
+ """Helper function to list by criteria"""
377
+
378
+ projection_expression: str | None = None
379
+ expression_attribute_names: dict | None = None
380
+
381
+ if do_projections:
382
+ projection_expression = model.projection_expression
383
+ expression_attribute_names = model.projection_expression_attribute_names
384
+
385
+ response = self.query(
386
+ key=key,
387
+ index_name=index_name,
388
+ table_name=table_name,
389
+ start_key=start_key,
390
+ projection_expression=projection_expression,
391
+ expression_attribute_names=expression_attribute_names,
392
+ ascending=ascending,
393
+ strongly_consistent=strongly_consistent,
394
+ )
395
+
396
+ return response
397
+
398
+ def has_more_records(self, response: dict) -> bool:
399
+ """
400
+ Check if there are more records to process.
401
+ This based on the existance of the LastEvaluatedKey in the response.
402
+ Parameters:
403
+ response (dict): dynamodb response dictionary
404
+
405
+ Returns:
406
+ bool: True if there are more records, False otherwise
407
+ """
408
+
409
+ return "LastEvaluatedKey" in response
410
+
411
+ def last_key(self, response: dict) -> dict | None:
412
+ """
413
+ Get the LastEvaluatedKey, which can be used to continue processing the results
414
+ Parameters:
415
+ response (dict): dynamodb response dictionary
416
+
417
+ Returns:
418
+ dict | None: The last key or None if not found
419
+ """
420
+
421
+ return response.get("LastEvaluatedKey")
422
+
423
+ def items(self, response: dict) -> list:
424
+ """
425
+ Get the Items from the dynamodb response
426
+ Parameters:
427
+ response (dict): dynamodb response dictionary
428
+
429
+ Returns:
430
+ list: A list or empty array/list if no items found
431
+ """
432
+
433
+ return response.get("Items", [])
434
+
435
+ def item(self, response: dict) -> dict:
436
+ """
437
+ Get the Item from the dynamodb response
438
+ Parameters:
439
+ response (dict): dynamodb response dictionary
440
+
441
+ Returns:
442
+ dict: A dictionary or empty dictionary if no item found
443
+ """
444
+
445
+ return response.get("Item", {})