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
File without changes
@@ -0,0 +1,177 @@
1
+ """
2
+ Duration parsing utilities.
3
+
4
+ Supports duration strings like:
5
+ - "30s" - 30 seconds
6
+ - "5m" - 5 minutes
7
+ - "2h" - 2 hours
8
+ - "3d" - 3 days
9
+ - "1w" - 1 week
10
+ """
11
+
12
+ import re
13
+ from datetime import datetime, timedelta
14
+
15
+
16
+ def parse_duration(duration: str | int | timedelta | datetime) -> int:
17
+ """
18
+ Parse duration to seconds.
19
+
20
+ Args:
21
+ duration: Duration as:
22
+ - str: Duration string ("5s", "2m", "1h")
23
+ - int: Seconds
24
+ - timedelta: Python timedelta
25
+ - datetime: Future time (calculates delta from now)
26
+
27
+ Returns:
28
+ Number of seconds
29
+
30
+ Raises:
31
+ ValueError: If duration format is invalid
32
+
33
+ Examples:
34
+ >>> parse_duration("30s")
35
+ 30
36
+ >>> parse_duration("5m")
37
+ 300
38
+ >>> parse_duration("2h")
39
+ 7200
40
+ >>> parse_duration(60)
41
+ 60
42
+ """
43
+ if isinstance(duration, str):
44
+ return parse_duration_string(duration)
45
+
46
+ if isinstance(duration, int):
47
+ return duration
48
+
49
+ if isinstance(duration, timedelta):
50
+ return int(duration.total_seconds())
51
+
52
+ if isinstance(duration, datetime):
53
+ # Calculate seconds from now until that datetime
54
+ delta = duration - datetime.utcnow()
55
+ return max(0, int(delta.total_seconds()))
56
+
57
+ raise TypeError(
58
+ f"Duration must be str, int, timedelta, or datetime, got {type(duration).__name__}"
59
+ )
60
+
61
+
62
+ def parse_duration_string(duration: str) -> int:
63
+ """
64
+ Parse duration string to seconds.
65
+
66
+ Supported formats:
67
+ - {number}s - seconds
68
+ - {number}m - minutes
69
+ - {number}h - hours
70
+ - {number}d - days
71
+ - {number}w - weeks
72
+
73
+ Args:
74
+ duration: Duration string
75
+
76
+ Returns:
77
+ Number of seconds
78
+
79
+ Raises:
80
+ ValueError: If format is invalid
81
+
82
+ Examples:
83
+ >>> parse_duration_string("30s")
84
+ 30
85
+ >>> parse_duration_string("5m")
86
+ 300
87
+ >>> parse_duration_string("2h")
88
+ 7200
89
+ >>> parse_duration_string("3d")
90
+ 259200
91
+ >>> parse_duration_string("1w")
92
+ 604800
93
+ """
94
+ pattern = r"^(\d+)([smhdw])$"
95
+ match = re.match(pattern, duration.lower().strip())
96
+
97
+ if not match:
98
+ raise ValueError(
99
+ f"Invalid duration format: '{duration}'. "
100
+ f"Expected format: <number><unit> where unit is s/m/h/d/w "
101
+ f"(e.g., '30s', '5m', '2h', '3d', '1w')"
102
+ )
103
+
104
+ value_str, unit = match.groups()
105
+ value = int(value_str)
106
+
107
+ # Conversion multipliers
108
+ multipliers = {
109
+ "s": 1, # seconds
110
+ "m": 60, # minutes
111
+ "h": 3600, # hours
112
+ "d": 86400, # days
113
+ "w": 604800, # weeks
114
+ }
115
+
116
+ return value * multipliers[unit]
117
+
118
+
119
+ def format_duration(seconds: int) -> str:
120
+ """
121
+ Format seconds as human-readable duration string.
122
+
123
+ Args:
124
+ seconds: Number of seconds
125
+
126
+ Returns:
127
+ Human-readable duration string
128
+
129
+ Examples:
130
+ >>> format_duration(30)
131
+ '30s'
132
+ >>> format_duration(300)
133
+ '5m'
134
+ >>> format_duration(7200)
135
+ '2h'
136
+ >>> format_duration(259200)
137
+ '3d'
138
+ >>> format_duration(604800)
139
+ '1w'
140
+ """
141
+ if seconds < 60:
142
+ return f"{seconds}s"
143
+
144
+ if seconds < 3600:
145
+ minutes = seconds // 60
146
+ return f"{minutes}m"
147
+
148
+ if seconds < 86400:
149
+ hours = seconds // 3600
150
+ return f"{hours}h"
151
+
152
+ if seconds < 604800:
153
+ days = seconds // 86400
154
+ return f"{days}d"
155
+
156
+ weeks = seconds // 604800
157
+ return f"{weeks}w"
158
+
159
+
160
+ def duration_to_timedelta(duration: str | int) -> timedelta:
161
+ """
162
+ Convert duration to Python timedelta.
163
+
164
+ Args:
165
+ duration: Duration as string or int (seconds)
166
+
167
+ Returns:
168
+ Python timedelta object
169
+
170
+ Examples:
171
+ >>> duration_to_timedelta("5m")
172
+ timedelta(seconds=300)
173
+ >>> duration_to_timedelta(300)
174
+ timedelta(seconds=300)
175
+ """
176
+ seconds = parse_duration(duration)
177
+ return timedelta(seconds=seconds)
@@ -0,0 +1,391 @@
1
+ """
2
+ Schedule time calculation utilities.
3
+
4
+ Handles cron expression parsing, interval calculation, and calendar-based scheduling.
5
+ Uses croniter for cron expression parsing.
6
+ """
7
+
8
+ import random
9
+ from datetime import datetime, timedelta
10
+ from zoneinfo import ZoneInfo
11
+
12
+ from croniter import croniter
13
+
14
+ from pyworkflow.storage.schemas import CalendarSpec, ScheduleSpec
15
+ from pyworkflow.utils.duration import parse_duration
16
+
17
+
18
+ def calculate_next_run_time(
19
+ spec: ScheduleSpec,
20
+ last_run: datetime | None = None,
21
+ now: datetime | None = None,
22
+ ) -> datetime | None:
23
+ """
24
+ Calculate the next run time for a schedule.
25
+
26
+ Args:
27
+ spec: Schedule specification
28
+ last_run: Last execution time (for interval-based)
29
+ now: Current time (defaults to now in spec's timezone)
30
+
31
+ Returns:
32
+ Next run datetime (timezone-aware) or None if schedule has ended
33
+
34
+ Examples:
35
+ >>> spec = ScheduleSpec(cron="0 9 * * *")
36
+ >>> next_run = calculate_next_run_time(spec)
37
+
38
+ >>> spec = ScheduleSpec(interval="5m")
39
+ >>> next_run = calculate_next_run_time(spec, last_run=datetime.now(UTC))
40
+ """
41
+ tz = ZoneInfo(spec.timezone)
42
+
43
+ if now is None:
44
+ now = datetime.now(tz)
45
+ elif now.tzinfo is None:
46
+ now = now.replace(tzinfo=tz)
47
+
48
+ # Check if schedule has ended
49
+ if spec.end_at:
50
+ end_at = spec.end_at
51
+ if end_at.tzinfo is None:
52
+ end_at = end_at.replace(tzinfo=tz)
53
+ if now >= end_at:
54
+ return None
55
+
56
+ # Check if schedule hasn't started yet
57
+ if spec.start_at:
58
+ start_at = spec.start_at
59
+ if start_at.tzinfo is None:
60
+ start_at = start_at.replace(tzinfo=tz)
61
+ base_time = start_at if now < start_at else now
62
+ else:
63
+ base_time = now
64
+
65
+ next_time: datetime | None = None
66
+
67
+ if spec.cron:
68
+ next_time = _next_cron_time(spec.cron, base_time, tz)
69
+ elif spec.interval:
70
+ next_time = _next_interval_time(spec.interval, last_run, base_time, tz)
71
+ elif spec.calendar:
72
+ next_time = _next_calendar_time(spec.calendar, base_time, tz)
73
+
74
+ if next_time is None:
75
+ return None
76
+
77
+ # Apply jitter if specified
78
+ if spec.jitter:
79
+ jitter_seconds = parse_duration(spec.jitter)
80
+ jitter = random.randint(0, jitter_seconds)
81
+ next_time = next_time + timedelta(seconds=jitter)
82
+
83
+ # Check if next_time is after end_at
84
+ if spec.end_at:
85
+ end_at = spec.end_at
86
+ if end_at.tzinfo is None:
87
+ end_at = end_at.replace(tzinfo=tz)
88
+ if next_time >= end_at:
89
+ return None
90
+
91
+ return next_time
92
+
93
+
94
+ def _next_cron_time(
95
+ cron_expr: str,
96
+ base_time: datetime,
97
+ tz: ZoneInfo,
98
+ ) -> datetime:
99
+ """
100
+ Calculate next cron execution time.
101
+
102
+ Args:
103
+ cron_expr: Cron expression (e.g., "0 9 * * *")
104
+ base_time: Base time to calculate from
105
+ tz: Timezone
106
+
107
+ Returns:
108
+ Next cron execution time
109
+ """
110
+ if base_time.tzinfo is None:
111
+ base_time = base_time.replace(tzinfo=tz)
112
+
113
+ cron = croniter(cron_expr, base_time)
114
+ return cron.get_next(datetime)
115
+
116
+
117
+ def _next_interval_time(
118
+ interval: str,
119
+ last_run: datetime | None,
120
+ base_time: datetime,
121
+ tz: ZoneInfo,
122
+ ) -> datetime:
123
+ """
124
+ Calculate next interval execution time.
125
+
126
+ Args:
127
+ interval: Interval string (e.g., "5m", "1h")
128
+ last_run: Last run time
129
+ base_time: Current time
130
+ tz: Timezone
131
+
132
+ Returns:
133
+ Next interval execution time
134
+ """
135
+ interval_seconds = parse_duration(interval)
136
+
137
+ if last_run is None:
138
+ # First run - start immediately (at base_time)
139
+ return base_time
140
+
141
+ if last_run.tzinfo is None:
142
+ last_run = last_run.replace(tzinfo=tz)
143
+
144
+ next_time = last_run + timedelta(seconds=interval_seconds)
145
+
146
+ if next_time < base_time:
147
+ # Catch up - calculate how many intervals have passed
148
+ elapsed = (base_time - last_run).total_seconds()
149
+ intervals_passed = int(elapsed / interval_seconds)
150
+ next_time = last_run + timedelta(seconds=interval_seconds * (intervals_passed + 1))
151
+
152
+ return next_time
153
+
154
+
155
+ def _next_calendar_time(
156
+ calendars: list[CalendarSpec],
157
+ base_time: datetime,
158
+ tz: ZoneInfo,
159
+ ) -> datetime | None:
160
+ """
161
+ Calculate next calendar execution time.
162
+
163
+ Args:
164
+ calendars: List of calendar specifications
165
+ base_time: Base time to calculate from
166
+ tz: Timezone
167
+
168
+ Returns:
169
+ Next matching calendar time, or None if no match found
170
+ """
171
+ candidates: list[datetime] = []
172
+
173
+ for cal in calendars:
174
+ next_time = _next_calendar_match(cal, base_time, tz)
175
+ if next_time:
176
+ candidates.append(next_time)
177
+
178
+ return min(candidates) if candidates else None
179
+
180
+
181
+ def _next_calendar_match(
182
+ cal: CalendarSpec,
183
+ base_time: datetime,
184
+ tz: ZoneInfo,
185
+ ) -> datetime | None:
186
+ """
187
+ Find next datetime matching a CalendarSpec.
188
+
189
+ This function searches forward from base_time to find the next
190
+ datetime that matches all specified constraints in the CalendarSpec.
191
+
192
+ Args:
193
+ cal: Calendar specification
194
+ base_time: Base time to start searching from
195
+ tz: Timezone
196
+
197
+ Returns:
198
+ Next matching datetime, or None if no match in next year
199
+ """
200
+ if base_time.tzinfo is None:
201
+ base_time = base_time.replace(tzinfo=tz)
202
+
203
+ # Start from the next second after base_time
204
+ current = base_time + timedelta(seconds=1)
205
+
206
+ # Set to the specified time
207
+ current = current.replace(
208
+ hour=cal.hour,
209
+ minute=cal.minute,
210
+ second=cal.second,
211
+ microsecond=0,
212
+ )
213
+
214
+ # If this time has passed today, move to tomorrow
215
+ if current <= base_time:
216
+ current = current + timedelta(days=1)
217
+
218
+ # Search up to 366 days ahead (one full year + leap day)
219
+ max_iterations = 366
220
+ for _ in range(max_iterations):
221
+ matches = True
222
+
223
+ # Check month constraint
224
+ if cal.month is not None and current.month != cal.month:
225
+ matches = False
226
+
227
+ # Check day_of_month constraint
228
+ if cal.day_of_month is not None and current.day != cal.day_of_month:
229
+ matches = False
230
+
231
+ # Check day_of_week constraint (0=Monday, 6=Sunday)
232
+ if cal.day_of_week is not None and current.weekday() != cal.day_of_week:
233
+ matches = False
234
+
235
+ if matches:
236
+ return current
237
+
238
+ # Move to next day
239
+ current = current + timedelta(days=1)
240
+ current = current.replace(
241
+ hour=cal.hour,
242
+ minute=cal.minute,
243
+ second=cal.second,
244
+ microsecond=0,
245
+ )
246
+
247
+ # No match found within the search window
248
+ return None
249
+
250
+
251
+ def calculate_backfill_times(
252
+ spec: ScheduleSpec,
253
+ start_time: datetime,
254
+ end_time: datetime,
255
+ ) -> list[datetime]:
256
+ """
257
+ Calculate all scheduled times in a time range for backfill.
258
+
259
+ This is used to create runs for times that were missed
260
+ (e.g., due to scheduler downtime).
261
+
262
+ Args:
263
+ spec: Schedule specification
264
+ start_time: Start of backfill range
265
+ end_time: End of backfill range
266
+
267
+ Returns:
268
+ List of scheduled execution times in chronological order
269
+
270
+ Examples:
271
+ >>> spec = ScheduleSpec(cron="0 * * * *") # Every hour
272
+ >>> times = calculate_backfill_times(
273
+ ... spec,
274
+ ... datetime(2024, 1, 1, 0, 0),
275
+ ... datetime(2024, 1, 1, 3, 0),
276
+ ... )
277
+ >>> len(times) # 0:00, 1:00, 2:00 (3:00 is end, not included)
278
+ 3
279
+ """
280
+ times: list[datetime] = []
281
+
282
+ # Use start_time as the reference point
283
+ current = start_time
284
+
285
+ # Disable jitter for backfill to get consistent times
286
+ spec_no_jitter = ScheduleSpec(
287
+ cron=spec.cron,
288
+ interval=spec.interval,
289
+ calendar=spec.calendar,
290
+ timezone=spec.timezone,
291
+ start_at=spec.start_at,
292
+ end_at=spec.end_at,
293
+ jitter=None, # No jitter for backfill
294
+ )
295
+
296
+ # For interval-based, we need the last run before start_time
297
+ last_run = None
298
+ if spec.interval:
299
+ # Calculate what the last run would have been before start_time
300
+ interval_seconds = parse_duration(spec.interval)
301
+ if spec.start_at and spec.start_at < start_time:
302
+ # Calculate intervals since start_at
303
+ elapsed = (start_time - spec.start_at).total_seconds()
304
+ intervals = int(elapsed / interval_seconds)
305
+ last_run = spec.start_at + timedelta(seconds=intervals * interval_seconds)
306
+ else:
307
+ last_run = start_time
308
+
309
+ max_iterations = 10000 # Safety limit
310
+ iteration = 0
311
+
312
+ while iteration < max_iterations:
313
+ next_time = calculate_next_run_time(spec_no_jitter, last_run, current)
314
+
315
+ if next_time is None:
316
+ break
317
+
318
+ if next_time >= end_time:
319
+ break
320
+
321
+ times.append(next_time)
322
+
323
+ # Move past this time
324
+ current = next_time + timedelta(seconds=1)
325
+ last_run = next_time
326
+ iteration += 1
327
+
328
+ return times
329
+
330
+
331
+ def validate_cron_expression(cron_expr: str) -> bool:
332
+ """
333
+ Validate a cron expression.
334
+
335
+ Args:
336
+ cron_expr: Cron expression to validate
337
+
338
+ Returns:
339
+ True if valid, False otherwise
340
+
341
+ Examples:
342
+ >>> validate_cron_expression("0 9 * * *")
343
+ True
344
+ >>> validate_cron_expression("invalid")
345
+ False
346
+ """
347
+ try:
348
+ croniter(cron_expr)
349
+ return True
350
+ except (ValueError, KeyError):
351
+ return False
352
+
353
+
354
+ def describe_schedule(spec: ScheduleSpec) -> str:
355
+ """
356
+ Generate a human-readable description of a schedule.
357
+
358
+ Args:
359
+ spec: Schedule specification
360
+
361
+ Returns:
362
+ Human-readable description
363
+
364
+ Examples:
365
+ >>> spec = ScheduleSpec(cron="0 9 * * *")
366
+ >>> describe_schedule(spec)
367
+ 'Cron: 0 9 * * * (UTC)'
368
+
369
+ >>> spec = ScheduleSpec(interval="5m")
370
+ >>> describe_schedule(spec)
371
+ 'Every 5m (UTC)'
372
+ """
373
+ tz_str = f" ({spec.timezone})"
374
+
375
+ if spec.cron:
376
+ return f"Cron: {spec.cron}{tz_str}"
377
+ elif spec.interval:
378
+ return f"Every {spec.interval}{tz_str}"
379
+ elif spec.calendar:
380
+ parts = []
381
+ for cal in spec.calendar:
382
+ part = f"{cal.hour:02d}:{cal.minute:02d}:{cal.second:02d}"
383
+ if cal.day_of_week is not None:
384
+ days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
385
+ part = f"{days[cal.day_of_week]} at {part}"
386
+ elif cal.day_of_month is not None:
387
+ part = f"Day {cal.day_of_month} at {part}"
388
+ parts.append(part)
389
+ return f"Calendar: {', '.join(parts)}{tz_str}"
390
+ else:
391
+ return "No schedule defined"