matrix-planner 0.1.0__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.
- matrix_planner/__init__.py +33 -0
- matrix_planner/decorators.py +34 -0
- matrix_planner/planner.py +141 -0
- matrix_planner/schedules.py +195 -0
- matrix_planner/task.py +53 -0
- matrix_planner-0.1.0.dist-info/METADATA +123 -0
- matrix_planner-0.1.0.dist-info/RECORD +9 -0
- matrix_planner-0.1.0.dist-info/WHEEL +4 -0
- matrix_planner-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""matrix-planner: a lightweight async task scheduler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .schedules import (
|
|
6
|
+
CronSchedule,
|
|
7
|
+
ExecutionTime,
|
|
8
|
+
IntervalSchedule,
|
|
9
|
+
MonotonicExecutionTime,
|
|
10
|
+
OnceSchedule,
|
|
11
|
+
Schedule,
|
|
12
|
+
WallClockExecutionTime,
|
|
13
|
+
interval_seconds,
|
|
14
|
+
)
|
|
15
|
+
from .task import Task
|
|
16
|
+
from .planner import Planner
|
|
17
|
+
from .decorators import every, cron, once
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Schedule",
|
|
21
|
+
"IntervalSchedule",
|
|
22
|
+
"CronSchedule",
|
|
23
|
+
"OnceSchedule",
|
|
24
|
+
"ExecutionTime",
|
|
25
|
+
"MonotonicExecutionTime",
|
|
26
|
+
"WallClockExecutionTime",
|
|
27
|
+
"interval_seconds",
|
|
28
|
+
"Task",
|
|
29
|
+
"Planner",
|
|
30
|
+
"every",
|
|
31
|
+
"cron",
|
|
32
|
+
"once",
|
|
33
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta, tzinfo
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from .schedules import CronSchedule, IntervalSchedule, OnceSchedule
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def every(
|
|
10
|
+
value: str | timedelta | float | int,
|
|
11
|
+
*,
|
|
12
|
+
anchor: Literal["start", "end"] = "start",
|
|
13
|
+
) -> IntervalSchedule:
|
|
14
|
+
"""Create an IntervalSchedule.
|
|
15
|
+
|
|
16
|
+
Usage: every("10s"), every("5m"), every(timedelta(hours=1)).
|
|
17
|
+
"""
|
|
18
|
+
return IntervalSchedule(every=value, anchor=anchor)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cron(expr: str, *, tz: tzinfo | None = None) -> CronSchedule:
|
|
22
|
+
"""Create a CronSchedule from a cron expression.
|
|
23
|
+
|
|
24
|
+
Usage: cron("*/5 * * * *"), cron("0 9 * * 1-5", tz=timezone.utc).
|
|
25
|
+
"""
|
|
26
|
+
return CronSchedule(expr=expr, tz=tz)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def once(at: datetime) -> OnceSchedule:
|
|
30
|
+
"""Create a OnceSchedule that fires at the given datetime.
|
|
31
|
+
|
|
32
|
+
Usage: once(datetime(2026, 12, 31, 23, 59)).
|
|
33
|
+
"""
|
|
34
|
+
return OnceSchedule(at=at)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import functools
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from .schedules import Schedule, interval_seconds
|
|
9
|
+
from .task import Task
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("planner")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Planner:
|
|
15
|
+
"""Lightweight async task scheduler.
|
|
16
|
+
|
|
17
|
+
Each registered task runs in its own coroutine:
|
|
18
|
+
for deadline in task.schedule:
|
|
19
|
+
sleep(deadline.delay())
|
|
20
|
+
execute(task)
|
|
21
|
+
|
|
22
|
+
Sync functions are offloaded to the default executor to avoid
|
|
23
|
+
blocking the event loop; async functions are awaited directly.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self._tasks: list[Task] = []
|
|
28
|
+
self._runners: list[asyncio.Task] = []
|
|
29
|
+
self._running = False
|
|
30
|
+
|
|
31
|
+
# -- registration ---------------------------------------------------
|
|
32
|
+
|
|
33
|
+
def add(self, task: Task) -> Task:
|
|
34
|
+
"""Register a task. Returns the task for chaining."""
|
|
35
|
+
self._tasks.append(task)
|
|
36
|
+
return task
|
|
37
|
+
|
|
38
|
+
def task(self, schedule: Schedule, **opts) -> Callable[[Callable], Task]:
|
|
39
|
+
"""Decorator shortcut that registers a function as a task.
|
|
40
|
+
|
|
41
|
+
@planner.task(every("10s"))
|
|
42
|
+
def heartbeat(): ...
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def decorator(func: Callable) -> Task:
|
|
46
|
+
t = Task(func=func, schedule=schedule, **opts)
|
|
47
|
+
self.add(t)
|
|
48
|
+
return t
|
|
49
|
+
|
|
50
|
+
return decorator
|
|
51
|
+
|
|
52
|
+
# -- lifecycle ------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
async def run(self) -> None:
|
|
55
|
+
"""Start all registered tasks and wait until they finish."""
|
|
56
|
+
if self._running:
|
|
57
|
+
raise RuntimeError("Planner is already running")
|
|
58
|
+
|
|
59
|
+
self._running = True
|
|
60
|
+
self._runners = [
|
|
61
|
+
asyncio.create_task(self._run_task(t), name=t.name)
|
|
62
|
+
for t in self._tasks
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
await asyncio.gather(*self._runners)
|
|
67
|
+
finally:
|
|
68
|
+
self._running = False
|
|
69
|
+
|
|
70
|
+
def run_forever(self) -> None:
|
|
71
|
+
"""Sync entry point: calls asyncio.run(self.run()) and handles Ctrl+C."""
|
|
72
|
+
try:
|
|
73
|
+
asyncio.run(self.run())
|
|
74
|
+
except KeyboardInterrupt:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
async def stop(self) -> None:
|
|
78
|
+
"""Cancel all running tasks and wait for them to finish."""
|
|
79
|
+
self._running = False
|
|
80
|
+
for runner in self._runners:
|
|
81
|
+
runner.cancel()
|
|
82
|
+
if self._runners:
|
|
83
|
+
await asyncio.gather(*self._runners, return_exceptions=True)
|
|
84
|
+
self._runners.clear()
|
|
85
|
+
|
|
86
|
+
# -- internals ------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
async def _run_task(self, task: Task) -> None:
|
|
89
|
+
"""Execute task on every deadline from its schedule."""
|
|
90
|
+
for deadline in task.schedule:
|
|
91
|
+
if not self._running:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
delay = deadline.delay()
|
|
95
|
+
if delay > 0:
|
|
96
|
+
await asyncio.sleep(delay)
|
|
97
|
+
|
|
98
|
+
if not self._running:
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
await self._execute(task)
|
|
102
|
+
|
|
103
|
+
async def _execute(self, task: Task) -> None:
|
|
104
|
+
"""Call the task function with timeout and retry logic."""
|
|
105
|
+
attempt = 0
|
|
106
|
+
|
|
107
|
+
while True:
|
|
108
|
+
try:
|
|
109
|
+
if task.is_async:
|
|
110
|
+
coro = task.func(*task.args, **task.kwargs) # noqa
|
|
111
|
+
else:
|
|
112
|
+
loop = asyncio.get_running_loop()
|
|
113
|
+
coro = loop.run_in_executor(
|
|
114
|
+
None,
|
|
115
|
+
functools.partial(task.func, *task.args, **task.kwargs),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if task.timeout is not None:
|
|
119
|
+
coro = asyncio.wait_for(coro, timeout=interval_seconds(task.timeout))
|
|
120
|
+
|
|
121
|
+
await coro
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
except asyncio.TimeoutError:
|
|
125
|
+
logger.warning("Task %r timed out", task.name)
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
attempt += 1
|
|
130
|
+
|
|
131
|
+
if task.on_error is not None:
|
|
132
|
+
task.on_error(exc)
|
|
133
|
+
elif attempt > task.max_retries:
|
|
134
|
+
logger.exception("Task %r failed", task.name)
|
|
135
|
+
|
|
136
|
+
if attempt > task.max_retries:
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
delay = task.backoff(attempt) if callable(task.backoff) else task.backoff
|
|
140
|
+
if delay:
|
|
141
|
+
await asyncio.sleep(delay)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timedelta, tzinfo
|
|
8
|
+
from typing import Iterator, Literal, Protocol
|
|
9
|
+
|
|
10
|
+
from croniter import croniter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def interval_seconds(value: str | timedelta | float | int) -> float:
|
|
14
|
+
"""Convert an interval specification to seconds."""
|
|
15
|
+
if isinstance(value, timedelta):
|
|
16
|
+
seconds = value.total_seconds()
|
|
17
|
+
|
|
18
|
+
elif isinstance(value, (int, float)):
|
|
19
|
+
seconds = float(value)
|
|
20
|
+
|
|
21
|
+
elif isinstance(value, str):
|
|
22
|
+
unit = value[-1]
|
|
23
|
+
factor = {
|
|
24
|
+
"s": 1,
|
|
25
|
+
"m": 60,
|
|
26
|
+
"h": 3600,
|
|
27
|
+
"d": 86400,
|
|
28
|
+
}.get(unit)
|
|
29
|
+
|
|
30
|
+
if factor is None:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"Invalid interval '{value!r}'. Expected something like '10s', '5m', '2h', '7d'."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
seconds = float(value[:-1]) * factor
|
|
36
|
+
|
|
37
|
+
else:
|
|
38
|
+
raise TypeError(f"Unsupported interval type: {type(value)!r}")
|
|
39
|
+
|
|
40
|
+
if seconds <= 0:
|
|
41
|
+
raise ValueError("Interval must be greater than zero.")
|
|
42
|
+
|
|
43
|
+
return seconds
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Execution time abstraction
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ExecutionTime(Protocol):
|
|
52
|
+
"""Protocol for objects that know when to fire next."""
|
|
53
|
+
|
|
54
|
+
def delay(self) -> float:
|
|
55
|
+
"""Seconds until the deadline is reached."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(slots=True, frozen=True, kw_only=True)
|
|
59
|
+
class MonotonicExecutionTime:
|
|
60
|
+
"""Deadline based on time.monotonic(). Suitable for interval schedules."""
|
|
61
|
+
|
|
62
|
+
when: float
|
|
63
|
+
|
|
64
|
+
def delay(self) -> float:
|
|
65
|
+
return max(0.0, self.when - time.monotonic())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(slots=True, frozen=True, kw_only=True)
|
|
69
|
+
class WallClockExecutionTime:
|
|
70
|
+
"""Deadline based on datetime. Suitable for cron / once schedules."""
|
|
71
|
+
|
|
72
|
+
when: datetime
|
|
73
|
+
|
|
74
|
+
def delay(self) -> float:
|
|
75
|
+
return max(
|
|
76
|
+
0.0,
|
|
77
|
+
(self.when - datetime.now(self.when.tzinfo)).total_seconds(),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Base schedule
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Schedule(ABC, Iterator[ExecutionTime]):
|
|
87
|
+
"""Stateful iterator over future dispatch deadlines."""
|
|
88
|
+
|
|
89
|
+
def __iter__(self) -> Schedule:
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def __next__(self) -> ExecutionTime:
|
|
94
|
+
...
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Interval
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(slots=True, kw_only=True)
|
|
103
|
+
class IntervalSchedule(Schedule):
|
|
104
|
+
"""Schedule that fires at fixed intervals.
|
|
105
|
+
|
|
106
|
+
anchor="start" — first tick happens *interval* after creation,
|
|
107
|
+
subsequent ticks follow the same cadence.
|
|
108
|
+
anchor="end" — interval is measured from the end of each execution
|
|
109
|
+
(genuine fixed delay).
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
every: str | timedelta | float | int
|
|
113
|
+
anchor: Literal["start", "end"] = "start"
|
|
114
|
+
|
|
115
|
+
_seconds: float = field(init=False)
|
|
116
|
+
_next: float | None = field(init=False, default=None)
|
|
117
|
+
|
|
118
|
+
def __post_init__(self) -> None:
|
|
119
|
+
self._seconds = interval_seconds(self.every)
|
|
120
|
+
|
|
121
|
+
if self.anchor not in ("start", "end"):
|
|
122
|
+
raise ValueError("anchor must be 'start' or 'end'")
|
|
123
|
+
|
|
124
|
+
# noinspection PyTypeChecker
|
|
125
|
+
def __next__(self) -> ExecutionTime:
|
|
126
|
+
now = time.monotonic()
|
|
127
|
+
if self.anchor == "end":
|
|
128
|
+
return MonotonicExecutionTime(
|
|
129
|
+
when=now + self._seconds,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if self._next is None:
|
|
133
|
+
self._next = now + self._seconds
|
|
134
|
+
else:
|
|
135
|
+
self._next += self._seconds
|
|
136
|
+
|
|
137
|
+
return MonotonicExecutionTime(
|
|
138
|
+
when=self._next,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Cron
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(slots=True, kw_only=True)
|
|
148
|
+
class CronSchedule(Schedule):
|
|
149
|
+
"""Schedule that fires at wall-clock times matching a cron expression."""
|
|
150
|
+
|
|
151
|
+
expr: str
|
|
152
|
+
tz: tzinfo | None = None
|
|
153
|
+
|
|
154
|
+
_next: datetime | None = field(init=False, default=None)
|
|
155
|
+
|
|
156
|
+
def __next__(self) -> ExecutionTime:
|
|
157
|
+
|
|
158
|
+
if self._next is None:
|
|
159
|
+
base = datetime.now(self.tz)
|
|
160
|
+
else:
|
|
161
|
+
base = self._next
|
|
162
|
+
|
|
163
|
+
self._next = croniter(
|
|
164
|
+
self.expr,
|
|
165
|
+
base,
|
|
166
|
+
).get_next(datetime)
|
|
167
|
+
|
|
168
|
+
return WallClockExecutionTime(
|
|
169
|
+
when=self._next,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# Once
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(slots=True, kw_only=True)
|
|
179
|
+
class OnceSchedule(Schedule):
|
|
180
|
+
"""Schedule that fires exactly once at a given datetime."""
|
|
181
|
+
|
|
182
|
+
at: datetime
|
|
183
|
+
|
|
184
|
+
_done: bool = field(init=False, default=False)
|
|
185
|
+
|
|
186
|
+
def __next__(self) -> ExecutionTime:
|
|
187
|
+
|
|
188
|
+
if self._done:
|
|
189
|
+
raise StopIteration
|
|
190
|
+
|
|
191
|
+
self._done = True
|
|
192
|
+
|
|
193
|
+
return WallClockExecutionTime(
|
|
194
|
+
when=self.at,
|
|
195
|
+
)
|
matrix_planner/task.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .schedules import Schedule
|
|
10
|
+
|
|
11
|
+
BackoffValue = float | int | Callable[[int], float]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True, kw_only=True)
|
|
15
|
+
class Task:
|
|
16
|
+
"""Function bound to a schedule.
|
|
17
|
+
|
|
18
|
+
Task does not run itself — the scheduler does that. Task only
|
|
19
|
+
describes *what* to call, *with what arguments*, and *how to
|
|
20
|
+
react to failures*.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
func: Callable[..., Any]
|
|
24
|
+
schedule: Schedule
|
|
25
|
+
name: str | None = None
|
|
26
|
+
args: tuple = ()
|
|
27
|
+
kwargs: dict = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
# Retry policy for exceptions raised inside func.
|
|
30
|
+
max_retries: int = 0
|
|
31
|
+
backoff: BackoffValue = 0.0
|
|
32
|
+
|
|
33
|
+
# If set, exceptions are forwarded here instead of being re-raised.
|
|
34
|
+
on_error: Callable[[Exception], None] | None = None
|
|
35
|
+
|
|
36
|
+
# Per-execution timeout. Accepts 15, timedelta(seconds=15), "15s", etc.
|
|
37
|
+
timeout: str | timedelta | float | int | None = None
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
if self.name is None:
|
|
41
|
+
self.name = getattr(self.func, "__name__", repr(self.func))
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_async(self) -> bool:
|
|
45
|
+
"""True if the wrapped function is a coroutine function."""
|
|
46
|
+
return inspect.iscoroutinefunction(self.func)
|
|
47
|
+
|
|
48
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
49
|
+
"""Call the task function directly, bypassing the scheduler."""
|
|
50
|
+
return self.func(*(args or self.args), **(kwargs or self.kwargs))
|
|
51
|
+
|
|
52
|
+
def __repr__(self) -> str:
|
|
53
|
+
return f"Task(name={self.name!r}, schedule={self.schedule!r})"
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: matrix-planner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight async task scheduler
|
|
5
|
+
Project-URL: Homepage, https://github.com/matrixd0t/matrix-planner
|
|
6
|
+
Project-URL: Repository, https://github.com/matrixd0t/matrix-planner
|
|
7
|
+
Author: dotmatrix
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Requires-Python: >=3.13
|
|
31
|
+
Requires-Dist: croniter>=6.2.3
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# matrix-planner
|
|
35
|
+
|
|
36
|
+
Lightweight async task scheduler for Python 3.13+. Single dependency: `croniter`.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import asyncio
|
|
40
|
+
from matrix_planner import Planner, every
|
|
41
|
+
|
|
42
|
+
planner = Planner()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@planner.task(every("10s"))
|
|
46
|
+
def heartbeat() -> None:
|
|
47
|
+
print("tick")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@planner.task(
|
|
51
|
+
every("30s"),
|
|
52
|
+
max_retries=3,
|
|
53
|
+
backoff=lambda a: 2 ** a, # 2, 4, 8… seconds between retries
|
|
54
|
+
timeout="15s", # cancel and log if execution exceeds 15s
|
|
55
|
+
)
|
|
56
|
+
async def fetch() -> None:
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
planner.run_forever()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Key features
|
|
64
|
+
|
|
65
|
+
| Feature | API |
|
|
66
|
+
|---|---|
|
|
67
|
+
| Fixed-interval schedule | `every("10s")`, `every(timedelta(minutes=5))` |
|
|
68
|
+
| Cron schedule | `cron("*/5 * * * *")` |
|
|
69
|
+
| One-shot schedule | `once(datetime(2026, 12, 31))` |
|
|
70
|
+
| Decorator registration | `@planner.task(every("2s"))` |
|
|
71
|
+
| Manual registration | `planner.add(Task(func=..., schedule=...))` |
|
|
72
|
+
| Retry policy | `max_retries=N`, `backoff=float \| Callable[[int], float]` |
|
|
73
|
+
| Per-execution timeout | `timeout="15s" \| timedelta \| float` |
|
|
74
|
+
| Error callback | `on_error=my_handler` |
|
|
75
|
+
| Sync + async functions | sync dispatched via `run_in_executor` |
|
|
76
|
+
| Direct task invocation | `task()` bypasses scheduler (useful in tests) |
|
|
77
|
+
| `anchor="start"` | first tick *interval* after creation, steady cadence |
|
|
78
|
+
| `anchor="end"` | interval measured from end of each execution |
|
|
79
|
+
|
|
80
|
+
## Schedules
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
# Every 10 seconds (first tick after 10s from now)
|
|
84
|
+
s = every("10s")
|
|
85
|
+
|
|
86
|
+
# Every 5 minutes, anchored to end of execution
|
|
87
|
+
s = every("5m", anchor="end")
|
|
88
|
+
|
|
89
|
+
# Cron expression (fires at 09:00 every weekday)
|
|
90
|
+
s = cron("0 9 * * 1-5")
|
|
91
|
+
|
|
92
|
+
# Cron with timezone
|
|
93
|
+
s = cron("0 9 * * *", tz=timezone.utc)
|
|
94
|
+
|
|
95
|
+
# Fire once at a specific datetime
|
|
96
|
+
s = once(datetime(2026, 12, 31, 23, 59))
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Task parameters
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
Task(
|
|
103
|
+
func=my_fn,
|
|
104
|
+
schedule=every("10s"),
|
|
105
|
+
name="my-task", # defaults to func.__name__
|
|
106
|
+
args=(1, 2),
|
|
107
|
+
kwargs={"key": "val"},
|
|
108
|
+
max_retries=2,
|
|
109
|
+
backoff=1.0, # fixed delay, or lambda a: 2 ** a
|
|
110
|
+
timeout="15s", # None = no limit
|
|
111
|
+
on_error=lambda e: ..., # called instead of logging on failure
|
|
112
|
+
)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Requirements
|
|
116
|
+
|
|
117
|
+
Python 3.13+. Single dependency: `croniter>=6.2.3`.
|
|
118
|
+
|
|
119
|
+
Install: `uv add matrix-planner` or `pip install matrix-planner`.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
Written with love by dotmatrix.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
matrix_planner/__init__.py,sha256=Obd5pNdkL7xBkhqoG67Daegyw7o0tjcOSKvPZKYTBto,649
|
|
2
|
+
matrix_planner/decorators.py,sha256=WprTORIW6Ul7PsHyqpmRZqO5j6NXqCiPaLjZbk5_Myw,914
|
|
3
|
+
matrix_planner/planner.py,sha256=y4PUwakNpQfYXE3ZfVnLKsnutq0Jxe4-_JktXzzQgBc,4297
|
|
4
|
+
matrix_planner/schedules.py,sha256=uqTxrzKmRkLPUnvSbV6BUohDajTZXOyY2htWhfgyo_I,5204
|
|
5
|
+
matrix_planner/task.py,sha256=6t7R9rWOEvA74PgFFV1hu9zoZKmSGmPlLjFOHfUEX-Q,1664
|
|
6
|
+
matrix_planner-0.1.0.dist-info/METADATA,sha256=GeKDQqLKhRSK-ZhSPgJTuBMh1e7EV-uuxqimLwKwRno,3904
|
|
7
|
+
matrix_planner-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
matrix_planner-0.1.0.dist-info/licenses/LICENSE,sha256=JuDRkpJ1tG2YXCjsW0iF9Ob8IH0K2Sd3c9WMTBPfO7o,1055
|
|
9
|
+
matrix_planner-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|