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,246 @@
1
+ """A Scheduler that can run awaitables or standard sync callables on a schedule once or repeatedly."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import itertools
7
+ import logging
8
+ import threading
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Callable
14
+ from concurrent.futures import Future
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class Event:
20
+ """An event created by Scheduler that will block on wait until it's set."""
21
+
22
+ def __init__(self, scheduler: Scheduler, asyncio_event: asyncio.Event) -> None:
23
+ self._scheduler: Scheduler = scheduler
24
+ self._asyncio_event: asyncio.Event = asyncio_event
25
+ self._exception: Exception | None = None
26
+
27
+ def set(self):
28
+ """Set the event with this to unblock wait."""
29
+ self._scheduler.set_event(self._asyncio_event)
30
+
31
+ def set_exception(self, exception: Exception):
32
+ """Set exception and unblock waiters."""
33
+ self._exception = exception
34
+ self._scheduler.set_event(self._asyncio_event)
35
+
36
+ def wait(self, timeout: float | None = None, *, clear_on_set: bool = True) -> bool:
37
+ """Wait until the event is set.
38
+
39
+ Args:
40
+ timeout (int | float | None): Wait for event to set until this timeout.
41
+ clear_on_set (bool): Remove the event from the Scheduler on completion.
42
+ Use this if you won't re-use the event.
43
+
44
+ Returns:
45
+ True when set. False if the event timed out without being set.
46
+
47
+ Raises:
48
+ Exception: If an exception was stored via set_exception().
49
+ """
50
+ result = self._scheduler.wait_for_event(self._asyncio_event, timeout)
51
+ if clear_on_set:
52
+ self._scheduler.remove_event(self._asyncio_event)
53
+ if result and self._exception:
54
+ raise self._exception
55
+ return result
56
+
57
+ def remove(self):
58
+ """Remove the event from the Scheduler. Do this to avoid build-up of many events in the scheduler."""
59
+ self._scheduler.remove_event(self._asyncio_event)
60
+
61
+
62
+ class Scheduler:
63
+ """A Scheduler to run callables later, repeatedly or raise events."""
64
+
65
+ def __init__(self) -> None:
66
+ self._loop: asyncio.AbstractEventLoop = asyncio.new_event_loop()
67
+ self._ready_event: threading.Event = threading.Event()
68
+ self._thread: threading.Thread = threading.Thread(
69
+ target=self._start_loop, daemon=True
70
+ )
71
+ self._running: bool = False
72
+ self._events: set[asyncio.Event] = set()
73
+
74
+ # region context manager
75
+ def __enter__(self):
76
+ self.start()
77
+ return self
78
+
79
+ def __exit__(self, exc_type, exc_val, exc_tb):
80
+ self.stop()
81
+
82
+ # endregion context manager
83
+
84
+ # region event loop
85
+ def start(self):
86
+ """Start the scheduler. Not thread-safe."""
87
+ if self._running:
88
+ return
89
+
90
+ self._running = True
91
+
92
+ self._thread.start()
93
+ # Wait for inside of loop to notify it's ready (meaning _start_loop has completed)
94
+ self._ready_event.wait()
95
+
96
+ def stop(self):
97
+ """Stop the scheduler, releasing resources. Not thread-safe."""
98
+ if not self._running:
99
+ return
100
+
101
+ self._running = False
102
+ self._loop.call_soon_threadsafe(self._cleanup_and_stop)
103
+ self._thread.join()
104
+
105
+ def is_started(self) -> bool:
106
+ """Return True if the scheduler is started."""
107
+ return self._running
108
+
109
+ def event_count(self) -> int:
110
+ """Return the number of events in the scheduler."""
111
+ return len(self._events)
112
+
113
+ def task_count(self) -> int:
114
+ """Return the number of tasks in the scheduler."""
115
+ if not self._running:
116
+ return 0
117
+ return len(asyncio.all_tasks(self._loop))
118
+
119
+ def _cleanup_and_stop(self):
120
+ """Cancel all tasks and clear all events. Stop the event-loop."""
121
+ # Cancel all tasks
122
+ for task in asyncio.all_tasks(self._loop):
123
+ task.cancel()
124
+
125
+ # Clear events (don't set them)
126
+ self._events.clear()
127
+
128
+ self._loop.stop()
129
+
130
+ def _start_loop(self):
131
+ """Initialize the event-loop. The ready event notifies that the loop is started."""
132
+ asyncio.set_event_loop(self._loop)
133
+ # signal that loop is ready from within the loop
134
+ self._loop.call_soon(self._ready_event.set)
135
+ # block indefinitely - call_soon with the read_event will run soon as the loop starts
136
+ self._loop.run_forever()
137
+
138
+ # endregion event loop
139
+ # region Tasks
140
+ def call_later(
141
+ self,
142
+ func: Callable[[], Any],
143
+ delay: float = 0,
144
+ count: int | None = 1,
145
+ completion_event: Event | None = None,
146
+ ) -> Future[Any]:
147
+ """Call func after the delay.
148
+
149
+ If func is async it runs inside a thread-safe coroutine. If func is sync it runs in its own
150
+ threadpool, so it won't block the event loop.
151
+
152
+ Args:
153
+ func (Callable[[], Any]): The function to call later. This can be an async or a standard
154
+ sync function.
155
+ delay (float | int): Delay in seconds before calling func.
156
+ count (int | None): Number of times to call func. Default is 1 (call once).
157
+ Use None for infinite repeats.
158
+ completion_event (Event | None): Event to notify on exception.
159
+
160
+ Returns: Future that completes when the scheduled work is done.
161
+ """
162
+ # infinite counter if count = None, else it maxes out at count
163
+ loop_iter: itertools.count[int] | range = (
164
+ itertools.count() if count is None else range(count)
165
+ )
166
+
167
+ async def delayed_func() -> Any:
168
+ try:
169
+ for _ in loop_iter:
170
+ await asyncio.sleep(delay)
171
+
172
+ try:
173
+ if asyncio.iscoroutinefunction(func):
174
+ result = await func()
175
+ else:
176
+ result = await asyncio.to_thread(func)
177
+ return result # noqa: TRY300
178
+ except Exception as err:
179
+ if completion_event:
180
+ completion_event.set_exception(err)
181
+ else:
182
+ msg: str = "error in scheduled task"
183
+ logger.exception(msg)
184
+ raise
185
+ except asyncio.CancelledError: # noqa: TRY302
186
+ # might want to handle more things here
187
+ raise
188
+
189
+ future: Future[Any] = asyncio.run_coroutine_threadsafe(
190
+ delayed_func(), self._loop
191
+ )
192
+ return future
193
+
194
+ # endregion Tasks
195
+
196
+ # region Events
197
+
198
+ def create_event(self) -> Event:
199
+ """Create an event controlled by the Scheduler to signal between threads and coroutines."""
200
+ # create event inside the Scheduler event-loop
201
+ future: Future[asyncio.Event] = asyncio.run_coroutine_threadsafe(
202
+ self._create_event(), self._loop
203
+ )
204
+
205
+ # Add timeout to prevent surprising "hangs" if for whatever reason event fails to create.
206
+ # result with block. Do NOT call anything in _create_event that calls back into scheduler
207
+ # methods because it could create a circular depdendency which will deadlock.
208
+ event = future.result(timeout=5.0)
209
+ return Event(self, event)
210
+
211
+ def wait_for_event(
212
+ self, event: asyncio.Event, timeout: float | None = None
213
+ ) -> bool:
214
+ """Run event's wait inside the Scheduler event-loop."""
215
+ if event not in self._events:
216
+ return False
217
+
218
+ future: Future[bool] = asyncio.run_coroutine_threadsafe(
219
+ asyncio.wait_for(event.wait(), timeout), self._loop
220
+ )
221
+
222
+ try:
223
+ return future.result()
224
+ except TimeoutError:
225
+ return False
226
+
227
+ def set_event(self, event: asyncio.Event):
228
+ """Set event inside the Scheduler event-loop."""
229
+ if event in self._events:
230
+ self._loop.call_soon_threadsafe(event.set)
231
+
232
+ def remove_event(self, event: asyncio.Event):
233
+ """Remove event from Scheduler in the Scheduler event-loop."""
234
+
235
+ def _remove():
236
+ self._events.discard(event)
237
+
238
+ self._loop.call_soon_threadsafe(_remove)
239
+
240
+ async def _create_event(self) -> asyncio.Event:
241
+ """Create event and add it to the scheduler events list."""
242
+ event = asyncio.Event()
243
+ self._events.add(event)
244
+ return event
245
+
246
+ # endregion Events
@@ -0,0 +1,147 @@
1
+ """Base classes and protocols for execution stores."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC
6
+ from enum import Enum
7
+ from typing import TYPE_CHECKING, Protocol
8
+
9
+
10
+ if TYPE_CHECKING:
11
+ from aws_durable_execution_sdk_python.lambda_service import Operation
12
+
13
+ from aws_durable_execution_sdk_python_testing.execution import Execution
14
+
15
+
16
+ class StoreType(Enum):
17
+ """Supported execution store types."""
18
+
19
+ MEMORY = "memory"
20
+ FILESYSTEM = "filesystem"
21
+ SQLITE = "sqlite"
22
+
23
+
24
+ class ExecutionStore(Protocol):
25
+ """Protocol for execution storage implementations."""
26
+
27
+ # ignore cover because coverage doesn't understand elipses
28
+ def save(self, execution: Execution) -> None: ... # pragma: no cover
29
+ def load(self, execution_arn: str) -> Execution: ... # pragma: no cover
30
+ def update(self, execution: Execution) -> None: ... # pragma: no cover
31
+ def query(
32
+ self,
33
+ function_name: str | None = None,
34
+ execution_name: str | None = None,
35
+ status_filter: str | None = None,
36
+ started_after: str | None = None,
37
+ started_before: str | None = None,
38
+ limit: int | None = None,
39
+ offset: int = 0,
40
+ reverse_order: bool = False, # noqa: FBT001, FBT002
41
+ ) -> tuple[list[Execution], str | None]: ... # pragma: no cover
42
+ def list_all(
43
+ self,
44
+ ) -> list[Execution]: ... # pragma: no cover # Keep for backward compatibility
45
+
46
+
47
+ class BaseExecutionStore(ExecutionStore):
48
+ """Base implementation for execution stores with shared query logic."""
49
+
50
+ @staticmethod
51
+ def process_query(
52
+ executions: list[Execution],
53
+ function_name: str | None = None,
54
+ execution_name: str | None = None,
55
+ status_filter: str | None = None,
56
+ started_after: str | None = None,
57
+ started_before: str | None = None,
58
+ limit: int | None = None,
59
+ offset: int = 0,
60
+ reverse_order: bool = False, # noqa: FBT001, FBT002
61
+ ) -> tuple[list[Execution], str | None]:
62
+ """Apply filtering, sorting, and pagination to executions."""
63
+ # Apply filters
64
+ filtered: list[Execution] = []
65
+ for execution in executions:
66
+ if function_name and execution.start_input.function_name != function_name:
67
+ continue
68
+ if (
69
+ execution_name
70
+ and execution.start_input.execution_name != execution_name
71
+ ):
72
+ continue
73
+
74
+ # Status filtering
75
+ if status_filter and execution.current_status().value != status_filter:
76
+ continue
77
+
78
+ # Time filtering
79
+ if started_after or started_before:
80
+ try:
81
+ operation: Operation = execution.get_operation_execution_started()
82
+ if operation.start_timestamp:
83
+ timestamp: float = (
84
+ operation.start_timestamp.timestamp()
85
+ if hasattr(operation.start_timestamp, "timestamp")
86
+ else operation.start_timestamp.replace(
87
+ tzinfo=UTC
88
+ ).timestamp()
89
+ )
90
+ if started_after and timestamp < float(started_after):
91
+ continue
92
+ if started_before and timestamp > float(started_before):
93
+ continue
94
+ except (ValueError, AttributeError):
95
+ continue
96
+
97
+ filtered.append(execution)
98
+
99
+ # Sort by start timestamp
100
+ def get_sort_key(exe: Execution):
101
+ try:
102
+ op: Operation = exe.get_operation_execution_started()
103
+ if op.start_timestamp:
104
+ return (
105
+ op.start_timestamp.timestamp()
106
+ if hasattr(op.start_timestamp, "timestamp")
107
+ else op.start_timestamp.replace(tzinfo=UTC).timestamp()
108
+ )
109
+ except Exception: # noqa: BLE001, S110
110
+ pass
111
+ return 0
112
+
113
+ filtered.sort(key=get_sort_key, reverse=reverse_order)
114
+
115
+ # Apply pagination
116
+ if limit is not None and limit > 0:
117
+ end_idx: int = offset + limit
118
+ paginated: list[Execution] = filtered[offset:end_idx]
119
+ has_more: bool = end_idx < len(filtered)
120
+ next_marker: str | None = str(end_idx) if has_more else None
121
+ return paginated, next_marker
122
+ return filtered[offset:], None
123
+
124
+ def query(
125
+ self,
126
+ function_name: str | None = None,
127
+ execution_name: str | None = None,
128
+ status_filter: str | None = None,
129
+ started_after: str | None = None,
130
+ started_before: str | None = None,
131
+ limit: int | None = None,
132
+ offset: int = 0,
133
+ reverse_order: bool = False, # noqa: FBT001, FBT002
134
+ ) -> tuple[list[Execution], str | None]:
135
+ """Apply filtering, sorting, and pagination to executions."""
136
+ executions: list[Execution] = self.list_all()
137
+ return self.process_query(
138
+ executions,
139
+ function_name=function_name,
140
+ execution_name=execution_name,
141
+ status_filter=status_filter,
142
+ started_after=started_after,
143
+ started_before=started_before,
144
+ limit=limit,
145
+ offset=offset,
146
+ reverse_order=reverse_order,
147
+ )
@@ -0,0 +1,104 @@
1
+ """File system-based execution store implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+
10
+ from aws_durable_execution_sdk_python_testing.exceptions import (
11
+ ResourceNotFoundException,
12
+ )
13
+ from aws_durable_execution_sdk_python_testing.execution import Execution
14
+ from aws_durable_execution_sdk_python_testing.stores.base import (
15
+ BaseExecutionStore,
16
+ )
17
+
18
+
19
+ class DateTimeEncoder(json.JSONEncoder):
20
+ """Custom JSON encoder that handles datetime objects."""
21
+
22
+ def default(self, obj):
23
+ if isinstance(obj, datetime):
24
+ return obj.timestamp()
25
+ return super().default(obj)
26
+
27
+
28
+ def datetime_object_hook(obj):
29
+ """JSON object hook to convert unix timestamps back to datetime objects."""
30
+ if isinstance(obj, dict):
31
+ for key, value in obj.items():
32
+ if isinstance(value, int | float) and key.endswith(
33
+ ("_timestamp", "_time", "Timestamp", "Time")
34
+ ):
35
+ try: # noqa: SIM105
36
+ obj[key] = datetime.fromtimestamp(value, tz=UTC)
37
+ except (ValueError, OSError):
38
+ # Leave as number if not a valid timestamp
39
+ pass
40
+ return obj
41
+
42
+
43
+ class FileSystemExecutionStore(BaseExecutionStore):
44
+ """File system-based execution store for persistence."""
45
+
46
+ def __init__(self, storage_dir: Path) -> None:
47
+ self._storage_dir = storage_dir
48
+
49
+ @classmethod
50
+ def create(cls, storage_dir: str | Path | None = None) -> FileSystemExecutionStore:
51
+ """Create a FileSystemExecutionStore with directory creation.
52
+
53
+ Args:
54
+ storage_dir: Directory path for storage. Defaults to '.durable_executions'
55
+
56
+ Returns:
57
+ FileSystemExecutionStore instance with created directory
58
+ """
59
+ path = Path(storage_dir) if storage_dir else Path(".durable_executions")
60
+ path.mkdir(exist_ok=True)
61
+ return cls(storage_dir=path)
62
+
63
+ def _get_file_path(self, execution_arn: str) -> Path:
64
+ """Get file path for execution ARN."""
65
+ # Use ARN as filename with .json extension, replacing unsafe characters
66
+ safe_filename = execution_arn.replace(":", "_").replace("/", "_")
67
+ return self._storage_dir / f"{safe_filename}.json"
68
+
69
+ def save(self, execution: Execution) -> None:
70
+ """Save execution to file system."""
71
+ file_path = self._get_file_path(execution.durable_execution_arn)
72
+ data = execution.to_dict()
73
+
74
+ with open(file_path, "w", encoding="utf-8") as f:
75
+ json.dump(data, f, indent=2, cls=DateTimeEncoder)
76
+
77
+ def load(self, execution_arn: str) -> Execution:
78
+ """Load execution from file system."""
79
+ file_path = self._get_file_path(execution_arn)
80
+ if not file_path.exists():
81
+ msg = f"Execution {execution_arn} not found"
82
+ raise ResourceNotFoundException(msg)
83
+
84
+ with open(file_path, encoding="utf-8") as f:
85
+ data = json.load(f, object_hook=datetime_object_hook)
86
+
87
+ return Execution.from_dict(data)
88
+
89
+ def update(self, execution: Execution) -> None:
90
+ """Update execution in file system (same as save)."""
91
+ self.save(execution)
92
+
93
+ def list_all(self) -> list[Execution]:
94
+ """List all executions from file system."""
95
+ executions = []
96
+ for file_path in self._storage_dir.glob("*.json"):
97
+ try:
98
+ with open(file_path, encoding="utf-8") as f:
99
+ data = json.load(f, object_hook=datetime_object_hook)
100
+ executions.append(Execution.from_dict(data))
101
+ except (json.JSONDecodeError, KeyError, OSError) as e:
102
+ logging.warning("Skipping corrupted file %s: %s", file_path, e)
103
+ continue
104
+ return executions
@@ -0,0 +1,38 @@
1
+ """In-memory execution store implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from threading import Lock
6
+ from typing import TYPE_CHECKING
7
+
8
+ from aws_durable_execution_sdk_python_testing.stores.base import (
9
+ BaseExecutionStore,
10
+ )
11
+
12
+
13
+ if TYPE_CHECKING:
14
+ from aws_durable_execution_sdk_python_testing.execution import Execution
15
+
16
+
17
+ class InMemoryExecutionStore(BaseExecutionStore):
18
+ """Dict-based storage for testing."""
19
+
20
+ def __init__(self) -> None:
21
+ self._store: dict[str, Execution] = {}
22
+ self._lock: Lock = Lock()
23
+
24
+ def save(self, execution: Execution) -> None:
25
+ with self._lock:
26
+ self._store[execution.durable_execution_arn] = execution
27
+
28
+ def load(self, execution_arn: str) -> Execution:
29
+ with self._lock:
30
+ return self._store[execution_arn]
31
+
32
+ def update(self, execution: Execution) -> None:
33
+ with self._lock:
34
+ self._store[execution.durable_execution_arn] = execution
35
+
36
+ def list_all(self) -> list[Execution]:
37
+ with self._lock:
38
+ return list(self._store.values())