aws-durable-execution-sdk-python-testing 1.0.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 (52) hide show
  1. aws_durable_execution_sdk_python_testing/__about__.py +4 -0
  2. aws_durable_execution_sdk_python_testing/__init__.py +20 -0
  3. aws_durable_execution_sdk_python_testing/checkpoint/__init__.py +1 -0
  4. aws_durable_execution_sdk_python_testing/checkpoint/processor.py +101 -0
  5. aws_durable_execution_sdk_python_testing/checkpoint/processors/__init__.py +1 -0
  6. aws_durable_execution_sdk_python_testing/checkpoint/processors/base.py +199 -0
  7. aws_durable_execution_sdk_python_testing/checkpoint/processors/callback.py +89 -0
  8. aws_durable_execution_sdk_python_testing/checkpoint/processors/context.py +59 -0
  9. aws_durable_execution_sdk_python_testing/checkpoint/processors/execution.py +52 -0
  10. aws_durable_execution_sdk_python_testing/checkpoint/processors/step.py +124 -0
  11. aws_durable_execution_sdk_python_testing/checkpoint/processors/wait.py +95 -0
  12. aws_durable_execution_sdk_python_testing/checkpoint/transformer.py +104 -0
  13. aws_durable_execution_sdk_python_testing/checkpoint/validators/__init__.py +1 -0
  14. aws_durable_execution_sdk_python_testing/checkpoint/validators/checkpoint.py +242 -0
  15. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/__init__.py +1 -0
  16. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/callback.py +45 -0
  17. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/context.py +73 -0
  18. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/execution.py +47 -0
  19. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/invoke.py +56 -0
  20. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/step.py +106 -0
  21. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/wait.py +54 -0
  22. aws_durable_execution_sdk_python_testing/checkpoint/validators/transitions.py +66 -0
  23. aws_durable_execution_sdk_python_testing/cli.py +501 -0
  24. aws_durable_execution_sdk_python_testing/client.py +50 -0
  25. aws_durable_execution_sdk_python_testing/exceptions.py +288 -0
  26. aws_durable_execution_sdk_python_testing/execution.py +440 -0
  27. aws_durable_execution_sdk_python_testing/executor.py +1214 -0
  28. aws_durable_execution_sdk_python_testing/invoker.py +327 -0
  29. aws_durable_execution_sdk_python_testing/model.py +3267 -0
  30. aws_durable_execution_sdk_python_testing/observer.py +144 -0
  31. aws_durable_execution_sdk_python_testing/py.typed +1 -0
  32. aws_durable_execution_sdk_python_testing/runner.py +1185 -0
  33. aws_durable_execution_sdk_python_testing/scheduler.py +246 -0
  34. aws_durable_execution_sdk_python_testing/stores/__init__.py +1 -0
  35. aws_durable_execution_sdk_python_testing/stores/base.py +147 -0
  36. aws_durable_execution_sdk_python_testing/stores/filesystem.py +104 -0
  37. aws_durable_execution_sdk_python_testing/stores/memory.py +38 -0
  38. aws_durable_execution_sdk_python_testing/stores/sqlite.py +283 -0
  39. aws_durable_execution_sdk_python_testing/token.py +49 -0
  40. aws_durable_execution_sdk_python_testing/web/__init__.py +1 -0
  41. aws_durable_execution_sdk_python_testing/web/errors.py +8 -0
  42. aws_durable_execution_sdk_python_testing/web/handlers.py +807 -0
  43. aws_durable_execution_sdk_python_testing/web/models.py +266 -0
  44. aws_durable_execution_sdk_python_testing/web/routes.py +684 -0
  45. aws_durable_execution_sdk_python_testing/web/serialization.py +245 -0
  46. aws_durable_execution_sdk_python_testing/web/server.py +243 -0
  47. aws_durable_execution_sdk_python_testing-1.0.0.dist-info/METADATA +221 -0
  48. aws_durable_execution_sdk_python_testing-1.0.0.dist-info/RECORD +52 -0
  49. aws_durable_execution_sdk_python_testing-1.0.0.dist-info/WHEEL +4 -0
  50. aws_durable_execution_sdk_python_testing-1.0.0.dist-info/entry_points.txt +2 -0
  51. aws_durable_execution_sdk_python_testing-1.0.0.dist-info/licenses/LICENSE +175 -0
  52. aws_durable_execution_sdk_python_testing-1.0.0.dist-info/licenses/NOTICE +1 -0
@@ -0,0 +1,440 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import replace
5
+ from datetime import UTC, datetime
6
+ from enum import Enum
7
+ from threading import Lock
8
+ from typing import Any
9
+ from uuid import uuid4
10
+
11
+ from aws_durable_execution_sdk_python.execution import (
12
+ DurableExecutionInvocationOutput,
13
+ InvocationStatus,
14
+ )
15
+ from aws_durable_execution_sdk_python.lambda_service import (
16
+ ErrorObject,
17
+ ExecutionDetails,
18
+ Operation,
19
+ OperationStatus,
20
+ OperationType,
21
+ OperationUpdate,
22
+ )
23
+
24
+ from aws_durable_execution_sdk_python_testing.exceptions import (
25
+ IllegalStateException,
26
+ InvalidParameterValueException,
27
+ )
28
+
29
+ # Import AWS exceptions
30
+ from aws_durable_execution_sdk_python_testing.model import (
31
+ InvocationCompletedDetails,
32
+ StartDurableExecutionInput,
33
+ )
34
+ from aws_durable_execution_sdk_python_testing.token import (
35
+ CheckpointToken,
36
+ CallbackToken,
37
+ )
38
+
39
+
40
+ class ExecutionStatus(Enum):
41
+ """Execution status for API responses."""
42
+
43
+ RUNNING = "RUNNING"
44
+ SUCCEEDED = "SUCCEEDED"
45
+ FAILED = "FAILED"
46
+ STOPPED = "STOPPED"
47
+ TIMED_OUT = "TIMED_OUT"
48
+
49
+
50
+ class Execution:
51
+ """Execution state."""
52
+
53
+ def __init__(
54
+ self,
55
+ durable_execution_arn: str,
56
+ start_input: StartDurableExecutionInput,
57
+ operations: list[Operation],
58
+ ):
59
+ self.durable_execution_arn: str = durable_execution_arn
60
+ # operation is frozen, it won't mutate - no need to clone/deep-copy
61
+ self.start_input: StartDurableExecutionInput = start_input
62
+ self.operations: list[Operation] = operations
63
+ self.updates: list[OperationUpdate] = []
64
+ self.invocation_completions: list[InvocationCompletedDetails] = []
65
+ self.used_tokens: set[str] = set()
66
+ # TODO: this will need to persist/rehydrate depending on inmemory vs sqllite store
67
+ self._token_sequence: int = 0
68
+ self._state_lock: Lock = Lock()
69
+ self.is_complete: bool = False
70
+ self.result: DurableExecutionInvocationOutput | None = None
71
+ self.consecutive_failed_invocation_attempts: int = 0
72
+ self.close_status: ExecutionStatus | None = None
73
+
74
+ @property
75
+ def token_sequence(self) -> int:
76
+ """Get current token sequence value."""
77
+ return self._token_sequence
78
+
79
+ def current_status(self) -> ExecutionStatus:
80
+ """Get execution status."""
81
+ if not self.is_complete:
82
+ return ExecutionStatus.RUNNING
83
+
84
+ if not self.close_status:
85
+ msg: str = "close_status must be set when execution is complete"
86
+ raise IllegalStateException(msg)
87
+
88
+ return self.close_status
89
+
90
+ @staticmethod
91
+ def new(input: StartDurableExecutionInput) -> Execution: # noqa: A002
92
+ # make a nicer arn
93
+ # Pattern: arn:(aws[a-zA-Z-]*)?:lambda:[a-z]{2}(-gov)?-[a-z]+-\d{1}:\d{12}:durable-execution:[a-zA-Z0-9-_\.]+:[a-zA-Z0-9-_\.]+:[a-zA-Z0-9-_\.]+
94
+ # Example: arn:aws:lambda:us-east-1:123456789012:durable-execution:myDurableFunction:myDurableExecutionName:ce67da72-3701-4f83-9174-f4189d27b0a5
95
+ return Execution(
96
+ durable_execution_arn=str(uuid4()), start_input=input, operations=[]
97
+ )
98
+
99
+ def to_dict(self) -> dict[str, Any]:
100
+ """Serialize execution to dictionary."""
101
+ return {
102
+ "DurableExecutionArn": self.durable_execution_arn,
103
+ "StartInput": self.start_input.to_dict(),
104
+ "Operations": [op.to_dict() for op in self.operations],
105
+ "Updates": [update.to_dict() for update in self.updates],
106
+ "InvocationCompletions": [
107
+ completion.to_dict() for completion in self.invocation_completions
108
+ ],
109
+ "UsedTokens": list(self.used_tokens),
110
+ "TokenSequence": self._token_sequence,
111
+ "IsComplete": self.is_complete,
112
+ "Result": self.result.to_dict() if self.result else None,
113
+ "ConsecutiveFailedInvocationAttempts": self.consecutive_failed_invocation_attempts,
114
+ "CloseStatus": self.close_status.value if self.close_status else None,
115
+ }
116
+
117
+ @classmethod
118
+ def from_dict(cls, data: dict[str, Any]) -> Execution:
119
+ """Deserialize execution from dictionary."""
120
+ # Reconstruct start_input
121
+ start_input = StartDurableExecutionInput.from_dict(data["StartInput"])
122
+
123
+ # Reconstruct operations
124
+ operations = [Operation.from_dict(op_data) for op_data in data["Operations"]]
125
+
126
+ # Create execution
127
+ execution = cls(
128
+ durable_execution_arn=data["DurableExecutionArn"],
129
+ start_input=start_input,
130
+ operations=operations,
131
+ )
132
+
133
+ # Set additional fields
134
+ execution.updates = [
135
+ OperationUpdate.from_dict(update_data) for update_data in data["Updates"]
136
+ ]
137
+ execution.invocation_completions = [
138
+ InvocationCompletedDetails.from_dict(item)
139
+ for item in data.get("InvocationCompletions", [])
140
+ ]
141
+ execution.used_tokens = set(data["UsedTokens"])
142
+ execution._token_sequence = data["TokenSequence"] # noqa: SLF001
143
+ execution.is_complete = data["IsComplete"]
144
+ execution.result = (
145
+ DurableExecutionInvocationOutput.from_dict(data["Result"])
146
+ if data["Result"]
147
+ else None
148
+ )
149
+ execution.consecutive_failed_invocation_attempts = data[
150
+ "ConsecutiveFailedInvocationAttempts"
151
+ ]
152
+ close_status_str = data.get("CloseStatus")
153
+ execution.close_status = (
154
+ ExecutionStatus(close_status_str) if close_status_str else None
155
+ )
156
+
157
+ return execution
158
+
159
+ def start(self) -> None:
160
+ if self.start_input.invocation_id is None:
161
+ msg: str = "invocation_id is required"
162
+ raise InvalidParameterValueException(msg)
163
+ with self._state_lock:
164
+ self.operations.append(
165
+ Operation(
166
+ operation_id=self.start_input.invocation_id,
167
+ parent_id=None,
168
+ name=self.start_input.execution_name,
169
+ start_timestamp=datetime.now(UTC),
170
+ operation_type=OperationType.EXECUTION,
171
+ status=OperationStatus.STARTED,
172
+ execution_details=ExecutionDetails(
173
+ input_payload=self.start_input.get_normalized_input()
174
+ ),
175
+ )
176
+ )
177
+
178
+ def get_operation_execution_started(self) -> Operation:
179
+ if not self.operations:
180
+ msg: str = "execution not started."
181
+
182
+ raise IllegalStateException(msg)
183
+
184
+ return self.operations[0]
185
+
186
+ def get_new_checkpoint_token(self) -> str:
187
+ """Generate a new checkpoint token with incremented sequence"""
188
+ with self._state_lock:
189
+ self._token_sequence += 1
190
+ new_token_sequence = self._token_sequence
191
+ token = CheckpointToken(
192
+ execution_arn=self.durable_execution_arn,
193
+ token_sequence=new_token_sequence,
194
+ )
195
+ token_str = token.to_str()
196
+ self.used_tokens.add(token_str)
197
+ return token_str
198
+
199
+ def get_navigable_operations(self) -> list[Operation]:
200
+ """Get list of operations, but exclude child operations where the parent has already completed."""
201
+ return self.operations
202
+
203
+ def get_assertable_operations(self) -> list[Operation]:
204
+ """Get list of operations, but exclude the EXECUTION operations"""
205
+ # TODO: this excludes EXECUTION at start, but can there be an EXECUTION at the end if there was a checkpoint with large payload?
206
+ return self.operations[1:]
207
+
208
+ def has_pending_operations(self, execution: Execution) -> bool:
209
+ """True if execution has pending operations."""
210
+
211
+ for operation in execution.operations:
212
+ if (
213
+ operation.operation_type == OperationType.STEP
214
+ and operation.status == OperationStatus.PENDING
215
+ ) or (
216
+ operation.operation_type
217
+ in [
218
+ OperationType.WAIT,
219
+ OperationType.CALLBACK,
220
+ OperationType.CHAINED_INVOKE,
221
+ ]
222
+ and operation.status == OperationStatus.STARTED
223
+ ):
224
+ return True
225
+ return False
226
+
227
+ def record_invocation_completion(
228
+ self, start_timestamp: datetime, end_timestamp: datetime, request_id: str
229
+ ) -> None:
230
+ """Record an invocation completion event."""
231
+ self.invocation_completions.append(
232
+ InvocationCompletedDetails(
233
+ start_timestamp=start_timestamp,
234
+ end_timestamp=end_timestamp,
235
+ request_id=request_id,
236
+ )
237
+ )
238
+
239
+ def complete_success(self, result: str | None) -> None:
240
+ """Complete execution successfully (DecisionType.COMPLETE_WORKFLOW_EXECUTION)."""
241
+ self.result = DurableExecutionInvocationOutput(
242
+ status=InvocationStatus.SUCCEEDED, result=result
243
+ )
244
+ self.is_complete = True
245
+ self.close_status = ExecutionStatus.SUCCEEDED
246
+ self._end_execution(OperationStatus.SUCCEEDED)
247
+
248
+ def complete_fail(self, error: ErrorObject) -> None:
249
+ """Complete execution with failure (DecisionType.FAIL_WORKFLOW_EXECUTION)."""
250
+ self.result = DurableExecutionInvocationOutput(
251
+ status=InvocationStatus.FAILED, error=error
252
+ )
253
+ self.is_complete = True
254
+ self.close_status = ExecutionStatus.FAILED
255
+ self._end_execution(OperationStatus.FAILED)
256
+
257
+ def complete_timeout(self, error: ErrorObject) -> None:
258
+ """Complete execution with timeout."""
259
+ self.result = DurableExecutionInvocationOutput(
260
+ status=InvocationStatus.FAILED, error=error
261
+ )
262
+ self.is_complete = True
263
+ self.close_status = ExecutionStatus.TIMED_OUT
264
+ self._end_execution(OperationStatus.TIMED_OUT)
265
+
266
+ def complete_stopped(self, error: ErrorObject) -> None:
267
+ """Complete execution as terminated (TerminateWorkflowExecutionV2Request)."""
268
+ self.result = DurableExecutionInvocationOutput(
269
+ status=InvocationStatus.FAILED, error=error
270
+ )
271
+ self.is_complete = True
272
+ self.close_status = ExecutionStatus.STOPPED
273
+ self._end_execution(OperationStatus.STOPPED)
274
+
275
+ def find_operation(self, operation_id: str) -> tuple[int, Operation]:
276
+ """Find operation by ID, return index and operation."""
277
+ for i, operation in enumerate(self.operations):
278
+ if operation.operation_id == operation_id:
279
+ return i, operation
280
+ msg: str = f"Attempting to update state of an Operation [{operation_id}] that doesn't exist"
281
+ raise IllegalStateException(msg)
282
+
283
+ def find_callback_operation(self, callback_id: str) -> tuple[int, Operation]:
284
+ """Find callback operation by callback_id, return index and operation."""
285
+ for i, operation in enumerate(self.operations):
286
+ if (
287
+ operation.operation_type == OperationType.CALLBACK
288
+ and operation.callback_details
289
+ and operation.callback_details.callback_id == callback_id
290
+ ):
291
+ return i, operation
292
+ msg: str = f"Callback operation with callback_id [{callback_id}] not found"
293
+ raise IllegalStateException(msg)
294
+
295
+ def complete_wait(self, operation_id: str) -> Operation:
296
+ """Complete WAIT operation when timer fires."""
297
+ index, operation = self.find_operation(operation_id)
298
+
299
+ # Validate
300
+ if operation.status != OperationStatus.STARTED:
301
+ msg_wait_not_started: str = f"Attempting to transition a Wait Operation[{operation_id}] to SUCCEEDED when it's not STARTED"
302
+ raise IllegalStateException(msg_wait_not_started)
303
+ if operation.operation_type != OperationType.WAIT:
304
+ msg_not_wait: str = (
305
+ f"Expected WAIT operation, got {operation.operation_type}"
306
+ )
307
+ raise IllegalStateException(msg_not_wait)
308
+
309
+ # Thread-safe increment sequence and operation update
310
+ with self._state_lock:
311
+ self._token_sequence += 1
312
+ # Build and assign updated operation
313
+ self.operations[index] = replace(
314
+ operation,
315
+ status=OperationStatus.SUCCEEDED,
316
+ end_timestamp=datetime.now(UTC),
317
+ )
318
+ return self.operations[index]
319
+
320
+ def complete_retry(self, operation_id: str) -> Operation:
321
+ """Complete STEP retry when timer fires."""
322
+ index, operation = self.find_operation(operation_id)
323
+
324
+ # Validate
325
+ if operation.status != OperationStatus.PENDING:
326
+ msg_step_not_pending: str = f"Attempting to transition a Step Operation[{operation_id}] to READY when it's not PENDING"
327
+ raise IllegalStateException(msg_step_not_pending)
328
+ if operation.operation_type != OperationType.STEP:
329
+ msg_not_step: str = (
330
+ f"Expected STEP operation, got {operation.operation_type}"
331
+ )
332
+ raise IllegalStateException(msg_not_step)
333
+
334
+ # Thread-safe increment sequence and operation update
335
+ with self._state_lock:
336
+ self._token_sequence += 1
337
+ # Build updated step_details with cleared next_attempt_timestamp
338
+ new_step_details = None
339
+ if operation.step_details:
340
+ new_step_details = replace(
341
+ operation.step_details, next_attempt_timestamp=None
342
+ )
343
+
344
+ # Build updated operation
345
+ updated_operation = replace(
346
+ operation, status=OperationStatus.READY, step_details=new_step_details
347
+ )
348
+
349
+ # Assign
350
+ self.operations[index] = updated_operation
351
+ return updated_operation
352
+
353
+ def complete_callback_success(
354
+ self, callback_id: str, result: bytes | None = None
355
+ ) -> Operation:
356
+ """Complete CALLBACK operation with success."""
357
+ index, operation = self.find_callback_operation(callback_id)
358
+ if operation.status != OperationStatus.STARTED:
359
+ msg: str = f"Callback operation [{callback_id}] is not in STARTED state"
360
+ raise IllegalStateException(msg)
361
+
362
+ with self._state_lock:
363
+ self._token_sequence += 1
364
+ updated_callback_details = None
365
+ if operation.callback_details:
366
+ updated_callback_details = replace(
367
+ operation.callback_details,
368
+ result=result.decode() if result else None,
369
+ )
370
+
371
+ self.operations[index] = replace(
372
+ operation,
373
+ status=OperationStatus.SUCCEEDED,
374
+ end_timestamp=datetime.now(UTC),
375
+ callback_details=updated_callback_details,
376
+ )
377
+ return self.operations[index]
378
+
379
+ def complete_callback_failure(
380
+ self, callback_id: str, error: ErrorObject
381
+ ) -> Operation:
382
+ """Complete CALLBACK operation with failure."""
383
+ index, operation = self.find_callback_operation(callback_id)
384
+
385
+ if operation.status != OperationStatus.STARTED:
386
+ msg: str = f"Callback operation [{callback_id}] is not in STARTED state"
387
+ raise IllegalStateException(msg)
388
+
389
+ with self._state_lock:
390
+ self._token_sequence += 1
391
+ updated_callback_details = None
392
+ if operation.callback_details:
393
+ updated_callback_details = replace(
394
+ operation.callback_details, error=error
395
+ )
396
+
397
+ self.operations[index] = replace(
398
+ operation,
399
+ status=OperationStatus.FAILED,
400
+ end_timestamp=datetime.now(UTC),
401
+ callback_details=updated_callback_details,
402
+ )
403
+ return self.operations[index]
404
+
405
+ def complete_callback_timeout(
406
+ self, callback_id: str, error: ErrorObject
407
+ ) -> Operation:
408
+ """Complete CALLBACK operation with timeout."""
409
+ index, operation = self.find_callback_operation(callback_id)
410
+
411
+ if operation.status != OperationStatus.STARTED:
412
+ msg: str = f"Callback operation [{callback_id}] is not in STARTED state"
413
+ raise IllegalStateException(msg)
414
+
415
+ with self._state_lock:
416
+ self._token_sequence += 1
417
+ updated_callback_details = None
418
+ if operation.callback_details:
419
+ updated_callback_details = replace(
420
+ operation.callback_details, error=error
421
+ )
422
+
423
+ self.operations[index] = replace(
424
+ operation,
425
+ status=OperationStatus.TIMED_OUT,
426
+ end_timestamp=datetime.now(UTC),
427
+ callback_details=updated_callback_details,
428
+ )
429
+ return self.operations[index]
430
+
431
+ def _end_execution(self, status: OperationStatus) -> None:
432
+ """Set the end_timestamp on the main EXECUTION operation when execution completes."""
433
+ execution_op: Operation = self.get_operation_execution_started()
434
+ if execution_op.operation_type == OperationType.EXECUTION:
435
+ with self._state_lock:
436
+ self.operations[0] = replace(
437
+ execution_op,
438
+ status=status,
439
+ end_timestamp=datetime.now(UTC),
440
+ )