clear-skies-aws 1.10.2__py3-none-any.whl → 2.0.2__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 (86) hide show
  1. {clear_skies_aws-1.10.2.dist-info → clear_skies_aws-2.0.2.dist-info}/METADATA +36 -35
  2. clear_skies_aws-2.0.2.dist-info/RECORD +63 -0
  3. {clear_skies_aws-1.10.2.dist-info → clear_skies_aws-2.0.2.dist-info}/WHEEL +1 -1
  4. clear_skies_aws-2.0.2.dist-info/licenses/LICENSE +21 -0
  5. clearskies_aws/__init__.py +15 -2
  6. clearskies_aws/actions/__init__.py +13 -106
  7. clearskies_aws/actions/action_aws.py +74 -57
  8. clearskies_aws/actions/assume_role.py +43 -30
  9. clearskies_aws/actions/ses.py +82 -73
  10. clearskies_aws/actions/sns.py +27 -30
  11. clearskies_aws/actions/sqs.py +32 -33
  12. clearskies_aws/actions/step_function.py +38 -31
  13. clearskies_aws/backends/__init__.py +11 -4
  14. clearskies_aws/backends/backend.py +106 -0
  15. clearskies_aws/backends/dynamo_db_backend.py +150 -155
  16. clearskies_aws/backends/dynamo_db_condition_parser.py +40 -80
  17. clearskies_aws/backends/dynamo_db_parti_ql_backend.py +179 -337
  18. clearskies_aws/backends/sqs_backend.py +32 -51
  19. clearskies_aws/configs/__init__.py +0 -0
  20. clearskies_aws/contexts/__init__.py +23 -10
  21. clearskies_aws/contexts/cli_web_socket_mock.py +19 -0
  22. clearskies_aws/contexts/lambda_alb.py +76 -0
  23. clearskies_aws/contexts/lambda_api_gateway.py +75 -28
  24. clearskies_aws/contexts/lambda_api_gateway_web_socket.py +56 -29
  25. clearskies_aws/contexts/lambda_invocation.py +15 -44
  26. clearskies_aws/contexts/lambda_sns.py +8 -33
  27. clearskies_aws/contexts/lambda_sqs_standard_partial_batch.py +14 -36
  28. clearskies_aws/di/__init__.py +6 -1
  29. clearskies_aws/di/aws_additional_config_auto_import.py +37 -0
  30. clearskies_aws/di/inject/__init__.py +6 -0
  31. clearskies_aws/di/inject/boto3.py +15 -0
  32. clearskies_aws/di/inject/boto3_session.py +13 -0
  33. clearskies_aws/di/inject/parameter_store.py +15 -0
  34. clearskies_aws/{handlers → endpoints}/secrets_manager_rotation.py +76 -55
  35. clearskies_aws/endpoints/simple_body_routing.py +41 -0
  36. clearskies_aws/input_outputs/__init__.py +21 -8
  37. clearskies_aws/input_outputs/{cli_websocket_mock.py → cli_web_socket_mock.py} +9 -3
  38. clearskies_aws/input_outputs/lambda_alb.py +53 -0
  39. clearskies_aws/input_outputs/lambda_api_gateway.py +106 -88
  40. clearskies_aws/input_outputs/lambda_api_gateway_web_socket.py +69 -6
  41. clearskies_aws/input_outputs/lambda_input_output.py +87 -0
  42. clearskies_aws/input_outputs/lambda_invocation.py +77 -26
  43. clearskies_aws/input_outputs/lambda_sns.py +66 -39
  44. clearskies_aws/input_outputs/lambda_sqs_standard.py +70 -40
  45. clearskies_aws/mocks/actions/ses.py +25 -19
  46. clearskies_aws/mocks/actions/sns.py +18 -12
  47. clearskies_aws/mocks/actions/sqs.py +18 -12
  48. clearskies_aws/mocks/actions/step_function.py +19 -13
  49. clearskies_aws/models/__init__.py +0 -0
  50. clearskies_aws/models/web_socket_connection_model.py +182 -0
  51. clearskies_aws/secrets/__init__.py +13 -7
  52. clearskies_aws/secrets/additional_configs/__init__.py +10 -2
  53. clearskies_aws/secrets/additional_configs/iam_db_auth.py +26 -16
  54. clearskies_aws/secrets/additional_configs/iam_db_auth_with_ssm.py +43 -39
  55. clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +30 -31
  56. clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssm_bastion.py +70 -49
  57. clearskies_aws/secrets/akeyless_with_ssm_cache.py +32 -18
  58. clearskies_aws/secrets/parameter_store.py +34 -32
  59. clearskies_aws/secrets/secrets.py +16 -0
  60. clearskies_aws/secrets/secrets_manager.py +78 -57
  61. clear_skies_aws-1.10.2.dist-info/LICENSE +0 -7
  62. clear_skies_aws-1.10.2.dist-info/RECORD +0 -71
  63. clearskies_aws/actions/assume_role_test.py +0 -72
  64. clearskies_aws/actions/ses_test.py +0 -89
  65. clearskies_aws/actions/sns_test.py +0 -77
  66. clearskies_aws/actions/sqs_test.py +0 -127
  67. clearskies_aws/actions/step_function_test.py +0 -103
  68. clearskies_aws/backends/dynamo_db_backend_test.py +0 -300
  69. clearskies_aws/backends/dynamo_db_condition_parser_test.py +0 -266
  70. clearskies_aws/backends/dynamo_db_parti_ql_backend_test.py +0 -544
  71. clearskies_aws/backends/sqs_backend_test.py +0 -31
  72. clearskies_aws/contexts/cli.py +0 -19
  73. clearskies_aws/contexts/cli_websocket_mock.py +0 -33
  74. clearskies_aws/contexts/lambda_elb.py +0 -30
  75. clearskies_aws/contexts/lambda_http_gateway.py +0 -30
  76. clearskies_aws/contexts/lambda_sqs_standard_partial_batch_test.py +0 -66
  77. clearskies_aws/contexts/wsgi.py +0 -19
  78. clearskies_aws/di/standard_dependencies.py +0 -60
  79. clearskies_aws/handlers/simple_body_routing.py +0 -39
  80. clearskies_aws/input_outputs/lambda_api_gateway_test.py +0 -87
  81. clearskies_aws/input_outputs/lambda_elb.py +0 -21
  82. clearskies_aws/input_outputs/lambda_http_gateway.py +0 -12
  83. clearskies_aws/secrets/parameter_store_test.py +0 -18
  84. clearskies_aws/secrets/secrets_manager_test.py +0 -18
  85. clearskies_aws/web_socket_connection_model.py +0 -43
  86. clearskies_aws/{handlers → endpoints}/__init__.py +1 -1
@@ -1,18 +1,23 @@
1
+ from __future__ import annotations
2
+
1
3
  import base64
2
4
  import json
3
5
  from decimal import Decimal
4
- from typing import Any, Callable, Dict, List, Tuple
6
+ from typing import Any, Callable
5
7
 
8
+ import clearskies
6
9
  from boto3.dynamodb import conditions as dynamodb_conditions
7
10
  from clearskies import model
8
11
  from clearskies.autodoc.schema import String as AutoDocString
9
- from clearskies.backends.backend import Backend
10
- from clearskies.column_types.boolean import Boolean
11
- from clearskies.column_types.float import Float
12
- from clearskies.column_types.integer import Integer
12
+ from clearskies.columns.boolean import Boolean
13
+ from clearskies.columns.float import Float
14
+ from clearskies.columns.integer import Integer
15
+ from types_boto3_dynamodb import DynamoDBServiceResource
16
+
17
+ from clearskies_aws.backends import backend
13
18
 
14
19
 
15
- class DynamoDBBackend(Backend):
20
+ class DynamoDBBackend(backend.Backend):
16
21
  """
17
22
  DynamoDB is complicated.
18
23
 
@@ -70,21 +75,19 @@ class DynamoDBBackend(Backend):
70
75
  Any other filter/sort options will become unreliable as soon as the table grows past the maximum result size.
71
76
  """
72
77
 
73
- _boto3 = None
74
- _environment = None
75
- _dynamodb = None
78
+ dynamodb: DynamoDBServiceResource
76
79
 
77
80
  _allowed_configs = [
78
- 'table_name',
79
- 'wheres',
80
- 'sorts',
81
- 'limit',
82
- 'pagination',
83
- 'model_columns',
81
+ "table_name",
82
+ "wheres",
83
+ "sorts",
84
+ "limit",
85
+ "pagination",
86
+ "model_columns",
84
87
  ]
85
88
 
86
89
  _required_configs = [
87
- 'table_name',
90
+ "table_name",
88
91
  ]
89
92
 
90
93
  _table_indexes = None
@@ -94,114 +97,111 @@ class DynamoDBBackend(Backend):
94
97
  # this is the list of operators that we can use when querying a dynamodb index and their corresponding
95
98
  # key method name in dynamodb
96
99
  _index_operators = {
97
- '=': 'eq',
98
- '<': 'lt',
99
- '>': 'gt',
100
- '>=': 'gte',
101
- '<=': 'lte',
100
+ "=": "eq",
101
+ "<": "lt",
102
+ ">": "gt",
103
+ ">=": "gte",
104
+ "<=": "lte",
102
105
  }
103
106
 
104
107
  # this is a map from clearskies operators to the equivalent dynamodb attribute operators
105
108
  _attribute_operators = {
106
- '!=': 'ne',
107
- '<=': 'lte',
108
- '>=': 'gte',
109
- '>': 'gt',
110
- '<': 'lt',
111
- '=': 'eq',
112
- 'IS NOT NULL': 'exists',
113
- 'IS NULL': 'not_exists',
114
- 'IS NOT': 'ne',
115
- 'IS': 'eq',
116
- 'LIKE': '', # requires special handling
109
+ "!=": "ne",
110
+ "<=": "lte",
111
+ ">=": "gte",
112
+ ">": "gt",
113
+ "<": "lt",
114
+ "=": "eq",
115
+ "IS NOT NULL": "exists",
116
+ "IS NULL": "not_exists",
117
+ "IS NOT": "ne",
118
+ "IS": "eq",
119
+ "LIKE": "", # requires special handling
117
120
  }
118
121
 
119
- def __init__(self, boto3, environment):
120
- self._boto3 = boto3
121
- self._environment = environment
122
- if not environment.get('AWS_REGION', True):
123
- raise ValueError('To use DynamoDB you must use set AWS_REGION in the .env file or an environment variable')
122
+ def __init__(self):
123
+ if not self.environment.get("AWS_REGION", True):
124
+ raise ValueError("To use DynamoDB you must use set AWS_REGION in the .env file or an environment variable")
124
125
 
125
- self._dynamodb = self._boto3.resource('dynamodb', region_name=environment.get('AWS_REGION', True))
126
+ self.dynamodb = self.boto3.resource("dynamodb", region_name=self.environment.get("AWS_REGION", True))
126
127
  self._table_indexes = {}
127
128
  self._model_columns_cache = {}
128
129
 
129
- def configure(self):
130
- pass
130
+ @classmethod
131
+ def clear_table_cache(cls):
132
+ cls._table_indexes = {}
133
+ cls._model_columns_cache = {}
131
134
 
132
135
  def update(self, id, data, model):
133
136
  # when we run an update column we must include the sort column on the primary
134
137
  # index (if it exists)
135
138
  sort_column_name = self._find_primary_sort_column(model)
136
- key = {model.id_column_name: model.__getattr__(model.id_column_name)}
139
+ key = {model.id_column_name: model.get_columns()[model.id_column_name]}
137
140
  if sort_column_name:
138
141
  key[sort_column_name] = data.get(
139
- sort_column_name,
140
- model.columns()[sort_column_name].to_backend(model._data)
142
+ sort_column_name, model.get_columns()[sort_column_name].to_backend(model._data)
141
143
  )
142
- table = self._dynamodb.Table(model.table_name())
144
+ table = self.dynamodb.Table(model.destination_name())
143
145
 
144
146
  data = self.excessive_type_casting(data)
145
147
 
146
148
  updated = table.update_item(
147
149
  Key=key,
148
- UpdateExpression='SET ' + ', '.join([f"#{column_name} = :{column_name}" for column_name in data.keys()]),
150
+ UpdateExpression="SET " + ", ".join([f"#{column_name} = :{column_name}" for column_name in data.keys()]),
149
151
  ExpressionAttributeValues={
150
- **{f':{column_name}': value
151
- for (column_name, value) in data.items()},
152
+ **{f":{column_name}": value for (column_name, value) in data.items()},
152
153
  },
153
154
  ExpressionAttributeNames={
154
- **{f'#{column_name}': column_name
155
- for column_name in data.keys()},
155
+ **{f"#{column_name}": column_name for column_name in data.keys()},
156
156
  },
157
157
  ReturnValues="ALL_NEW",
158
158
  )
159
- return self._map_from_boto3(updated['Attributes'])
159
+ return self._map_from_boto3(updated["Attributes"])
160
160
 
161
161
  def create(self, data, model):
162
- table = self._dynamodb.Table(model.table_name())
162
+ table = self.dynamodb.Table(model.destination_name())
163
163
  table.put_item(Item=data)
164
164
  return {**data}
165
165
 
166
166
  def excessive_type_casting(self, data):
167
- for (key, value) in data.items():
167
+ for key, value in data.items():
168
168
  if isinstance(value, float):
169
169
  data[key] = Decimal(value)
170
170
  return data
171
171
 
172
172
  def delete(self, id, model):
173
- table = self._dynamodb.Table(model.table_name())
173
+ table = self.dynamodb.Table(model.table_name())
174
174
  table.delete_item(Key={model.id_column_name: model.__getattr__(model.id_column_name)})
175
175
  return True
176
176
 
177
177
  def count(self, configuration, model):
178
- response = self._dynamodb_query(configuration, model, 'COUNT')
179
- return response['Count']
180
-
181
- def records(self,
182
- configuration: Dict[str, Any],
183
- model: model.Model,
184
- next_page_data: Dict[str, str] = None) -> List[Dict[str, Any]]:
185
- response = self._dynamodb_query(configuration, model, 'ALL_ATTRIBUTES')
186
- if 'LastEvaluatedKey' in response and response['LastEvaluatedKey'] is not None and type(next_page_data) == dict:
187
- next_page_data['next_token'] = self.serialize_next_token_for_response(
188
- self._map_from_boto3(response['LastEvaluatedKey'])
178
+ response = self.dynamodb_query(configuration, model, "COUNT")
179
+ return response["Count"]
180
+
181
+ def records(
182
+ self, configuration: dict[str, Any], model: model.Model, next_page_data: dict[str, str] = None
183
+ ) -> list[dict[str, Any]]:
184
+ response = self.dynamodb_query(configuration, model, "ALL_ATTRIBUTES")
185
+ if "LastEvaluatedKey" in response and response["LastEvaluatedKey"] is not None and type(next_page_data) == dict:
186
+ next_page_data["next_token"] = self.serialize_next_token_for_response(
187
+ self._map_from_boto3(response["LastEvaluatedKey"])
189
188
  )
190
- return [self._map_from_boto3(item) for item in response['Items']]
189
+ return [self._map_from_boto3(item) for item in response["Items"]]
191
190
 
192
191
  def _dynamodb_query(self, configuration, model, select_type):
193
- [filter_expression, key_condition_expression, index_name,
194
- scan_index_forward] = self._create_dynamodb_query_parameters(configuration, model)
195
- table = self._dynamodb.Table(model.table_name())
192
+ [filter_expression, key_condition_expression, index_name, scan_index_forward] = (
193
+ self._create_dynamodb_query_parameters(configuration, model)
194
+ )
195
+ table = self.dynamodb.Table(model.table_name())
196
196
 
197
197
  # so we want to put together the kwargs for scan/query:
198
198
  kwargs = {
199
- 'IndexName': index_name,
200
- 'KeyConditionExpression': key_condition_expression,
201
- 'FilterExpression': filter_expression,
202
- 'Select': select_type,
203
- 'ExclusiveStartKey': self.restore_next_token_from_config(configuration['pagination'].get('next_token')),
204
- 'Limit': configuration['limit'] if configuration['limit'] and select_type != 'COUNT' else None
199
+ "IndexName": index_name,
200
+ "KeyConditionExpression": key_condition_expression,
201
+ "FilterExpression": filter_expression,
202
+ "Select": select_type,
203
+ "ExclusiveStartKey": self.restore_next_token_from_config(configuration["pagination"].get("next_token")),
204
+ "Limit": configuration["limit"] if configuration["limit"] and select_type != "COUNT" else None,
205
205
  }
206
206
  # the trouble is that boto3 isn't okay with parameters of None.
207
207
  # therefore, we need to remove any of the above keys that are None
@@ -209,7 +209,7 @@ class DynamoDBBackend(Backend):
209
209
 
210
210
  if key_condition_expression:
211
211
  # add the scan index forward setting for key conditions
212
- kwargs['ScanIndexForward'] = scan_index_forward
212
+ kwargs["ScanIndexForward"] = scan_index_forward
213
213
  return table.query(**kwargs)
214
214
  return table.scan(**kwargs)
215
215
 
@@ -217,13 +217,13 @@ class DynamoDBBackend(Backend):
217
217
  # DynamoDB only supports sorting by a single column, and only if we can find a supporting index
218
218
  # figure out if and what we are sorting by.
219
219
  sort_column = None
220
- sort_direction = 'asc'
221
- if 'sorts' in configuration and configuration['sorts']:
222
- sort_column = configuration['sorts'][0]['column']
223
- sort_direction = configuration['sorts'][0]['direction']
220
+ sort_direction = "asc"
221
+ if "sorts" in configuration and configuration["sorts"]:
222
+ sort_column = configuration["sorts"][0]["column"]
223
+ sort_direction = configuration["sorts"][0]["direction"]
224
224
 
225
225
  # if we have neither sort nor a where then we have a simple query and can finish up now.
226
- if not sort_column and not configuration['wheres']:
226
+ if not sort_column and not configuration["wheres"]:
227
227
  return [None, None, None, True]
228
228
 
229
229
  # so the thing here is that if we find a condition that corresponds to an indexed
@@ -234,7 +234,7 @@ class DynamoDBBackend(Backend):
234
234
  # the query operation in dynamodb, so searching on an indexed column doesn't guarantee
235
235
  # that we can use a query.
236
236
  [key_condition_expression, index_name, remaining_conditions] = self._find_key_condition_expressions(
237
- configuration['wheres'],
237
+ configuration["wheres"],
238
238
  model.id_column_name,
239
239
  sort_column,
240
240
  model,
@@ -243,8 +243,8 @@ class DynamoDBBackend(Backend):
243
243
  return [
244
244
  self._as_attr_filter_expressions(remaining_conditions, model),
245
245
  key_condition_expression,
246
- index_name, # we don't need to specify the name of the primary index
247
- sort_direction.lower() == 'asc',
246
+ index_name, # we don't need to specify the name of the primary index
247
+ sort_direction.lower() == "asc",
248
248
  ]
249
249
 
250
250
  def _find_key_condition_expressions(self, conditions, id_column_name, sort_column, model):
@@ -271,13 +271,13 @@ class DynamoDBBackend(Backend):
271
271
  id_conditions = []
272
272
  indexable_conditions = []
273
273
  secondary_conditions = []
274
- for (index, condition) in enumerate(conditions):
275
- column_name = condition['column']
274
+ for index, condition in enumerate(conditions):
275
+ column_name = condition["column"]
276
276
  # if the column isn't a hash index and isn't an equals search, then this condition "anchor" an index search.
277
- if column_name not in indexes or condition['operator'] != '=':
277
+ if column_name not in indexes or condition["operator"] != "=":
278
278
  # however, it may still contribute to a secondary condition in an index search, so record it
279
279
  # if it uses a supporting operator
280
- if condition['operator'] in self._index_operators:
280
+ if condition["operator"] in self._index_operators:
281
281
  secondary_conditions.append(index)
282
282
 
283
283
  # if we get here then we have an '=' condition on a hash attribute in an index - we can use an index!
@@ -352,31 +352,31 @@ class DynamoDBBackend(Backend):
352
352
  """
353
353
  # the condition for the primary condition
354
354
  index_condition = conditions[primary_condition_index]
355
- index_data = indexes[index_condition['column']]
355
+ index_data = indexes[index_condition["column"]]
356
356
 
357
357
  # our secondary columns are just suggestions, so see if we can actually use any
358
358
  index_condition_counts = {}
359
359
  for condition_index in secondary_condition_indexes:
360
360
  secondary_condition = conditions[condition_index]
361
- secondary_column = secondary_condition['column']
362
- if secondary_column not in index_data['sortable_columns']:
361
+ secondary_column = secondary_condition["column"]
362
+ if secondary_column not in index_data["sortable_columns"]:
363
363
  continue
364
- secondary_index = index_data['sortable_columns'][secondary_column]
364
+ secondary_index = index_data["sortable_columns"][secondary_column]
365
365
  if secondary_index not in index_condition_counts:
366
- index_condition_counts[secondary_index] = {'count': 0, 'condition_indexes': []}
367
- index_condition_counts[secondary_index]['count'] += 1
368
- index_condition_counts[secondary_index]['condition_indexes'].append(condition_index)
366
+ index_condition_counts[secondary_index] = {"count": 0, "condition_indexes": []}
367
+ index_condition_counts[secondary_index]["count"] += 1
368
+ index_condition_counts[secondary_index]["condition_indexes"].append(condition_index)
369
369
 
370
370
  # now we can decide which index to use. Prefer an index that hits some secondary conditions,
371
371
  # or an index that hits the sort column, or the default index.
372
372
  used_condition_indexes = [primary_condition_index]
373
373
  if index_condition_counts:
374
- index_to_use = max(index_condition_counts, key=lambda key: index_condition_counts[key]['count'])
375
- used_condition_indexes.extend(index_condition_counts[index_to_use]['condition_indexes'])
376
- elif sort_column in index_data['sortable_columns']:
377
- index_to_use = index_data['sortable_columns'][sort_column]
374
+ index_to_use = max(index_condition_counts, key=lambda key: index_condition_counts[key]["count"])
375
+ used_condition_indexes.extend(index_condition_counts[index_to_use]["condition_indexes"])
376
+ elif sort_column in index_data["sortable_columns"]:
377
+ index_to_use = index_data["sortable_columns"][sort_column]
378
378
  else:
379
- index_to_use = index_data['default_index_name']
379
+ index_to_use = index_data["default_index_name"]
380
380
 
381
381
  # now build our key expression. For every condition in used_condition_indexes, add it to
382
382
  # a key expression, and remove it from the conditions array. Do this backwards to make sure
@@ -386,11 +386,12 @@ class DynamoDBBackend(Backend):
386
386
  key_condition_expression = None
387
387
  for condition_index in used_condition_indexes:
388
388
  condition = conditions[condition_index]
389
- dynamodb_operator_method = self._index_operators[condition['operator']]
390
- raw_search_value = condition['values'][0] if condition['values'] else None
391
- value = self._value_for_condition_expression(raw_search_value, condition['column'], model)
392
- condition_expression = getattr(dynamodb_conditions.Key(condition['column']),
393
- dynamodb_operator_method)(value)
389
+ dynamodb_operator_method = self._index_operators[condition["operator"]]
390
+ raw_search_value = condition["values"][0] if condition["values"] else None
391
+ value = self._value_for_condition_expression(raw_search_value, condition["column"], model)
392
+ condition_expression = getattr(dynamodb_conditions.Key(condition["column"]), dynamodb_operator_method)(
393
+ value
394
+ )
394
395
  # add to our key condition expression
395
396
  if key_condition_expression is None:
396
397
  key_condition_expression = condition_expression
@@ -409,27 +410,27 @@ class DynamoDBBackend(Backend):
409
410
  def _as_attr_filter_expressions(self, conditions, model):
410
411
  filter_expression = None
411
412
  for condition in conditions:
412
- operator = condition['operator']
413
- value = condition['values'][0] if condition['values'] else None
414
- column_name = condition['column']
413
+ operator = condition["operator"]
414
+ value = condition["values"][0] if condition["values"] else None
415
+ column_name = condition["column"]
415
416
  if operator not in self._attribute_operators:
416
417
  raise ValueError(
417
418
  f"I was asked to filter by operator '{operator}' but this operator is not supported by DynamoDB"
418
419
  )
419
420
 
420
421
  # a couple of our operators require special handling
421
- if operator == 'LIKE':
422
- if value[0] != '%' and value[-1] == '%':
423
- condition_expression = dynamodb_conditions.Attr(column_name).begins_with(value.rstrip('%'))
424
- elif value[0] == '%' and value[-1] != '%':
422
+ if operator == "LIKE":
423
+ if value[0] != "%" and value[-1] == "%":
424
+ condition_expression = dynamodb_conditions.Attr(column_name).begins_with(value.rstrip("%"))
425
+ elif value[0] == "%" and value[-1] != "%":
425
426
  raise ValueError("DynamoDB doesn't support the 'ends_with' operator")
426
- elif value[0] == '%' and value[-1] == '%':
427
- condition_expression = dynamodb_conditions.Attr(column_name).contains(value.strip('%'))
427
+ elif value[0] == "%" and value[-1] == "%":
428
+ condition_expression = dynamodb_conditions.Attr(column_name).contains(value.strip("%"))
428
429
  else:
429
430
  condition_expression = dynamodb_conditions.Attr(column_name).eq(value)
430
- elif operator == 'IS NULL':
431
+ elif operator == "IS NULL":
431
432
  condition_expression = dynamodb_conditions.Attr(column_name).exists()
432
- elif operator == 'IS NOT NULL':
433
+ elif operator == "IS NOT NULL":
433
434
  condition_expression = dynamodb_conditions.Attr(column_name).not_exists()
434
435
  else:
435
436
  dynamodb_operator = self._attribute_operators[operator]
@@ -459,7 +460,7 @@ class DynamoDBBackend(Backend):
459
460
  return value
460
461
 
461
462
  def _get_indexes_for_model(self, model):
462
- """ Loads up the indexes for the DynamoDB table for the given model """
463
+ """Load indexes for the DynamoDB table for the given model."""
463
464
  if model.table_name() in self._table_indexes:
464
465
  return self._table_indexes[model.table_name()]
465
466
 
@@ -479,11 +480,11 @@ class DynamoDBBackend(Backend):
479
480
  # and then is further subdivided for columns that have RANGE/Sort attributes, giving you
480
481
  # the index name for that HASH+RANGE combination.
481
482
  table_indexes = {}
482
- table = self._dynamodb.Table(model.table_name())
483
+ table = self.dynamodb.Table(model.table_name())
483
484
  schemas = []
484
485
  # the primary index for the table doesn't have a name, and it will be used by default
485
486
  # if we don't specify an index name. Therefore, we just pass around None for it's name
486
- schemas.append({'IndexName': None, 'KeySchema': table.key_schema})
487
+ schemas.append({"IndexName": None, "KeySchema": table.key_schema})
487
488
  global_secondary_indexes = table.global_secondary_indexes
488
489
  local_secondary_indexes = table.local_secondary_indexes
489
490
  if global_secondary_indexes is not None:
@@ -491,17 +492,17 @@ class DynamoDBBackend(Backend):
491
492
  if local_secondary_indexes is not None:
492
493
  schemas.extend(table.local_secondary_indexes)
493
494
  for schema in schemas:
494
- hash_column = ''
495
- range_column = ''
496
- for key in schema['KeySchema']:
497
- if key['KeyType'] == 'RANGE':
498
- range_column = key['AttributeName']
499
- if key['KeyType'] == 'HASH':
500
- hash_column = key['AttributeName']
495
+ hash_column = ""
496
+ range_column = ""
497
+ for key in schema["KeySchema"]:
498
+ if key["KeyType"] == "RANGE":
499
+ range_column = key["AttributeName"]
500
+ if key["KeyType"] == "HASH":
501
+ hash_column = key["AttributeName"]
501
502
  if hash_column not in table_indexes:
502
- table_indexes[hash_column] = {'default_index_name': schema['IndexName'], 'sortable_columns': {}}
503
+ table_indexes[hash_column] = {"default_index_name": schema["IndexName"], "sortable_columns": {}}
503
504
  if range_column:
504
- table_indexes[hash_column]['sortable_columns'][range_column] = schema['IndexName']
505
+ table_indexes[hash_column]["sortable_columns"][range_column] = schema["IndexName"]
505
506
 
506
507
  self._table_indexes[model.table_name()] = table_indexes
507
508
  return table_indexes
@@ -511,7 +512,7 @@ class DynamoDBBackend(Backend):
511
512
  primary_indexes = indexes.get(model.id_column_name)
512
513
  if not primary_indexes:
513
514
  return None
514
- for (column_name, index_name) in primary_indexes['sortable_columns'].items():
515
+ for column_name, index_name in primary_indexes["sortable_columns"].items():
515
516
  # the primary index doesn't have a name, so we want the record with a name of None
516
517
  if index_name is None:
517
518
  return column_name
@@ -532,32 +533,32 @@ class DynamoDBBackend(Backend):
532
533
 
533
534
  for key in self._required_configs:
534
535
  if key not in configuration:
535
- raise KeyError(f'Missing required configuration key {key}')
536
+ raise KeyError(f"Missing required configuration key {key}")
536
537
 
537
538
  for key in self._allowed_configs:
538
- if not key in configuration:
539
- configuration[key] = [] if key[-1] == 's' else ''
539
+ if key not in configuration:
540
+ configuration[key] = [] if key[-1] == "s" else ""
540
541
 
541
542
  return configuration
542
543
 
543
- def validate_pagination_kwargs(self, kwargs: Dict[str, Any], case_mapping: Callable) -> str:
544
+ def validate_pagination_kwargs(self, kwargs: dict[str, Any], case_mapping: Callable) -> str:
544
545
  extra_keys = set(kwargs.keys()) - set(self.allowed_pagination_keys())
545
546
  if len(extra_keys):
546
- key_name = case_mapping('next_token')
547
+ key_name = case_mapping("next_token")
547
548
  return "Invalid pagination key(s): '" + "','".join(extra_keys) + f"'. Only '{key_name}' is allowed"
548
- if 'next_token' not in kwargs:
549
- key_name = case_mapping('next_token')
549
+ if "next_token" not in kwargs:
550
+ key_name = case_mapping("next_token")
550
551
  return f"You must specify '{key_name}' when setting pagination"
551
552
  # the next token should be a urlsafe-base64 encoded JSON string
552
553
  try:
553
- json.loads(base64.urlsafe_b64decode(kwargs['next_token']))
554
+ json.loads(base64.urlsafe_b64decode(kwargs["next_token"]))
554
555
  except:
555
- key_name = case_mapping('next_token')
556
+ key_name = case_mapping("next_token")
556
557
  return "The provided '{key_name}' appears to be invalid."
557
- return ''
558
+ return ""
558
559
 
559
- def allowed_pagination_keys(self) -> List[str]:
560
- return ['next_token']
560
+ def allowed_pagination_keys(self) -> list[str]:
561
+ return ["next_token"]
561
562
 
562
563
  def restore_next_token_from_config(self, next_token):
563
564
  if not next_token:
@@ -568,23 +569,19 @@ class DynamoDBBackend(Backend):
568
569
  return None
569
570
 
570
571
  def serialize_next_token_for_response(self, last_evaluated_key):
571
- return base64.urlsafe_b64encode(json.dumps(last_evaluated_key).encode('utf-8')).decode('utf8')
572
+ return base64.urlsafe_b64encode(json.dumps(last_evaluated_key).encode("utf-8")).decode("utf8")
572
573
 
573
- def documentation_pagination_next_page_response(self, case_mapping: Callable) -> List[Any]:
574
- return [AutoDocString(case_mapping('next_token'))]
574
+ def documentation_pagination_next_page_response(self, case_mapping: Callable) -> list[Any]:
575
+ return [AutoDocString(case_mapping("next_token"))]
575
576
 
576
- def documentation_pagination_next_page_example(self, case_mapping: Callable) -> Dict[str, Any]:
577
- return {case_mapping('next_token'): ''}
577
+ def documentation_pagination_next_page_example(self, case_mapping: Callable) -> dict[str, Any]:
578
+ return {case_mapping("next_token"): ""}
578
579
 
579
- def documentation_pagination_parameters(self, case_mapping: Callable) -> List[Tuple[Any]]:
580
- return [(
581
- AutoDocString(case_mapping('next_token'), example=''), 'A token to fetch the next page of results'
582
- )]
580
+ def documentation_pagination_parameters(self, case_mapping: Callable) -> list[tuple[Any]]:
581
+ return [(AutoDocString(case_mapping("next_token"), example=""), "A token to fetch the next page of results")]
583
582
 
584
583
  def column_from_backend(self, column, value):
585
- """
586
- We have a couple columns we want to override transformations for
587
- """
584
+ """We have a couple columns we want to override transformations for."""
588
585
  # We're pretty much ignoring the BOOL type for dynamodb, because it doesn't work in indexes
589
586
  # (and 99% of the time when I have a boolean, it gets used in an index). Therefore,
590
587
  # convert boolean values to "0", "1".
@@ -598,9 +595,7 @@ class DynamoDBBackend(Backend):
598
595
  return super().column_from_backend(column, value)
599
596
 
600
597
  def column_to_backend(self, column, backend_data):
601
- """
602
- We have a couple columns we want to override transformations for
603
- """
598
+ """We have a couple columns we want to override transformations for."""
604
599
  # most importantly, there's no need to transform a JSON column in either direction
605
600
  if isinstance(column, Boolean):
606
601
  if column.name not in backend_data: