pyworkflow-engine 0.1.7__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 (196) hide show
  1. dashboard/backend/app/__init__.py +1 -0
  2. dashboard/backend/app/config.py +32 -0
  3. dashboard/backend/app/controllers/__init__.py +6 -0
  4. dashboard/backend/app/controllers/run_controller.py +86 -0
  5. dashboard/backend/app/controllers/workflow_controller.py +33 -0
  6. dashboard/backend/app/dependencies/__init__.py +5 -0
  7. dashboard/backend/app/dependencies/storage.py +50 -0
  8. dashboard/backend/app/repositories/__init__.py +6 -0
  9. dashboard/backend/app/repositories/run_repository.py +80 -0
  10. dashboard/backend/app/repositories/workflow_repository.py +27 -0
  11. dashboard/backend/app/rest/__init__.py +8 -0
  12. dashboard/backend/app/rest/v1/__init__.py +12 -0
  13. dashboard/backend/app/rest/v1/health.py +33 -0
  14. dashboard/backend/app/rest/v1/runs.py +133 -0
  15. dashboard/backend/app/rest/v1/workflows.py +41 -0
  16. dashboard/backend/app/schemas/__init__.py +23 -0
  17. dashboard/backend/app/schemas/common.py +16 -0
  18. dashboard/backend/app/schemas/event.py +24 -0
  19. dashboard/backend/app/schemas/hook.py +25 -0
  20. dashboard/backend/app/schemas/run.py +54 -0
  21. dashboard/backend/app/schemas/step.py +28 -0
  22. dashboard/backend/app/schemas/workflow.py +31 -0
  23. dashboard/backend/app/server.py +87 -0
  24. dashboard/backend/app/services/__init__.py +6 -0
  25. dashboard/backend/app/services/run_service.py +240 -0
  26. dashboard/backend/app/services/workflow_service.py +155 -0
  27. dashboard/backend/main.py +18 -0
  28. docs/concepts/cancellation.mdx +362 -0
  29. docs/concepts/continue-as-new.mdx +434 -0
  30. docs/concepts/events.mdx +266 -0
  31. docs/concepts/fault-tolerance.mdx +370 -0
  32. docs/concepts/hooks.mdx +552 -0
  33. docs/concepts/limitations.mdx +167 -0
  34. docs/concepts/schedules.mdx +775 -0
  35. docs/concepts/sleep.mdx +312 -0
  36. docs/concepts/steps.mdx +301 -0
  37. docs/concepts/workflows.mdx +255 -0
  38. docs/guides/cli.mdx +942 -0
  39. docs/guides/configuration.mdx +560 -0
  40. docs/introduction.mdx +155 -0
  41. docs/quickstart.mdx +279 -0
  42. examples/__init__.py +1 -0
  43. examples/celery/__init__.py +1 -0
  44. examples/celery/durable/docker-compose.yml +55 -0
  45. examples/celery/durable/pyworkflow.config.yaml +12 -0
  46. examples/celery/durable/workflows/__init__.py +122 -0
  47. examples/celery/durable/workflows/basic.py +87 -0
  48. examples/celery/durable/workflows/batch_processing.py +102 -0
  49. examples/celery/durable/workflows/cancellation.py +273 -0
  50. examples/celery/durable/workflows/child_workflow_patterns.py +240 -0
  51. examples/celery/durable/workflows/child_workflows.py +202 -0
  52. examples/celery/durable/workflows/continue_as_new.py +260 -0
  53. examples/celery/durable/workflows/fault_tolerance.py +210 -0
  54. examples/celery/durable/workflows/hooks.py +211 -0
  55. examples/celery/durable/workflows/idempotency.py +112 -0
  56. examples/celery/durable/workflows/long_running.py +99 -0
  57. examples/celery/durable/workflows/retries.py +101 -0
  58. examples/celery/durable/workflows/schedules.py +209 -0
  59. examples/celery/transient/01_basic_workflow.py +91 -0
  60. examples/celery/transient/02_fault_tolerance.py +257 -0
  61. examples/celery/transient/__init__.py +20 -0
  62. examples/celery/transient/pyworkflow.config.yaml +25 -0
  63. examples/local/__init__.py +1 -0
  64. examples/local/durable/01_basic_workflow.py +94 -0
  65. examples/local/durable/02_file_storage.py +132 -0
  66. examples/local/durable/03_retries.py +169 -0
  67. examples/local/durable/04_long_running.py +119 -0
  68. examples/local/durable/05_event_log.py +145 -0
  69. examples/local/durable/06_idempotency.py +148 -0
  70. examples/local/durable/07_hooks.py +334 -0
  71. examples/local/durable/08_cancellation.py +233 -0
  72. examples/local/durable/09_child_workflows.py +198 -0
  73. examples/local/durable/10_child_workflow_patterns.py +265 -0
  74. examples/local/durable/11_continue_as_new.py +249 -0
  75. examples/local/durable/12_schedules.py +198 -0
  76. examples/local/durable/__init__.py +1 -0
  77. examples/local/transient/01_quick_tasks.py +87 -0
  78. examples/local/transient/02_retries.py +130 -0
  79. examples/local/transient/03_sleep.py +141 -0
  80. examples/local/transient/__init__.py +1 -0
  81. pyworkflow/__init__.py +256 -0
  82. pyworkflow/aws/__init__.py +68 -0
  83. pyworkflow/aws/context.py +234 -0
  84. pyworkflow/aws/handler.py +184 -0
  85. pyworkflow/aws/testing.py +310 -0
  86. pyworkflow/celery/__init__.py +41 -0
  87. pyworkflow/celery/app.py +198 -0
  88. pyworkflow/celery/scheduler.py +315 -0
  89. pyworkflow/celery/tasks.py +1746 -0
  90. pyworkflow/cli/__init__.py +132 -0
  91. pyworkflow/cli/__main__.py +6 -0
  92. pyworkflow/cli/commands/__init__.py +1 -0
  93. pyworkflow/cli/commands/hooks.py +640 -0
  94. pyworkflow/cli/commands/quickstart.py +495 -0
  95. pyworkflow/cli/commands/runs.py +773 -0
  96. pyworkflow/cli/commands/scheduler.py +130 -0
  97. pyworkflow/cli/commands/schedules.py +794 -0
  98. pyworkflow/cli/commands/setup.py +703 -0
  99. pyworkflow/cli/commands/worker.py +413 -0
  100. pyworkflow/cli/commands/workflows.py +1257 -0
  101. pyworkflow/cli/output/__init__.py +1 -0
  102. pyworkflow/cli/output/formatters.py +321 -0
  103. pyworkflow/cli/output/styles.py +121 -0
  104. pyworkflow/cli/utils/__init__.py +1 -0
  105. pyworkflow/cli/utils/async_helpers.py +30 -0
  106. pyworkflow/cli/utils/config.py +130 -0
  107. pyworkflow/cli/utils/config_generator.py +344 -0
  108. pyworkflow/cli/utils/discovery.py +53 -0
  109. pyworkflow/cli/utils/docker_manager.py +651 -0
  110. pyworkflow/cli/utils/interactive.py +364 -0
  111. pyworkflow/cli/utils/storage.py +115 -0
  112. pyworkflow/config.py +329 -0
  113. pyworkflow/context/__init__.py +63 -0
  114. pyworkflow/context/aws.py +230 -0
  115. pyworkflow/context/base.py +416 -0
  116. pyworkflow/context/local.py +930 -0
  117. pyworkflow/context/mock.py +381 -0
  118. pyworkflow/core/__init__.py +0 -0
  119. pyworkflow/core/exceptions.py +353 -0
  120. pyworkflow/core/registry.py +313 -0
  121. pyworkflow/core/scheduled.py +328 -0
  122. pyworkflow/core/step.py +494 -0
  123. pyworkflow/core/workflow.py +294 -0
  124. pyworkflow/discovery.py +248 -0
  125. pyworkflow/engine/__init__.py +0 -0
  126. pyworkflow/engine/events.py +879 -0
  127. pyworkflow/engine/executor.py +682 -0
  128. pyworkflow/engine/replay.py +273 -0
  129. pyworkflow/observability/__init__.py +19 -0
  130. pyworkflow/observability/logging.py +234 -0
  131. pyworkflow/primitives/__init__.py +33 -0
  132. pyworkflow/primitives/child_handle.py +174 -0
  133. pyworkflow/primitives/child_workflow.py +372 -0
  134. pyworkflow/primitives/continue_as_new.py +101 -0
  135. pyworkflow/primitives/define_hook.py +150 -0
  136. pyworkflow/primitives/hooks.py +97 -0
  137. pyworkflow/primitives/resume_hook.py +210 -0
  138. pyworkflow/primitives/schedule.py +545 -0
  139. pyworkflow/primitives/shield.py +96 -0
  140. pyworkflow/primitives/sleep.py +100 -0
  141. pyworkflow/runtime/__init__.py +21 -0
  142. pyworkflow/runtime/base.py +179 -0
  143. pyworkflow/runtime/celery.py +310 -0
  144. pyworkflow/runtime/factory.py +101 -0
  145. pyworkflow/runtime/local.py +706 -0
  146. pyworkflow/scheduler/__init__.py +9 -0
  147. pyworkflow/scheduler/local.py +248 -0
  148. pyworkflow/serialization/__init__.py +0 -0
  149. pyworkflow/serialization/decoder.py +146 -0
  150. pyworkflow/serialization/encoder.py +162 -0
  151. pyworkflow/storage/__init__.py +54 -0
  152. pyworkflow/storage/base.py +612 -0
  153. pyworkflow/storage/config.py +185 -0
  154. pyworkflow/storage/dynamodb.py +1315 -0
  155. pyworkflow/storage/file.py +827 -0
  156. pyworkflow/storage/memory.py +549 -0
  157. pyworkflow/storage/postgres.py +1161 -0
  158. pyworkflow/storage/schemas.py +486 -0
  159. pyworkflow/storage/sqlite.py +1136 -0
  160. pyworkflow/utils/__init__.py +0 -0
  161. pyworkflow/utils/duration.py +177 -0
  162. pyworkflow/utils/schedule.py +391 -0
  163. pyworkflow_engine-0.1.7.dist-info/METADATA +687 -0
  164. pyworkflow_engine-0.1.7.dist-info/RECORD +196 -0
  165. pyworkflow_engine-0.1.7.dist-info/WHEEL +5 -0
  166. pyworkflow_engine-0.1.7.dist-info/entry_points.txt +2 -0
  167. pyworkflow_engine-0.1.7.dist-info/licenses/LICENSE +21 -0
  168. pyworkflow_engine-0.1.7.dist-info/top_level.txt +5 -0
  169. tests/examples/__init__.py +0 -0
  170. tests/integration/__init__.py +0 -0
  171. tests/integration/test_cancellation.py +330 -0
  172. tests/integration/test_child_workflows.py +439 -0
  173. tests/integration/test_continue_as_new.py +428 -0
  174. tests/integration/test_dynamodb_storage.py +1146 -0
  175. tests/integration/test_fault_tolerance.py +369 -0
  176. tests/integration/test_schedule_storage.py +484 -0
  177. tests/unit/__init__.py +0 -0
  178. tests/unit/backends/__init__.py +1 -0
  179. tests/unit/backends/test_dynamodb_storage.py +1554 -0
  180. tests/unit/backends/test_postgres_storage.py +1281 -0
  181. tests/unit/backends/test_sqlite_storage.py +1460 -0
  182. tests/unit/conftest.py +41 -0
  183. tests/unit/test_cancellation.py +364 -0
  184. tests/unit/test_child_workflows.py +680 -0
  185. tests/unit/test_continue_as_new.py +441 -0
  186. tests/unit/test_event_limits.py +316 -0
  187. tests/unit/test_executor.py +320 -0
  188. tests/unit/test_fault_tolerance.py +334 -0
  189. tests/unit/test_hooks.py +495 -0
  190. tests/unit/test_registry.py +261 -0
  191. tests/unit/test_replay.py +420 -0
  192. tests/unit/test_schedule_schemas.py +285 -0
  193. tests/unit/test_schedule_utils.py +286 -0
  194. tests/unit/test_scheduled_workflow.py +274 -0
  195. tests/unit/test_step.py +353 -0
  196. tests/unit/test_workflow.py +243 -0
@@ -0,0 +1,248 @@
1
+ """
2
+ Local scheduler for PyWorkflow.
3
+
4
+ Provides a polling-based scheduler that runs in the same process as your application.
5
+ This is the local runtime equivalent of Celery Beat.
6
+ """
7
+
8
+ import asyncio
9
+ from datetime import UTC, datetime
10
+
11
+ from loguru import logger
12
+
13
+ from pyworkflow.primitives.schedule import trigger_schedule
14
+ from pyworkflow.storage.base import StorageBackend
15
+ from pyworkflow.storage.schemas import OverlapPolicy, Schedule
16
+ from pyworkflow.utils.schedule import calculate_next_run_time
17
+
18
+
19
+ class LocalScheduler:
20
+ """
21
+ Local scheduler that polls storage for due schedules.
22
+
23
+ This is the local runtime equivalent of Celery Beat. It polls storage
24
+ for schedules that are due and triggers them using the configured runtime.
25
+
26
+ Usage:
27
+ from pyworkflow.scheduler import LocalScheduler
28
+ from pyworkflow.storage import InMemoryStorageBackend
29
+
30
+ storage = InMemoryStorageBackend()
31
+ scheduler = LocalScheduler(storage=storage, poll_interval=5.0)
32
+
33
+ # Run forever
34
+ await scheduler.run()
35
+
36
+ # Or run for a specific duration
37
+ await scheduler.run(duration=60.0) # Run for 60 seconds
38
+
39
+ Example with CLI:
40
+ pyworkflow scheduler run --poll-interval 5
41
+
42
+ Args:
43
+ storage: Storage backend to use. If None, uses configured storage.
44
+ poll_interval: Seconds between storage polls (default: 5.0)
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ storage: StorageBackend | None = None,
50
+ poll_interval: float = 5.0,
51
+ ):
52
+ """
53
+ Initialize the local scheduler.
54
+
55
+ Args:
56
+ storage: Storage backend to use. If None, uses configured storage.
57
+ poll_interval: Seconds between storage polls (default: 5.0)
58
+ """
59
+ self._storage = storage
60
+ self.poll_interval = poll_interval
61
+ self._running = False
62
+ self._start_time: datetime | None = None
63
+
64
+ @property
65
+ def storage(self) -> StorageBackend:
66
+ """Get the storage backend, resolving from config if needed."""
67
+ if self._storage is None:
68
+ from pyworkflow.config import get_config
69
+
70
+ config = get_config()
71
+ if config.storage is None:
72
+ raise ValueError(
73
+ "Storage backend required. Configure storage or pass to constructor."
74
+ )
75
+ self._storage = config.storage
76
+ return self._storage
77
+
78
+ async def run(self, duration: float | None = None) -> None:
79
+ """
80
+ Run the scheduler loop.
81
+
82
+ Args:
83
+ duration: Optional duration in seconds to run. If None, runs forever.
84
+ """
85
+ self._running = True
86
+ self._start_time = datetime.now(UTC)
87
+
88
+ logger.info(
89
+ "Local scheduler started",
90
+ poll_interval=self.poll_interval,
91
+ duration=duration,
92
+ )
93
+
94
+ try:
95
+ while self._running:
96
+ # Check duration limit
97
+ if duration is not None:
98
+ elapsed = (datetime.now(UTC) - self._start_time).total_seconds()
99
+ if elapsed >= duration:
100
+ logger.info("Scheduler duration limit reached")
101
+ break
102
+
103
+ try:
104
+ await self._tick()
105
+ except Exception as e:
106
+ logger.error(f"Scheduler tick failed: {e}")
107
+
108
+ await asyncio.sleep(self.poll_interval)
109
+ finally:
110
+ self._running = False
111
+ logger.info("Local scheduler stopped")
112
+
113
+ async def _tick(self) -> None:
114
+ """Process due schedules in a single tick."""
115
+ now = datetime.now(UTC)
116
+ due_schedules = await self.storage.get_due_schedules(now)
117
+
118
+ if due_schedules:
119
+ logger.debug(f"Found {len(due_schedules)} due schedule(s)")
120
+
121
+ for schedule in due_schedules:
122
+ await self._process_schedule(schedule, now)
123
+
124
+ async def _process_schedule(self, schedule: Schedule, now: datetime) -> None:
125
+ """
126
+ Process a single due schedule.
127
+
128
+ Args:
129
+ schedule: The schedule to process
130
+ now: Current timestamp
131
+ """
132
+ # Check overlap policy
133
+ should_run, reason = await self._check_overlap_policy(schedule)
134
+
135
+ if not should_run:
136
+ logger.info(
137
+ f"Skipping schedule: {reason}",
138
+ schedule_id=schedule.schedule_id,
139
+ )
140
+ schedule.skipped_runs += 1
141
+ schedule.next_run_time = calculate_next_run_time(schedule.spec, last_run=now, now=now)
142
+ await self.storage.update_schedule(schedule)
143
+ return
144
+
145
+ # Trigger the schedule (uses runtime-agnostic start())
146
+ try:
147
+ logger.info(
148
+ "Triggering schedule",
149
+ schedule_id=schedule.schedule_id,
150
+ workflow_name=schedule.workflow_name,
151
+ )
152
+ run_id = await trigger_schedule(schedule.schedule_id, storage=self.storage)
153
+ logger.info(
154
+ "Schedule triggered successfully",
155
+ schedule_id=schedule.schedule_id,
156
+ run_id=run_id,
157
+ )
158
+ except Exception as e:
159
+ logger.error(
160
+ "Failed to trigger schedule",
161
+ schedule_id=schedule.schedule_id,
162
+ error=str(e),
163
+ )
164
+ # Update failed runs count
165
+ schedule.failed_runs += 1
166
+ schedule.next_run_time = calculate_next_run_time(schedule.spec, last_run=now, now=now)
167
+ await self.storage.update_schedule(schedule)
168
+
169
+ async def _check_overlap_policy(self, schedule: Schedule) -> tuple[bool, str]:
170
+ """
171
+ Check if schedule should run based on overlap policy.
172
+
173
+ Args:
174
+ schedule: The schedule to check
175
+
176
+ Returns:
177
+ Tuple of (should_run, reason if not running)
178
+ """
179
+ # No running workflows = always run
180
+ if not schedule.running_run_ids:
181
+ return True, ""
182
+
183
+ policy = schedule.overlap_policy
184
+
185
+ if policy == OverlapPolicy.SKIP:
186
+ return False, "previous run still active (SKIP policy)"
187
+
188
+ elif policy == OverlapPolicy.ALLOW_ALL:
189
+ return True, ""
190
+
191
+ elif policy == OverlapPolicy.BUFFER_ONE:
192
+ if schedule.buffered_count >= 1:
193
+ return False, "already buffered one run (BUFFER_ONE policy)"
194
+ schedule.buffered_count += 1
195
+ return True, ""
196
+
197
+ elif policy == OverlapPolicy.BUFFER_ALL:
198
+ schedule.buffered_count += 1
199
+ return True, ""
200
+
201
+ elif policy == OverlapPolicy.CANCEL_OTHER:
202
+ # Cancel running workflows before starting new one
203
+ from pyworkflow.engine.executor import cancel_workflow
204
+
205
+ for run_id in list(schedule.running_run_ids):
206
+ try:
207
+ await cancel_workflow(run_id)
208
+ logger.info(
209
+ "Cancelled previous run",
210
+ schedule_id=schedule.schedule_id,
211
+ run_id=run_id,
212
+ )
213
+ except Exception as e:
214
+ logger.warning(
215
+ "Failed to cancel previous run",
216
+ schedule_id=schedule.schedule_id,
217
+ run_id=run_id,
218
+ error=str(e),
219
+ )
220
+ return True, ""
221
+
222
+ # Default: allow
223
+ return True, ""
224
+
225
+ def stop(self) -> None:
226
+ """Stop the scheduler gracefully."""
227
+ logger.info("Stopping local scheduler...")
228
+ self._running = False
229
+
230
+ @property
231
+ def is_running(self) -> bool:
232
+ """Check if the scheduler is currently running."""
233
+ return self._running
234
+
235
+ async def tick_once(self) -> int:
236
+ """
237
+ Process due schedules once (useful for testing).
238
+
239
+ Returns:
240
+ Number of schedules processed
241
+ """
242
+ now = datetime.now(UTC)
243
+ due_schedules = await self.storage.get_due_schedules(now)
244
+
245
+ for schedule in due_schedules:
246
+ await self._process_schedule(schedule, now)
247
+
248
+ return len(due_schedules)
File without changes
@@ -0,0 +1,146 @@
1
+ """
2
+ Custom decoding for complex Python types.
3
+
4
+ Reverses the encoding performed by encoder.py to reconstruct Python objects.
5
+ """
6
+
7
+ import base64
8
+ import json
9
+ from datetime import date, datetime, timedelta
10
+ from decimal import Decimal
11
+ from typing import Any
12
+
13
+ import cloudpickle
14
+
15
+
16
+ def enhanced_json_decoder(dct: dict) -> Any:
17
+ """
18
+ Decode custom JSON types back to Python objects.
19
+
20
+ Args:
21
+ dct: Dictionary from JSON parsing
22
+
23
+ Returns:
24
+ Decoded Python object
25
+ """
26
+ if "__type__" not in dct:
27
+ return dct
28
+
29
+ type_name = dct["__type__"]
30
+
31
+ # Datetime types
32
+ if type_name == "datetime":
33
+ return datetime.fromisoformat(dct["value"])
34
+
35
+ if type_name == "date":
36
+ return date.fromisoformat(dct["value"])
37
+
38
+ if type_name == "timedelta":
39
+ return timedelta(seconds=dct["value"])
40
+
41
+ # Numeric types
42
+ if type_name == "decimal":
43
+ return Decimal(dct["value"])
44
+
45
+ # Enum types
46
+ if type_name == "enum":
47
+ # Dynamically import and reconstruct enum
48
+ module_name, class_name = dct["class"].rsplit(".", 1)
49
+ try:
50
+ module = __import__(module_name, fromlist=[class_name])
51
+ enum_class = getattr(module, class_name)
52
+ return enum_class(dct["value"])
53
+ except (ImportError, AttributeError, ValueError):
54
+ # If enum can't be reconstructed, return the dict
55
+ return dct
56
+
57
+ # Exception types
58
+ if type_name == "exception":
59
+ # Reconstruct exception
60
+ exc_class_name = dct["class"]
61
+ try:
62
+ # Try to get exception class from builtins
63
+ exc_class = getattr(__builtins__, exc_class_name, Exception)
64
+ except (AttributeError, TypeError):
65
+ exc_class = Exception
66
+
67
+ try:
68
+ return exc_class(*dct.get("args", []))
69
+ except Exception:
70
+ # If reconstruction fails, return generic exception
71
+ return Exception(dct.get("message", "Unknown error"))
72
+
73
+ # Binary data
74
+ if type_name == "bytes":
75
+ return base64.b64decode(dct["value"])
76
+
77
+ # Sets
78
+ if type_name == "set":
79
+ return set(dct["value"])
80
+
81
+ # Cloudpickle objects
82
+ if type_name == "cloudpickle":
83
+ try:
84
+ return cloudpickle.loads(base64.b64decode(dct["value"]))
85
+ except Exception:
86
+ # If unpickling fails, return the dict
87
+ return dct
88
+
89
+ # Unknown type - return as-is
90
+ return dct
91
+
92
+
93
+ def deserialize(json_str: str) -> Any:
94
+ """
95
+ Deserialize JSON string to Python object.
96
+
97
+ Args:
98
+ json_str: JSON string
99
+
100
+ Returns:
101
+ Deserialized Python object
102
+
103
+ Examples:
104
+ >>> deserialize('{"name": "Alice", "age": 30}')
105
+ {'name': 'Alice', 'age': 30}
106
+
107
+ >>> deserialize('{"__type__": "datetime", "value": "2025-01-15T10:30:00"}')
108
+ datetime.datetime(2025, 1, 15, 10, 30)
109
+ """
110
+ return json.loads(json_str, object_hook=enhanced_json_decoder)
111
+
112
+
113
+ def deserialize_args(json_str: str) -> tuple[Any, ...]:
114
+ """
115
+ Deserialize JSON string to tuple of arguments.
116
+
117
+ Args:
118
+ json_str: JSON string of list
119
+
120
+ Returns:
121
+ Tuple of arguments
122
+
123
+ Examples:
124
+ >>> deserialize_args('["hello", 42, true]')
125
+ ('hello', 42, True)
126
+ """
127
+ args_list = json.loads(json_str, object_hook=enhanced_json_decoder)
128
+ return tuple(args_list) if isinstance(args_list, list) else ()
129
+
130
+
131
+ def deserialize_kwargs(json_str: str) -> dict:
132
+ """
133
+ Deserialize JSON string to dictionary of keyword arguments.
134
+
135
+ Args:
136
+ json_str: JSON string of dict
137
+
138
+ Returns:
139
+ Dictionary of keyword arguments
140
+
141
+ Examples:
142
+ >>> deserialize_kwargs('{"name": "Alice", "age": 30}')
143
+ {'name': 'Alice', 'age': 30}
144
+ """
145
+ kwargs_dict = json.loads(json_str, object_hook=enhanced_json_decoder)
146
+ return kwargs_dict if isinstance(kwargs_dict, dict) else {}
@@ -0,0 +1,162 @@
1
+ """
2
+ Custom encoding for complex Python types.
3
+
4
+ Supports serialization of:
5
+ - Primitives (int, str, bool, float, None)
6
+ - Collections (list, dict, tuple, set)
7
+ - Dates (datetime, date, timedelta)
8
+ - Special types (Decimal, Enum, Exception, bytes)
9
+ - Complex objects (via cloudpickle)
10
+ """
11
+
12
+ import base64
13
+ import json
14
+ from datetime import date, datetime, timedelta
15
+ from decimal import Decimal
16
+ from enum import Enum
17
+ from typing import Any
18
+
19
+ import cloudpickle
20
+
21
+
22
+ class EnhancedJSONEncoder(json.JSONEncoder):
23
+ """
24
+ JSON encoder with support for additional Python types.
25
+
26
+ Handles datetime, Decimal, Enum, Exception, bytes, and complex objects.
27
+ """
28
+
29
+ def default(self, obj: Any) -> Any:
30
+ """
31
+ Encode object to JSON-serializable form.
32
+
33
+ Args:
34
+ obj: Object to encode
35
+
36
+ Returns:
37
+ JSON-serializable representation
38
+ """
39
+ # Datetime types
40
+ if isinstance(obj, datetime):
41
+ return {
42
+ "__type__": "datetime",
43
+ "value": obj.isoformat(),
44
+ }
45
+
46
+ if isinstance(obj, date):
47
+ return {
48
+ "__type__": "date",
49
+ "value": obj.isoformat(),
50
+ }
51
+
52
+ if isinstance(obj, timedelta):
53
+ return {
54
+ "__type__": "timedelta",
55
+ "value": obj.total_seconds(),
56
+ }
57
+
58
+ # Numeric types
59
+ if isinstance(obj, Decimal):
60
+ return {
61
+ "__type__": "decimal",
62
+ "value": str(obj),
63
+ }
64
+
65
+ # Enum types
66
+ if isinstance(obj, Enum):
67
+ return {
68
+ "__type__": "enum",
69
+ "class": f"{obj.__class__.__module__}.{obj.__class__.__name__}",
70
+ "value": obj.value,
71
+ }
72
+
73
+ # Exception types
74
+ if isinstance(obj, Exception):
75
+ return {
76
+ "__type__": "exception",
77
+ "class": obj.__class__.__name__,
78
+ "message": str(obj),
79
+ "args": obj.args,
80
+ }
81
+
82
+ # Binary data
83
+ if isinstance(obj, bytes):
84
+ return {
85
+ "__type__": "bytes",
86
+ "value": base64.b64encode(obj).decode("ascii"),
87
+ }
88
+
89
+ # Sets (convert to list)
90
+ if isinstance(obj, set):
91
+ return {
92
+ "__type__": "set",
93
+ "value": list(obj),
94
+ }
95
+
96
+ # Complex objects - fall back to cloudpickle
97
+ try:
98
+ return {
99
+ "__type__": "cloudpickle",
100
+ "value": base64.b64encode(cloudpickle.dumps(obj)).decode("ascii"),
101
+ }
102
+ except Exception as e:
103
+ # If cloudpickle fails, raise serialization error
104
+ raise TypeError(
105
+ f"Object of type {type(obj).__name__} is not JSON serializable "
106
+ f"and could not be pickled: {e}"
107
+ ) from e
108
+
109
+
110
+ def serialize(obj: Any) -> str:
111
+ """
112
+ Serialize Python object to JSON string.
113
+
114
+ Args:
115
+ obj: Object to serialize
116
+
117
+ Returns:
118
+ JSON string
119
+
120
+ Examples:
121
+ >>> serialize({"name": "Alice", "age": 30})
122
+ '{"name": "Alice", "age": 30}'
123
+
124
+ >>> from datetime import datetime
125
+ >>> serialize(datetime(2025, 1, 15, 10, 30))
126
+ '{"__type__": "datetime", "value": "2025-01-15T10:30:00"}'
127
+ """
128
+ return json.dumps(obj, cls=EnhancedJSONEncoder)
129
+
130
+
131
+ def serialize_args(*args: Any) -> str:
132
+ """
133
+ Serialize positional arguments to JSON string.
134
+
135
+ Args:
136
+ *args: Positional arguments
137
+
138
+ Returns:
139
+ JSON string of list
140
+
141
+ Examples:
142
+ >>> serialize_args("hello", 42, True)
143
+ '["hello", 42, true]'
144
+ """
145
+ return json.dumps(list(args), cls=EnhancedJSONEncoder)
146
+
147
+
148
+ def serialize_kwargs(**kwargs: Any) -> str:
149
+ """
150
+ Serialize keyword arguments to JSON string.
151
+
152
+ Args:
153
+ **kwargs: Keyword arguments
154
+
155
+ Returns:
156
+ JSON string of dict
157
+
158
+ Examples:
159
+ >>> serialize_kwargs(name="Alice", age=30)
160
+ '{"name": "Alice", "age": 30}'
161
+ """
162
+ return json.dumps(kwargs, cls=EnhancedJSONEncoder)
@@ -0,0 +1,54 @@
1
+ """
2
+ Storage backends for PyWorkflow.
3
+
4
+ Provides different storage implementations for workflow state persistence.
5
+ """
6
+
7
+ from pyworkflow.storage.base import StorageBackend
8
+ from pyworkflow.storage.config import config_to_storage, storage_to_config
9
+ from pyworkflow.storage.file import FileStorageBackend
10
+ from pyworkflow.storage.memory import InMemoryStorageBackend
11
+ from pyworkflow.storage.schemas import (
12
+ Hook,
13
+ HookStatus,
14
+ RunStatus,
15
+ StepExecution,
16
+ StepStatus,
17
+ WorkflowRun,
18
+ )
19
+
20
+ # SQLite backend - optional import (requires sqlite3 in Python build)
21
+ try:
22
+ from pyworkflow.storage.sqlite import SQLiteStorageBackend
23
+ except ImportError:
24
+ SQLiteStorageBackend = None # type: ignore
25
+
26
+ # PostgreSQL backend - optional import (requires asyncpg)
27
+ try:
28
+ from pyworkflow.storage.postgres import PostgresStorageBackend
29
+ except ImportError:
30
+ PostgresStorageBackend = None # type: ignore
31
+
32
+ # DynamoDB backend - optional import (requires aiobotocore)
33
+ try:
34
+ from pyworkflow.storage.dynamodb import DynamoDBStorageBackend
35
+ except ImportError:
36
+ DynamoDBStorageBackend = None # type: ignore
37
+
38
+ __all__ = [
39
+ "StorageBackend",
40
+ "FileStorageBackend",
41
+ "InMemoryStorageBackend",
42
+ "SQLiteStorageBackend",
43
+ "PostgresStorageBackend",
44
+ "DynamoDBStorageBackend",
45
+ "WorkflowRun",
46
+ "StepExecution",
47
+ "Hook",
48
+ "RunStatus",
49
+ "StepStatus",
50
+ "HookStatus",
51
+ # Config utilities
52
+ "storage_to_config",
53
+ "config_to_storage",
54
+ ]