Skip to content

Commit 610fc38

Browse files
author
Chidebele Dunamis
committed
feats:add scheduler
1 parent bb2afb2 commit 610fc38

9 files changed

Lines changed: 1498 additions & 0 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""
2+
Nexios Scheduler - Job Scheduling for Nexios
3+
4+
Provides interval-based, cron-based, and one-time job scheduling
5+
integrated with the Nexios application lifecycle and dependency injection.
6+
"""
7+
from __future__ import annotations
8+
9+
from typing import Optional
10+
11+
from nexios import NexiosApp
12+
13+
from .config import (
14+
CronTrigger,
15+
DateTimeTrigger,
16+
IntervalTrigger,
17+
JobStatus,
18+
SchedulerConfig,
19+
)
20+
from .dependency import SchedulerDepend, SchedulerDepends
21+
from .manager import SchedulerManager
22+
from .models import JobCallback, ScheduledJob
23+
24+
__all__ = [
25+
# Main classes
26+
"SchedulerManager",
27+
"ScheduledJob",
28+
"SchedulerConfig",
29+
"JobStatus",
30+
# Triggers
31+
"IntervalTrigger",
32+
"CronTrigger",
33+
"DateTimeTrigger",
34+
# Dependency injection
35+
"SchedulerDepend",
36+
"SchedulerDepends",
37+
# Utility functions
38+
"setup_scheduler",
39+
"get_scheduler",
40+
]
41+
42+
43+
def setup_scheduler(
44+
app: NexiosApp, config: Optional[SchedulerConfig] = None
45+
) -> SchedulerManager:
46+
"""Set up the scheduler for a Nexios application.
47+
48+
Initialises the ``SchedulerManager``, attaches it as ``app.scheduler``,
49+
and registers the startup hook.
50+
51+
Args:
52+
app: The Nexios application instance.
53+
config: Optional scheduler configuration.
54+
55+
Returns:
56+
The initialised ``SchedulerManager`` instance.
57+
58+
Example::
59+
60+
from nexios import NexiosApp
61+
from nexios_contrib.scheduler import (
62+
setup_scheduler,
63+
IntervalTrigger,
64+
)
65+
66+
app = NexiosApp()
67+
scheduler = setup_scheduler(app)
68+
69+
async def my_task():
70+
print("tick")
71+
72+
scheduler.add_job(my_task, IntervalTrigger(seconds=30))
73+
"""
74+
if not hasattr(app, "scheduler"):
75+
scheduler = SchedulerManager(app, config=config)
76+
app.scheduler = scheduler
77+
app.on_startup(scheduler.start)
78+
return app.scheduler
79+
80+
81+
def get_scheduler(app: NexiosApp) -> SchedulerManager:
82+
"""Retrieve the scheduler instance from a Nexios app.
83+
84+
Args:
85+
app: The Nexios application instance.
86+
87+
Returns:
88+
The ``SchedulerManager`` instance.
89+
90+
Raises:
91+
AttributeError: If the scheduler has not been initialised.
92+
"""
93+
scheduler = getattr(app, "scheduler", None)
94+
if scheduler is None:
95+
raise AttributeError(
96+
"Scheduler not initialised. Call setup_scheduler(app) during app setup."
97+
)
98+
return scheduler

nexios_contrib/scheduler/config.py

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
"""
2+
Scheduler configuration for Nexios.
3+
4+
This module provides configuration options and enums for the scheduler system.
5+
"""
6+
from __future__ import annotations
7+
8+
import logging
9+
from dataclasses import dataclass, field
10+
from enum import Enum
11+
from typing import Any, Dict, Optional
12+
13+
14+
class JobStatus(str, Enum):
15+
"""Status of a scheduled job."""
16+
17+
ACTIVE = "ACTIVE"
18+
PAUSED = "PAUSED"
19+
COMPLETED = "COMPLETED"
20+
FAILED = "FAILED"
21+
CANCELLED = "CANCELLED"
22+
23+
24+
class TriggerType(str, Enum):
25+
"""Type of trigger for a scheduled job."""
26+
27+
INTERVAL = "INTERVAL"
28+
CRON = "CRON"
29+
DATETIME = "DATETIME"
30+
31+
32+
@dataclass
33+
class IntervalTrigger:
34+
"""Trigger that fires at fixed intervals.
35+
36+
Attributes:
37+
seconds: Number of seconds between runs.
38+
minutes: Number of minutes between runs.
39+
hours: Number of hours between runs.
40+
days: Number of days between runs.
41+
start_now: If True, the job runs immediately upon scheduling.
42+
Otherwise, it waits for the first interval to elapse.
43+
"""
44+
45+
seconds: int = 0
46+
minutes: int = 0
47+
hours: int = 0
48+
days: int = 0
49+
start_now: bool = True
50+
51+
def __post_init__(self) -> None:
52+
if self.seconds < 0 or self.minutes < 0 or self.hours < 0 or self.days < 0:
53+
raise ValueError("Interval values must be non-negative")
54+
total = self.as_seconds()
55+
if total <= 0:
56+
raise ValueError("Total interval must be greater than 0 seconds")
57+
58+
def as_seconds(self) -> float:
59+
"""Return the total interval in seconds."""
60+
return (
61+
self.days * 86400
62+
+ self.hours * 3600
63+
+ self.minutes * 60
64+
+ self.seconds
65+
)
66+
67+
68+
@dataclass
69+
class CronTrigger:
70+
"""Trigger that fires based on a cron expression.
71+
72+
Supports standard 5-field cron expressions:
73+
minute hour day_of_month month day_of_week
74+
75+
Each field supports:
76+
- Exact values: ``5``
77+
- Wildcards: ``*``
78+
- Ranges: ``1-5``
79+
- Step values: ``*/5``
80+
- Lists: ``1,3,5``
81+
- Combinations: ``1-5,10``
82+
83+
Special strings:
84+
``"@hourly"``, ``"@daily"``, ``"@weekly"``, ``"@monthly"``,
85+
``"@yearly"``, ``"@every_minute"``
86+
87+
Args:
88+
expr: Cron expression string (5-field or special alias).
89+
"""
90+
91+
expr: str
92+
93+
def __post_init__(self) -> None:
94+
resolved = self._resolve_alias(self.expr)
95+
self._fields = self._parse_expression(resolved)
96+
97+
@staticmethod
98+
def _resolve_alias(expr: str) -> str:
99+
aliases = {
100+
"@every_minute": "* * * * *",
101+
"@hourly": "0 * * * *",
102+
"@daily": "0 0 * * *",
103+
"@weekly": "0 0 * * 0",
104+
"@monthly": "0 0 1 * *",
105+
"@yearly": "0 0 1 1 *",
106+
}
107+
return aliases.get(expr, expr)
108+
109+
@staticmethod
110+
def _parse_expression(expr: str) -> list[list[str]]:
111+
parts = expr.strip().split()
112+
if len(parts) != 5:
113+
raise ValueError(
114+
f"Invalid cron expression: {expr!r}. "
115+
f"Expected 5 fields (minute hour day month weekday), got {len(parts)}."
116+
)
117+
field_names = ["minute", "hour", "day_of_month", "month", "day_of_week"]
118+
fields: list[list[str]] = []
119+
for name, part in zip(field_names, parts):
120+
parsed = CronTrigger._parse_field(part, name)
121+
fields.append(parsed)
122+
return fields
123+
124+
@staticmethod
125+
def _parse_field(part: str, name: str) -> list[str]:
126+
"""Parse a single cron field into a list of valid values."""
127+
ranges = {
128+
"minute": (0, 59),
129+
"hour": (0, 23),
130+
"day_of_month": (1, 31),
131+
"month": (1, 12),
132+
"day_of_week": (0, 6),
133+
}
134+
if name not in ranges:
135+
raise ValueError(f"Unknown cron field: {name}")
136+
lo, hi = ranges[name]
137+
138+
values: set[int] = set()
139+
for segment in part.split(","):
140+
segment = segment.strip()
141+
if not segment:
142+
continue
143+
144+
step = 1
145+
if "/" in segment:
146+
segment, step_str = segment.split("/", 1)
147+
step = int(step_str)
148+
149+
if segment == "*":
150+
values.update(range(lo, hi + 1, step))
151+
elif "-" in segment:
152+
start_str, end_str = segment.split("-", 1)
153+
start = int(start_str)
154+
end = int(end_str)
155+
values.update(range(start, end + 1, step))
156+
else:
157+
values.add(int(segment))
158+
159+
return [str(v) for v in sorted(values)]
160+
161+
def get_next_run(self, from_timestamp: float) -> float:
162+
"""Calculate the next datetime this cron expression fires at.
163+
164+
Uses a simple minute-resolution iteration starting from ``from_timestamp``.
165+
"""
166+
import calendar
167+
import time as time_module
168+
from datetime import datetime, timedelta, timezone
169+
170+
dt = datetime.fromtimestamp(from_timestamp, tz=timezone.utc)
171+
172+
# Start from the next full minute
173+
dt = dt.replace(second=0, microsecond=0) + timedelta(minutes=1)
174+
175+
for _ in range(525600): # search up to 1 year ahead
176+
minute_vals = self._fields[0]
177+
hour_vals = self._fields[1]
178+
day_vals = self._fields[2]
179+
month_vals = self._fields[3]
180+
weekday_vals = self._fields[4]
181+
182+
month_match = str(dt.month) in month_vals
183+
day_match = str(dt.day) in day_vals
184+
weekday_match = str(dt.weekday()) in weekday_vals
185+
hour_match = str(dt.hour) in hour_vals
186+
minute_match = str(dt.minute) in minute_vals
187+
188+
# day_of_week OR day_of_month match (standard cron behavior)
189+
day_valid = day_match or weekday_match
190+
191+
if month_match and day_valid and hour_match and minute_match:
192+
return dt.timestamp()
193+
194+
dt += timedelta(minutes=1)
195+
196+
raise RuntimeError(
197+
f"Could not find next run time for cron expression: {self.expr}"
198+
)
199+
200+
201+
@dataclass
202+
class DateTimeTrigger:
203+
"""Trigger that fires once at a specific datetime.
204+
205+
Args:
206+
run_date: ISO-8601 datetime string (e.g. ``"2026-12-25T10:30:00"``).
207+
If no timezone is specified, UTC is assumed.
208+
"""
209+
210+
run_date: str
211+
212+
def __post_init__(self) -> None:
213+
# Validate on construction
214+
self.get_run_timestamp()
215+
216+
def get_run_timestamp(self) -> float:
217+
"""Get the target timestamp for this trigger."""
218+
from datetime import datetime, timezone
219+
220+
# Try parsing with various formats
221+
for fmt in [
222+
"%Y-%m-%dT%H:%M:%S",
223+
"%Y-%m-%dT%H:%M:%S%z",
224+
"%Y-%m-%d %H:%M:%S",
225+
"%Y-%m-%d",
226+
]:
227+
try:
228+
dt = datetime.strptime(self.run_date, fmt)
229+
if dt.tzinfo is None:
230+
dt = dt.replace(tzinfo=timezone.utc)
231+
return dt.timestamp()
232+
except ValueError:
233+
continue
234+
235+
raise ValueError(
236+
f"Could not parse datetime: {self.run_date!r}. "
237+
f"Expected ISO-8601 format (e.g. '2026-12-25T10:30:00')."
238+
)
239+
240+
241+
@dataclass
242+
class SchedulerConfig:
243+
"""Configuration for the scheduler.
244+
245+
Attributes:
246+
timezone: Timezone string (e.g. ``"UTC"``, ``"America/New_York"``).
247+
If None, UTC is used.
248+
max_concurrent_jobs: Maximum number of jobs that can run simultaneously.
249+
log_level: Logging level for scheduler-related logs.
250+
job_defaults: Default settings applied to every job.
251+
Supported keys:
252+
- ``max_instances`` (int): Max concurrent instances of the
253+
same job. Default: 3.
254+
- ``misfire_grace_time`` (int): Seconds after the scheduled
255+
fire time that the job will still be accepted. Default: 30.
256+
- ``coalesce`` (bool): If True, missed firings are merged
257+
into one. Default: True.
258+
"""
259+
260+
timezone: Optional[str] = None
261+
max_concurrent_jobs: int = 10
262+
log_level: int = logging.INFO
263+
job_defaults: Dict[str, Any] = field(
264+
default_factory=lambda: {
265+
"max_instances": 3,
266+
"misfire_grace_time": 30,
267+
"coalesce": True,
268+
}
269+
)
270+
271+
def to_dict(self) -> Dict[str, Any]:
272+
"""Convert the configuration to a dictionary."""
273+
return {
274+
"timezone": self.timezone,
275+
"max_concurrent_jobs": self.max_concurrent_jobs,
276+
"log_level": self.log_level,
277+
"job_defaults": self.job_defaults.copy(),
278+
}
279+
280+
281+
# Default configuration singleton
282+
DEFAULT_CONFIG = SchedulerConfig()

0 commit comments

Comments
 (0)