boto3-assist 0.32.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.
Files changed (67) hide show
  1. boto3_assist/__init__.py +0 -0
  2. boto3_assist/aws_config.py +199 -0
  3. boto3_assist/aws_lambda/event_info.py +414 -0
  4. boto3_assist/aws_lambda/mock_context.py +5 -0
  5. boto3_assist/boto3session.py +87 -0
  6. boto3_assist/cloudwatch/cloudwatch_connection.py +84 -0
  7. boto3_assist/cloudwatch/cloudwatch_connection_tracker.py +17 -0
  8. boto3_assist/cloudwatch/cloudwatch_log_connection.py +62 -0
  9. boto3_assist/cloudwatch/cloudwatch_logs.py +39 -0
  10. boto3_assist/cloudwatch/cloudwatch_query.py +191 -0
  11. boto3_assist/cognito/cognito_authorizer.py +169 -0
  12. boto3_assist/cognito/cognito_connection.py +59 -0
  13. boto3_assist/cognito/cognito_utility.py +514 -0
  14. boto3_assist/cognito/jwks_cache.py +21 -0
  15. boto3_assist/cognito/user.py +27 -0
  16. boto3_assist/connection.py +146 -0
  17. boto3_assist/connection_tracker.py +120 -0
  18. boto3_assist/dynamodb/dynamodb.py +1206 -0
  19. boto3_assist/dynamodb/dynamodb_connection.py +113 -0
  20. boto3_assist/dynamodb/dynamodb_helpers.py +333 -0
  21. boto3_assist/dynamodb/dynamodb_importer.py +102 -0
  22. boto3_assist/dynamodb/dynamodb_index.py +507 -0
  23. boto3_assist/dynamodb/dynamodb_iservice.py +29 -0
  24. boto3_assist/dynamodb/dynamodb_key.py +130 -0
  25. boto3_assist/dynamodb/dynamodb_model_base.py +382 -0
  26. boto3_assist/dynamodb/dynamodb_model_base_interfaces.py +34 -0
  27. boto3_assist/dynamodb/dynamodb_re_indexer.py +165 -0
  28. boto3_assist/dynamodb/dynamodb_reindexer.py +165 -0
  29. boto3_assist/dynamodb/dynamodb_reserved_words.py +52 -0
  30. boto3_assist/dynamodb/dynamodb_reserved_words.txt +573 -0
  31. boto3_assist/dynamodb/readme.md +68 -0
  32. boto3_assist/dynamodb/troubleshooting.md +7 -0
  33. boto3_assist/ec2/ec2_connection.py +57 -0
  34. boto3_assist/environment_services/__init__.py +0 -0
  35. boto3_assist/environment_services/environment_loader.py +128 -0
  36. boto3_assist/environment_services/environment_variables.py +219 -0
  37. boto3_assist/erc/__init__.py +64 -0
  38. boto3_assist/erc/ecr_connection.py +57 -0
  39. boto3_assist/errors/custom_exceptions.py +46 -0
  40. boto3_assist/http_status_codes.py +80 -0
  41. boto3_assist/models/serializable_model.py +9 -0
  42. boto3_assist/role_assumption_mixin.py +38 -0
  43. boto3_assist/s3/s3.py +64 -0
  44. boto3_assist/s3/s3_bucket.py +67 -0
  45. boto3_assist/s3/s3_connection.py +76 -0
  46. boto3_assist/s3/s3_event_data.py +168 -0
  47. boto3_assist/s3/s3_object.py +695 -0
  48. boto3_assist/securityhub/securityhub.py +150 -0
  49. boto3_assist/securityhub/securityhub_connection.py +57 -0
  50. boto3_assist/session_setup_mixin.py +70 -0
  51. boto3_assist/ssm/connection.py +57 -0
  52. boto3_assist/ssm/parameter_store/parameter_store.py +116 -0
  53. boto3_assist/utilities/datetime_utility.py +349 -0
  54. boto3_assist/utilities/decimal_conversion_utility.py +140 -0
  55. boto3_assist/utilities/dictionary_utility.py +32 -0
  56. boto3_assist/utilities/file_operations.py +135 -0
  57. boto3_assist/utilities/http_utility.py +48 -0
  58. boto3_assist/utilities/logging_utility.py +0 -0
  59. boto3_assist/utilities/numbers_utility.py +329 -0
  60. boto3_assist/utilities/serialization_utility.py +664 -0
  61. boto3_assist/utilities/string_utility.py +337 -0
  62. boto3_assist/version.py +1 -0
  63. boto3_assist-0.32.0.dist-info/METADATA +76 -0
  64. boto3_assist-0.32.0.dist-info/RECORD +67 -0
  65. boto3_assist-0.32.0.dist-info/WHEEL +4 -0
  66. boto3_assist-0.32.0.dist-info/licenses/LICENSE-EXPLAINED.txt +11 -0
  67. boto3_assist-0.32.0.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1,1206 @@
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, Dict, Any
9
+ from botocore.exceptions import ClientError
10
+ from boto3.dynamodb.conditions import Attr
11
+
12
+ from aws_lambda_powertools import Logger
13
+ from boto3.dynamodb.conditions import (
14
+ Key,
15
+ # And,
16
+ # Equals,
17
+ ComparisonCondition,
18
+ ConditionBase,
19
+ )
20
+ from .dynamodb_connection import DynamoDBConnection
21
+ from .dynamodb_helpers import DynamoDBHelpers
22
+ from .dynamodb_model_base import DynamoDBModelBase
23
+ from ..utilities.string_utility import StringUtility
24
+ from ..utilities.decimal_conversion_utility import DecimalConversionUtility
25
+ from .dynamodb_index import DynamoDBIndex
26
+
27
+ logger = Logger()
28
+
29
+
30
+ class DynamoDB(DynamoDBConnection):
31
+ """
32
+ DynamoDB. Wrapper for basic DynamoDB Connection and Actions
33
+
34
+ Inherits:
35
+ DynamoDBConnection
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ aws_profile: Optional[str] = None,
42
+ aws_region: Optional[str] = None,
43
+ aws_end_point_url: Optional[str] = None,
44
+ aws_access_key_id: Optional[str] = None,
45
+ aws_secret_access_key: Optional[str] = None,
46
+ assume_role_arn: Optional[str] = None,
47
+ assume_role_chain: Optional[List[str]] = None,
48
+ assume_role_duration_seconds: Optional[int] = 3600,
49
+ ) -> None:
50
+ super().__init__(
51
+ aws_profile=aws_profile,
52
+ aws_region=aws_region,
53
+ aws_end_point_url=aws_end_point_url,
54
+ aws_access_key_id=aws_access_key_id,
55
+ assume_role_arn=assume_role_arn,
56
+ assume_role_chain=assume_role_chain,
57
+ assume_role_duration_seconds=assume_role_duration_seconds,
58
+ )
59
+ self.helpers: DynamoDBHelpers = DynamoDBHelpers()
60
+ self.log_dynamodb_item_size: bool = bool(
61
+ os.getenv("LOG_DYNAMODB_ITEM_SIZE", "False").lower() == "true"
62
+ )
63
+ self.convert_decimals: bool = bool(
64
+ os.getenv("DYNAMODB_CONVERT_DECIMALS", "True").lower() == "true"
65
+ )
66
+ logger.setLevel(os.getenv("LOG_LEVEL", "INFO"))
67
+
68
+ def _apply_decimal_conversion(self, response: Dict[str, Any]) -> Dict[str, Any]:
69
+ """
70
+ Apply decimal conversion to DynamoDB response if enabled.
71
+
72
+ Args:
73
+ response: The DynamoDB response dictionary
74
+
75
+ Returns:
76
+ The response with decimal conversion applied if enabled
77
+ """
78
+ if not self.convert_decimals:
79
+ return response
80
+
81
+ return DecimalConversionUtility.convert_decimals_to_native_types(response)
82
+
83
+ def save(
84
+ self,
85
+ item: dict | DynamoDBModelBase,
86
+ table_name: str,
87
+ source: Optional[str] = None,
88
+ fail_if_exists: bool = False,
89
+ condition_expression: Optional[str] = None,
90
+ expression_attribute_names: Optional[dict] = None,
91
+ expression_attribute_values: Optional[dict] = None,
92
+ ) -> dict:
93
+ """
94
+ Save an item to the database with optional conditional expressions.
95
+
96
+ Args:
97
+ item (dict): DynamoDB Dictionary Object or DynamoDBModelBase.
98
+ Supports the "client" or "resource" syntax
99
+ table_name (str): The DynamoDb Table Name
100
+ source (str, optional): The source of the call, used for logging. Defaults to None.
101
+ fail_if_exists (bool, optional): Only allow it to insert once.
102
+ Fail if it already exits. This is useful for loggers, historical records,
103
+ tasks, etc. that should only be created once
104
+ condition_expression (str, optional): Custom condition expression.
105
+ Example: "attribute_not_exists(#pk)" or "#version = :expected_version"
106
+ expression_attribute_names (dict, optional): Attribute name mappings.
107
+ Example: {"#version": "version", "#status": "status"}
108
+ expression_attribute_values (dict, optional): Attribute value mappings.
109
+ Example: {":expected_version": 1, ":active": "active"}
110
+
111
+ Raises:
112
+ ClientError: Client specific errors
113
+ RuntimeError: Conditional check failed
114
+ Exception: Any Error Raised
115
+
116
+ Returns:
117
+ dict: The Response from DynamoDB's put_item actions.
118
+ It does not return the saved object, only the response.
119
+
120
+ Examples:
121
+ >>> # Simple save
122
+ >>> db.save(item=user, table_name="users")
123
+
124
+ >>> # Prevent duplicates
125
+ >>> db.save(item=user, table_name="users", fail_if_exists=True)
126
+
127
+ >>> # Optimistic locking with version check
128
+ >>> db.save(
129
+ ... item=user,
130
+ ... table_name="users",
131
+ ... condition_expression="#version = :expected_version",
132
+ ... expression_attribute_names={"#version": "version"},
133
+ ... expression_attribute_values={":expected_version": 5}
134
+ ... )
135
+ """
136
+ response: Dict[str, Any] = {}
137
+
138
+ try:
139
+ if not isinstance(item, dict):
140
+ # attempt to convert it
141
+ if not isinstance(item, DynamoDBModelBase):
142
+ raise RuntimeError(
143
+ f"Item is not a dictionary or DynamoDBModelBase. Type: {type(item).__name__}. "
144
+ "In order to prep the model for saving, it needs to already be dictionary or support "
145
+ "the to_resource_dictionary() method, which is available when you inherit from DynamoDBModelBase. "
146
+ "Unable to save item to DynamoDB. The entry was not saved."
147
+ )
148
+ try:
149
+ item = item.to_resource_dictionary()
150
+ except Exception as e: # pylint: disable=w0718
151
+ logger.exception(e)
152
+ raise RuntimeError(
153
+ "An error occurred during model conversion. The entry was not saved. "
154
+ ) from e
155
+
156
+ if isinstance(item, dict):
157
+ self.__log_item_size(item=item)
158
+
159
+ # Convert native numeric types to Decimal for DynamoDB
160
+ # (DynamoDB doesn't accept float, requires Decimal)
161
+ item = DecimalConversionUtility.convert_native_types_to_decimals(item)
162
+
163
+ if isinstance(item, dict) and isinstance(next(iter(item.values())), dict):
164
+ # Use boto3.client syntax
165
+ # client API style
166
+ params = {
167
+ "TableName": table_name,
168
+ "Item": item,
169
+ }
170
+
171
+ # Handle conditional expressions
172
+ if condition_expression:
173
+ # Custom condition provided
174
+ params["ConditionExpression"] = condition_expression
175
+ if expression_attribute_names:
176
+ params["ExpressionAttributeNames"] = expression_attribute_names
177
+ if expression_attribute_values:
178
+ params["ExpressionAttributeValues"] = expression_attribute_values
179
+ elif fail_if_exists:
180
+ # only insert if the item does *not* already exist
181
+ params["ConditionExpression"] = (
182
+ "attribute_not_exists(#pk) AND attribute_not_exists(#sk)"
183
+ )
184
+ params["ExpressionAttributeNames"] = {"#pk": "pk", "#sk": "sk"}
185
+
186
+ response = dict(self.dynamodb_client.put_item(**params))
187
+
188
+ else:
189
+ # Use boto3.resource syntax
190
+ table = self.dynamodb_resource.Table(table_name)
191
+
192
+ # Build put_item parameters
193
+ put_params = {"Item": item}
194
+
195
+ # Handle conditional expressions
196
+ if condition_expression:
197
+ # Custom condition provided
198
+ # Convert string condition to boto3 condition object if needed
199
+ put_params["ConditionExpression"] = condition_expression
200
+ if expression_attribute_names:
201
+ put_params["ExpressionAttributeNames"] = expression_attribute_names
202
+ if expression_attribute_values:
203
+ put_params["ExpressionAttributeValues"] = expression_attribute_values
204
+ elif fail_if_exists:
205
+ put_params["ConditionExpression"] = (
206
+ Attr("pk").not_exists() & Attr("sk").not_exists()
207
+ )
208
+
209
+ response = dict(table.put_item(**put_params))
210
+
211
+ except ClientError as e:
212
+ error_code = e.response["Error"]["Code"]
213
+
214
+ if error_code == "ConditionalCheckFailedException":
215
+ # Enhanced error message for conditional check failures
216
+ if fail_if_exists:
217
+ raise RuntimeError(
218
+ f"Item with pk={item['pk']} already exists in {table_name}"
219
+ ) from e
220
+ elif condition_expression:
221
+ raise RuntimeError(
222
+ f"Conditional check failed for item in {table_name}. "
223
+ f"Condition: {condition_expression}"
224
+ ) from e
225
+ else:
226
+ raise RuntimeError(
227
+ f"Conditional check failed for item in {table_name}"
228
+ ) from e
229
+
230
+ logger.exception(
231
+ {"source": f"{source}", "metric_filter": "put_item", "error": str(e)}
232
+ )
233
+ raise
234
+
235
+ except Exception as e: # pylint: disable=w0718
236
+ logger.exception(
237
+ {"source": f"{source}", "metric_filter": "put_item", "error": str(e)}
238
+ )
239
+ raise
240
+
241
+ return response
242
+
243
+ def __log_item_size(self, item: dict):
244
+ if not isinstance(item, dict):
245
+ warning = f"Item is not a dictionary. Type: {type(item).__name__}"
246
+ logger.warning(warning)
247
+ return
248
+
249
+ if self.log_dynamodb_item_size:
250
+ size_bytes: int = StringUtility.get_size_in_bytes(item)
251
+ size_kb: float = StringUtility.get_size_in_kb(item)
252
+ logger.info({"item_size": {"bytes": size_bytes, "kb": f"{size_kb:.2f}kb"}})
253
+
254
+ if size_kb > 390:
255
+ logger.warning(
256
+ {
257
+ "item_size": {
258
+ "bytes": size_bytes,
259
+ "kb": f"{size_kb:.2f}kb",
260
+ },
261
+ "warning": "approaching limit",
262
+ }
263
+ )
264
+
265
+ @overload
266
+ def get(
267
+ self,
268
+ *,
269
+ table_name: str,
270
+ model: DynamoDBModelBase,
271
+ do_projections: bool = False,
272
+ strongly_consistent: bool = False,
273
+ return_consumed_capacity: Optional[str] = None,
274
+ projection_expression: Optional[str] = None,
275
+ expression_attribute_names: Optional[dict] = None,
276
+ source: Optional[str] = None,
277
+ call_type: str = "resource",
278
+ ) -> Dict[str, Any]: ...
279
+
280
+ @overload
281
+ def get(
282
+ self,
283
+ key: dict,
284
+ table_name: str,
285
+ *,
286
+ strongly_consistent: bool = False,
287
+ return_consumed_capacity: Optional[str] = None,
288
+ projection_expression: Optional[str] = None,
289
+ expression_attribute_names: Optional[dict] = None,
290
+ source: Optional[str] = None,
291
+ call_type: str = "resource",
292
+ ) -> Dict[str, Any]: ...
293
+
294
+ def get(
295
+ self,
296
+ key: Optional[dict] = None,
297
+ table_name: Optional[str] = None,
298
+ model: Optional[DynamoDBModelBase] = None,
299
+ do_projections: bool = False,
300
+ strongly_consistent: bool = False,
301
+ return_consumed_capacity: Optional[str] = None,
302
+ projection_expression: Optional[str] = None,
303
+ expression_attribute_names: Optional[dict] = None,
304
+ source: Optional[str] = None,
305
+ call_type: str = "resource",
306
+ ) -> Dict[str, Any]:
307
+ """
308
+ Description:
309
+ generic get_item dynamoDb call
310
+ Parameters:
311
+ key: a dictionary object representing the primary key
312
+ model: a model instance of DynamoDBModelBase
313
+ """
314
+
315
+ if model is not None:
316
+ if table_name is None:
317
+ raise ValueError("table_name must be provided when model is used.")
318
+ if key is not None:
319
+ raise ValueError(
320
+ "key cannot be provided when model is used. "
321
+ "When using the model, we'll automatically use the key defined."
322
+ )
323
+ key = model.indexes.primary.key()
324
+ if do_projections:
325
+ projection_expression = model.projection_expression
326
+ expression_attribute_names = model.projection_expression_attribute_names
327
+ elif key is None and model is None:
328
+ raise ValueError("Either 'key' or 'model' must be provided.")
329
+
330
+ response = None
331
+ try:
332
+ kwargs = {
333
+ "ConsistentRead": strongly_consistent,
334
+ "ReturnConsumedCapacity": return_consumed_capacity,
335
+ "ProjectionExpression": projection_expression,
336
+ "ExpressionAttributeNames": expression_attribute_names,
337
+ }
338
+ # only pass in args that aren't none
339
+ valid_kwargs = {k: v for k, v in kwargs.items() if v is not None}
340
+
341
+ if table_name is None:
342
+ raise ValueError("table_name must be provided.")
343
+ if call_type == "resource":
344
+ table = self.dynamodb_resource.Table(table_name)
345
+ response = dict(table.get_item(Key=key, **valid_kwargs)) # type: ignore[arg-type]
346
+ elif call_type == "client":
347
+ response = dict(
348
+ self.dynamodb_client.get_item(
349
+ Key=key,
350
+ TableName=table_name,
351
+ **valid_kwargs, # type: ignore[arg-type]
352
+ )
353
+ )
354
+ else:
355
+ raise ValueError(
356
+ f"Unknown call_type of {call_type}. Supported call_types [resource | client]"
357
+ )
358
+ except Exception as e: # pylint: disable=w0718
359
+ logger.exception(
360
+ {"source": f"{source}", "metric_filter": "get_item", "error": str(e)}
361
+ )
362
+
363
+ response = {"exception": str(e)}
364
+ if self.raise_on_error:
365
+ raise e
366
+
367
+ # Apply decimal conversion to the response
368
+ return self._apply_decimal_conversion(response)
369
+
370
+ def update_item(
371
+ self,
372
+ table_name: str,
373
+ key: dict,
374
+ update_expression: str,
375
+ expression_attribute_values: Optional[dict] = None,
376
+ expression_attribute_names: Optional[dict] = None,
377
+ condition_expression: Optional[str] = None,
378
+ return_values: str = "NONE",
379
+ ) -> dict:
380
+ """
381
+ Update an item in DynamoDB with an update expression.
382
+
383
+ Update expressions allow you to modify specific attributes without replacing
384
+ the entire item. Supports SET, ADD, REMOVE, and DELETE operations.
385
+
386
+ Args:
387
+ table_name: The DynamoDB table name
388
+ key: Primary key dict, e.g., {"pk": "user#123", "sk": "user#123"}
389
+ update_expression: Update expression string, e.g., "SET #name = :name, age = age + :inc"
390
+ expression_attribute_values: Value mappings, e.g., {":name": "Alice", ":inc": 1}
391
+ expression_attribute_names: Attribute name mappings for reserved words, e.g., {"#name": "name"}
392
+ condition_expression: Optional condition that must be met, e.g., "attribute_exists(pk)"
393
+ return_values: What to return after update:
394
+ - "NONE" (default): Nothing
395
+ - "ALL_OLD": All attributes before update
396
+ - "UPDATED_OLD": Only updated attributes before update
397
+ - "ALL_NEW": All attributes after update
398
+ - "UPDATED_NEW": Only updated attributes after update
399
+
400
+ Returns:
401
+ dict: DynamoDB response with optional Attributes based on return_values
402
+
403
+ Raises:
404
+ RuntimeError: If condition expression fails
405
+ ClientError: For other DynamoDB errors
406
+
407
+ Examples:
408
+ >>> # Simple SET operation
409
+ >>> db.update_item(
410
+ ... table_name="users",
411
+ ... key={"pk": "user#123", "sk": "user#123"},
412
+ ... update_expression="SET email = :email",
413
+ ... expression_attribute_values={":email": "new@example.com"}
414
+ ... )
415
+
416
+ >>> # Atomic counter
417
+ >>> db.update_item(
418
+ ... table_name="users",
419
+ ... key={"pk": "user#123", "sk": "user#123"},
420
+ ... update_expression="ADD view_count :inc",
421
+ ... expression_attribute_values={":inc": 1}
422
+ ... )
423
+
424
+ >>> # Multiple operations with reserved word
425
+ >>> db.update_item(
426
+ ... table_name="users",
427
+ ... key={"pk": "user#123", "sk": "user#123"},
428
+ ... update_expression="SET #status = :status, updated_at = :now REMOVE temp_field",
429
+ ... expression_attribute_names={"#status": "status"},
430
+ ... expression_attribute_values={":status": "active", ":now": "2024-10-15"}
431
+ ... )
432
+
433
+ >>> # Conditional update with return value
434
+ >>> response = db.update_item(
435
+ ... table_name="users",
436
+ ... key={"pk": "user#123", "sk": "user#123"},
437
+ ... update_expression="SET email = :email",
438
+ ... expression_attribute_values={":email": "new@example.com"},
439
+ ... condition_expression="attribute_exists(pk)",
440
+ ... return_values="ALL_NEW"
441
+ ... )
442
+ >>> updated_user = response['Attributes']
443
+ """
444
+ table = self.dynamodb_resource.Table(table_name)
445
+
446
+ # Build update parameters
447
+ params = {
448
+ "Key": key,
449
+ "UpdateExpression": update_expression,
450
+ "ReturnValues": return_values
451
+ }
452
+
453
+ if expression_attribute_values:
454
+ params["ExpressionAttributeValues"] = expression_attribute_values
455
+
456
+ if expression_attribute_names:
457
+ params["ExpressionAttributeNames"] = expression_attribute_names
458
+
459
+ if condition_expression:
460
+ params["ConditionExpression"] = condition_expression
461
+
462
+ try:
463
+ response = dict(table.update_item(**params))
464
+
465
+ # Apply decimal conversion if response contains attributes
466
+ return self._apply_decimal_conversion(response)
467
+
468
+ except ClientError as e:
469
+ error_code = e.response["Error"]["Code"]
470
+
471
+ if error_code == "ConditionalCheckFailedException":
472
+ raise RuntimeError(
473
+ f"Conditional check failed for update in {table_name}. "
474
+ f"Condition: {condition_expression}"
475
+ ) from e
476
+
477
+ logger.exception(f"Error in update_item: {str(e)}")
478
+ raise
479
+
480
+ def query(
481
+ self,
482
+ key: dict | Key | ConditionBase | ComparisonCondition | DynamoDBIndex,
483
+ table_name: str,
484
+ *,
485
+ index_name: Optional[str] = None,
486
+ ascending: bool = False,
487
+ source: Optional[str] = None,
488
+ strongly_consistent: bool = False,
489
+ projection_expression: Optional[str] = None,
490
+ expression_attribute_names: Optional[dict] = None,
491
+ start_key: Optional[dict] = None,
492
+ limit: Optional[int] = None,
493
+ ) -> dict:
494
+ """
495
+ Run a query and return a list of items
496
+ Args:
497
+ key (Key): _description_
498
+ index_name (str, optional): _description_. Defaults to None.
499
+ ascending (bool, optional): _description_. Defaults to False.
500
+ table_name (str, optional): _description_. Defaults to None.
501
+ source (str, optional): The source of the query. Used for logging. Defaults to None.
502
+
503
+ Returns:
504
+ dict: dynamodb response dictionary
505
+ """
506
+
507
+ logger.debug({"action": "query", "source": source})
508
+ if not key:
509
+ raise ValueError("Query failed: key must be provided.")
510
+
511
+ if not table_name:
512
+ raise ValueError("Query failed: table_name must be provided.")
513
+
514
+ if isinstance(key, DynamoDBIndex):
515
+ if not index_name:
516
+ index_name = key.name
517
+ # turn it into a key expected by dynamodb
518
+ key = key.key(query_key=True)
519
+
520
+ kwargs: dict = {}
521
+
522
+ if index_name and index_name != "primary":
523
+ # only include the index_name if we are not using our "primary" pk/sk
524
+ kwargs["IndexName"] = f"{index_name}"
525
+ kwargs["TableName"] = f"{table_name}"
526
+ kwargs["KeyConditionExpression"] = key
527
+ kwargs["ScanIndexForward"] = ascending
528
+ kwargs["ConsistentRead"] = strongly_consistent
529
+
530
+ if projection_expression:
531
+ kwargs["ProjectionExpression"] = projection_expression
532
+
533
+ if expression_attribute_names:
534
+ kwargs["ExpressionAttributeNames"] = expression_attribute_names
535
+
536
+ if start_key:
537
+ kwargs["ExclusiveStartKey"] = start_key
538
+
539
+ if limit:
540
+ kwargs["Limit"] = limit
541
+
542
+ if table_name is None:
543
+ raise ValueError("Query failed: table_name must be provided.")
544
+
545
+ table = self.dynamodb_resource.Table(table_name)
546
+ response: dict = {}
547
+ try:
548
+ response = dict(table.query(**kwargs))
549
+ except Exception as e: # pylint: disable=w0718
550
+ logger.exception(
551
+ {"source": f"{source}", "metric_filter": "query", "error": str(e)}
552
+ )
553
+ response = {"exception": str(e)}
554
+ if self.raise_on_error:
555
+ raise e
556
+
557
+ # Apply decimal conversion to the response
558
+ return self._apply_decimal_conversion(response)
559
+
560
+ @overload
561
+ def delete(self, *, table_name: str, model: DynamoDBModelBase) -> dict:
562
+ pass
563
+
564
+ @overload
565
+ def delete(
566
+ self,
567
+ *,
568
+ table_name: str,
569
+ primary_key: dict,
570
+ ) -> dict:
571
+ pass
572
+
573
+ def delete(
574
+ self,
575
+ *,
576
+ primary_key: Optional[dict] = None,
577
+ table_name: Optional[str] = None,
578
+ model: Optional[DynamoDBModelBase] = None,
579
+ ):
580
+ """deletes an item from the database"""
581
+
582
+ if model is not None:
583
+ if table_name is None:
584
+ raise ValueError("table_name must be provided when model is used.")
585
+ if primary_key is not None:
586
+ raise ValueError("primary_key cannot be provided when model is used.")
587
+ primary_key = model.indexes.primary.key()
588
+
589
+ response = None
590
+
591
+ if table_name is None or primary_key is None:
592
+ raise ValueError("table_name and primary_key must be provided.")
593
+
594
+ table = self.dynamodb_resource.Table(table_name)
595
+ response = table.delete_item(Key=primary_key)
596
+
597
+ return response
598
+
599
+ def list_tables(self) -> List[str]:
600
+ """Get a list of tables from the current connection"""
601
+ tables = list(self.dynamodb_resource.tables.all())
602
+ table_list: List[str] = []
603
+ if len(tables) > 0:
604
+ for table in tables:
605
+ table_list.append(table.name)
606
+
607
+ return table_list
608
+
609
+ def query_by_criteria(
610
+ self,
611
+ *,
612
+ model: DynamoDBModelBase,
613
+ table_name: str,
614
+ index_name: str,
615
+ key: dict | Key | ConditionBase | ComparisonCondition,
616
+ start_key: Optional[dict] = None,
617
+ do_projections: bool = False,
618
+ ascending: bool = False,
619
+ strongly_consistent: bool = False,
620
+ limit: Optional[int] = None,
621
+ ) -> dict:
622
+ """Helper function to list by criteria"""
623
+
624
+ projection_expression: str | None = None
625
+ expression_attribute_names: dict | None = None
626
+
627
+ if do_projections:
628
+ projection_expression = model.projection_expression
629
+ expression_attribute_names = model.projection_expression_attribute_names
630
+
631
+ response = self.query(
632
+ key=key,
633
+ index_name=index_name,
634
+ table_name=table_name,
635
+ start_key=start_key,
636
+ projection_expression=projection_expression,
637
+ expression_attribute_names=expression_attribute_names,
638
+ ascending=ascending,
639
+ strongly_consistent=strongly_consistent,
640
+ limit=limit,
641
+ )
642
+
643
+ return response
644
+
645
+ def has_more_records(self, response: dict) -> bool:
646
+ """
647
+ Check if there are more records to process.
648
+ This based on the existance of the LastEvaluatedKey in the response.
649
+ Parameters:
650
+ response (dict): dynamodb response dictionary
651
+
652
+ Returns:
653
+ bool: True if there are more records, False otherwise
654
+ """
655
+
656
+ return "LastEvaluatedKey" in response
657
+
658
+ def last_key(self, response: dict) -> dict | None:
659
+ """
660
+ Get the LastEvaluatedKey, which can be used to continue processing the results
661
+ Parameters:
662
+ response (dict): dynamodb response dictionary
663
+
664
+ Returns:
665
+ dict | None: The last key or None if not found
666
+ """
667
+
668
+ return response.get("LastEvaluatedKey")
669
+
670
+ def items(self, response: dict) -> list:
671
+ """
672
+ Get the Items from the dynamodb response
673
+ Parameters:
674
+ response (dict): dynamodb response dictionary
675
+
676
+ Returns:
677
+ list: A list or empty array/list if no items found
678
+ """
679
+
680
+ return response.get("Items", [])
681
+
682
+ def item(self, response: dict) -> dict:
683
+ """
684
+ Get the Item from the dynamodb response
685
+ Parameters:
686
+ response (dict): dynamodb response dictionary
687
+
688
+ Returns:
689
+ dict: A dictionary or empty dictionary if no item found
690
+ """
691
+
692
+ return response.get("Item", {})
693
+
694
+ def batch_get_item(
695
+ self,
696
+ keys: list[dict],
697
+ table_name: str,
698
+ *,
699
+ projection_expression: Optional[str] = None,
700
+ expression_attribute_names: Optional[dict] = None,
701
+ consistent_read: bool = False,
702
+ ) -> dict:
703
+ """
704
+ Retrieve multiple items from DynamoDB in a single request.
705
+
706
+ DynamoDB allows up to 100 items per batch_get_item call. This method
707
+ automatically chunks larger requests and handles unprocessed keys with
708
+ exponential backoff retry logic.
709
+
710
+ Args:
711
+ keys: List of key dictionaries. Each dict must contain the primary key
712
+ (and sort key if applicable) for the items to retrieve.
713
+ Example: [{"pk": "user#1", "sk": "user#1"}, {"pk": "user#2", "sk": "user#2"}]
714
+ table_name: The DynamoDB table name
715
+ projection_expression: Optional comma-separated list of attributes to retrieve
716
+ expression_attribute_names: Optional dict mapping attribute name placeholders to actual names
717
+ consistent_read: If True, uses strongly consistent reads (costs more RCUs)
718
+
719
+ Returns:
720
+ dict: Response containing:
721
+ - 'Items': List of retrieved items (with Decimal conversion applied)
722
+ - 'UnprocessedKeys': Any keys that couldn't be processed after retries
723
+ - 'ConsumedCapacity': Capacity units consumed (if available)
724
+
725
+ Example:
726
+ >>> keys = [
727
+ ... {"pk": "user#user-001", "sk": "user#user-001"},
728
+ ... {"pk": "user#user-002", "sk": "user#user-002"},
729
+ ... {"pk": "user#user-003", "sk": "user#user-003"}
730
+ ... ]
731
+ >>> response = db.batch_get_item(keys=keys, table_name="users")
732
+ >>> items = response['Items']
733
+ >>> print(f"Retrieved {len(items)} items")
734
+
735
+ Note:
736
+ - Maximum 100 items per request (automatically chunked)
737
+ - Each item can be up to 400 KB
738
+ - Maximum 16 MB total response size
739
+ - Unprocessed keys are automatically retried with exponential backoff
740
+ """
741
+ import time
742
+
743
+ all_items = []
744
+ unprocessed_keys = []
745
+
746
+ # DynamoDB limit: 100 items per batch_get_item call
747
+ BATCH_SIZE = 100
748
+
749
+ # Chunk keys into batches of 100
750
+ for i in range(0, len(keys), BATCH_SIZE):
751
+ batch_keys = keys[i:i + BATCH_SIZE]
752
+
753
+ # Build request parameters
754
+ request_items = {
755
+ table_name: {
756
+ 'Keys': batch_keys,
757
+ 'ConsistentRead': consistent_read
758
+ }
759
+ }
760
+
761
+ # Add projection if provided
762
+ if projection_expression:
763
+ request_items[table_name]['ProjectionExpression'] = projection_expression
764
+ if expression_attribute_names:
765
+ request_items[table_name]['ExpressionAttributeNames'] = expression_attribute_names
766
+
767
+ # Retry logic for unprocessed keys
768
+ max_retries = 5
769
+ retry_count = 0
770
+ backoff_time = 0.1 # Start with 100ms
771
+
772
+ while retry_count <= max_retries:
773
+ try:
774
+ response = self.dynamodb_resource.meta.client.batch_get_item(
775
+ RequestItems=request_items
776
+ )
777
+
778
+ # Collect items from this batch
779
+ if 'Responses' in response and table_name in response['Responses']:
780
+ batch_items = response['Responses'][table_name]
781
+ all_items.extend(batch_items)
782
+
783
+ # Check for unprocessed keys
784
+ if 'UnprocessedKeys' in response and response['UnprocessedKeys']:
785
+ if table_name in response['UnprocessedKeys']:
786
+ unprocessed = response['UnprocessedKeys'][table_name]
787
+
788
+ if retry_count < max_retries:
789
+ # Retry with exponential backoff
790
+ logger.warning(
791
+ f"Batch get has {len(unprocessed['Keys'])} unprocessed keys. "
792
+ f"Retrying in {backoff_time}s (attempt {retry_count + 1}/{max_retries})"
793
+ )
794
+ time.sleep(backoff_time)
795
+ request_items = {table_name: unprocessed}
796
+ backoff_time *= 2 # Exponential backoff
797
+ retry_count += 1
798
+ continue
799
+ else:
800
+ # Max retries reached, collect remaining unprocessed keys
801
+ logger.error(
802
+ f"Max retries reached. {len(unprocessed['Keys'])} keys remain unprocessed"
803
+ )
804
+ unprocessed_keys.extend(unprocessed['Keys'])
805
+ break
806
+ else:
807
+ # No unprocessed keys, we're done with this batch
808
+ break
809
+
810
+ except ClientError as e:
811
+ error_code = e.response['Error']['Code']
812
+ if error_code == 'ProvisionedThroughputExceededException' and retry_count < max_retries:
813
+ logger.warning(
814
+ f"Throughput exceeded. Retrying in {backoff_time}s (attempt {retry_count + 1}/{max_retries})"
815
+ )
816
+ time.sleep(backoff_time)
817
+ backoff_time *= 2
818
+ retry_count += 1
819
+ continue
820
+ else:
821
+ logger.exception(f"Error in batch_get_item: {str(e)}")
822
+ raise
823
+
824
+ # Apply decimal conversion to all items
825
+ result = {
826
+ 'Items': all_items,
827
+ 'Count': len(all_items),
828
+ 'UnprocessedKeys': unprocessed_keys
829
+ }
830
+
831
+ return self._apply_decimal_conversion(result)
832
+
833
+ def batch_write_item(
834
+ self,
835
+ items: list[dict],
836
+ table_name: str,
837
+ *,
838
+ operation: str = "put"
839
+ ) -> dict:
840
+ """
841
+ Write or delete multiple items in a single request.
842
+
843
+ DynamoDB allows up to 25 write operations per batch_write_item call.
844
+ This method automatically chunks larger requests and handles unprocessed
845
+ items with exponential backoff retry logic.
846
+
847
+ Args:
848
+ items: List of items to write or delete
849
+ - For 'put': Full item dictionaries
850
+ - For 'delete': Key-only dictionaries (pk, sk)
851
+ table_name: The DynamoDB table name
852
+ operation: Either 'put' (default) or 'delete'
853
+
854
+ Returns:
855
+ dict: Response containing:
856
+ - 'UnprocessedItems': Items that couldn't be processed after retries
857
+ - 'ProcessedCount': Number of successfully processed items
858
+ - 'UnprocessedCount': Number of unprocessed items
859
+
860
+ Example (Put):
861
+ >>> items = [
862
+ ... {"pk": "user#1", "sk": "user#1", "name": "Alice"},
863
+ ... {"pk": "user#2", "sk": "user#2", "name": "Bob"},
864
+ ... {"pk": "user#3", "sk": "user#3", "name": "Charlie"}
865
+ ... ]
866
+ >>> response = db.batch_write_item(items=items, table_name="users")
867
+ >>> print(f"Processed {response['ProcessedCount']} items")
868
+
869
+ Example (Delete):
870
+ >>> keys = [
871
+ ... {"pk": "user#1", "sk": "user#1"},
872
+ ... {"pk": "user#2", "sk": "user#2"}
873
+ ... ]
874
+ >>> response = db.batch_write_item(
875
+ ... items=keys,
876
+ ... table_name="users",
877
+ ... operation="delete"
878
+ ... )
879
+
880
+ Note:
881
+ - Maximum 25 operations per request (automatically chunked)
882
+ - Each item can be up to 400 KB
883
+ - Maximum 16 MB total request size
884
+ - No conditional writes in batch operations
885
+ - Unprocessed items are automatically retried with exponential backoff
886
+ """
887
+ import time
888
+
889
+ if operation not in ['put', 'delete']:
890
+ raise ValueError(f"Invalid operation '{operation}'. Must be 'put' or 'delete'")
891
+
892
+ # DynamoDB limit: 25 operations per batch_write_item call
893
+ BATCH_SIZE = 25
894
+
895
+ total_processed = 0
896
+ all_unprocessed = []
897
+
898
+ # Chunk items into batches of 25
899
+ for i in range(0, len(items), BATCH_SIZE):
900
+ batch_items = items[i:i + BATCH_SIZE]
901
+
902
+ # Build request items
903
+ write_requests = []
904
+ for item in batch_items:
905
+ if operation == 'put':
906
+ write_requests.append({'PutRequest': {'Item': item}})
907
+ else: # delete
908
+ write_requests.append({'DeleteRequest': {'Key': item}})
909
+
910
+ request_items = {table_name: write_requests}
911
+
912
+ # Retry logic for unprocessed items
913
+ max_retries = 5
914
+ retry_count = 0
915
+ backoff_time = 0.1 # Start with 100ms
916
+
917
+ while retry_count <= max_retries:
918
+ try:
919
+ response = self.dynamodb_resource.meta.client.batch_write_item(
920
+ RequestItems=request_items
921
+ )
922
+
923
+ # Count processed items from this batch
924
+ processed_in_batch = len(batch_items)
925
+
926
+ # Check for unprocessed items
927
+ if 'UnprocessedItems' in response and response['UnprocessedItems']:
928
+ if table_name in response['UnprocessedItems']:
929
+ unprocessed = response['UnprocessedItems'][table_name]
930
+ unprocessed_count = len(unprocessed)
931
+ processed_in_batch -= unprocessed_count
932
+
933
+ if retry_count < max_retries:
934
+ # Retry with exponential backoff
935
+ logger.warning(
936
+ f"Batch write has {unprocessed_count} unprocessed items. "
937
+ f"Retrying in {backoff_time}s (attempt {retry_count + 1}/{max_retries})"
938
+ )
939
+ time.sleep(backoff_time)
940
+ request_items = {table_name: unprocessed}
941
+ backoff_time *= 2 # Exponential backoff
942
+ retry_count += 1
943
+ continue
944
+ else:
945
+ # Max retries reached
946
+ logger.error(
947
+ f"Max retries reached. {unprocessed_count} items remain unprocessed"
948
+ )
949
+ all_unprocessed.extend(unprocessed)
950
+ break
951
+
952
+ # Successfully processed this batch
953
+ total_processed += processed_in_batch
954
+ break
955
+
956
+ except ClientError as e:
957
+ error_code = e.response['Error']['Code']
958
+ if error_code == 'ProvisionedThroughputExceededException' and retry_count < max_retries:
959
+ logger.warning(
960
+ f"Throughput exceeded. Retrying in {backoff_time}s (attempt {retry_count + 1}/{max_retries})"
961
+ )
962
+ time.sleep(backoff_time)
963
+ backoff_time *= 2
964
+ retry_count += 1
965
+ continue
966
+ else:
967
+ logger.exception(f"Error in batch_write_item: {str(e)}")
968
+ raise
969
+
970
+ return {
971
+ 'ProcessedCount': total_processed,
972
+ 'UnprocessedCount': len(all_unprocessed),
973
+ 'UnprocessedItems': all_unprocessed
974
+ }
975
+
976
+ def transact_write_items(
977
+ self,
978
+ operations: list[dict],
979
+ *,
980
+ client_request_token: Optional[str] = None,
981
+ return_consumed_capacity: str = "NONE",
982
+ return_item_collection_metrics: str = "NONE"
983
+ ) -> dict:
984
+ """
985
+ Execute multiple write operations as an atomic transaction.
986
+
987
+ All operations succeed or all fail together. This is critical for
988
+ maintaining data consistency across multiple items. Supports up to
989
+ 100 operations per transaction (increased from 25 in 2023).
990
+
991
+ Args:
992
+ operations: List of transaction operation dictionaries. Each dict must
993
+ have one of: 'Put', 'Update', 'Delete', or 'ConditionCheck'
994
+ Example:
995
+ [
996
+ {
997
+ 'Put': {
998
+ 'TableName': 'users',
999
+ 'Item': {'pk': 'user#1', 'sk': 'user#1', 'name': 'Alice'}
1000
+ }
1001
+ },
1002
+ {
1003
+ 'Update': {
1004
+ 'TableName': 'accounts',
1005
+ 'Key': {'pk': 'account#1', 'sk': 'account#1'},
1006
+ 'UpdateExpression': 'SET balance = balance - :amount',
1007
+ 'ExpressionAttributeValues': {':amount': 100}
1008
+ }
1009
+ }
1010
+ ]
1011
+ client_request_token: Optional idempotency token for retry safety
1012
+ return_consumed_capacity: 'INDEXES', 'TOTAL', or 'NONE' (default)
1013
+ return_item_collection_metrics: 'SIZE' or 'NONE' (default)
1014
+
1015
+ Returns:
1016
+ dict: Transaction response containing:
1017
+ - 'ConsumedCapacity': Capacity consumed (if requested)
1018
+ - 'ItemCollectionMetrics': Metrics (if requested)
1019
+
1020
+ Raises:
1021
+ TransactionCanceledException: If transaction fails due to:
1022
+ - Conditional check failure
1023
+ - Item size too large
1024
+ - Throughput exceeded
1025
+ - Duplicate request
1026
+
1027
+ Example:
1028
+ >>> # Transfer money between accounts atomically
1029
+ >>> operations = [
1030
+ ... {
1031
+ ... 'Update': {
1032
+ ... 'TableName': 'accounts',
1033
+ ... 'Key': {'pk': 'account#123', 'sk': 'account#123'},
1034
+ ... 'UpdateExpression': 'SET balance = balance - :amount',
1035
+ ... 'ExpressionAttributeValues': {':amount': 100},
1036
+ ... 'ConditionExpression': 'balance >= :amount'
1037
+ ... }
1038
+ ... },
1039
+ ... {
1040
+ ... 'Update': {
1041
+ ... 'TableName': 'accounts',
1042
+ ... 'Key': {'pk': 'account#456', 'sk': 'account#456'},
1043
+ ... 'UpdateExpression': 'SET balance = balance + :amount',
1044
+ ... 'ExpressionAttributeValues': {':amount': 100}
1045
+ ... }
1046
+ ... }
1047
+ ... ]
1048
+ >>> response = db.transact_write_items(operations=operations)
1049
+
1050
+ Note:
1051
+ - Maximum 100 operations per transaction (AWS limit as of 2023)
1052
+ - Each item can be up to 400 KB
1053
+ - Maximum 4 MB total transaction size
1054
+ - Cannot target same item multiple times in one transaction
1055
+ - All operations must succeed or all fail (atomic)
1056
+ - Uses strongly consistent reads for condition checks
1057
+ """
1058
+ if not operations:
1059
+ raise ValueError("At least one operation is required")
1060
+
1061
+ if len(operations) > 100:
1062
+ raise ValueError(
1063
+ f"Transaction supports maximum 100 operations, got {len(operations)}. "
1064
+ "Consider splitting into multiple transactions."
1065
+ )
1066
+
1067
+ params = {
1068
+ 'TransactItems': operations,
1069
+ 'ReturnConsumedCapacity': return_consumed_capacity,
1070
+ 'ReturnItemCollectionMetrics': return_item_collection_metrics
1071
+ }
1072
+
1073
+ if client_request_token:
1074
+ params['ClientRequestToken'] = client_request_token
1075
+
1076
+ try:
1077
+ response = self.dynamodb_resource.meta.client.transact_write_items(**params)
1078
+ return response
1079
+
1080
+ except ClientError as e:
1081
+ error_code = e.response['Error']['Code']
1082
+
1083
+ if error_code == 'TransactionCanceledException':
1084
+ # Parse cancellation reasons
1085
+ reasons = e.response.get('CancellationReasons', [])
1086
+ logger.error(f"Transaction cancelled. Reasons: {reasons}")
1087
+
1088
+ # Enhance error message with specific reason
1089
+ if reasons:
1090
+ reason_messages = []
1091
+ for idx, reason in enumerate(reasons):
1092
+ if reason.get('Code'):
1093
+ reason_messages.append(
1094
+ f"Operation {idx}: {reason['Code']} - {reason.get('Message', '')}"
1095
+ )
1096
+
1097
+ raise RuntimeError(
1098
+ f"Transaction failed: {'; '.join(reason_messages)}"
1099
+ ) from e
1100
+
1101
+ logger.exception(f"Error in transact_write_items: {str(e)}")
1102
+ raise
1103
+
1104
+ def transact_get_items(
1105
+ self,
1106
+ keys: list[dict],
1107
+ *,
1108
+ return_consumed_capacity: str = "NONE"
1109
+ ) -> dict:
1110
+ """
1111
+ Retrieve multiple items with strong consistency as a transaction.
1112
+
1113
+ Unlike batch_get_item, this provides a consistent snapshot across all items
1114
+ using strongly consistent reads. Maximum 100 items per transaction.
1115
+
1116
+ Args:
1117
+ keys: List of get operation dictionaries. Each dict must specify:
1118
+ - 'Key': The item's primary key
1119
+ - 'TableName': The table name
1120
+ - 'ProjectionExpression': Optional projection
1121
+ - 'ExpressionAttributeNames': Optional attribute names
1122
+ Example:
1123
+ [
1124
+ {
1125
+ 'Key': {'pk': 'user#1', 'sk': 'user#1'},
1126
+ 'TableName': 'users'
1127
+ },
1128
+ {
1129
+ 'Key': {'pk': 'order#123', 'sk': 'order#123'},
1130
+ 'TableName': 'orders',
1131
+ 'ProjectionExpression': 'id,total,#status',
1132
+ 'ExpressionAttributeNames': {'#status': 'status'}
1133
+ }
1134
+ ]
1135
+ return_consumed_capacity: 'INDEXES', 'TOTAL', or 'NONE' (default)
1136
+
1137
+ Returns:
1138
+ dict: Response containing:
1139
+ - 'Items': List of retrieved items (with Decimal conversion)
1140
+ - 'ConsumedCapacity': Capacity consumed (if requested)
1141
+
1142
+ Example:
1143
+ >>> keys = [
1144
+ ... {
1145
+ ... 'Key': {'pk': 'user#123', 'sk': 'user#123'},
1146
+ ... 'TableName': 'users'
1147
+ ... },
1148
+ ... {
1149
+ ... 'Key': {'pk': 'account#123', 'sk': 'account#123'},
1150
+ ... 'TableName': 'accounts'
1151
+ ... }
1152
+ ... ]
1153
+ >>> response = db.transact_get_items(keys=keys)
1154
+ >>> items = response['Items']
1155
+
1156
+ Note:
1157
+ - Maximum 100 items per transaction
1158
+ - Always uses strongly consistent reads
1159
+ - More expensive than batch_get_item (2x RCUs)
1160
+ - Provides snapshot isolation across items
1161
+ - Cannot be combined with transact_write_items
1162
+ """
1163
+ if not keys:
1164
+ raise ValueError("At least one key is required")
1165
+
1166
+ if len(keys) > 100:
1167
+ raise ValueError(
1168
+ f"Transaction supports maximum 100 items, got {len(keys)}. "
1169
+ "Use batch_get_item for larger requests."
1170
+ )
1171
+
1172
+ # Build transaction get items
1173
+ transact_items = []
1174
+ for key_spec in keys:
1175
+ get_item = {'Get': key_spec}
1176
+ transact_items.append(get_item)
1177
+
1178
+ params = {
1179
+ 'TransactItems': transact_items,
1180
+ 'ReturnConsumedCapacity': return_consumed_capacity
1181
+ }
1182
+
1183
+ try:
1184
+ response = self.dynamodb_resource.meta.client.transact_get_items(**params)
1185
+
1186
+ # Extract items from response
1187
+ items = []
1188
+ if 'Responses' in response:
1189
+ for item_response in response['Responses']:
1190
+ if 'Item' in item_response:
1191
+ items.append(item_response['Item'])
1192
+
1193
+ result = {
1194
+ 'Items': items,
1195
+ 'Count': len(items)
1196
+ }
1197
+
1198
+ if 'ConsumedCapacity' in response:
1199
+ result['ConsumedCapacity'] = response['ConsumedCapacity']
1200
+
1201
+ # Apply decimal conversion
1202
+ return self._apply_decimal_conversion(result)
1203
+
1204
+ except ClientError as e:
1205
+ logger.exception(f"Error in transact_get_items: {str(e)}")
1206
+ raise