errands
small jobs the runtime does for the studio.
git clone https://owenrusk.dev/errands.git
commit 70b7d0e9e4bdee176923ae740c32a3b88f648ae3 parent eaa26d47660b1e538e49ad87ac1c3eeb5d5aaace author Owen Rusk <owen@papermothgames.com> date 2024-12-10 14:30:41 -0600
jobs: a loop for the things that run on their own
| errands/__main__.py | +8 | -2 |
| errands/jobs.py | +50 | -0 |
| tests/test_jobs.py | +35 | -0 |
diff --git a/errands/__main__.py b/errands/__main__.py index 6953272..2f5d99d 100644 --- a/errands/__main__.py +++ b/errands/__main__.py @@ -1,12 +1,18 @@ +# python -m errands run the jobs that run on their own # python -m errands serve tool calls on stdin, one json object per line # python -m errands tools what it can call +import logging import sys-from . import tools+from . import jobs, tools def main(argv: list[str]) -> int: command = argv[1] if len(argv) > 1 else "" + if command == "run": + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s") + jobs.forever() + return 0 if command == "serve": for line in sys.stdin: if line.strip(): @@ -16,7 +22,7 @@ def main(argv: list[str]) -> int: for name in sorted(tools.TOOLS): print(name) return 0- print("usage: python -m errands serve|tools", file=sys.stderr)+ print("usage: python -m errands run|serve|tools", file=sys.stderr) return 2 diff --git a/errands/jobs.py b/errands/jobs.py new file mode 100644 index 0000000..d9a1925 --- /dev/null +++ b/errands/jobs.py @@ -0,0 +1,50 @@ +# the things that run on their own. one loop, checked once a minute. +import logging +import time +from dataclasses import dataclass +from datetime import datetime +from typing import Callable + +from . import posts + +log = logging.getLogger("errands") + + +@dataclass +class Job: + name: str + run: Callable[[], object] + every: int | None = None # minutes + at: str | None = None # "HH:MM", once a day + + +JOBS = [ + Job("posts", posts.publish_due, every=15), +] + + +def is_due(job: Job, now: datetime, last: datetime | None) -> bool: + if job.every is not None: + return last is None or (now - last).total_seconds() >= job.every * 60 + # a daily job missed while errands was down runs when it comes back. + hour, minute = map(int, job.at.split(":")) + today = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + return now >= today and (last is None or last < today) + + +def tick(now: datetime, last: dict[str, datetime], jobs: list[Job] | None = None) -> list[str]: + ran = [] + for job in JOBS if jobs is None else jobs: + if is_due(job, now, last.get(job.name)): + last[job.name] = now + log.info("%s", job.name) + job.run() + ran.append(job.name) + return ran + + +def forever() -> None: + last: dict[str, datetime] = {} + while True: + tick(datetime.now().astimezone(), last) + time.sleep(60 - time.time() % 60) diff --git a/tests/test_jobs.py b/tests/test_jobs.py new file mode 100644 index 0000000..1458778 --- /dev/null +++ b/tests/test_jobs.py @@ -0,0 +1,35 @@ +import unittest +from datetime import datetime, timedelta, timezone + +from errands import jobs + +CENTRAL = timezone(timedelta(hours=-6)) + + +def at(hour, minute=0, day=10): + return datetime(2024, 12, day, hour, minute, tzinfo=CENTRAL) + + +class DueTest(unittest.TestCase): + def test_every_n_minutes(self): + job = jobs.Job("x", lambda: None, every=15) + self.assertTrue(jobs.is_due(job, at(9), None)) + self.assertFalse(jobs.is_due(job, at(9, 10), at(9))) + self.assertTrue(jobs.is_due(job, at(9, 15), at(9))) + + def test_once_a_day(self): + job = jobs.Job("x", lambda: None, at="09:00") + self.assertFalse(jobs.is_due(job, at(8, 59), None)) + self.assertTrue(jobs.is_due(job, at(9), at(9, day=9))) + self.assertFalse(jobs.is_due(job, at(13), at(9))) + self.assertTrue(jobs.is_due(job, at(9, day=11), at(9))) + + +class TickTest(unittest.TestCase): + def test_runs_what_is_due_and_remembers(self): + calls = [] + job = jobs.Job("x", lambda: calls.append(1), every=5) + last = {} + self.assertEqual(jobs.tick(at(9), last, [job]), ["x"]) + self.assertEqual(jobs.tick(at(9, 1), last, [job]), []) + self.assertEqual(calls, [1])