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.
- dashboard/backend/app/__init__.py +1 -0
- dashboard/backend/app/config.py +32 -0
- dashboard/backend/app/controllers/__init__.py +6 -0
- dashboard/backend/app/controllers/run_controller.py +86 -0
- dashboard/backend/app/controllers/workflow_controller.py +33 -0
- dashboard/backend/app/dependencies/__init__.py +5 -0
- dashboard/backend/app/dependencies/storage.py +50 -0
- dashboard/backend/app/repositories/__init__.py +6 -0
- dashboard/backend/app/repositories/run_repository.py +80 -0
- dashboard/backend/app/repositories/workflow_repository.py +27 -0
- dashboard/backend/app/rest/__init__.py +8 -0
- dashboard/backend/app/rest/v1/__init__.py +12 -0
- dashboard/backend/app/rest/v1/health.py +33 -0
- dashboard/backend/app/rest/v1/runs.py +133 -0
- dashboard/backend/app/rest/v1/workflows.py +41 -0
- dashboard/backend/app/schemas/__init__.py +23 -0
- dashboard/backend/app/schemas/common.py +16 -0
- dashboard/backend/app/schemas/event.py +24 -0
- dashboard/backend/app/schemas/hook.py +25 -0
- dashboard/backend/app/schemas/run.py +54 -0
- dashboard/backend/app/schemas/step.py +28 -0
- dashboard/backend/app/schemas/workflow.py +31 -0
- dashboard/backend/app/server.py +87 -0
- dashboard/backend/app/services/__init__.py +6 -0
- dashboard/backend/app/services/run_service.py +240 -0
- dashboard/backend/app/services/workflow_service.py +155 -0
- dashboard/backend/main.py +18 -0
- docs/concepts/cancellation.mdx +362 -0
- docs/concepts/continue-as-new.mdx +434 -0
- docs/concepts/events.mdx +266 -0
- docs/concepts/fault-tolerance.mdx +370 -0
- docs/concepts/hooks.mdx +552 -0
- docs/concepts/limitations.mdx +167 -0
- docs/concepts/schedules.mdx +775 -0
- docs/concepts/sleep.mdx +312 -0
- docs/concepts/steps.mdx +301 -0
- docs/concepts/workflows.mdx +255 -0
- docs/guides/cli.mdx +942 -0
- docs/guides/configuration.mdx +560 -0
- docs/introduction.mdx +155 -0
- docs/quickstart.mdx +279 -0
- examples/__init__.py +1 -0
- examples/celery/__init__.py +1 -0
- examples/celery/durable/docker-compose.yml +55 -0
- examples/celery/durable/pyworkflow.config.yaml +12 -0
- examples/celery/durable/workflows/__init__.py +122 -0
- examples/celery/durable/workflows/basic.py +87 -0
- examples/celery/durable/workflows/batch_processing.py +102 -0
- examples/celery/durable/workflows/cancellation.py +273 -0
- examples/celery/durable/workflows/child_workflow_patterns.py +240 -0
- examples/celery/durable/workflows/child_workflows.py +202 -0
- examples/celery/durable/workflows/continue_as_new.py +260 -0
- examples/celery/durable/workflows/fault_tolerance.py +210 -0
- examples/celery/durable/workflows/hooks.py +211 -0
- examples/celery/durable/workflows/idempotency.py +112 -0
- examples/celery/durable/workflows/long_running.py +99 -0
- examples/celery/durable/workflows/retries.py +101 -0
- examples/celery/durable/workflows/schedules.py +209 -0
- examples/celery/transient/01_basic_workflow.py +91 -0
- examples/celery/transient/02_fault_tolerance.py +257 -0
- examples/celery/transient/__init__.py +20 -0
- examples/celery/transient/pyworkflow.config.yaml +25 -0
- examples/local/__init__.py +1 -0
- examples/local/durable/01_basic_workflow.py +94 -0
- examples/local/durable/02_file_storage.py +132 -0
- examples/local/durable/03_retries.py +169 -0
- examples/local/durable/04_long_running.py +119 -0
- examples/local/durable/05_event_log.py +145 -0
- examples/local/durable/06_idempotency.py +148 -0
- examples/local/durable/07_hooks.py +334 -0
- examples/local/durable/08_cancellation.py +233 -0
- examples/local/durable/09_child_workflows.py +198 -0
- examples/local/durable/10_child_workflow_patterns.py +265 -0
- examples/local/durable/11_continue_as_new.py +249 -0
- examples/local/durable/12_schedules.py +198 -0
- examples/local/durable/__init__.py +1 -0
- examples/local/transient/01_quick_tasks.py +87 -0
- examples/local/transient/02_retries.py +130 -0
- examples/local/transient/03_sleep.py +141 -0
- examples/local/transient/__init__.py +1 -0
- pyworkflow/__init__.py +256 -0
- pyworkflow/aws/__init__.py +68 -0
- pyworkflow/aws/context.py +234 -0
- pyworkflow/aws/handler.py +184 -0
- pyworkflow/aws/testing.py +310 -0
- pyworkflow/celery/__init__.py +41 -0
- pyworkflow/celery/app.py +198 -0
- pyworkflow/celery/scheduler.py +315 -0
- pyworkflow/celery/tasks.py +1746 -0
- pyworkflow/cli/__init__.py +132 -0
- pyworkflow/cli/__main__.py +6 -0
- pyworkflow/cli/commands/__init__.py +1 -0
- pyworkflow/cli/commands/hooks.py +640 -0
- pyworkflow/cli/commands/quickstart.py +495 -0
- pyworkflow/cli/commands/runs.py +773 -0
- pyworkflow/cli/commands/scheduler.py +130 -0
- pyworkflow/cli/commands/schedules.py +794 -0
- pyworkflow/cli/commands/setup.py +703 -0
- pyworkflow/cli/commands/worker.py +413 -0
- pyworkflow/cli/commands/workflows.py +1257 -0
- pyworkflow/cli/output/__init__.py +1 -0
- pyworkflow/cli/output/formatters.py +321 -0
- pyworkflow/cli/output/styles.py +121 -0
- pyworkflow/cli/utils/__init__.py +1 -0
- pyworkflow/cli/utils/async_helpers.py +30 -0
- pyworkflow/cli/utils/config.py +130 -0
- pyworkflow/cli/utils/config_generator.py +344 -0
- pyworkflow/cli/utils/discovery.py +53 -0
- pyworkflow/cli/utils/docker_manager.py +651 -0
- pyworkflow/cli/utils/interactive.py +364 -0
- pyworkflow/cli/utils/storage.py +115 -0
- pyworkflow/config.py +329 -0
- pyworkflow/context/__init__.py +63 -0
- pyworkflow/context/aws.py +230 -0
- pyworkflow/context/base.py +416 -0
- pyworkflow/context/local.py +930 -0
- pyworkflow/context/mock.py +381 -0
- pyworkflow/core/__init__.py +0 -0
- pyworkflow/core/exceptions.py +353 -0
- pyworkflow/core/registry.py +313 -0
- pyworkflow/core/scheduled.py +328 -0
- pyworkflow/core/step.py +494 -0
- pyworkflow/core/workflow.py +294 -0
- pyworkflow/discovery.py +248 -0
- pyworkflow/engine/__init__.py +0 -0
- pyworkflow/engine/events.py +879 -0
- pyworkflow/engine/executor.py +682 -0
- pyworkflow/engine/replay.py +273 -0
- pyworkflow/observability/__init__.py +19 -0
- pyworkflow/observability/logging.py +234 -0
- pyworkflow/primitives/__init__.py +33 -0
- pyworkflow/primitives/child_handle.py +174 -0
- pyworkflow/primitives/child_workflow.py +372 -0
- pyworkflow/primitives/continue_as_new.py +101 -0
- pyworkflow/primitives/define_hook.py +150 -0
- pyworkflow/primitives/hooks.py +97 -0
- pyworkflow/primitives/resume_hook.py +210 -0
- pyworkflow/primitives/schedule.py +545 -0
- pyworkflow/primitives/shield.py +96 -0
- pyworkflow/primitives/sleep.py +100 -0
- pyworkflow/runtime/__init__.py +21 -0
- pyworkflow/runtime/base.py +179 -0
- pyworkflow/runtime/celery.py +310 -0
- pyworkflow/runtime/factory.py +101 -0
- pyworkflow/runtime/local.py +706 -0
- pyworkflow/scheduler/__init__.py +9 -0
- pyworkflow/scheduler/local.py +248 -0
- pyworkflow/serialization/__init__.py +0 -0
- pyworkflow/serialization/decoder.py +146 -0
- pyworkflow/serialization/encoder.py +162 -0
- pyworkflow/storage/__init__.py +54 -0
- pyworkflow/storage/base.py +612 -0
- pyworkflow/storage/config.py +185 -0
- pyworkflow/storage/dynamodb.py +1315 -0
- pyworkflow/storage/file.py +827 -0
- pyworkflow/storage/memory.py +549 -0
- pyworkflow/storage/postgres.py +1161 -0
- pyworkflow/storage/schemas.py +486 -0
- pyworkflow/storage/sqlite.py +1136 -0
- pyworkflow/utils/__init__.py +0 -0
- pyworkflow/utils/duration.py +177 -0
- pyworkflow/utils/schedule.py +391 -0
- pyworkflow_engine-0.1.7.dist-info/METADATA +687 -0
- pyworkflow_engine-0.1.7.dist-info/RECORD +196 -0
- pyworkflow_engine-0.1.7.dist-info/WHEEL +5 -0
- pyworkflow_engine-0.1.7.dist-info/entry_points.txt +2 -0
- pyworkflow_engine-0.1.7.dist-info/licenses/LICENSE +21 -0
- pyworkflow_engine-0.1.7.dist-info/top_level.txt +5 -0
- tests/examples/__init__.py +0 -0
- tests/integration/__init__.py +0 -0
- tests/integration/test_cancellation.py +330 -0
- tests/integration/test_child_workflows.py +439 -0
- tests/integration/test_continue_as_new.py +428 -0
- tests/integration/test_dynamodb_storage.py +1146 -0
- tests/integration/test_fault_tolerance.py +369 -0
- tests/integration/test_schedule_storage.py +484 -0
- tests/unit/__init__.py +0 -0
- tests/unit/backends/__init__.py +1 -0
- tests/unit/backends/test_dynamodb_storage.py +1554 -0
- tests/unit/backends/test_postgres_storage.py +1281 -0
- tests/unit/backends/test_sqlite_storage.py +1460 -0
- tests/unit/conftest.py +41 -0
- tests/unit/test_cancellation.py +364 -0
- tests/unit/test_child_workflows.py +680 -0
- tests/unit/test_continue_as_new.py +441 -0
- tests/unit/test_event_limits.py +316 -0
- tests/unit/test_executor.py +320 -0
- tests/unit/test_fault_tolerance.py +334 -0
- tests/unit/test_hooks.py +495 -0
- tests/unit/test_registry.py +261 -0
- tests/unit/test_replay.py +420 -0
- tests/unit/test_schedule_schemas.py +285 -0
- tests/unit/test_schedule_utils.py +286 -0
- tests/unit/test_scheduled_workflow.py +274 -0
- tests/unit/test_step.py +353 -0
- 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
|
+
]
|