pyworkflow-engine 0.1.7__py3-none-any.whl → 0.1.10__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.
- pyworkflow/__init__.py +10 -1
- pyworkflow/celery/tasks.py +272 -24
- pyworkflow/cli/__init__.py +4 -1
- pyworkflow/cli/commands/runs.py +4 -4
- pyworkflow/cli/commands/setup.py +203 -4
- pyworkflow/cli/utils/config_generator.py +76 -3
- pyworkflow/cli/utils/docker_manager.py +232 -0
- pyworkflow/config.py +94 -17
- pyworkflow/context/__init__.py +13 -0
- pyworkflow/context/base.py +26 -0
- pyworkflow/context/local.py +80 -0
- pyworkflow/context/step_context.py +295 -0
- pyworkflow/core/registry.py +6 -1
- pyworkflow/core/step.py +141 -0
- pyworkflow/core/workflow.py +56 -0
- pyworkflow/engine/events.py +30 -0
- pyworkflow/engine/replay.py +39 -0
- pyworkflow/primitives/child_workflow.py +1 -1
- pyworkflow/runtime/local.py +1 -1
- pyworkflow/storage/__init__.py +14 -0
- pyworkflow/storage/base.py +35 -0
- pyworkflow/storage/cassandra.py +1747 -0
- pyworkflow/storage/config.py +69 -0
- pyworkflow/storage/dynamodb.py +31 -2
- pyworkflow/storage/file.py +28 -0
- pyworkflow/storage/memory.py +18 -0
- pyworkflow/storage/mysql.py +1159 -0
- pyworkflow/storage/postgres.py +27 -2
- pyworkflow/storage/schemas.py +4 -3
- pyworkflow/storage/sqlite.py +25 -2
- {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.dist-info}/METADATA +7 -4
- pyworkflow_engine-0.1.10.dist-info/RECORD +91 -0
- pyworkflow_engine-0.1.10.dist-info/top_level.txt +1 -0
- dashboard/backend/app/__init__.py +0 -1
- dashboard/backend/app/config.py +0 -32
- dashboard/backend/app/controllers/__init__.py +0 -6
- dashboard/backend/app/controllers/run_controller.py +0 -86
- dashboard/backend/app/controllers/workflow_controller.py +0 -33
- dashboard/backend/app/dependencies/__init__.py +0 -5
- dashboard/backend/app/dependencies/storage.py +0 -50
- dashboard/backend/app/repositories/__init__.py +0 -6
- dashboard/backend/app/repositories/run_repository.py +0 -80
- dashboard/backend/app/repositories/workflow_repository.py +0 -27
- dashboard/backend/app/rest/__init__.py +0 -8
- dashboard/backend/app/rest/v1/__init__.py +0 -12
- dashboard/backend/app/rest/v1/health.py +0 -33
- dashboard/backend/app/rest/v1/runs.py +0 -133
- dashboard/backend/app/rest/v1/workflows.py +0 -41
- dashboard/backend/app/schemas/__init__.py +0 -23
- dashboard/backend/app/schemas/common.py +0 -16
- dashboard/backend/app/schemas/event.py +0 -24
- dashboard/backend/app/schemas/hook.py +0 -25
- dashboard/backend/app/schemas/run.py +0 -54
- dashboard/backend/app/schemas/step.py +0 -28
- dashboard/backend/app/schemas/workflow.py +0 -31
- dashboard/backend/app/server.py +0 -87
- dashboard/backend/app/services/__init__.py +0 -6
- dashboard/backend/app/services/run_service.py +0 -240
- dashboard/backend/app/services/workflow_service.py +0 -155
- dashboard/backend/main.py +0 -18
- docs/concepts/cancellation.mdx +0 -362
- docs/concepts/continue-as-new.mdx +0 -434
- docs/concepts/events.mdx +0 -266
- docs/concepts/fault-tolerance.mdx +0 -370
- docs/concepts/hooks.mdx +0 -552
- docs/concepts/limitations.mdx +0 -167
- docs/concepts/schedules.mdx +0 -775
- docs/concepts/sleep.mdx +0 -312
- docs/concepts/steps.mdx +0 -301
- docs/concepts/workflows.mdx +0 -255
- docs/guides/cli.mdx +0 -942
- docs/guides/configuration.mdx +0 -560
- docs/introduction.mdx +0 -155
- docs/quickstart.mdx +0 -279
- examples/__init__.py +0 -1
- examples/celery/__init__.py +0 -1
- examples/celery/durable/docker-compose.yml +0 -55
- examples/celery/durable/pyworkflow.config.yaml +0 -12
- examples/celery/durable/workflows/__init__.py +0 -122
- examples/celery/durable/workflows/basic.py +0 -87
- examples/celery/durable/workflows/batch_processing.py +0 -102
- examples/celery/durable/workflows/cancellation.py +0 -273
- examples/celery/durable/workflows/child_workflow_patterns.py +0 -240
- examples/celery/durable/workflows/child_workflows.py +0 -202
- examples/celery/durable/workflows/continue_as_new.py +0 -260
- examples/celery/durable/workflows/fault_tolerance.py +0 -210
- examples/celery/durable/workflows/hooks.py +0 -211
- examples/celery/durable/workflows/idempotency.py +0 -112
- examples/celery/durable/workflows/long_running.py +0 -99
- examples/celery/durable/workflows/retries.py +0 -101
- examples/celery/durable/workflows/schedules.py +0 -209
- examples/celery/transient/01_basic_workflow.py +0 -91
- examples/celery/transient/02_fault_tolerance.py +0 -257
- examples/celery/transient/__init__.py +0 -20
- examples/celery/transient/pyworkflow.config.yaml +0 -25
- examples/local/__init__.py +0 -1
- examples/local/durable/01_basic_workflow.py +0 -94
- examples/local/durable/02_file_storage.py +0 -132
- examples/local/durable/03_retries.py +0 -169
- examples/local/durable/04_long_running.py +0 -119
- examples/local/durable/05_event_log.py +0 -145
- examples/local/durable/06_idempotency.py +0 -148
- examples/local/durable/07_hooks.py +0 -334
- examples/local/durable/08_cancellation.py +0 -233
- examples/local/durable/09_child_workflows.py +0 -198
- examples/local/durable/10_child_workflow_patterns.py +0 -265
- examples/local/durable/11_continue_as_new.py +0 -249
- examples/local/durable/12_schedules.py +0 -198
- examples/local/durable/__init__.py +0 -1
- examples/local/transient/01_quick_tasks.py +0 -87
- examples/local/transient/02_retries.py +0 -130
- examples/local/transient/03_sleep.py +0 -141
- examples/local/transient/__init__.py +0 -1
- pyworkflow_engine-0.1.7.dist-info/RECORD +0 -196
- pyworkflow_engine-0.1.7.dist-info/top_level.txt +0 -5
- tests/examples/__init__.py +0 -0
- tests/integration/__init__.py +0 -0
- tests/integration/test_cancellation.py +0 -330
- tests/integration/test_child_workflows.py +0 -439
- tests/integration/test_continue_as_new.py +0 -428
- tests/integration/test_dynamodb_storage.py +0 -1146
- tests/integration/test_fault_tolerance.py +0 -369
- tests/integration/test_schedule_storage.py +0 -484
- tests/unit/__init__.py +0 -0
- tests/unit/backends/__init__.py +0 -1
- tests/unit/backends/test_dynamodb_storage.py +0 -1554
- tests/unit/backends/test_postgres_storage.py +0 -1281
- tests/unit/backends/test_sqlite_storage.py +0 -1460
- tests/unit/conftest.py +0 -41
- tests/unit/test_cancellation.py +0 -364
- tests/unit/test_child_workflows.py +0 -680
- tests/unit/test_continue_as_new.py +0 -441
- tests/unit/test_event_limits.py +0 -316
- tests/unit/test_executor.py +0 -320
- tests/unit/test_fault_tolerance.py +0 -334
- tests/unit/test_hooks.py +0 -495
- tests/unit/test_registry.py +0 -261
- tests/unit/test_replay.py +0 -420
- tests/unit/test_schedule_schemas.py +0 -285
- tests/unit/test_schedule_utils.py +0 -286
- tests/unit/test_scheduled_workflow.py +0 -274
- tests/unit/test_step.py +0 -353
- tests/unit/test_workflow.py +0 -243
- {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.dist-info}/WHEEL +0 -0
- {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.dist-info}/entry_points.txt +0 -0
- {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,285 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Unit tests for schedule schemas and data models.
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
from datetime import UTC, datetime
|
|
6
|
-
|
|
7
|
-
from pyworkflow.storage.schemas import (
|
|
8
|
-
CalendarSpec,
|
|
9
|
-
OverlapPolicy,
|
|
10
|
-
Schedule,
|
|
11
|
-
ScheduleSpec,
|
|
12
|
-
ScheduleStatus,
|
|
13
|
-
)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class TestOverlapPolicy:
|
|
17
|
-
"""Test OverlapPolicy enum."""
|
|
18
|
-
|
|
19
|
-
def test_overlap_policy_values(self):
|
|
20
|
-
"""Test all overlap policy values exist."""
|
|
21
|
-
assert OverlapPolicy.SKIP.value == "skip"
|
|
22
|
-
assert OverlapPolicy.BUFFER_ONE.value == "buffer_one"
|
|
23
|
-
assert OverlapPolicy.BUFFER_ALL.value == "buffer_all"
|
|
24
|
-
assert OverlapPolicy.CANCEL_OTHER.value == "cancel_other"
|
|
25
|
-
assert OverlapPolicy.ALLOW_ALL.value == "allow_all"
|
|
26
|
-
|
|
27
|
-
def test_overlap_policy_from_string(self):
|
|
28
|
-
"""Test creating OverlapPolicy from string value."""
|
|
29
|
-
assert OverlapPolicy("skip") == OverlapPolicy.SKIP
|
|
30
|
-
assert OverlapPolicy("buffer_one") == OverlapPolicy.BUFFER_ONE
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
class TestScheduleStatus:
|
|
34
|
-
"""Test ScheduleStatus enum."""
|
|
35
|
-
|
|
36
|
-
def test_schedule_status_values(self):
|
|
37
|
-
"""Test all schedule status values exist."""
|
|
38
|
-
assert ScheduleStatus.ACTIVE.value == "active"
|
|
39
|
-
assert ScheduleStatus.PAUSED.value == "paused"
|
|
40
|
-
assert ScheduleStatus.DELETED.value == "deleted"
|
|
41
|
-
|
|
42
|
-
def test_schedule_status_from_string(self):
|
|
43
|
-
"""Test creating ScheduleStatus from string value."""
|
|
44
|
-
assert ScheduleStatus("active") == ScheduleStatus.ACTIVE
|
|
45
|
-
assert ScheduleStatus("paused") == ScheduleStatus.PAUSED
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
class TestCalendarSpec:
|
|
49
|
-
"""Test CalendarSpec dataclass."""
|
|
50
|
-
|
|
51
|
-
def test_calendar_spec_defaults(self):
|
|
52
|
-
"""Test CalendarSpec default values."""
|
|
53
|
-
spec = CalendarSpec()
|
|
54
|
-
assert spec.second == 0
|
|
55
|
-
assert spec.minute == 0
|
|
56
|
-
assert spec.hour == 0
|
|
57
|
-
assert spec.day_of_month is None
|
|
58
|
-
assert spec.month is None
|
|
59
|
-
assert spec.day_of_week is None
|
|
60
|
-
|
|
61
|
-
def test_calendar_spec_with_values(self):
|
|
62
|
-
"""Test CalendarSpec with specific values."""
|
|
63
|
-
spec = CalendarSpec(
|
|
64
|
-
second=30,
|
|
65
|
-
minute=15,
|
|
66
|
-
hour=9,
|
|
67
|
-
day_of_month=1,
|
|
68
|
-
month=6,
|
|
69
|
-
day_of_week=1,
|
|
70
|
-
)
|
|
71
|
-
assert spec.second == 30
|
|
72
|
-
assert spec.minute == 15
|
|
73
|
-
assert spec.hour == 9
|
|
74
|
-
assert spec.day_of_month == 1
|
|
75
|
-
assert spec.month == 6
|
|
76
|
-
assert spec.day_of_week == 1
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
class TestScheduleSpec:
|
|
80
|
-
"""Test ScheduleSpec dataclass."""
|
|
81
|
-
|
|
82
|
-
def test_schedule_spec_defaults(self):
|
|
83
|
-
"""Test ScheduleSpec default values."""
|
|
84
|
-
spec = ScheduleSpec()
|
|
85
|
-
assert spec.cron is None
|
|
86
|
-
assert spec.interval is None
|
|
87
|
-
assert spec.calendar is None
|
|
88
|
-
assert spec.timezone == "UTC"
|
|
89
|
-
assert spec.start_at is None
|
|
90
|
-
assert spec.end_at is None
|
|
91
|
-
assert spec.jitter is None
|
|
92
|
-
|
|
93
|
-
def test_schedule_spec_with_cron(self):
|
|
94
|
-
"""Test ScheduleSpec with cron expression."""
|
|
95
|
-
spec = ScheduleSpec(cron="0 9 * * *")
|
|
96
|
-
assert spec.cron == "0 9 * * *"
|
|
97
|
-
assert spec.interval is None
|
|
98
|
-
|
|
99
|
-
def test_schedule_spec_with_interval(self):
|
|
100
|
-
"""Test ScheduleSpec with interval."""
|
|
101
|
-
spec = ScheduleSpec(interval="5m")
|
|
102
|
-
assert spec.interval == "5m"
|
|
103
|
-
assert spec.cron is None
|
|
104
|
-
|
|
105
|
-
def test_schedule_spec_with_calendar(self):
|
|
106
|
-
"""Test ScheduleSpec with calendar entries."""
|
|
107
|
-
calendars = [
|
|
108
|
-
CalendarSpec(day_of_month=1, hour=0, minute=0),
|
|
109
|
-
CalendarSpec(day_of_month=15, hour=12, minute=0),
|
|
110
|
-
]
|
|
111
|
-
spec = ScheduleSpec(calendar=calendars)
|
|
112
|
-
assert len(spec.calendar) == 2
|
|
113
|
-
assert spec.calendar[0].day_of_month == 1
|
|
114
|
-
|
|
115
|
-
def test_schedule_spec_with_timezone(self):
|
|
116
|
-
"""Test ScheduleSpec with custom timezone."""
|
|
117
|
-
spec = ScheduleSpec(cron="0 9 * * *", timezone="America/New_York")
|
|
118
|
-
assert spec.timezone == "America/New_York"
|
|
119
|
-
|
|
120
|
-
def test_schedule_spec_with_time_bounds(self):
|
|
121
|
-
"""Test ScheduleSpec with start_at and end_at."""
|
|
122
|
-
start = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
|
|
123
|
-
end = datetime(2024, 12, 31, 23, 59, 59, tzinfo=UTC)
|
|
124
|
-
|
|
125
|
-
spec = ScheduleSpec(
|
|
126
|
-
cron="0 9 * * *",
|
|
127
|
-
start_at=start,
|
|
128
|
-
end_at=end,
|
|
129
|
-
)
|
|
130
|
-
assert spec.start_at == start
|
|
131
|
-
assert spec.end_at == end
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
class TestSchedule:
|
|
135
|
-
"""Test Schedule dataclass."""
|
|
136
|
-
|
|
137
|
-
def test_schedule_defaults(self):
|
|
138
|
-
"""Test Schedule default values."""
|
|
139
|
-
spec = ScheduleSpec(cron="0 9 * * *")
|
|
140
|
-
schedule = Schedule(
|
|
141
|
-
schedule_id="test_sched",
|
|
142
|
-
workflow_name="test_workflow",
|
|
143
|
-
spec=spec,
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
assert schedule.schedule_id == "test_sched"
|
|
147
|
-
assert schedule.workflow_name == "test_workflow"
|
|
148
|
-
assert schedule.status == ScheduleStatus.ACTIVE
|
|
149
|
-
assert schedule.args == "[]"
|
|
150
|
-
assert schedule.kwargs == "{}"
|
|
151
|
-
assert schedule.overlap_policy == OverlapPolicy.SKIP
|
|
152
|
-
assert schedule.total_runs == 0
|
|
153
|
-
assert schedule.successful_runs == 0
|
|
154
|
-
assert schedule.failed_runs == 0
|
|
155
|
-
assert schedule.skipped_runs == 0
|
|
156
|
-
assert schedule.buffered_count == 0
|
|
157
|
-
assert schedule.running_run_ids == []
|
|
158
|
-
|
|
159
|
-
def test_schedule_with_all_values(self):
|
|
160
|
-
"""Test Schedule with all values specified."""
|
|
161
|
-
spec = ScheduleSpec(cron="0 9 * * *")
|
|
162
|
-
now = datetime.now(UTC)
|
|
163
|
-
|
|
164
|
-
schedule = Schedule(
|
|
165
|
-
schedule_id="sched_123",
|
|
166
|
-
workflow_name="my_workflow",
|
|
167
|
-
spec=spec,
|
|
168
|
-
status=ScheduleStatus.PAUSED,
|
|
169
|
-
args='["arg1", "arg2"]',
|
|
170
|
-
kwargs='{"key": "value"}',
|
|
171
|
-
overlap_policy=OverlapPolicy.BUFFER_ONE,
|
|
172
|
-
created_at=now,
|
|
173
|
-
updated_at=now,
|
|
174
|
-
next_run_time=now,
|
|
175
|
-
last_run_at=now,
|
|
176
|
-
total_runs=10,
|
|
177
|
-
successful_runs=8,
|
|
178
|
-
failed_runs=2,
|
|
179
|
-
skipped_runs=1,
|
|
180
|
-
buffered_count=0,
|
|
181
|
-
running_run_ids=["run_1", "run_2"],
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
assert schedule.schedule_id == "sched_123"
|
|
185
|
-
assert schedule.status == ScheduleStatus.PAUSED
|
|
186
|
-
assert schedule.overlap_policy == OverlapPolicy.BUFFER_ONE
|
|
187
|
-
assert schedule.total_runs == 10
|
|
188
|
-
assert schedule.successful_runs == 8
|
|
189
|
-
assert schedule.failed_runs == 2
|
|
190
|
-
assert len(schedule.running_run_ids) == 2
|
|
191
|
-
|
|
192
|
-
def test_schedule_to_dict(self):
|
|
193
|
-
"""Test Schedule to_dict method."""
|
|
194
|
-
spec = ScheduleSpec(cron="0 9 * * *", timezone="UTC")
|
|
195
|
-
now = datetime.now(UTC)
|
|
196
|
-
|
|
197
|
-
schedule = Schedule(
|
|
198
|
-
schedule_id="sched_test",
|
|
199
|
-
workflow_name="test_workflow",
|
|
200
|
-
spec=spec,
|
|
201
|
-
created_at=now,
|
|
202
|
-
)
|
|
203
|
-
|
|
204
|
-
data = schedule.to_dict()
|
|
205
|
-
|
|
206
|
-
assert data["schedule_id"] == "sched_test"
|
|
207
|
-
assert data["workflow_name"] == "test_workflow"
|
|
208
|
-
assert data["status"] == "active"
|
|
209
|
-
assert data["overlap_policy"] == "skip"
|
|
210
|
-
assert "spec" in data
|
|
211
|
-
assert data["spec"]["cron"] == "0 9 * * *"
|
|
212
|
-
|
|
213
|
-
def test_schedule_from_dict(self):
|
|
214
|
-
"""Test Schedule from_dict method."""
|
|
215
|
-
now = datetime.now(UTC)
|
|
216
|
-
data = {
|
|
217
|
-
"schedule_id": "sched_from_dict",
|
|
218
|
-
"workflow_name": "dict_workflow",
|
|
219
|
-
"spec": {
|
|
220
|
-
"cron": "*/5 * * * *",
|
|
221
|
-
"interval": None,
|
|
222
|
-
"calendar": None,
|
|
223
|
-
"timezone": "UTC",
|
|
224
|
-
"start_at": None,
|
|
225
|
-
"end_at": None,
|
|
226
|
-
"jitter": None,
|
|
227
|
-
},
|
|
228
|
-
"status": "active",
|
|
229
|
-
"args": "[]",
|
|
230
|
-
"kwargs": "{}",
|
|
231
|
-
"overlap_policy": "buffer_one",
|
|
232
|
-
"created_at": now.isoformat(),
|
|
233
|
-
"updated_at": None,
|
|
234
|
-
"next_run_time": None,
|
|
235
|
-
"last_run_at": None,
|
|
236
|
-
"total_runs": 5,
|
|
237
|
-
"successful_runs": 4,
|
|
238
|
-
"failed_runs": 1,
|
|
239
|
-
"skipped_runs": 0,
|
|
240
|
-
"buffered_count": 0,
|
|
241
|
-
"running_run_ids": [],
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
schedule = Schedule.from_dict(data)
|
|
245
|
-
|
|
246
|
-
assert schedule.schedule_id == "sched_from_dict"
|
|
247
|
-
assert schedule.workflow_name == "dict_workflow"
|
|
248
|
-
assert schedule.spec.cron == "*/5 * * * *"
|
|
249
|
-
assert schedule.status == ScheduleStatus.ACTIVE
|
|
250
|
-
assert schedule.overlap_policy == OverlapPolicy.BUFFER_ONE
|
|
251
|
-
assert schedule.total_runs == 5
|
|
252
|
-
|
|
253
|
-
def test_schedule_roundtrip(self):
|
|
254
|
-
"""Test Schedule to_dict/from_dict roundtrip."""
|
|
255
|
-
spec = ScheduleSpec(
|
|
256
|
-
cron="0 */4 * * *",
|
|
257
|
-
timezone="Europe/London",
|
|
258
|
-
)
|
|
259
|
-
now = datetime.now(UTC)
|
|
260
|
-
|
|
261
|
-
original = Schedule(
|
|
262
|
-
schedule_id="roundtrip_test",
|
|
263
|
-
workflow_name="roundtrip_workflow",
|
|
264
|
-
spec=spec,
|
|
265
|
-
status=ScheduleStatus.ACTIVE,
|
|
266
|
-
overlap_policy=OverlapPolicy.CANCEL_OTHER,
|
|
267
|
-
created_at=now,
|
|
268
|
-
total_runs=100,
|
|
269
|
-
successful_runs=95,
|
|
270
|
-
failed_runs=5,
|
|
271
|
-
)
|
|
272
|
-
|
|
273
|
-
# Convert to dict and back
|
|
274
|
-
data = original.to_dict()
|
|
275
|
-
restored = Schedule.from_dict(data)
|
|
276
|
-
|
|
277
|
-
assert restored.schedule_id == original.schedule_id
|
|
278
|
-
assert restored.workflow_name == original.workflow_name
|
|
279
|
-
assert restored.spec.cron == original.spec.cron
|
|
280
|
-
assert restored.spec.timezone == original.spec.timezone
|
|
281
|
-
assert restored.status == original.status
|
|
282
|
-
assert restored.overlap_policy == original.overlap_policy
|
|
283
|
-
assert restored.total_runs == original.total_runs
|
|
284
|
-
assert restored.successful_runs == original.successful_runs
|
|
285
|
-
assert restored.failed_runs == original.failed_runs
|
|
@@ -1,286 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Unit tests for schedule utility functions.
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
from datetime import UTC, datetime, timedelta
|
|
6
|
-
|
|
7
|
-
from pyworkflow.storage.schemas import CalendarSpec, ScheduleSpec
|
|
8
|
-
from pyworkflow.utils.schedule import (
|
|
9
|
-
calculate_backfill_times,
|
|
10
|
-
calculate_next_run_time,
|
|
11
|
-
describe_schedule,
|
|
12
|
-
validate_cron_expression,
|
|
13
|
-
)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class TestValidateCronExpression:
|
|
17
|
-
"""Test cron expression validation."""
|
|
18
|
-
|
|
19
|
-
def test_valid_cron_expressions(self):
|
|
20
|
-
"""Test valid cron expressions."""
|
|
21
|
-
valid_expressions = [
|
|
22
|
-
"* * * * *", # Every minute
|
|
23
|
-
"0 * * * *", # Every hour
|
|
24
|
-
"0 0 * * *", # Every day at midnight
|
|
25
|
-
"0 9 * * *", # Every day at 9 AM
|
|
26
|
-
"0 9 * * 1", # Every Monday at 9 AM
|
|
27
|
-
"0 0 1 * *", # First day of every month
|
|
28
|
-
"*/5 * * * *", # Every 5 minutes
|
|
29
|
-
"0 */4 * * *", # Every 4 hours
|
|
30
|
-
"0 9-17 * * 1-5", # 9 AM to 5 PM, Monday to Friday
|
|
31
|
-
"0 0 1,15 * *", # 1st and 15th of every month
|
|
32
|
-
]
|
|
33
|
-
|
|
34
|
-
for expr in valid_expressions:
|
|
35
|
-
assert validate_cron_expression(expr), f"Expected '{expr}' to be valid"
|
|
36
|
-
|
|
37
|
-
def test_invalid_cron_expressions(self):
|
|
38
|
-
"""Test invalid cron expressions."""
|
|
39
|
-
invalid_expressions = [
|
|
40
|
-
"", # Empty
|
|
41
|
-
"* * *", # Too few fields
|
|
42
|
-
"60 * * * *", # Invalid minute
|
|
43
|
-
"* 25 * * *", # Invalid hour
|
|
44
|
-
"* * 32 * *", # Invalid day of month
|
|
45
|
-
"* * * 13 *", # Invalid month
|
|
46
|
-
"* * * * 8", # Invalid day of week
|
|
47
|
-
"invalid", # Not a cron expression
|
|
48
|
-
]
|
|
49
|
-
|
|
50
|
-
for expr in invalid_expressions:
|
|
51
|
-
assert not validate_cron_expression(expr), f"Expected '{expr}' to be invalid"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
class TestCalculateNextRunTime:
|
|
55
|
-
"""Test next run time calculation."""
|
|
56
|
-
|
|
57
|
-
def test_next_run_time_cron(self):
|
|
58
|
-
"""Test next run time calculation for cron expression."""
|
|
59
|
-
spec = ScheduleSpec(cron="0 9 * * *") # Daily at 9 AM
|
|
60
|
-
now = datetime(2024, 1, 15, 8, 0, 0, tzinfo=UTC)
|
|
61
|
-
|
|
62
|
-
next_time = calculate_next_run_time(spec, now=now)
|
|
63
|
-
|
|
64
|
-
assert next_time is not None
|
|
65
|
-
# Should be at 9 AM (today or tomorrow)
|
|
66
|
-
assert next_time.hour == 9
|
|
67
|
-
assert next_time.minute == 0
|
|
68
|
-
|
|
69
|
-
def test_next_run_time_interval(self):
|
|
70
|
-
"""Test next run time calculation for interval."""
|
|
71
|
-
spec = ScheduleSpec(interval="5m")
|
|
72
|
-
now = datetime(2024, 1, 15, 8, 0, 0, tzinfo=UTC)
|
|
73
|
-
|
|
74
|
-
next_time = calculate_next_run_time(spec, now=now)
|
|
75
|
-
|
|
76
|
-
assert next_time is not None
|
|
77
|
-
# First run with no last_run returns base_time (runs immediately)
|
|
78
|
-
assert next_time == now
|
|
79
|
-
|
|
80
|
-
def test_next_run_time_interval_with_last_run(self):
|
|
81
|
-
"""Test next run time calculation for interval with last run."""
|
|
82
|
-
spec = ScheduleSpec(interval="10m")
|
|
83
|
-
now = datetime(2024, 1, 15, 8, 0, 0, tzinfo=UTC)
|
|
84
|
-
last_run = datetime(2024, 1, 15, 7, 55, 0, tzinfo=UTC)
|
|
85
|
-
|
|
86
|
-
next_time = calculate_next_run_time(spec, last_run=last_run, now=now)
|
|
87
|
-
|
|
88
|
-
assert next_time is not None
|
|
89
|
-
# Should be 10 minutes after last run
|
|
90
|
-
expected = last_run + timedelta(minutes=10)
|
|
91
|
-
assert next_time == expected
|
|
92
|
-
|
|
93
|
-
def test_next_run_time_calendar(self):
|
|
94
|
-
"""Test next run time calculation for calendar spec."""
|
|
95
|
-
spec = ScheduleSpec(calendar=[CalendarSpec(day_of_month=1, hour=0, minute=0)])
|
|
96
|
-
now = datetime(2024, 1, 15, 8, 0, 0, tzinfo=UTC)
|
|
97
|
-
|
|
98
|
-
next_time = calculate_next_run_time(spec, now=now)
|
|
99
|
-
|
|
100
|
-
assert next_time is not None
|
|
101
|
-
# Should be first of next month
|
|
102
|
-
assert next_time.day == 1
|
|
103
|
-
assert next_time.hour == 0
|
|
104
|
-
assert next_time.minute == 0
|
|
105
|
-
|
|
106
|
-
def test_next_run_time_empty_spec(self):
|
|
107
|
-
"""Test next run time with empty spec returns None."""
|
|
108
|
-
spec = ScheduleSpec() # No cron, interval, or calendar
|
|
109
|
-
|
|
110
|
-
next_time = calculate_next_run_time(spec)
|
|
111
|
-
|
|
112
|
-
assert next_time is None
|
|
113
|
-
|
|
114
|
-
def test_next_run_time_respects_start_at(self):
|
|
115
|
-
"""Test next run time respects start_at constraint."""
|
|
116
|
-
start_at = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC)
|
|
117
|
-
spec = ScheduleSpec(
|
|
118
|
-
interval="1h",
|
|
119
|
-
start_at=start_at,
|
|
120
|
-
)
|
|
121
|
-
now = datetime(2024, 1, 15, 8, 0, 0, tzinfo=UTC)
|
|
122
|
-
|
|
123
|
-
next_time = calculate_next_run_time(spec, now=now)
|
|
124
|
-
|
|
125
|
-
assert next_time is not None
|
|
126
|
-
# Should not be before start_at
|
|
127
|
-
assert next_time >= start_at
|
|
128
|
-
|
|
129
|
-
def test_next_run_time_respects_end_at(self):
|
|
130
|
-
"""Test next run time returns None after end_at."""
|
|
131
|
-
end_at = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
|
|
132
|
-
spec = ScheduleSpec(
|
|
133
|
-
interval="1h",
|
|
134
|
-
end_at=end_at,
|
|
135
|
-
)
|
|
136
|
-
now = datetime(2024, 1, 15, 8, 0, 0, tzinfo=UTC)
|
|
137
|
-
|
|
138
|
-
next_time = calculate_next_run_time(spec, now=now)
|
|
139
|
-
|
|
140
|
-
# Should be None since we're past end_at
|
|
141
|
-
assert next_time is None
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
class TestCalculateBackfillTimes:
|
|
145
|
-
"""Test backfill time calculation."""
|
|
146
|
-
|
|
147
|
-
def test_backfill_times_cron(self):
|
|
148
|
-
"""Test backfill times for cron expression."""
|
|
149
|
-
spec = ScheduleSpec(cron="0 9 * * *") # Daily at 9 AM
|
|
150
|
-
start = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
|
|
151
|
-
end = datetime(2024, 1, 5, 0, 0, 0, tzinfo=UTC)
|
|
152
|
-
|
|
153
|
-
times = calculate_backfill_times(spec, start, end)
|
|
154
|
-
|
|
155
|
-
# Should have 4 times (Jan 1, 2, 3, 4 at 9 AM)
|
|
156
|
-
assert len(times) == 4
|
|
157
|
-
for t in times:
|
|
158
|
-
assert t.hour == 9
|
|
159
|
-
assert t.minute == 0
|
|
160
|
-
|
|
161
|
-
def test_backfill_times_interval(self):
|
|
162
|
-
"""Test backfill times for interval."""
|
|
163
|
-
spec = ScheduleSpec(interval="1h")
|
|
164
|
-
start = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
|
|
165
|
-
end = datetime(2024, 1, 1, 5, 0, 0, tzinfo=UTC)
|
|
166
|
-
|
|
167
|
-
times = calculate_backfill_times(spec, start, end)
|
|
168
|
-
|
|
169
|
-
# Backfill starts from start and goes up to (but not including) end
|
|
170
|
-
# hours 1, 2, 3, 4 (first interval happens at start+1h)
|
|
171
|
-
assert len(times) >= 4
|
|
172
|
-
|
|
173
|
-
def test_backfill_times_empty_range(self):
|
|
174
|
-
"""Test backfill times with empty range."""
|
|
175
|
-
spec = ScheduleSpec(cron="0 9 * * *")
|
|
176
|
-
start = datetime(2024, 1, 1, 10, 0, 0, tzinfo=UTC)
|
|
177
|
-
end = datetime(2024, 1, 1, 11, 0, 0, tzinfo=UTC)
|
|
178
|
-
|
|
179
|
-
times = calculate_backfill_times(spec, start, end)
|
|
180
|
-
|
|
181
|
-
# No 9 AM in this range
|
|
182
|
-
assert len(times) == 0
|
|
183
|
-
|
|
184
|
-
def test_backfill_times_invalid_range(self):
|
|
185
|
-
"""Test backfill times with start after end."""
|
|
186
|
-
spec = ScheduleSpec(interval="1h")
|
|
187
|
-
start = datetime(2024, 1, 2, 0, 0, 0, tzinfo=UTC)
|
|
188
|
-
end = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
|
|
189
|
-
|
|
190
|
-
times = calculate_backfill_times(spec, start, end)
|
|
191
|
-
|
|
192
|
-
assert len(times) == 0
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
class TestDescribeSchedule:
|
|
196
|
-
"""Test schedule description generation."""
|
|
197
|
-
|
|
198
|
-
def test_describe_cron(self):
|
|
199
|
-
"""Test description for cron expression."""
|
|
200
|
-
spec = ScheduleSpec(cron="0 9 * * *")
|
|
201
|
-
|
|
202
|
-
description = describe_schedule(spec)
|
|
203
|
-
|
|
204
|
-
assert "cron" in description.lower() or "0 9 * * *" in description
|
|
205
|
-
|
|
206
|
-
def test_describe_interval(self):
|
|
207
|
-
"""Test description for interval."""
|
|
208
|
-
spec = ScheduleSpec(interval="5m")
|
|
209
|
-
|
|
210
|
-
description = describe_schedule(spec)
|
|
211
|
-
|
|
212
|
-
assert "5m" in description or "interval" in description.lower()
|
|
213
|
-
|
|
214
|
-
def test_describe_calendar(self):
|
|
215
|
-
"""Test description for calendar spec."""
|
|
216
|
-
spec = ScheduleSpec(calendar=[CalendarSpec(day_of_month=1, hour=0, minute=0)])
|
|
217
|
-
|
|
218
|
-
description = describe_schedule(spec)
|
|
219
|
-
|
|
220
|
-
assert description # Should have some description
|
|
221
|
-
|
|
222
|
-
def test_describe_empty_spec(self):
|
|
223
|
-
"""Test description for empty spec."""
|
|
224
|
-
spec = ScheduleSpec()
|
|
225
|
-
|
|
226
|
-
description = describe_schedule(spec)
|
|
227
|
-
|
|
228
|
-
assert "no" in description.lower() or "unspecified" in description.lower()
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
class TestIntervalParsing:
|
|
232
|
-
"""Test interval duration parsing with last_run."""
|
|
233
|
-
|
|
234
|
-
def test_seconds_interval(self):
|
|
235
|
-
"""Test seconds interval parsing."""
|
|
236
|
-
spec = ScheduleSpec(interval="30s")
|
|
237
|
-
now = datetime(2024, 1, 1, 0, 0, 30, tzinfo=UTC)
|
|
238
|
-
last_run = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
|
|
239
|
-
|
|
240
|
-
next_time = calculate_next_run_time(spec, last_run=last_run, now=now)
|
|
241
|
-
|
|
242
|
-
expected = last_run + timedelta(seconds=30)
|
|
243
|
-
assert next_time == expected
|
|
244
|
-
|
|
245
|
-
def test_minutes_interval(self):
|
|
246
|
-
"""Test minutes interval parsing."""
|
|
247
|
-
spec = ScheduleSpec(interval="15m")
|
|
248
|
-
now = datetime(2024, 1, 1, 0, 15, 0, tzinfo=UTC)
|
|
249
|
-
last_run = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
|
|
250
|
-
|
|
251
|
-
next_time = calculate_next_run_time(spec, last_run=last_run, now=now)
|
|
252
|
-
|
|
253
|
-
expected = last_run + timedelta(minutes=15)
|
|
254
|
-
assert next_time == expected
|
|
255
|
-
|
|
256
|
-
def test_hours_interval(self):
|
|
257
|
-
"""Test hours interval parsing."""
|
|
258
|
-
spec = ScheduleSpec(interval="2h")
|
|
259
|
-
now = datetime(2024, 1, 1, 2, 0, 0, tzinfo=UTC)
|
|
260
|
-
last_run = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
|
|
261
|
-
|
|
262
|
-
next_time = calculate_next_run_time(spec, last_run=last_run, now=now)
|
|
263
|
-
|
|
264
|
-
expected = last_run + timedelta(hours=2)
|
|
265
|
-
assert next_time == expected
|
|
266
|
-
|
|
267
|
-
def test_days_interval(self):
|
|
268
|
-
"""Test days interval parsing."""
|
|
269
|
-
spec = ScheduleSpec(interval="1d")
|
|
270
|
-
now = datetime(2024, 1, 2, 0, 0, 0, tzinfo=UTC)
|
|
271
|
-
last_run = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
|
|
272
|
-
|
|
273
|
-
next_time = calculate_next_run_time(spec, last_run=last_run, now=now)
|
|
274
|
-
|
|
275
|
-
expected = last_run + timedelta(days=1)
|
|
276
|
-
assert next_time == expected
|
|
277
|
-
|
|
278
|
-
def test_first_interval_runs_immediately(self):
|
|
279
|
-
"""Test that first interval run (no last_run) runs at base_time."""
|
|
280
|
-
spec = ScheduleSpec(interval="5m")
|
|
281
|
-
now = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
|
|
282
|
-
|
|
283
|
-
next_time = calculate_next_run_time(spec, now=now)
|
|
284
|
-
|
|
285
|
-
# First run should be immediate (at now)
|
|
286
|
-
assert next_time == now
|