pyworkflow-engine 0.1.7__py3-none-any.whl → 0.1.9__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 (145) hide show
  1. pyworkflow/__init__.py +10 -1
  2. pyworkflow/celery/tasks.py +272 -24
  3. pyworkflow/cli/__init__.py +4 -1
  4. pyworkflow/cli/commands/runs.py +4 -4
  5. pyworkflow/cli/commands/setup.py +203 -4
  6. pyworkflow/cli/utils/config_generator.py +76 -3
  7. pyworkflow/cli/utils/docker_manager.py +232 -0
  8. pyworkflow/context/__init__.py +13 -0
  9. pyworkflow/context/base.py +26 -0
  10. pyworkflow/context/local.py +80 -0
  11. pyworkflow/context/step_context.py +295 -0
  12. pyworkflow/core/registry.py +6 -1
  13. pyworkflow/core/step.py +141 -0
  14. pyworkflow/core/workflow.py +56 -0
  15. pyworkflow/engine/events.py +30 -0
  16. pyworkflow/engine/replay.py +39 -0
  17. pyworkflow/primitives/child_workflow.py +1 -1
  18. pyworkflow/runtime/local.py +1 -1
  19. pyworkflow/storage/__init__.py +14 -0
  20. pyworkflow/storage/base.py +35 -0
  21. pyworkflow/storage/cassandra.py +1747 -0
  22. pyworkflow/storage/config.py +69 -0
  23. pyworkflow/storage/dynamodb.py +31 -2
  24. pyworkflow/storage/file.py +28 -0
  25. pyworkflow/storage/memory.py +18 -0
  26. pyworkflow/storage/mysql.py +1159 -0
  27. pyworkflow/storage/postgres.py +27 -2
  28. pyworkflow/storage/schemas.py +4 -3
  29. pyworkflow/storage/sqlite.py +25 -2
  30. {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.9.dist-info}/METADATA +7 -4
  31. pyworkflow_engine-0.1.9.dist-info/RECORD +91 -0
  32. pyworkflow_engine-0.1.9.dist-info/top_level.txt +1 -0
  33. dashboard/backend/app/__init__.py +0 -1
  34. dashboard/backend/app/config.py +0 -32
  35. dashboard/backend/app/controllers/__init__.py +0 -6
  36. dashboard/backend/app/controllers/run_controller.py +0 -86
  37. dashboard/backend/app/controllers/workflow_controller.py +0 -33
  38. dashboard/backend/app/dependencies/__init__.py +0 -5
  39. dashboard/backend/app/dependencies/storage.py +0 -50
  40. dashboard/backend/app/repositories/__init__.py +0 -6
  41. dashboard/backend/app/repositories/run_repository.py +0 -80
  42. dashboard/backend/app/repositories/workflow_repository.py +0 -27
  43. dashboard/backend/app/rest/__init__.py +0 -8
  44. dashboard/backend/app/rest/v1/__init__.py +0 -12
  45. dashboard/backend/app/rest/v1/health.py +0 -33
  46. dashboard/backend/app/rest/v1/runs.py +0 -133
  47. dashboard/backend/app/rest/v1/workflows.py +0 -41
  48. dashboard/backend/app/schemas/__init__.py +0 -23
  49. dashboard/backend/app/schemas/common.py +0 -16
  50. dashboard/backend/app/schemas/event.py +0 -24
  51. dashboard/backend/app/schemas/hook.py +0 -25
  52. dashboard/backend/app/schemas/run.py +0 -54
  53. dashboard/backend/app/schemas/step.py +0 -28
  54. dashboard/backend/app/schemas/workflow.py +0 -31
  55. dashboard/backend/app/server.py +0 -87
  56. dashboard/backend/app/services/__init__.py +0 -6
  57. dashboard/backend/app/services/run_service.py +0 -240
  58. dashboard/backend/app/services/workflow_service.py +0 -155
  59. dashboard/backend/main.py +0 -18
  60. docs/concepts/cancellation.mdx +0 -362
  61. docs/concepts/continue-as-new.mdx +0 -434
  62. docs/concepts/events.mdx +0 -266
  63. docs/concepts/fault-tolerance.mdx +0 -370
  64. docs/concepts/hooks.mdx +0 -552
  65. docs/concepts/limitations.mdx +0 -167
  66. docs/concepts/schedules.mdx +0 -775
  67. docs/concepts/sleep.mdx +0 -312
  68. docs/concepts/steps.mdx +0 -301
  69. docs/concepts/workflows.mdx +0 -255
  70. docs/guides/cli.mdx +0 -942
  71. docs/guides/configuration.mdx +0 -560
  72. docs/introduction.mdx +0 -155
  73. docs/quickstart.mdx +0 -279
  74. examples/__init__.py +0 -1
  75. examples/celery/__init__.py +0 -1
  76. examples/celery/durable/docker-compose.yml +0 -55
  77. examples/celery/durable/pyworkflow.config.yaml +0 -12
  78. examples/celery/durable/workflows/__init__.py +0 -122
  79. examples/celery/durable/workflows/basic.py +0 -87
  80. examples/celery/durable/workflows/batch_processing.py +0 -102
  81. examples/celery/durable/workflows/cancellation.py +0 -273
  82. examples/celery/durable/workflows/child_workflow_patterns.py +0 -240
  83. examples/celery/durable/workflows/child_workflows.py +0 -202
  84. examples/celery/durable/workflows/continue_as_new.py +0 -260
  85. examples/celery/durable/workflows/fault_tolerance.py +0 -210
  86. examples/celery/durable/workflows/hooks.py +0 -211
  87. examples/celery/durable/workflows/idempotency.py +0 -112
  88. examples/celery/durable/workflows/long_running.py +0 -99
  89. examples/celery/durable/workflows/retries.py +0 -101
  90. examples/celery/durable/workflows/schedules.py +0 -209
  91. examples/celery/transient/01_basic_workflow.py +0 -91
  92. examples/celery/transient/02_fault_tolerance.py +0 -257
  93. examples/celery/transient/__init__.py +0 -20
  94. examples/celery/transient/pyworkflow.config.yaml +0 -25
  95. examples/local/__init__.py +0 -1
  96. examples/local/durable/01_basic_workflow.py +0 -94
  97. examples/local/durable/02_file_storage.py +0 -132
  98. examples/local/durable/03_retries.py +0 -169
  99. examples/local/durable/04_long_running.py +0 -119
  100. examples/local/durable/05_event_log.py +0 -145
  101. examples/local/durable/06_idempotency.py +0 -148
  102. examples/local/durable/07_hooks.py +0 -334
  103. examples/local/durable/08_cancellation.py +0 -233
  104. examples/local/durable/09_child_workflows.py +0 -198
  105. examples/local/durable/10_child_workflow_patterns.py +0 -265
  106. examples/local/durable/11_continue_as_new.py +0 -249
  107. examples/local/durable/12_schedules.py +0 -198
  108. examples/local/durable/__init__.py +0 -1
  109. examples/local/transient/01_quick_tasks.py +0 -87
  110. examples/local/transient/02_retries.py +0 -130
  111. examples/local/transient/03_sleep.py +0 -141
  112. examples/local/transient/__init__.py +0 -1
  113. pyworkflow_engine-0.1.7.dist-info/RECORD +0 -196
  114. pyworkflow_engine-0.1.7.dist-info/top_level.txt +0 -5
  115. tests/examples/__init__.py +0 -0
  116. tests/integration/__init__.py +0 -0
  117. tests/integration/test_cancellation.py +0 -330
  118. tests/integration/test_child_workflows.py +0 -439
  119. tests/integration/test_continue_as_new.py +0 -428
  120. tests/integration/test_dynamodb_storage.py +0 -1146
  121. tests/integration/test_fault_tolerance.py +0 -369
  122. tests/integration/test_schedule_storage.py +0 -484
  123. tests/unit/__init__.py +0 -0
  124. tests/unit/backends/__init__.py +0 -1
  125. tests/unit/backends/test_dynamodb_storage.py +0 -1554
  126. tests/unit/backends/test_postgres_storage.py +0 -1281
  127. tests/unit/backends/test_sqlite_storage.py +0 -1460
  128. tests/unit/conftest.py +0 -41
  129. tests/unit/test_cancellation.py +0 -364
  130. tests/unit/test_child_workflows.py +0 -680
  131. tests/unit/test_continue_as_new.py +0 -441
  132. tests/unit/test_event_limits.py +0 -316
  133. tests/unit/test_executor.py +0 -320
  134. tests/unit/test_fault_tolerance.py +0 -334
  135. tests/unit/test_hooks.py +0 -495
  136. tests/unit/test_registry.py +0 -261
  137. tests/unit/test_replay.py +0 -420
  138. tests/unit/test_schedule_schemas.py +0 -285
  139. tests/unit/test_schedule_utils.py +0 -286
  140. tests/unit/test_scheduled_workflow.py +0 -274
  141. tests/unit/test_step.py +0 -353
  142. tests/unit/test_workflow.py +0 -243
  143. {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.9.dist-info}/WHEEL +0 -0
  144. {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.9.dist-info}/entry_points.txt +0 -0
  145. {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.9.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