dispatchbox 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.
- dispatchbox/__init__.py +3 -0
- dispatchbox/__main__.py +127 -0
- dispatchbox/chat.py +209 -0
- dispatchbox/cron.py +56 -0
- dispatchbox/feishu.py +304 -0
- dispatchbox/hub.py +550 -0
- dispatchbox/net.py +33 -0
- dispatchbox/rules.py +236 -0
- dispatchbox/server.py +480 -0
- dispatchbox/slack.py +251 -0
- dispatchbox/store.py +99 -0
- dispatchbox/worker.py +314 -0
- dispatchbox-0.1.0.dist-info/METADATA +293 -0
- dispatchbox-0.1.0.dist-info/RECORD +18 -0
- dispatchbox-0.1.0.dist-info/WHEEL +5 -0
- dispatchbox-0.1.0.dist-info/entry_points.txt +2 -0
- dispatchbox-0.1.0.dist-info/licenses/LICENSE +21 -0
- dispatchbox-0.1.0.dist-info/top_level.txt +1 -0
dispatchbox/__init__.py
ADDED
dispatchbox/__main__.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Command line entry point: `python -m dispatchbox hub|worker|send|check`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import socket
|
|
10
|
+
import sys
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .net import context_for
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main(argv: list[str] | None = None) -> int:
|
|
19
|
+
p = argparse.ArgumentParser(prog="dispatchbox", description="Route events to coding agents on your own machines.")
|
|
20
|
+
p.add_argument("--version", action="version", version=__version__)
|
|
21
|
+
p.add_argument("-v", "--verbose", action="store_true")
|
|
22
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
23
|
+
|
|
24
|
+
h = sub.add_parser("hub", help="run the hub (events in, tasks out)")
|
|
25
|
+
h.add_argument("--rules", default="rules.json")
|
|
26
|
+
h.add_argument("--db", default="dispatchbox.db")
|
|
27
|
+
h.add_argument("--host", default="127.0.0.1")
|
|
28
|
+
h.add_argument("--port", type=int, default=8787)
|
|
29
|
+
h.add_argument("--public-url", default=os.environ.get("DISPATCHBOX_PUBLIC_URL", ""),
|
|
30
|
+
help="URL people use to reach the hub, used in notification links")
|
|
31
|
+
|
|
32
|
+
w = sub.add_parser("worker", help="run a worker that pulls tasks and runs agents")
|
|
33
|
+
w.add_argument("--hub", default=os.environ.get("DISPATCHBOX_HUB", "http://127.0.0.1:8787"))
|
|
34
|
+
w.add_argument("--name", default=socket.gethostname().split(".")[0])
|
|
35
|
+
w.add_argument("--slots", type=int, default=2, help="how many runs at once")
|
|
36
|
+
w.add_argument("--allow-dir", action="append", dest="allow_dirs",
|
|
37
|
+
help="directory runs may use (repeatable); defaults to the current directory")
|
|
38
|
+
w.add_argument("--runners", default="claude,codex", help="comma list; add 'shell' to allow shell rules")
|
|
39
|
+
w.add_argument("--claude-bin", default="claude")
|
|
40
|
+
w.add_argument("--codex-bin", default="codex")
|
|
41
|
+
|
|
42
|
+
s = sub.add_parser("send", help="send one event to the hub")
|
|
43
|
+
s.add_argument("source")
|
|
44
|
+
s.add_argument("type")
|
|
45
|
+
s.add_argument("--subject", default="")
|
|
46
|
+
s.add_argument("--summary", default="")
|
|
47
|
+
s.add_argument("--link", default="")
|
|
48
|
+
s.add_argument("--payload", default="{}", help="JSON object")
|
|
49
|
+
s.add_argument("--hub", default=os.environ.get("DISPATCHBOX_HUB", "http://127.0.0.1:8787"))
|
|
50
|
+
|
|
51
|
+
c = sub.add_parser("check", help="validate a rules file and show which rule an event would hit")
|
|
52
|
+
c.add_argument("--rules", default="rules.json")
|
|
53
|
+
c.add_argument("--source")
|
|
54
|
+
c.add_argument("--type")
|
|
55
|
+
c.add_argument("--subject", default="")
|
|
56
|
+
|
|
57
|
+
args = p.parse_args(argv)
|
|
58
|
+
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO,
|
|
59
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
60
|
+
token = os.environ.get("DISPATCHBOX_TOKEN", "")
|
|
61
|
+
|
|
62
|
+
if args.cmd == "hub":
|
|
63
|
+
from .hub import Hub
|
|
64
|
+
from .server import serve
|
|
65
|
+
from .chat import ChatError, adapters_from_env
|
|
66
|
+
hub = Hub(args.rules, args.db, public_url=args.public_url)
|
|
67
|
+
try:
|
|
68
|
+
hub.adapters = adapters_from_env(hub, dict(os.environ))
|
|
69
|
+
except ChatError as e:
|
|
70
|
+
print(f"Cannot start: {e}", file=sys.stderr)
|
|
71
|
+
return 2
|
|
72
|
+
srv = serve(hub, args.host, args.port, token, os.environ.get("DISPATCHBOX_GITHUB_SECRET", ""))
|
|
73
|
+
logging.getLogger("dispatchbox").info("hub listening on http://%s:%d with %d rules, %d schedules, chat: %s",
|
|
74
|
+
args.host, args.port, len(hub.config.rules), len(hub.config.schedules),
|
|
75
|
+
", ".join(hub.adapters) or "none")
|
|
76
|
+
try:
|
|
77
|
+
srv.serve_forever()
|
|
78
|
+
except KeyboardInterrupt:
|
|
79
|
+
pass
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
if args.cmd == "worker":
|
|
83
|
+
from .worker import WorkerConfig, serve_worker
|
|
84
|
+
if not token:
|
|
85
|
+
print("Set DISPATCHBOX_TOKEN to the hub's token.", file=sys.stderr)
|
|
86
|
+
return 2
|
|
87
|
+
cfg = WorkerConfig(hub=args.hub, name=args.name, token=token, slots=args.slots,
|
|
88
|
+
allow_dirs=args.allow_dirs or [os.getcwd()],
|
|
89
|
+
runners=[r.strip() for r in args.runners.split(",") if r.strip()],
|
|
90
|
+
claude_bin=args.claude_bin, codex_bin=args.codex_bin)
|
|
91
|
+
try:
|
|
92
|
+
serve_worker(cfg)
|
|
93
|
+
except KeyboardInterrupt:
|
|
94
|
+
pass
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
if args.cmd == "send":
|
|
98
|
+
body = {"source": args.source, "type": args.type, "subject": args.subject, "summary": args.summary,
|
|
99
|
+
"link": args.link, "payload": json.loads(args.payload)}
|
|
100
|
+
req = urllib.request.Request(args.hub.rstrip("/") + "/events", data=json.dumps(body).encode(), method="POST",
|
|
101
|
+
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"})
|
|
102
|
+
try:
|
|
103
|
+
with urllib.request.urlopen(req, timeout=15, context=context_for(req.full_url)) as resp:
|
|
104
|
+
print(resp.read().decode())
|
|
105
|
+
except urllib.error.HTTPError as e:
|
|
106
|
+
print(f"{e.code}: {e.read().decode()}", file=sys.stderr)
|
|
107
|
+
return 1
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
if args.cmd == "check":
|
|
111
|
+
from .rules import load_config_file, normalize_event
|
|
112
|
+
try:
|
|
113
|
+
cfg = load_config_file(args.rules)
|
|
114
|
+
except Exception as e:
|
|
115
|
+
print(f"invalid: {e}", file=sys.stderr)
|
|
116
|
+
return 1
|
|
117
|
+
print(f"ok: {len(cfg.rules)} rules, {len(cfg.schedules)} schedules, dedupe window {cfg.dedupe_window}s")
|
|
118
|
+
if args.source and args.type:
|
|
119
|
+
ev = normalize_event({"source": args.source, "type": args.type, "subject": args.subject})
|
|
120
|
+
hits = cfg.match(ev)
|
|
121
|
+
print("matches: " + (", ".join(r.name for r in hits) if hits else "(none)"))
|
|
122
|
+
return 0
|
|
123
|
+
return 1
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
sys.exit(main())
|
dispatchbox/chat.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Shared plumbing for chat platforms (Feishu, Slack).
|
|
2
|
+
|
|
3
|
+
An adapter does four things:
|
|
4
|
+
1. turns chat messages that mention the bot (or DM it) into dispatchbox events,
|
|
5
|
+
2. posts a decision card with one button per option when a run needs a human,
|
|
6
|
+
3. records the button click as a decision and updates the card in place,
|
|
7
|
+
4. replies in the original thread when a run that came from chat finishes.
|
|
8
|
+
|
|
9
|
+
Platform specifics (auth, signatures, payload shapes, card formats) live in feishu.py and slack.py.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import threading
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
20
|
+
|
|
21
|
+
from .hub import DECISION_MARK, STATUS_MARK, SUMMARY_MARK
|
|
22
|
+
from .net import ssl_context
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from .hub import Hub
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger("dispatchbox.chat")
|
|
28
|
+
|
|
29
|
+
STATUS_LABEL = {
|
|
30
|
+
"succeeded": "✅ succeeded",
|
|
31
|
+
"failed": "❌ failed",
|
|
32
|
+
"lost": "⚠️ lost",
|
|
33
|
+
"awaiting_decision": "🟠 needs your decision",
|
|
34
|
+
"decided": "☑️ decided",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# (status, headers, body) returned to the HTTP layer
|
|
38
|
+
Response = tuple[int, dict[str, str], bytes]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def json_response(obj: Any, status: int = 200) -> Response:
|
|
42
|
+
return status, {"Content-Type": "application/json"}, json.dumps(obj, ensure_ascii=False).encode()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def empty_response(status: int = 200) -> Response:
|
|
46
|
+
return status, {}, b""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ChatError(Exception):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def http_json(method: str, url: str, body: Any = None, headers: dict[str, str] | None = None,
|
|
54
|
+
timeout: float = 10) -> dict[str, Any]:
|
|
55
|
+
data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None
|
|
56
|
+
req = urllib.request.Request(url, data=data, method=method,
|
|
57
|
+
headers={"Content-Type": "application/json; charset=utf-8", **(headers or {})})
|
|
58
|
+
try:
|
|
59
|
+
with urllib.request.urlopen(req, timeout=timeout, context=ssl_context()) as resp:
|
|
60
|
+
raw = resp.read()
|
|
61
|
+
except urllib.error.HTTPError as e:
|
|
62
|
+
raw = e.read()
|
|
63
|
+
try:
|
|
64
|
+
return json.loads(raw)
|
|
65
|
+
except json.JSONDecodeError:
|
|
66
|
+
raise ChatError(f"{method} {url} -> HTTP {e.code}: {raw[:300]!r}") from e
|
|
67
|
+
return json.loads(raw) if raw else {}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def in_background(fn: Callable[..., Any], *args: Any) -> None:
|
|
71
|
+
"""Chat platforms want an answer within ~3 seconds, so slow work (API calls) runs on a thread."""
|
|
72
|
+
def run() -> None:
|
|
73
|
+
try:
|
|
74
|
+
fn(*args)
|
|
75
|
+
except Exception:
|
|
76
|
+
log.exception("background chat task failed")
|
|
77
|
+
threading.Thread(target=run, daemon=True).start()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ChatAdapter:
|
|
81
|
+
name = "chat" # also the event source name and the URL suffix: /hooks/<name>
|
|
82
|
+
|
|
83
|
+
def __init__(self, hub: "Hub"):
|
|
84
|
+
self.hub = hub
|
|
85
|
+
|
|
86
|
+
# ---- configuration from the "chat" section of the rules file, re-read on every use so edits apply live
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def settings(self) -> dict[str, Any]:
|
|
90
|
+
# Chat requests are checked before ingest, so pick up allowlist edits here instead of
|
|
91
|
+
# waiting for the once-a-minute cron tick.
|
|
92
|
+
self.hub.reload_if_changed()
|
|
93
|
+
return (self.hub.config.chat or {}).get(self.name) or {}
|
|
94
|
+
|
|
95
|
+
def user_allowed(self, user_id: str, key: str = "allowed_users") -> bool:
|
|
96
|
+
"""Nobody is allowed unless listed. "*" allows everyone in the workspace."""
|
|
97
|
+
allowed = self.settings.get(key)
|
|
98
|
+
if allowed is None and key == "approvers":
|
|
99
|
+
allowed = self.settings.get("allowed_users")
|
|
100
|
+
allowed = allowed or []
|
|
101
|
+
return "*" in allowed or user_id in allowed
|
|
102
|
+
|
|
103
|
+
# ---- hooks called by the hub
|
|
104
|
+
|
|
105
|
+
def notify(self, run: dict[str, Any], link: str) -> None:
|
|
106
|
+
raise NotImplementedError
|
|
107
|
+
|
|
108
|
+
def run_finished(self, run: dict[str, Any], link: str) -> None:
|
|
109
|
+
"""Reply in the originating thread. Called for every run that reaches a resting state."""
|
|
110
|
+
raise NotImplementedError
|
|
111
|
+
|
|
112
|
+
def handle(self, headers: dict[str, str], body: bytes) -> Response:
|
|
113
|
+
"""Handle a request to /hooks/<name>."""
|
|
114
|
+
raise NotImplementedError
|
|
115
|
+
|
|
116
|
+
# ---- helpers shared by adapters
|
|
117
|
+
|
|
118
|
+
def origin(self, run: dict[str, Any]) -> dict[str, Any] | None:
|
|
119
|
+
"""The chat message this run (or the run it continues) came from, if it came from this platform."""
|
|
120
|
+
found = self._chat_origin(run, {self.name})
|
|
121
|
+
return found[1] if found else None
|
|
122
|
+
|
|
123
|
+
def from_chat(self, run: dict[str, Any]) -> bool:
|
|
124
|
+
"""True if the run came from any connected chat platform. That platform answers in the
|
|
125
|
+
original conversation, so the others stay quiet instead of offering a second set of buttons."""
|
|
126
|
+
return self._chat_origin(run, set(self.hub.adapters) | {self.name}) is not None
|
|
127
|
+
|
|
128
|
+
def _chat_origin(self, run: dict[str, Any], sources: set[str]) -> tuple[str, dict[str, Any]] | None:
|
|
129
|
+
seen: set[int] = set()
|
|
130
|
+
cur: dict[str, Any] | None = run
|
|
131
|
+
while cur and cur["id"] not in seen:
|
|
132
|
+
seen.add(cur["id"])
|
|
133
|
+
for e in cur.get("events") or []:
|
|
134
|
+
if e["source"] in sources and isinstance(e.get("payload"), dict) and e["payload"].get("chat"):
|
|
135
|
+
return e["source"], e["payload"]
|
|
136
|
+
parent = cur.get("parent_run") or cur.get("retry_of")
|
|
137
|
+
cur = self.hub.get_run(parent) if parent else None
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
def decide_from_chat(self, run_id: int, choice: str, user_id: str) -> tuple[bool, str, dict[str, Any] | None]:
|
|
141
|
+
"""Record a button click. Returns (ok, message for the clicker, run after the attempt)."""
|
|
142
|
+
from .hub import HubError
|
|
143
|
+
if not self.user_allowed(user_id, "approvers"):
|
|
144
|
+
log.warning("%s user %s tried to decide run %s but is not an approver", self.name, user_id, run_id)
|
|
145
|
+
return False, "You are not allowed to decide dispatchbox runs.", self.hub.get_run(run_id)
|
|
146
|
+
try:
|
|
147
|
+
self.hub.decide(run_id, choice, by=f"{self.name}:{user_id}")
|
|
148
|
+
except HubError as e:
|
|
149
|
+
return False, str(e), self.hub.get_run(run_id)
|
|
150
|
+
return True, f"Recorded: {choice}", self.hub.get_run(run_id)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def decision_text(run: dict[str, Any]) -> tuple[str, str, list[str]]:
|
|
154
|
+
"""(question, context, options) for a run awaiting a decision."""
|
|
155
|
+
req = run.get("decision_request") or {}
|
|
156
|
+
return str(req.get("question") or "The agent needs a decision."), str(req.get("context") or ""), \
|
|
157
|
+
[str(o) for o in (req.get("options") or [])][:5]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def answer_text(run: dict[str, Any], limit: int) -> str:
|
|
161
|
+
"""What the agent actually said, minus the DISPATCHBOX_* contract lines; the summary only if that is empty.
|
|
162
|
+
The answer is in the asker's language and has the detail; the summary is a one-liner for lists."""
|
|
163
|
+
lines = [l for l in (run.get("output_tail") or "").splitlines()
|
|
164
|
+
if not l.strip().startswith((DECISION_MARK, STATUS_MARK, SUMMARY_MARK))]
|
|
165
|
+
text = "\n".join(lines).strip() or str(run.get("summary") or "")
|
|
166
|
+
return text if len(text) <= limit else text[:limit - 1].rstrip() + "…"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def status_line(run: dict[str, Any]) -> str:
|
|
170
|
+
return f"{STATUS_LABEL.get(run['status'], run['status'])} · run #{run['id']} · {run['rule']}"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def decided_line(run: dict[str, Any]) -> str:
|
|
174
|
+
d = run.get("decision") or {}
|
|
175
|
+
who = str(d.get("by", "")).split(":", 1)[-1]
|
|
176
|
+
return f"Decision: {d.get('choice', '')}" + (f" (by {who})" if who else "")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def adapters_from_env(hub: "Hub", env: dict[str, str]) -> dict[str, ChatAdapter]:
|
|
180
|
+
"""Build the adapters whose credentials are present. A half-configured platform is an error, not a skip."""
|
|
181
|
+
adapters: dict[str, ChatAdapter] = {}
|
|
182
|
+
|
|
183
|
+
slack_keys = ("DISPATCHBOX_SLACK_BOT_TOKEN", "DISPATCHBOX_SLACK_SIGNING_SECRET")
|
|
184
|
+
have = [k for k in slack_keys if env.get(k)]
|
|
185
|
+
if have and len(have) != len(slack_keys):
|
|
186
|
+
missing = ", ".join(k for k in slack_keys if not env.get(k))
|
|
187
|
+
raise ChatError(f"Slack is half configured: set {missing} as well, or unset {', '.join(have)}")
|
|
188
|
+
if have:
|
|
189
|
+
from .slack import SlackAdapter
|
|
190
|
+
adapters["slack"] = SlackAdapter(hub, env["DISPATCHBOX_SLACK_BOT_TOKEN"], env["DISPATCHBOX_SLACK_SIGNING_SECRET"],
|
|
191
|
+
api_base=env.get("DISPATCHBOX_SLACK_API", "https://slack.com/api"))
|
|
192
|
+
|
|
193
|
+
feishu_keys = ("DISPATCHBOX_FEISHU_APP_ID", "DISPATCHBOX_FEISHU_APP_SECRET", "DISPATCHBOX_FEISHU_VERIFICATION_TOKEN")
|
|
194
|
+
have = [k for k in feishu_keys if env.get(k)]
|
|
195
|
+
if have and len(have) != len(feishu_keys):
|
|
196
|
+
missing = ", ".join(k for k in feishu_keys if not env.get(k))
|
|
197
|
+
raise ChatError(f"Feishu is half configured: set {missing} as well, or unset {', '.join(have)}")
|
|
198
|
+
if have:
|
|
199
|
+
from .feishu import API_BASE, TOKEN_URL, FeishuAdapter
|
|
200
|
+
adapters["feishu"] = FeishuAdapter(
|
|
201
|
+
hub, env["DISPATCHBOX_FEISHU_APP_ID"], env["DISPATCHBOX_FEISHU_APP_SECRET"], env["DISPATCHBOX_FEISHU_VERIFICATION_TOKEN"],
|
|
202
|
+
encrypt_key=env.get("DISPATCHBOX_FEISHU_ENCRYPT_KEY", ""),
|
|
203
|
+
api_base=env.get("DISPATCHBOX_FEISHU_API", API_BASE), token_url=env.get("DISPATCHBOX_FEISHU_TOKEN_URL", TOKEN_URL))
|
|
204
|
+
|
|
205
|
+
for name in adapters:
|
|
206
|
+
if not (hub.config.chat or {}).get(name):
|
|
207
|
+
log.warning("%s credentials are set but the rules file has no chat.%s section, so nobody is allowed "
|
|
208
|
+
"to use it yet", name, name)
|
|
209
|
+
return adapters
|
dispatchbox/cron.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Minimal 5-field cron matcher: minute hour day-of-month month day-of-week.
|
|
2
|
+
|
|
3
|
+
Supports `*`, `*/n`, `a`, `a-b`, `a-b/n` and comma lists. Day-of-week 0 and 7 are Sunday.
|
|
4
|
+
When both day fields are restricted, a time matches if either one matches (classic cron rule).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
_RANGES = [(0, 59), (0, 23), (1, 31), (1, 12), (0, 7)]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_field(text: str, lo: int, hi: int) -> set[int]:
|
|
15
|
+
values: set[int] = set()
|
|
16
|
+
for part in text.split(","):
|
|
17
|
+
step = 1
|
|
18
|
+
if "/" in part:
|
|
19
|
+
part, step_s = part.split("/", 1)
|
|
20
|
+
step = int(step_s)
|
|
21
|
+
if step <= 0:
|
|
22
|
+
raise ValueError(f"bad step in {text!r}")
|
|
23
|
+
if part == "*":
|
|
24
|
+
start, end = lo, hi
|
|
25
|
+
elif "-" in part:
|
|
26
|
+
a, b = part.split("-", 1)
|
|
27
|
+
start, end = int(a), int(b)
|
|
28
|
+
else:
|
|
29
|
+
start = int(part)
|
|
30
|
+
end = hi if step != 1 else start
|
|
31
|
+
if start < lo or end > hi or start > end:
|
|
32
|
+
raise ValueError(f"value out of range in {text!r}")
|
|
33
|
+
values.update(range(start, end + 1, step))
|
|
34
|
+
return values
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Cron:
|
|
38
|
+
def __init__(self, expr: str):
|
|
39
|
+
fields = expr.split()
|
|
40
|
+
if len(fields) != 5:
|
|
41
|
+
raise ValueError(f"cron needs 5 fields, got {expr!r}")
|
|
42
|
+
self.expr = expr
|
|
43
|
+
parsed = [_parse_field(f, lo, hi) for f, (lo, hi) in zip(fields, _RANGES)]
|
|
44
|
+
self.minutes, self.hours, self.days, self.months, dows = parsed
|
|
45
|
+
self.dows = {d % 7 for d in dows}
|
|
46
|
+
self.day_restricted = fields[2] != "*"
|
|
47
|
+
self.dow_restricted = fields[4] != "*"
|
|
48
|
+
|
|
49
|
+
def matches(self, t: datetime) -> bool:
|
|
50
|
+
if t.minute not in self.minutes or t.hour not in self.hours or t.month not in self.months:
|
|
51
|
+
return False
|
|
52
|
+
dom_ok = t.day in self.days
|
|
53
|
+
dow_ok = (t.isoweekday() % 7) in self.dows
|
|
54
|
+
if self.day_restricted and self.dow_restricted:
|
|
55
|
+
return dom_ok or dow_ok
|
|
56
|
+
return dom_ok and dow_ok
|