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
|
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"
|