owenrusk.dev

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])