matrix-planner 0.1.0__tar.gz

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.
@@ -0,0 +1,27 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ id-token: write
9
+ contents: read
10
+
11
+ jobs:
12
+ build-and-publish:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - name: Install uv
18
+ uses: astral-sh/setup-uv@v5
19
+
20
+ - name: Set up Python
21
+ run: uv python install
22
+
23
+ - name: Build package
24
+ run: uv build
25
+
26
+ - name: Publish to PyPI
27
+ run: uv publish
@@ -0,0 +1,168 @@
1
+ # Personal / local
2
+ uv.lock
3
+ agents.md
4
+
5
+ ### Python template
6
+ # Byte-compiled / optimized / DLL files
7
+ __pycache__/
8
+ *.py[cod]
9
+ *$py.class
10
+
11
+ # C extensions
12
+ *.so
13
+
14
+ # Distribution / packaging
15
+ .Python
16
+ build/
17
+ develop-eggs/
18
+ dist/
19
+ downloads/
20
+ eggs/
21
+ .eggs/
22
+ lib/
23
+ lib64/
24
+ parts/
25
+ sdist/
26
+ var/
27
+ wheels/
28
+ share/python-wheels/
29
+ *.egg-info/
30
+ .installed.cfg
31
+ *.egg
32
+ MANIFEST
33
+
34
+ # PyInstaller
35
+ # Usually these files are written by a python script from a template
36
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
37
+ *.manifest
38
+ *.spec
39
+
40
+ # Installer logs
41
+ pip-log.txt
42
+ pip-delete-this-directory.txt
43
+
44
+ # Unit test / coverage reports
45
+ htmlcov/
46
+ .tox/
47
+ .nox/
48
+ .coverage
49
+ .coverage.*
50
+ .cache
51
+ nosetests.xml
52
+ coverage.xml
53
+ *.cover
54
+ *.py,cover
55
+ .hypothesis/
56
+ .pytest_cache/
57
+ cover/
58
+
59
+ # Translations
60
+ *.mo
61
+ *.pot
62
+
63
+ # Django stuff:
64
+ *.log
65
+ local_settings.py
66
+ db.sqlite3
67
+ db.sqlite3-journal
68
+
69
+ # Flask stuff:
70
+ instance/
71
+ .webassets-cache
72
+
73
+ # Scrapy stuff:
74
+ .scrapy
75
+
76
+ # Sphinx documentation
77
+ docs/_build/
78
+
79
+ # PyBuilder
80
+ .pybuilder/
81
+ target/
82
+
83
+ # Jupyter Notebook
84
+ .ipynb_checkpoints
85
+
86
+ # IPython
87
+ profile_default/
88
+ ipython_config.py
89
+
90
+ # pyenv
91
+ # For a library or package, you might want to ignore these files since the code is
92
+ # intended to run in multiple environments; otherwise, check them in:
93
+ # .python-version
94
+
95
+ # pipenv
96
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
97
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
98
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
99
+ # install all needed dependencies.
100
+ #Pipfile.lock
101
+
102
+ # poetry
103
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
104
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
105
+ # commonly ignored for libraries.
106
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
107
+ #poetry.lock
108
+
109
+ # pdm
110
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
111
+ #pdm.lock
112
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
113
+ # in version control.
114
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
115
+ .pdm.toml
116
+ .pdm-python
117
+ .pdm-build/
118
+
119
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
120
+ __pypackages__/
121
+
122
+ # Celery stuff
123
+ celerybeat-schedule
124
+ celerybeat.pid
125
+
126
+ # SageMath parsed files
127
+ *.sage.py
128
+
129
+ # Environments
130
+ .env
131
+ .venv
132
+ env/
133
+ venv/
134
+ ENV/
135
+ env.bak/
136
+ venv.bak/
137
+
138
+ # Spyder project settings
139
+ .spyderproject
140
+ .spyproject
141
+
142
+ # Rope project settings
143
+ .ropeproject
144
+
145
+ # mkdocs documentation
146
+ /site
147
+
148
+ # mypy
149
+ .mypy_cache/
150
+ .dmypy.json
151
+ dmypy.json
152
+
153
+ # Pyre type checker
154
+ .pyre/
155
+
156
+ # pytype static type analyzer
157
+ .pytype/
158
+
159
+ # Cython debug symbols
160
+ cython_debug/
161
+
162
+ # PyCharm
163
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
164
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
165
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
166
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
167
+ .idea/
168
+
@@ -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.
@@ -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,90 @@
1
+ # matrix-planner
2
+
3
+ Lightweight async task scheduler for Python 3.13+. Single dependency: `croniter`.
4
+
5
+ ```python
6
+ import asyncio
7
+ from matrix_planner import Planner, every
8
+
9
+ planner = Planner()
10
+
11
+
12
+ @planner.task(every("10s"))
13
+ def heartbeat() -> None:
14
+ print("tick")
15
+
16
+
17
+ @planner.task(
18
+ every("30s"),
19
+ max_retries=3,
20
+ backoff=lambda a: 2 ** a, # 2, 4, 8… seconds between retries
21
+ timeout="15s", # cancel and log if execution exceeds 15s
22
+ )
23
+ async def fetch() -> None:
24
+ ...
25
+
26
+
27
+ planner.run_forever()
28
+ ```
29
+
30
+ ## Key features
31
+
32
+ | Feature | API |
33
+ |---|---|
34
+ | Fixed-interval schedule | `every("10s")`, `every(timedelta(minutes=5))` |
35
+ | Cron schedule | `cron("*/5 * * * *")` |
36
+ | One-shot schedule | `once(datetime(2026, 12, 31))` |
37
+ | Decorator registration | `@planner.task(every("2s"))` |
38
+ | Manual registration | `planner.add(Task(func=..., schedule=...))` |
39
+ | Retry policy | `max_retries=N`, `backoff=float \| Callable[[int], float]` |
40
+ | Per-execution timeout | `timeout="15s" \| timedelta \| float` |
41
+ | Error callback | `on_error=my_handler` |
42
+ | Sync + async functions | sync dispatched via `run_in_executor` |
43
+ | Direct task invocation | `task()` bypasses scheduler (useful in tests) |
44
+ | `anchor="start"` | first tick *interval* after creation, steady cadence |
45
+ | `anchor="end"` | interval measured from end of each execution |
46
+
47
+ ## Schedules
48
+
49
+ ```python
50
+ # Every 10 seconds (first tick after 10s from now)
51
+ s = every("10s")
52
+
53
+ # Every 5 minutes, anchored to end of execution
54
+ s = every("5m", anchor="end")
55
+
56
+ # Cron expression (fires at 09:00 every weekday)
57
+ s = cron("0 9 * * 1-5")
58
+
59
+ # Cron with timezone
60
+ s = cron("0 9 * * *", tz=timezone.utc)
61
+
62
+ # Fire once at a specific datetime
63
+ s = once(datetime(2026, 12, 31, 23, 59))
64
+ ```
65
+
66
+ ## Task parameters
67
+
68
+ ```python
69
+ Task(
70
+ func=my_fn,
71
+ schedule=every("10s"),
72
+ name="my-task", # defaults to func.__name__
73
+ args=(1, 2),
74
+ kwargs={"key": "val"},
75
+ max_retries=2,
76
+ backoff=1.0, # fixed delay, or lambda a: 2 ** a
77
+ timeout="15s", # None = no limit
78
+ on_error=lambda e: ..., # called instead of logging on failure
79
+ )
80
+ ```
81
+
82
+ ## Requirements
83
+
84
+ Python 3.13+. Single dependency: `croniter>=6.2.3`.
85
+
86
+ Install: `uv add matrix-planner` or `pip install matrix-planner`.
87
+
88
+ ---
89
+
90
+ Written with love by dotmatrix.
@@ -0,0 +1,212 @@
1
+ """Comprehensive examples covering all matrix-planner features."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from datetime import datetime, timedelta, timezone
8
+
9
+ from matrix_planner import (
10
+ CronSchedule,
11
+ IntervalSchedule,
12
+ OnceSchedule,
13
+ Planner,
14
+ Task,
15
+ cron,
16
+ every,
17
+ once,
18
+ )
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # 1. Basic interval — sync function
22
+ # ---------------------------------------------------------------------------
23
+
24
+ planner = Planner()
25
+
26
+
27
+ @planner.task(every("2s"))
28
+ def heartbeat() -> None:
29
+ print(f"[{datetime.now():%H:%M:%S}] heartbeat (sync)")
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # 2. Async function with exponential backoff and per-execution timeout
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ @planner.task(
38
+ every("3s"),
39
+ max_retries=3,
40
+ backoff=lambda a: 2 ** a, # 2, 4, 8… seconds between retries
41
+ timeout="1.5s", # kill if a single run exceeds 1.5s
42
+ )
43
+ async def fetch_status() -> None:
44
+ print(f"[{datetime.now():%H:%M:%S}] fetch_status started")
45
+ await asyncio.sleep(0.2)
46
+ print(f"[{datetime.now():%H:%M:%S}] fetch_status done")
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # 3. Cron schedule — fires at specific wall-clock times
51
+ # ---------------------------------------------------------------------------
52
+
53
+ planner2 = Planner()
54
+
55
+
56
+ @planner2.task(cron("*/1 * * * * *")) # every minute, at second 0
57
+ async def every_minute() -> None:
58
+ print(f"[{datetime.now():%H:%M:%S}] cron tick")
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # 4. Once schedule — fires a single time
63
+ # ---------------------------------------------------------------------------
64
+
65
+
66
+ def startup_message() -> None:
67
+ print(f"[{datetime.now():%H:%M:%S}] startup: one-time task")
68
+
69
+
70
+ planner.add(
71
+ Task(
72
+ func=startup_message,
73
+ schedule=once(datetime.now() + timedelta(seconds=3)),
74
+ )
75
+ )
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # 5. Interval with anchor="end" — delay measured from execution end
80
+ # ---------------------------------------------------------------------------
81
+
82
+ planner3 = Planner()
83
+
84
+
85
+ @planner3.task(IntervalSchedule(every="2s", anchor="end"))
86
+ async def poll() -> None:
87
+ print(f"[{datetime.now():%H:%M:%S}] poll (2s after previous finished)")
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # 6. on_error callback — intercept exceptions instead of logging
92
+ # ---------------------------------------------------------------------------
93
+
94
+ error_log: list[Exception] = []
95
+
96
+
97
+ def collect_error(exc: Exception) -> None:
98
+ error_log.append(exc)
99
+
100
+
101
+ @planner.task(every("4s"), max_retries=1, backoff=0.5, on_error=collect_error)
102
+ async def flaky_task() -> None:
103
+ print(f"[{datetime.now():%H:%M:%S}] flaky_task attempt")
104
+ raise ValueError("something went wrong")
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # 7. Direct task invocation (bypasses scheduler — useful in tests)
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ def add(a: int, b: int) -> int:
113
+ return a + b
114
+
115
+
116
+ direct_task = Task(func=add, schedule=every("10s"), args=(2, 3))
117
+ assert direct_task() == 5
118
+ print(f"[{datetime.now():%H:%M:%S}] direct call: add(2, 3) = {direct_task()}")
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # 8. Fixed backoff (same as old retry_delay)
123
+ # ---------------------------------------------------------------------------
124
+
125
+
126
+ @planner.task(every("5s"), max_retries=2, backoff=1.0)
127
+ def cleanup() -> None:
128
+ print(f"[{datetime.now():%H:%M:%S}] cleanup (fixed 1s backoff)")
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # 9. Custom backoff with cap
133
+ # ---------------------------------------------------------------------------
134
+
135
+
136
+ @planner.task(every("6s"), max_retries=4, backoff=lambda a: min(2 ** a, 10))
137
+ async def capped_backoff() -> None:
138
+ print(f"[{datetime.now():%H:%M:%S}] capped backoff demo")
139
+ raise RuntimeError("simulated")
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # 10. Using all explicit schedule classes without factory helpers
144
+ # ---------------------------------------------------------------------------
145
+
146
+ planner4 = Planner()
147
+
148
+ planner4.add(
149
+ Task(
150
+ func=lambda: print("explicit IntervalSchedule"),
151
+ schedule=IntervalSchedule(every="3s"),
152
+ )
153
+ )
154
+
155
+ planner4.add(
156
+ Task(
157
+ func=lambda: print("explicit CronSchedule"),
158
+ schedule=CronSchedule(expr="*/2 * * * * *"),
159
+ )
160
+ )
161
+
162
+ planner4.add(
163
+ Task(
164
+ func=lambda: print("explicit OnceSchedule"),
165
+ schedule=OnceSchedule(at=datetime.now(timezone.utc) + timedelta(seconds=5)),
166
+ )
167
+ )
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # 11. Timeout variants
172
+ # ---------------------------------------------------------------------------
173
+
174
+
175
+ @planner.task(every("3s"), timeout=timedelta(seconds=1))
176
+ async def timeout_timedelta() -> None:
177
+ print(f"[{datetime.now():%H:%M:%S}] timeout with timedelta")
178
+ await asyncio.sleep(0.1)
179
+
180
+
181
+ @planner.task(every("4s"), timeout=2.0)
182
+ async def timeout_float() -> None:
183
+ print(f"[{datetime.now():%H:%M:%S}] timeout with float")
184
+ await asyncio.sleep(0.1)
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Run demo (stop after 10 seconds)
189
+ # ---------------------------------------------------------------------------
190
+
191
+ logging.basicConfig(level=logging.WARNING, format="%(levelname)s:%(name)s:%(message)s")
192
+
193
+
194
+ async def run_all() -> None:
195
+ tasks = [
196
+ asyncio.create_task(planner.run()),
197
+ asyncio.create_task(planner2.run()),
198
+ asyncio.create_task(planner3.run()),
199
+ asyncio.create_task(planner4.run()),
200
+ ]
201
+
202
+ try:
203
+ await asyncio.wait_for(asyncio.gather(*tasks), timeout=10)
204
+ except asyncio.TimeoutError:
205
+ for p in (planner, planner2, planner3, planner4):
206
+ await p.stop()
207
+
208
+ print(f"Error log entries: {len(error_log)}")
209
+
210
+
211
+ if __name__ == "__main__":
212
+ asyncio.run(run_all())
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "matrix-planner"
7
+ version = "0.1.0"
8
+ description = "Lightweight async task scheduler"
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ license = { file = "LICENSE" }
12
+ authors = [
13
+ { name = "dotmatrix" },
14
+ ]
15
+ dependencies = [
16
+ "croniter>=6.2.3",
17
+ ]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/matrixd0t/matrix-planner"
21
+ Repository = "https://github.com/matrixd0t/matrix-planner"
@@ -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
+ )
@@ -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})"