clear-skies-aws 1.10.2__py3-none-any.whl → 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. {clear_skies_aws-1.10.2.dist-info → clear_skies_aws-2.0.1.dist-info}/METADATA +36 -35
  2. clear_skies_aws-2.0.1.dist-info/RECORD +4 -0
  3. {clear_skies_aws-1.10.2.dist-info → clear_skies_aws-2.0.1.dist-info}/WHEEL +1 -1
  4. clear_skies_aws-2.0.1.dist-info/licenses/LICENSE +21 -0
  5. clear_skies_aws-1.10.2.dist-info/LICENSE +0 -7
  6. clear_skies_aws-1.10.2.dist-info/RECORD +0 -71
  7. clearskies_aws/__init__.py +0 -2
  8. clearskies_aws/actions/__init__.py +0 -108
  9. clearskies_aws/actions/action_aws.py +0 -118
  10. clearskies_aws/actions/assume_role.py +0 -102
  11. clearskies_aws/actions/assume_role_test.py +0 -72
  12. clearskies_aws/actions/ses.py +0 -194
  13. clearskies_aws/actions/ses_test.py +0 -89
  14. clearskies_aws/actions/sns.py +0 -64
  15. clearskies_aws/actions/sns_test.py +0 -77
  16. clearskies_aws/actions/sqs.py +0 -82
  17. clearskies_aws/actions/sqs_test.py +0 -127
  18. clearskies_aws/actions/step_function.py +0 -66
  19. clearskies_aws/actions/step_function_test.py +0 -103
  20. clearskies_aws/backends/__init__.py +0 -12
  21. clearskies_aws/backends/dynamo_db_backend.py +0 -614
  22. clearskies_aws/backends/dynamo_db_backend_test.py +0 -300
  23. clearskies_aws/backends/dynamo_db_condition_parser.py +0 -365
  24. clearskies_aws/backends/dynamo_db_condition_parser_test.py +0 -266
  25. clearskies_aws/backends/dynamo_db_parti_ql_backend.py +0 -1123
  26. clearskies_aws/backends/dynamo_db_parti_ql_backend_test.py +0 -544
  27. clearskies_aws/backends/sqs_backend.py +0 -80
  28. clearskies_aws/backends/sqs_backend_test.py +0 -31
  29. clearskies_aws/contexts/__init__.py +0 -10
  30. clearskies_aws/contexts/cli.py +0 -19
  31. clearskies_aws/contexts/cli_websocket_mock.py +0 -33
  32. clearskies_aws/contexts/lambda_api_gateway.py +0 -30
  33. clearskies_aws/contexts/lambda_api_gateway_web_socket.py +0 -30
  34. clearskies_aws/contexts/lambda_elb.py +0 -30
  35. clearskies_aws/contexts/lambda_http_gateway.py +0 -30
  36. clearskies_aws/contexts/lambda_invocation.py +0 -48
  37. clearskies_aws/contexts/lambda_sns.py +0 -43
  38. clearskies_aws/contexts/lambda_sqs_standard_partial_batch.py +0 -51
  39. clearskies_aws/contexts/lambda_sqs_standard_partial_batch_test.py +0 -66
  40. clearskies_aws/contexts/wsgi.py +0 -19
  41. clearskies_aws/di/__init__.py +0 -1
  42. clearskies_aws/di/standard_dependencies.py +0 -60
  43. clearskies_aws/handlers/__init__.py +0 -2
  44. clearskies_aws/handlers/secrets_manager_rotation.py +0 -174
  45. clearskies_aws/handlers/simple_body_routing.py +0 -39
  46. clearskies_aws/input_outputs/__init__.py +0 -8
  47. clearskies_aws/input_outputs/cli_websocket_mock.py +0 -12
  48. clearskies_aws/input_outputs/lambda_api_gateway.py +0 -105
  49. clearskies_aws/input_outputs/lambda_api_gateway_test.py +0 -87
  50. clearskies_aws/input_outputs/lambda_api_gateway_web_socket.py +0 -8
  51. clearskies_aws/input_outputs/lambda_elb.py +0 -21
  52. clearskies_aws/input_outputs/lambda_http_gateway.py +0 -12
  53. clearskies_aws/input_outputs/lambda_invocation.py +0 -34
  54. clearskies_aws/input_outputs/lambda_sns.py +0 -52
  55. clearskies_aws/input_outputs/lambda_sqs_standard.py +0 -54
  56. clearskies_aws/mocks/__init__.py +0 -1
  57. clearskies_aws/mocks/actions/__init__.py +0 -6
  58. clearskies_aws/mocks/actions/ses.py +0 -28
  59. clearskies_aws/mocks/actions/sns.py +0 -23
  60. clearskies_aws/mocks/actions/sqs.py +0 -23
  61. clearskies_aws/mocks/actions/step_function.py +0 -26
  62. clearskies_aws/secrets/__init__.py +0 -7
  63. clearskies_aws/secrets/additional_configs/__init__.py +0 -54
  64. clearskies_aws/secrets/additional_configs/iam_db_auth.py +0 -29
  65. clearskies_aws/secrets/additional_configs/iam_db_auth_with_ssm.py +0 -92
  66. clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +0 -81
  67. clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssm_bastion.py +0 -141
  68. clearskies_aws/secrets/akeyless_with_ssm_cache.py +0 -46
  69. clearskies_aws/secrets/parameter_store.py +0 -50
  70. clearskies_aws/secrets/parameter_store_test.py +0 -18
  71. clearskies_aws/secrets/secrets_manager.py +0 -75
  72. clearskies_aws/secrets/secrets_manager_test.py +0 -18
  73. clearskies_aws/web_socket_connection_model.py +0 -43
@@ -1,1123 +0,0 @@
1
- import base64
2
- import binascii
3
- import json
4
- import logging
5
- import re
6
- from decimal import Decimal, InvalidOperation
7
- from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, Union
8
-
9
- from boto3.session import Session as Boto3Session
10
- from botocore.exceptions import ClientError
11
- from clearskies import Model as ClearSkiesModel
12
- from clearskies.autodoc.schema import String as AutoDocString
13
- from clearskies.backends import CursorBackend
14
- from types_boto3_dynamodb.client import DynamoDBClient
15
- from types_boto3_dynamodb.type_defs import (
16
- AttributeValueTypeDef,
17
- ExecuteStatementInputTypeDef,
18
- ExecuteStatementOutputTypeDef,
19
- GlobalSecondaryIndexDescriptionTypeDef,
20
- KeySchemaElementTypeDef,
21
- )
22
-
23
- from clearskies_aws.backends.dynamo_db_condition_parser import DynamoDBConditionParser
24
-
25
- logger = logging.getLogger(__name__)
26
-
27
-
28
- class DynamoDBPartiQLCursor:
29
- """
30
- Cursor for executing PartiQL statements against DynamoDB.
31
-
32
- This class wraps a Boto3 DynamoDB client to provide a simplified interface
33
- for statement execution and error handling.
34
- """
35
-
36
- def __init__(self, boto3_session: Boto3Session) -> None:
37
- """
38
- Initializes the DynamoDBPartiQLCursor.
39
-
40
- Args:
41
- boto3_session: An initialized Boto3 Session object.
42
- """
43
- self._session: Boto3Session = boto3_session
44
- self._client: DynamoDBClient = self._session.client("dynamodb")
45
-
46
- def execute(
47
- self,
48
- statement: str,
49
- parameters: Optional[List[AttributeValueTypeDef]] = None,
50
- Limit: Optional[int] = None,
51
- NextToken: Optional[str] = None,
52
- ConsistentRead: Optional[bool] = None,
53
- ) -> ExecuteStatementOutputTypeDef:
54
- """
55
- Execute a PartiQL statement against DynamoDB.
56
-
57
- Args:
58
- statement: The PartiQL statement string to execute.
59
- parameters: An optional list of parameters for the PartiQL statement.
60
- Limit: Optional limit for the number of items DynamoDB evaluates.
61
- NextToken: Optional token for paginating results from DynamoDB.
62
- ConsistentRead: Optional flag for strongly consistent reads.
63
-
64
- Returns:
65
- The output from the boto3 client's execute_statement method.
66
-
67
- Raises:
68
- ClientError: If the execution fails due to a client-side error.
69
- """
70
- try:
71
- call_args: ExecuteStatementInputTypeDef = {"Statement": statement}
72
- # Only include 'Parameters' if it's not None AND not empty
73
- if (
74
- parameters
75
- ): # This implies parameters is not None and parameters is not an empty list
76
- call_args["Parameters"] = parameters
77
- if Limit is not None:
78
- call_args["Limit"] = Limit
79
- if NextToken is not None:
80
- call_args["NextToken"] = NextToken
81
- if ConsistentRead is not None:
82
- call_args["ConsistentRead"] = ConsistentRead
83
-
84
- output: ExecuteStatementOutputTypeDef = self._client.execute_statement(
85
- **call_args
86
- )
87
- except ClientError as err:
88
- error_response: Dict[str, Any] = err.response.get("Error", {}) # type: ignore
89
- error_code: str = error_response.get("Code", "UnknownCode")
90
- error_message: str = error_response.get("Message", "Unknown error")
91
-
92
- parameters_str = str(parameters) if parameters is not None else "None"
93
-
94
- if error_code == "ResourceNotFoundException":
95
- logger.error(
96
- "Couldn't execute PartiQL '%s' with parameters '%s' because the table or index does not exist.",
97
- statement,
98
- parameters_str,
99
- )
100
- else:
101
- logger.error(
102
- "Couldn't execute PartiQL '%s' with parameters '%s'. Here's why: %s: %s",
103
- statement,
104
- parameters_str,
105
- error_code,
106
- error_message,
107
- )
108
- raise
109
- else:
110
- return output
111
-
112
-
113
- class DynamoDBPartiQLBackend(CursorBackend):
114
- """
115
- DynamoDB backend implementation that uses PartiQL for database interactions.
116
- Supports querying base tables and attempts to use Global Secondary Indexes (GSIs)
117
- when appropriate based on query conditions and sorting.
118
- The count() method uses native DynamoDB Query/Scan operations for accuracy.
119
- """
120
-
121
- _cursor: DynamoDBPartiQLCursor
122
- _allowed_configs: List[str] = [
123
- "table_name",
124
- "wheres",
125
- "sorts",
126
- "limit",
127
- "pagination",
128
- "model_columns",
129
- "selects",
130
- "select_all",
131
- "group_by_column",
132
- "joins",
133
- ]
134
- _required_configs: List[str] = ["table_name"]
135
-
136
- def __init__(self, dynamo_db_parti_ql_cursor: DynamoDBPartiQLCursor) -> None:
137
- """
138
- Initializes the DynamoDBPartiQLBackend.
139
- """
140
- super().__init__(dynamo_db_parti_ql_cursor)
141
- self.condition_parser: DynamoDBConditionParser = DynamoDBConditionParser()
142
- self._table_descriptions_cache: Dict[str, Dict[str, Any]] = {}
143
-
144
- def _get_table_description(self, table_name: str) -> Dict[str, Any]:
145
- """
146
- Retrieves and caches the DynamoDB table description.
147
- """
148
- if table_name not in self._table_descriptions_cache:
149
- try:
150
- self._table_descriptions_cache[table_name] = self._cursor._client.describe_table(TableName=table_name) # type: ignore
151
- except ClientError as e:
152
- logger.error(f"Failed to describe table '{table_name}': {e}")
153
- raise
154
- return self._table_descriptions_cache[table_name].get("Table", {})
155
-
156
- def _table_escape_character(self) -> str:
157
- """Returns the character used to escape table/index names."""
158
- return '"'
159
-
160
- def _column_escape_character(self) -> str:
161
- """Returns the character used to escape column names."""
162
- return '"'
163
-
164
- def _finalize_table_name(
165
- self, table_name: str, index_name: Optional[str] = None
166
- ) -> str:
167
- """
168
- Escapes a table name and optionally an index name for use in a PartiQL FROM clause.
169
- """
170
- if not table_name:
171
- return ""
172
- esc: str = self._table_escape_character()
173
- final_name = f"{esc}{str(table_name).strip(esc)}{esc}"
174
- if index_name:
175
- final_name += f".{esc}{index_name.strip(esc)}{esc}"
176
- return final_name
177
-
178
- def _conditions_as_wheres_and_parameters(
179
- self, conditions: List[Dict[str, Any]], default_table_name: str
180
- ) -> Tuple[str, List[AttributeValueTypeDef]]:
181
- """
182
- Converts where conditions into a PartiQL WHERE clause and parameters.
183
- """
184
- if not conditions:
185
- return "", []
186
-
187
- where_parts: List[str] = []
188
- parameters: List[AttributeValueTypeDef] = []
189
-
190
- for where in conditions:
191
- if not isinstance(where, dict):
192
- logger.warning(f"Skipping non-dictionary where condition: {where}")
193
- continue
194
-
195
- column: Optional[str] = where.get("column")
196
- operator: Optional[str] = where.get("operator")
197
- values: Optional[List[Any]] = where.get("values")
198
-
199
- if not column or not operator or values is None:
200
- logger.warning(
201
- f"Skipping malformed structured where condition: {where}"
202
- )
203
- continue
204
-
205
- value_parts: List[str] = []
206
- for v in values:
207
- if isinstance(v, str):
208
- value_parts.append(f"'{v}'")
209
- elif isinstance(v, bool):
210
- value_parts.append(str(v).lower())
211
- elif isinstance(v, (int, float, Decimal, type(None))):
212
- value_parts.append(str(v))
213
- else:
214
- value_parts.append(f"'{str(v)}'")
215
-
216
- condition_string: str = ""
217
- op_lower: str = operator.lower()
218
- if op_lower == "in":
219
- condition_string = f"{column} {operator} ({', '.join(value_parts)})"
220
- elif op_lower in self.condition_parser.operators_without_placeholders:
221
- condition_string = f"{column} {operator}"
222
- else:
223
- condition_string = (
224
- f"{column} {operator} {value_parts[0] if value_parts else ''}"
225
- )
226
-
227
- try:
228
- parsed: Dict[str, Any] = self.condition_parser.parse_condition(
229
- condition_string
230
- )
231
- where_parts.append(parsed["parsed"])
232
- parameters.extend(parsed["values"])
233
- except ValueError as e:
234
- logger.error(f"Error parsing condition '{condition_string}': {e}")
235
- continue
236
-
237
- if not where_parts:
238
- return "", []
239
- return " WHERE " + " AND ".join(where_parts), parameters
240
-
241
- def as_sql(
242
- self, configuration: Dict[str, Any]
243
- ) -> Tuple[str, List[AttributeValueTypeDef], Optional[int], Optional[str]]:
244
- """
245
- Constructs a PartiQL statement and parameters from a query configuration.
246
- """
247
- escape: str = self._column_escape_character()
248
- table_name: str = configuration.get("table_name", "")
249
- chosen_index_name: Optional[str] = configuration.get("_chosen_index_name")
250
-
251
- wheres, parameters = self._conditions_as_wheres_and_parameters(
252
- configuration.get("wheres", []), table_name
253
- )
254
-
255
- from_clause_target: str = self._finalize_table_name(
256
- table_name, chosen_index_name
257
- )
258
-
259
- selects: Optional[List[str]] = configuration.get("selects")
260
- select_clause: str
261
- if selects:
262
- select_clause = ", ".join(
263
- [f"{escape}{s.strip(escape)}{escape}" for s in selects]
264
- )
265
- if configuration.get("select_all"):
266
- logger.warning(
267
- "Both 'select_all=True' and specific 'selects' were provided. Using specific 'selects'."
268
- )
269
- else:
270
- select_clause = "*"
271
-
272
- order_by: str = ""
273
- sorts: Optional[List[Dict[str, str]]] = configuration.get("sorts")
274
- if sorts:
275
- sort_parts: List[str] = []
276
- for sort in sorts:
277
- column_name: str = sort["column"]
278
- direction: str = sort.get("direction", "ASC").upper()
279
- sort_parts.append(
280
- f"{escape}{column_name.strip(escape)}{escape} {direction}"
281
- )
282
- if sort_parts:
283
- order_by = " ORDER BY " + ", ".join(sort_parts)
284
-
285
- if configuration.get("group_by_column"):
286
- logger.warning(
287
- "Configuration included 'group_by_column=" + configuration.get("group_by_column") + "', " +
288
- "but GROUP BY is not supported by this DynamoDB PartiQL backend and will be ignored for SQL generation."
289
- )
290
-
291
- if configuration.get("joins"):
292
- logger.warning(
293
- "Configuration included 'joins=" + str(configuration.get("joins")) + "', " +
294
- "but JOINs are not supported by this DynamoDB PartiQL backend and will be ignored for SQL generation."
295
- )
296
-
297
- limit: Optional[int] = configuration.get("limit")
298
- if limit is not None:
299
- limit = int(limit)
300
-
301
- pagination: Dict[str, Any] = configuration.get("pagination", {})
302
- next_token: Optional[str] = pagination.get("next_token")
303
- if next_token is not None:
304
- next_token = str(next_token)
305
-
306
- if not from_clause_target:
307
- raise ValueError("Table name is required for constructing SQL query.")
308
-
309
- statement: str = (
310
- f"SELECT {select_clause} FROM {from_clause_target}{wheres}{order_by}".strip()
311
- )
312
-
313
- return statement, parameters, limit, next_token
314
-
315
- def records(
316
- self,
317
- configuration: Dict[str, Any],
318
- model: ClearSkiesModel,
319
- next_page_data: dict[str, Any] = {},
320
- ) -> Generator[Dict[str, Any], None, None]:
321
- """
322
- Fetches records from DynamoDB based on the provided configuration using PartiQL.
323
- """
324
- configuration = self._check_query_configuration(configuration, model)
325
-
326
- statement, params, limit, client_next_token_from_as_sql = self.as_sql(
327
- configuration
328
- )
329
-
330
- ddb_token_for_this_call: Optional[str] = self.restore_next_token_from_config(
331
- client_next_token_from_as_sql
332
- )
333
-
334
- cursor_limit: Optional[int] = None
335
- if limit is not None and limit > 0:
336
- cursor_limit = limit
337
-
338
- try:
339
- response: ExecuteStatementOutputTypeDef = self._cursor.execute(
340
- statement=statement,
341
- parameters=params,
342
- Limit=cursor_limit,
343
- NextToken=ddb_token_for_this_call,
344
- )
345
- except Exception as e:
346
- logger.error(
347
- f"Error executing PartiQL statement in records(): {statement}, error: {e}"
348
- )
349
- next_page_data = {}
350
- raise
351
-
352
- items_from_response: List[Dict[str, Any]] = response.get("Items", [])
353
-
354
- for item_raw in items_from_response:
355
- yield self._map_from_boto3(item_raw)
356
-
357
- next_token_from_ddb: Optional[str] = response.get("NextToken")
358
- if next_token_from_ddb:
359
- next_page_data["next_token"] = self.serialize_next_token_for_response(
360
- next_token_from_ddb
361
- )
362
-
363
- def _wheres_to_native_dynamo_expressions(
364
- self,
365
- conditions: List[Dict[str, Any]],
366
- partition_key_name: Optional[str],
367
- sort_key_name: Optional[str] = None,
368
- ) -> Dict[str, Any]:
369
- """
370
- Converts a list of 'where' condition dictionaries into DynamoDB native
371
- expression strings and attribute maps for Query/Scan operations.
372
- This implementation is more comprehensive than the previous one.
373
- """
374
- expression_attribute_names: Dict[str, str] = {}
375
- expression_attribute_values: Dict[str, AttributeValueTypeDef] = {}
376
- key_condition_parts: List[str] = []
377
- filter_expression_parts: List[str] = []
378
-
379
- name_counter = 0
380
- value_counter = 0
381
-
382
- # Helper to get unique placeholder names
383
- def get_name_placeholder(column_name: str) -> str:
384
- nonlocal name_counter
385
- # Sanitize column name for placeholder if it contains special characters
386
- sanitized_column_name = re.sub(r"[^a-zA-Z0-9_]", "", column_name)
387
- placeholder = f"#{sanitized_column_name}_{name_counter}"
388
- expression_attribute_names[placeholder] = column_name
389
- name_counter += 1
390
- return placeholder
391
-
392
- # Helper to get unique value placeholders and add values
393
- def get_value_placeholder(value: Any) -> str:
394
- nonlocal value_counter
395
- placeholder = f":val{value_counter}"
396
- expression_attribute_values[placeholder] = (
397
- self.condition_parser.to_dynamodb_attribute_value(value)
398
- )
399
- value_counter += 1
400
- return placeholder
401
-
402
- processed_condition_indices = set()
403
-
404
- # First, try to build KeyConditionExpression for Partition Key and Sort Key
405
- # Find partition key equality condition
406
- pk_condition_index = -1
407
- if partition_key_name:
408
- for i, cond in enumerate(conditions):
409
- if (
410
- cond.get("column") == partition_key_name
411
- and cond.get("operator") == "="
412
- and cond.get("values")
413
- ):
414
- pk_condition_index = i
415
- break
416
-
417
- if pk_condition_index != -1:
418
- pk_cond = conditions[pk_condition_index]
419
- pk_name_ph = get_name_placeholder(pk_cond["column"])
420
- pk_value_ph = get_value_placeholder(pk_cond["values"][0])
421
- key_condition_parts.append(f"{pk_name_ph} = {pk_value_ph}")
422
- processed_condition_indices.add(pk_condition_index)
423
-
424
- # If partition key found, check for sort key condition
425
- if sort_key_name:
426
- for i, cond in enumerate(conditions):
427
- if i in processed_condition_indices:
428
- continue
429
- if cond.get("column") == sort_key_name and cond.get("values"):
430
- op_lower = cond["operator"].lower()
431
- sk_name_ph = get_name_placeholder(cond["column"])
432
-
433
- if op_lower == "=":
434
- sk_value_ph = get_value_placeholder(cond["values"][0])
435
- key_condition_parts.append(f"{sk_name_ph} = {sk_value_ph}")
436
- elif op_lower == ">":
437
- sk_value_ph = get_value_placeholder(cond["values"][0])
438
- key_condition_parts.append(f"{sk_name_ph} > {sk_value_ph}")
439
- elif op_lower == "<":
440
- sk_value_ph = get_value_placeholder(cond["values"][0])
441
- key_condition_parts.append(f"{sk_name_ph} < {sk_value_ph}")
442
- elif op_lower == ">=":
443
- sk_value_ph = get_value_placeholder(cond["values"][0])
444
- key_condition_parts.append(f"{sk_name_ph} >= {sk_value_ph}")
445
- elif op_lower == "<=":
446
- sk_value_ph = get_value_placeholder(cond["values"][0])
447
- key_condition_parts.append(f"{sk_name_ph} <= {sk_value_ph}")
448
- elif op_lower == "between":
449
- if len(cond["values"]) == 2:
450
- sk_value1_ph = get_value_placeholder(cond["values"][0])
451
- sk_value2_ph = get_value_placeholder(cond["values"][1])
452
- key_condition_parts.append(
453
- f"{sk_name_ph} BETWEEN {sk_value1_ph} AND {sk_value2_ph}"
454
- )
455
- else:
456
- logger.warning(
457
- f"Skipping malformed BETWEEN condition for sort key: {cond}"
458
- )
459
- elif op_lower == "begins_with":
460
- sk_value_ph = get_value_placeholder(cond["values"][0])
461
- key_condition_parts.append(
462
- f"begins_with({sk_name_ph}, {sk_value_ph})"
463
- )
464
- else:
465
- # Other operators for sort key are not part of KeyConditionExpression
466
- # They will be handled in FilterExpression below
467
- continue
468
- processed_condition_indices.add(i)
469
- break # Assume only one sort key condition for KeyConditionExpression
470
-
471
- # Process all remaining conditions for FilterExpression
472
- for i, cond in enumerate(conditions):
473
- if i in processed_condition_indices:
474
- continue
475
-
476
- col_name = cond.get("column")
477
- op = cond.get("operator")
478
- vals = cond.get("values")
479
-
480
- if not col_name or not op or vals is None:
481
- continue
482
-
483
- name_ph = get_name_placeholder(col_name)
484
- op_lower = op.lower()
485
-
486
- if op_lower == "=":
487
- value_ph = get_value_placeholder(vals[0])
488
- filter_expression_parts.append(f"{name_ph} = {value_ph}")
489
- elif op_lower == "!=":
490
- value_ph = get_value_placeholder(vals[0])
491
- filter_expression_parts.append(f"{name_ph} <> {value_ph}")
492
- elif op_lower == ">":
493
- value_ph = get_value_placeholder(vals[0])
494
- filter_expression_parts.append(f"{name_ph} > {value_ph}")
495
- elif op_lower == "<":
496
- value_ph = get_value_placeholder(vals[0])
497
- filter_expression_parts.append(f"{name_ph} < {value_ph}")
498
- elif op_lower == ">=":
499
- value_ph = get_value_placeholder(vals[0])
500
- filter_expression_parts.append(f"{name_ph} >= {value_ph}")
501
- elif op_lower == "<=":
502
- value_ph = get_value_placeholder(vals[0])
503
- filter_expression_parts.append(f"{name_ph} <= {value_ph}")
504
- elif op_lower == "between":
505
- if len(vals) == 2:
506
- value1_ph = get_value_placeholder(vals[0])
507
- value2_ph = get_value_placeholder(vals[1])
508
- filter_expression_parts.append(
509
- f"{name_ph} BETWEEN {value1_ph} AND {value2_ph}"
510
- )
511
- else:
512
- logger.warning(f"Skipping malformed BETWEEN condition: {cond}")
513
- elif op_lower == "in":
514
- value_placeholders = ", ".join([get_value_placeholder(v) for v in vals])
515
- filter_expression_parts.append(f"{name_ph} IN ({value_placeholders})")
516
- elif op_lower == "contains":
517
- value_ph = get_value_placeholder(vals[0])
518
- filter_expression_parts.append(f"contains({name_ph}, {value_ph})")
519
- elif op_lower == "not contains":
520
- value_ph = get_value_placeholder(vals[0])
521
- filter_expression_parts.append(f"NOT contains({name_ph}, {value_ph})")
522
- elif op_lower == "begins_with":
523
- value_ph = get_value_placeholder(vals[0])
524
- filter_expression_parts.append(f"begins_with({name_ph}, {value_ph})")
525
- elif op_lower == "not begins_with":
526
- value_ph = get_value_placeholder(vals[0])
527
- filter_expression_parts.append(
528
- f"NOT begins_with({name_ph}, {value_ph})"
529
- )
530
- elif op_lower == "is null":
531
- filter_expression_parts.append(f"attribute_not_exists({name_ph})")
532
- elif op_lower == "is not null":
533
- filter_expression_parts.append(f"attribute_exists({name_ph})")
534
- elif (
535
- op_lower == "like"
536
- ): # Clearskies 'like' usually translates to begins_with or contains
537
- # This is a simplification. A full implementation might need to inspect '%' position.
538
- # For now, if it contains '%', assume 'contains'. If it ends with '%', assume 'begins_with'.
539
- # If no '%', it's an equality.
540
- if len(vals) > 0 and isinstance(vals[0], str):
541
- like_value = vals[0]
542
- if like_value.startswith("%") and like_value.endswith("%"):
543
- value_ph = get_value_placeholder(like_value.strip("%"))
544
- filter_expression_parts.append(
545
- f"contains({name_ph}, {value_ph})"
546
- )
547
- elif like_value.endswith("%"):
548
- value_ph = get_value_placeholder(like_value.rstrip("%"))
549
- filter_expression_parts.append(
550
- f"begins_with({name_ph}, {value_ph})"
551
- )
552
- else: # Treat as equality if no wildcards or complex pattern
553
- value_ph = get_value_placeholder(like_value)
554
- filter_expression_parts.append(f"{name_ph} = {value_ph}")
555
- else:
556
- logger.warning(f"Skipping unsupported LIKE condition: {cond}")
557
- else:
558
- logger.warning(
559
- f"Skipping unsupported operator '{op}' for native DynamoDB expressions: {cond}"
560
- )
561
-
562
- result: Dict[str, Any] = {}
563
- if key_condition_parts:
564
- result["KeyConditionExpression"] = " AND ".join(key_condition_parts)
565
- if filter_expression_parts:
566
- result["FilterExpression"] = " AND ".join(filter_expression_parts)
567
- if expression_attribute_names:
568
- result["ExpressionAttributeNames"] = expression_attribute_names
569
- if expression_attribute_values:
570
- result["ExpressionAttributeValues"] = expression_attribute_values
571
-
572
- return result
573
-
574
- def count(self, configuration: Dict[str, Any], model: ClearSkiesModel) -> int:
575
- """
576
- Counts records in DynamoDB using native Query or Scan operations.
577
- """
578
- configuration = self._check_query_configuration(configuration, model)
579
-
580
- table_name: str = configuration["table_name"]
581
- chosen_index_name: Optional[str] = configuration.get("_chosen_index_name")
582
- partition_key_for_target: Optional[str] = configuration.get(
583
- "_partition_key_for_target"
584
- )
585
- # Get sort key for the chosen target (base table or GSI)
586
- sort_key_for_target: Optional[str] = None
587
- table_description = self._get_table_description(table_name)
588
- if chosen_index_name:
589
- gsi_definitions: List[GlobalSecondaryIndexDescriptionTypeDef] = (
590
- table_description.get("GlobalSecondaryIndexes", [])
591
- )
592
- for gsi in gsi_definitions:
593
- if gsi.get("IndexName", "") == chosen_index_name:
594
- for key_element in gsi.get("KeySchema", []):
595
- if key_element["KeyType"] == "RANGE":
596
- sort_key_for_target = key_element["AttributeName"]
597
- break
598
- break
599
- else:
600
- base_table_key_schema: List[KeySchemaElementTypeDef] = (
601
- table_description.get("KeySchema", [])
602
- )
603
- for key_element in base_table_key_schema:
604
- if key_element["KeyType"] == "RANGE":
605
- sort_key_for_target = key_element["AttributeName"]
606
- break
607
-
608
- wheres_config = configuration.get("wheres", [])
609
-
610
- native_expressions = self._wheres_to_native_dynamo_expressions(
611
- wheres_config, partition_key_for_target, sort_key_for_target
612
- )
613
-
614
- params_for_native_call: Dict[str, Any] = {
615
- "TableName": table_name,
616
- "Select": "COUNT",
617
- }
618
- if chosen_index_name:
619
- params_for_native_call["IndexName"] = chosen_index_name
620
-
621
- can_use_query_for_count = False
622
- # A Query operation can be used for count if there is a KeyConditionExpression
623
- # that includes an equality condition on the partition key of the target (table or GSI).
624
- # We check if the partition key condition was successfully extracted into KeyConditionExpression.
625
- if (
626
- partition_key_for_target
627
- and f"#{re.sub(r'[^a-zA-Z0-9_]', '', partition_key_for_target)}_0"
628
- in native_expressions.get("ExpressionAttributeNames", {})
629
- and native_expressions.get("KeyConditionExpression")
630
- and f"#{re.sub(r'[^a-zA-Z0-9_]', '', partition_key_for_target)}_0 = :val0"
631
- in native_expressions[
632
- "KeyConditionExpression"
633
- ] # Simplified check, assumes first value is PK
634
- ):
635
- can_use_query_for_count = True
636
- params_for_native_call["KeyConditionExpression"] = native_expressions[
637
- "KeyConditionExpression"
638
- ]
639
- if native_expressions.get("FilterExpression"):
640
- params_for_native_call["FilterExpression"] = native_expressions[
641
- "FilterExpression"
642
- ]
643
- else:
644
- # Fall back to Scan, and all conditions (including any potential key conditions that
645
- # couldn't be used for a Query) go into FilterExpression.
646
- if native_expressions.get("FilterExpression"):
647
- params_for_native_call["FilterExpression"] = native_expressions[
648
- "FilterExpression"
649
- ]
650
- # If there's a KeyConditionExpression but no PK equality, it should also be part of the filter for scan.
651
- # This logic is now handled more robustly within _wheres_to_native_dynamo_expressions
652
- # by ensuring only true PK/SK conditions go to KeyConditionExpression initially.
653
-
654
- if native_expressions.get("ExpressionAttributeNames"):
655
- params_for_native_call["ExpressionAttributeNames"] = native_expressions[
656
- "ExpressionAttributeNames"
657
- ]
658
- if native_expressions.get("ExpressionAttributeValues"):
659
- params_for_native_call["ExpressionAttributeValues"] = native_expressions[
660
- "ExpressionAttributeValues"
661
- ]
662
-
663
- total_count = 0
664
- exclusive_start_key: Optional[Dict[str, AttributeValueTypeDef]] = None
665
-
666
- while True:
667
- if exclusive_start_key:
668
- params_for_native_call["ExclusiveStartKey"] = exclusive_start_key
669
-
670
- try:
671
- if can_use_query_for_count:
672
- logger.debug(
673
- f"Executing native DynamoDB Query (for count) with params: {params_for_native_call}"
674
- )
675
- response = self._cursor._client.query(**params_for_native_call) # type: ignore
676
- else:
677
- logger.debug(
678
- f"Executing native DynamoDB Scan (for count) with params: {params_for_native_call}"
679
- )
680
- response = self._cursor._client.scan(**params_for_native_call) # type: ignore
681
- except ClientError as e:
682
- logger.error(
683
- f"Error executing native DynamoDB operation for count: {e}. Params: {params_for_native_call}"
684
- )
685
- raise
686
-
687
- total_count += response.get("Count", 0)
688
- exclusive_start_key = response.get("LastEvaluatedKey")
689
- if not exclusive_start_key:
690
- break
691
-
692
- return total_count
693
-
694
- def create(self, data: Dict[str, Any], model: ClearSkiesModel) -> Dict[str, Any]:
695
- """
696
- Creates a new record in DynamoDB using PartiQL INSERT.
697
- """
698
- table_name: str = self._finalize_table_name(model.get_table_name())
699
-
700
- if not data:
701
- logger.warning("Create called with empty data. Nothing to insert.")
702
- return {}
703
-
704
- # Prepare parameters
705
- parameters: List[AttributeValueTypeDef] = []
706
-
707
- # Build the 'VALUE {key: ?, key: ?}' part and collect parameters
708
- value_struct_parts: List[str] = []
709
- for key, value in data.items():
710
- # Use single quotes around the key to match PartiQL documentation examples
711
- value_struct_parts.append(f"'{key}': ?")
712
- parameters.append(self.condition_parser.to_dynamodb_attribute_value(value))
713
- value_struct_clause = ", ".join(value_struct_parts)
714
-
715
- # Construct the INSERT statement with explicit struct format
716
- statement = f"INSERT INTO {table_name} VALUE {{{value_struct_clause}}}"
717
-
718
- try:
719
- self._cursor.execute(
720
- statement=statement,
721
- parameters=parameters,
722
- )
723
- return data
724
- except Exception as e:
725
- logger.error(
726
- f"Error executing INSERT PartiQL statement: {statement}, data: {data}, error: {e}"
727
- )
728
- raise
729
-
730
- def update(
731
- self, id_value: Any, data: Dict[str, Any], model: ClearSkiesModel
732
- ) -> Dict[str, Any]:
733
- """
734
- Updates an existing record in DynamoDB using PartiQL UPDATE.
735
- """
736
- table_name: str = self._finalize_table_name(model.get_table_name())
737
- id_column_name: str = model.id_column_name
738
- escaped_id_column: str = (
739
- f"{self._column_escape_character()}{id_column_name}{self._column_escape_character()}"
740
- )
741
-
742
- if not data:
743
- logger.warning(
744
- f"Update called with empty data for ID {id_value}. Returning ID only."
745
- )
746
- return {id_column_name: id_value}
747
-
748
- set_clauses: List[str] = []
749
- parameters: List[AttributeValueTypeDef] = []
750
- col_esc: str = self._column_escape_character()
751
-
752
- for key, value in data.items():
753
- if key == id_column_name:
754
- continue
755
- set_clauses.append(f"{col_esc}{key}{col_esc} = ?")
756
- parameters.append(self.condition_parser.to_dynamodb_attribute_value(value))
757
-
758
- if not set_clauses:
759
- logger.warning(
760
- f"Update called for ID {id_value} but no updatable fields found in data. Returning ID only."
761
- )
762
- return {id_column_name: id_value}
763
-
764
- parameters.append(self.condition_parser.to_dynamodb_attribute_value(id_value))
765
-
766
- set_statement: str = ", ".join(set_clauses)
767
- statement: str = (
768
- f"UPDATE {table_name} SET {set_statement} WHERE {escaped_id_column} = ? RETURNING ALL NEW *"
769
- )
770
-
771
- try:
772
- response = self._cursor.execute(statement=statement, parameters=parameters)
773
- items = response.get("Items", [])
774
- if items:
775
- return self._map_from_boto3(items[0])
776
- logger.warning(
777
- f"UPDATE statement did not return items for ID {id_value}. Returning input data merged with ID."
778
- )
779
- return {**data, id_column_name: id_value}
780
-
781
- except Exception as e:
782
- logger.error(
783
- f"Error executing UPDATE PartiQL statement: {statement}, data: {data}, id: {id_value}, error: {e}"
784
- )
785
- raise
786
-
787
- def delete(self, id_value: Any, model: ClearSkiesModel) -> bool:
788
- """
789
- Deletes a record from DynamoDB using PartiQL DELETE.
790
- """
791
- table_name: str = self._finalize_table_name(model.get_table_name())
792
- id_column_name: str = model.id_column_name
793
- escaped_id_column: str = (
794
- f"{self._column_escape_character()}{id_column_name}{self._column_escape_character()}"
795
- )
796
-
797
- parameters: List[AttributeValueTypeDef] = [
798
- self.condition_parser.to_dynamodb_attribute_value(id_value)
799
- ]
800
- statement: str = f"DELETE FROM {table_name} WHERE {escaped_id_column} = ?"
801
-
802
- try:
803
- self._cursor.execute(statement=statement, parameters=parameters)
804
- return True
805
- except Exception as e:
806
- logger.error(
807
- f"Error executing DELETE PartiQL statement: {statement}, id: {id_value}, error: {e}"
808
- )
809
- raise
810
-
811
- def _map_from_boto3(self, record: Dict[str, Any]) -> Dict[str, Any]:
812
- """
813
- Maps a raw record from DynamoDB (which uses AttributeValueTypeDef for values)
814
- to a dictionary with Python-native types.
815
-
816
- Args:
817
- record: A dictionary representing a record item from DynamoDB,
818
- where values are in AttributeValueTypeDef format.
819
-
820
- Returns:
821
- A dictionary with values unwrapped to Python native types.
822
- """
823
- return {
824
- key: self._map_from_boto3_value(value) for (key, value) in record.items()
825
- }
826
-
827
- def _map_from_boto3_value(self, attribute_value: AttributeValueTypeDef) -> Any:
828
- """
829
- Converts a single DynamoDB AttributeValueTypeDef to its Python native equivalent.
830
-
831
- Args:
832
- attribute_value: A DynamoDB AttributeValueTypeDef dictionary.
833
-
834
- Returns:
835
- The unwrapped Python native value.
836
- """
837
- if not isinstance(attribute_value, dict):
838
- return attribute_value
839
-
840
- if "S" in attribute_value:
841
- return attribute_value["S"]
842
- if "N" in attribute_value:
843
- try:
844
- return Decimal(attribute_value["N"])
845
- except InvalidOperation: # Changed from DecimalException
846
- logger.warning(
847
- f"Could not convert N value '{attribute_value['N']}' to Decimal."
848
- )
849
- return attribute_value["N"]
850
- if "BOOL" in attribute_value:
851
- return attribute_value["BOOL"]
852
- if "NULL" in attribute_value:
853
- return None
854
- if "B" in attribute_value:
855
- try:
856
- return base64.b64decode(attribute_value["B"])
857
- except (binascii.Error, TypeError) as e:
858
- logger.warning(
859
- f"Failed to decode base64 binary value: {attribute_value['B']}, error: {e}"
860
- )
861
- return attribute_value["B"] # Return raw if decoding fails
862
- if "L" in attribute_value:
863
- return [self._map_from_boto3_value(item) for item in attribute_value["L"]]
864
- if "M" in attribute_value:
865
- return {
866
- key: self._map_from_boto3_value(val)
867
- for key, val in attribute_value["M"].items()
868
- }
869
- if "SS" in attribute_value:
870
- return set(attribute_value["SS"])
871
- if "NS" in attribute_value:
872
- try:
873
- return set(Decimal(n_val) for n_val in attribute_value["NS"])
874
- except InvalidOperation: # Changed from DecimalException
875
- logger.warning(
876
- f"Could not convert one or more NS values in '{attribute_value['NS']}' to Decimal."
877
- )
878
- return set(attribute_value["NS"])
879
- if "BS" in attribute_value:
880
- try:
881
- return set(base64.b64decode(b_val) for b_val in attribute_value["BS"])
882
- except (binascii.Error, TypeError) as e:
883
- logger.warning(
884
- f"Failed to decode one or more base64 binary values in '{attribute_value['BS']}', error: {e}"
885
- )
886
- return set(attribute_value["BS"]) # Return raw if decoding fails
887
-
888
- logger.warning(f"Unrecognized DynamoDB attribute type: {attribute_value}")
889
- return attribute_value
890
-
891
- def _check_query_configuration(
892
- self, configuration: Dict[str, Any], model: ClearSkiesModel
893
- ) -> Dict[str, Any]:
894
- """
895
- Validates the query configuration, applies default values, and attempts to
896
- select an appropriate GSI if sorting is requested and conditions allow.
897
- It also stores the determined partition key for the target in the configuration.
898
- """
899
- for key in list(configuration.keys()):
900
- if key not in self._allowed_configs:
901
- raise KeyError(
902
- f"DynamoDBBackend does not support config '{key}'. You may be using the wrong backend"
903
- )
904
- for key in self._required_configs:
905
- if not configuration.get(key):
906
- raise KeyError(f"Missing required configuration key {key}")
907
-
908
- if "wheres" not in configuration:
909
- configuration["wheres"] = []
910
- if "sorts" not in configuration:
911
- configuration["sorts"] = []
912
- if "selects" not in configuration:
913
- configuration["selects"] = []
914
- if "model_columns" not in configuration:
915
- configuration["model_columns"] = []
916
- if "pagination" not in configuration:
917
- configuration["pagination"] = {}
918
- if "limit" not in configuration:
919
- configuration["limit"] = None
920
- if "select_all" not in configuration:
921
- configuration["select_all"] = False
922
- if "group_by_column" not in configuration:
923
- configuration["group_by_column"] = None
924
- if "joins" not in configuration:
925
- configuration["joins"] = []
926
-
927
- configuration["_chosen_index_name"] = None
928
- configuration["_partition_key_for_target"] = None
929
-
930
- if configuration.get("sorts") or configuration.get(
931
- "wheres"
932
- ): # Check for index even if not sorting, for count
933
- table_name_from_config: str = configuration.get("table_name", "")
934
- table_description = self._get_table_description(table_name_from_config)
935
-
936
- wheres = configuration.get("wheres", [])
937
- sort_column = (
938
- configuration.get("sorts")[0]["column"]
939
- if configuration.get("sorts")
940
- else None
941
- )
942
-
943
- key_to_check_for_equality: Optional[str] = None
944
- target_name_for_error_msg: str = table_name_from_config
945
- chosen_index_for_query: Optional[str] = None
946
- partition_key_for_chosen_target: Optional[str] = None
947
-
948
- gsi_definitions: List[GlobalSecondaryIndexDescriptionTypeDef] = (
949
- table_description.get("GlobalSecondaryIndexes", [])
950
- )
951
- if gsi_definitions:
952
- for gsi in gsi_definitions:
953
- gsi_name: str = gsi["IndexName"]
954
- gsi_key_schema: List[KeySchemaElementTypeDef] = gsi["KeySchema"]
955
- gsi_partition_key: Optional[str] = None
956
- gsi_sort_key: Optional[str] = None
957
-
958
- for key_element in gsi_key_schema:
959
- if key_element["KeyType"] == "HASH":
960
- gsi_partition_key = key_element["AttributeName"]
961
- elif key_element["KeyType"] == "RANGE":
962
- gsi_sort_key = key_element["AttributeName"]
963
-
964
- if gsi_partition_key and any(
965
- w.get("column") == gsi_partition_key
966
- and w.get("operator") == "="
967
- for w in wheres
968
- if isinstance(w, dict)
969
- ):
970
- if configuration.get("sorts"):
971
- if sort_column == gsi_partition_key and not gsi_sort_key:
972
- key_to_check_for_equality = gsi_partition_key
973
- chosen_index_for_query = gsi_name
974
- target_name_for_error_msg = (
975
- f"{table_name_from_config} (index: {gsi_name})"
976
- )
977
- partition_key_for_chosen_target = gsi_partition_key
978
- break
979
- if sort_column == gsi_sort_key:
980
- key_to_check_for_equality = gsi_partition_key
981
- chosen_index_for_query = gsi_name
982
- target_name_for_error_msg = (
983
- f"{table_name_from_config} (index: {gsi_name})"
984
- )
985
- partition_key_for_chosen_target = gsi_partition_key
986
- break
987
- else:
988
- key_to_check_for_equality = gsi_partition_key
989
- chosen_index_for_query = gsi_name
990
- target_name_for_error_msg = (
991
- f"{table_name_from_config} (index: {gsi_name})"
992
- )
993
- partition_key_for_chosen_target = gsi_partition_key
994
- break
995
-
996
- if not chosen_index_for_query:
997
- base_table_key_schema: List[KeySchemaElementTypeDef] = (
998
- table_description.get("KeySchema", [])
999
- )
1000
- if base_table_key_schema:
1001
- for key_element in base_table_key_schema:
1002
- if key_element["KeyType"] == "HASH":
1003
- key_to_check_for_equality = key_element["AttributeName"]
1004
- partition_key_for_chosen_target = key_element[
1005
- "AttributeName"
1006
- ]
1007
- break
1008
-
1009
- configuration["_chosen_index_name"] = chosen_index_for_query
1010
- configuration["_partition_key_for_target"] = partition_key_for_chosen_target
1011
-
1012
- if configuration.get("sorts"):
1013
- if not key_to_check_for_equality:
1014
- logger.warning(
1015
- f"Could not determine the required partition key for table/index '{target_name_for_error_msg}' "
1016
- f"to validate ORDER BY clause. The query may fail in DynamoDB."
1017
- )
1018
- else:
1019
- has_required_key_equality = any(
1020
- w.get("column") == key_to_check_for_equality
1021
- and w.get("operator") == "="
1022
- for w in wheres
1023
- if isinstance(w, dict)
1024
- )
1025
- if not has_required_key_equality:
1026
- raise ValueError(
1027
- f"DynamoDB PartiQL queries with ORDER BY on '{target_name_for_error_msg}' require an equality "
1028
- f"condition on its partition key ('{key_to_check_for_equality}') in the WHERE clause."
1029
- )
1030
- return configuration
1031
-
1032
- def validate_pagination_kwargs(
1033
- self, kwargs: Dict[str, Any], case_mapping: Callable[[str], str]
1034
- ) -> str:
1035
- """
1036
- Validates pagination keyword arguments.
1037
- """
1038
- extra_keys: set[str] = set(kwargs.keys()) - set(self.allowed_pagination_keys())
1039
- key_name: str = case_mapping("next_token")
1040
- if len(extra_keys):
1041
- return (
1042
- f"Invalid pagination key(s): '{','.join(sorted(list(extra_keys)))}'. "
1043
- f"Only '{key_name}' is allowed"
1044
- )
1045
- if "next_token" not in kwargs:
1046
- return f"You must specify '{key_name}' when setting pagination"
1047
- try:
1048
- token: Any = kwargs["next_token"]
1049
- if not isinstance(token, str) or not token:
1050
- raise ValueError("Token must be a non-empty string.")
1051
- json.loads(base64.urlsafe_b64decode(token))
1052
- except (TypeError, ValueError, binascii.Error, json.JSONDecodeError):
1053
- return f"The provided '{key_name}' appears to be invalid."
1054
- return ""
1055
-
1056
- def allowed_pagination_keys(self) -> List[str]:
1057
- """
1058
- Returns a list of allowed keys for pagination.
1059
- """
1060
- return ["next_token"]
1061
-
1062
- def restore_next_token_from_config(
1063
- self, next_token: Optional[str]
1064
- ) -> Optional[Any]:
1065
- """
1066
- Decodes a base64 encoded JSON string (next_token) into its original form.
1067
- """
1068
- if not next_token or not isinstance(next_token, str):
1069
- return None
1070
- try:
1071
- decoded_bytes: bytes = base64.urlsafe_b64decode(next_token)
1072
- restored_token: Any = json.loads(decoded_bytes)
1073
- return restored_token
1074
- except (TypeError, ValueError, binascii.Error, json.JSONDecodeError):
1075
- logger.warning(f"Failed to restore next_token: {next_token}")
1076
- return None
1077
-
1078
- def serialize_next_token_for_response(
1079
- self, ddb_next_token: Optional[str]
1080
- ) -> Optional[str]:
1081
- """
1082
- Serializes a DynamoDB PartiQL NextToken string into a base64 encoded JSON string.
1083
- """
1084
- if ddb_next_token is None:
1085
- return None
1086
- try:
1087
- json_string: str = json.dumps(ddb_next_token)
1088
- encoded_bytes: bytes = base64.urlsafe_b64encode(json_string.encode("utf-8"))
1089
- return encoded_bytes.decode("utf8")
1090
- except (TypeError, ValueError) as e:
1091
- logger.error(
1092
- f"Error serializing DDB next_token: {ddb_next_token}, error: {e}"
1093
- )
1094
- return None
1095
-
1096
- def documentation_pagination_next_page_response(
1097
- self, case_mapping: Callable[[str], str]
1098
- ) -> List[AutoDocString]:
1099
- """
1100
- Provides documentation for the 'next_page' (pagination token) in API responses.
1101
- """
1102
- return [AutoDocString(case_mapping("next_token"))]
1103
-
1104
- def documentation_pagination_next_page_example(
1105
- self, case_mapping: Callable[[str], str]
1106
- ) -> Dict[str, str]:
1107
- """
1108
- Provides an example value for the 'next_page' (pagination token) in API responses.
1109
- """
1110
- return {case_mapping("next_token"): ""}
1111
-
1112
- def documentation_pagination_parameters(
1113
- self, case_mapping: Callable[[str], str]
1114
- ) -> List[Tuple[AutoDocString, str]]:
1115
- """
1116
- Provides documentation for pagination parameters in API requests.
1117
- """
1118
- return [
1119
- (
1120
- AutoDocString(case_mapping("next_token"), example=""),
1121
- "A token to fetch the next page of results",
1122
- )
1123
- ]