The goal: write a cron-expression parser (next_run) so that all 21 tests pass. The models get three tools (read a file, write a file, run the tests) and a budget of twelve
turns. The test file is the spec; the expected dates come from an independent brute-force program the models never see. Our own tiny agent loop ran it; nothing was installed on the machine.
4 turns of 1224 s$0.117 final: 21 of 21 tests pass
"""5-field cron next_run implementation (standard library only)."""
from datetime import datetime, timedelta
__all__ = ["next_run"]
def _parse_int(token, whole):
# Strict integer parsing: only optional leading '-' and digits.
# int() alone would accept '+5', underscores ('1_0'), whitespace, etc.
s = token
if not s:
raise ValueError(f"Invalid cron field: {whole!r}")
body = s[1:] if s.startswith("-") else s
if not body.isdigit():
raise ValueError(f"Invalid cron field: {whole!r}")
return int(s)
def _parse_field(text, lo, hi):
if not text:
raise ValueError("Empty cron field")
values = set()
for part in text.split(","):
if not part:
raise ValueError(f"Invalid cron field: {text!r}")
if part.count("/") > 1:
raise ValueError(f"Invalid cron field: {text!r}")
if "/" in part:
base, step_token = part.split("/", 1)
step = _parse_int(step_token, text)
if step <= 0:
raise ValueError(f"Invalid step in cron field: {text!r}")
has_step = True
else:
base = part
step = 1
has_step = False
if not base:
raise ValueError(f"Invalid cron field: {text!r}")
if base == "*":
start, end = lo, hi
elif "-" in base:
# Range; also catches negative numbers / malformed ranges
# because split will produce empty or extra pieces.
pieces = base.split("-")
if len(pieces) != 2 or not pieces[0] or not pieces[1]:
raise ValueError(f"Invalid range in cron field: {text!r}")
start = _parse_int(pieces[0], text)
end = _parse_int(pieces[1], text)
if start < lo or end > hi or start > end:
raise ValueError(f"Range out of bounds in cron field: {text!r}")
else:
val = _parse_int(base, text)
if val < lo or val > hi:
raise ValueError(f"Value out of bounds in cron field: {text!r}")
if has_step:
# Step on a plain number: from that number to field max.
start, end = val, hi
else:
values.add(val)
continue
for v in range(start, end + 1, step):
values.add(v)
if not values:
raise ValueError(f"Invalid cron field: {text!r}")
return values
def next_run(expr: str, after: datetime) -> datetime:
if not isinstance(expr, str):
raise ValueError("Cron expression must be a string")
fields = expr.split()
if len(fields) != 5:
raise ValueError(f"Cron expression must have 5 fields: {expr!r}")
minute_f, hour_f, dom_f, month_f, dow_f = fields
minutes = _parse_field(minute_f, 0, 59)
hours = _parse_field(hour_f, 0, 23)
dom = _parse_field(dom_f, 1, 31)
months = _parse_field(month_f, 1, 12)
dow = _parse_field(dow_f, 0, 6)
dom_restricted = not dom_f.startswith("*")
dow_restricted = not dow_f.startswith("*")
both_restricted = dom_restricted and dow_restricted
sorted_minutes = sorted(minutes)
sorted_hours = sorted(hours)
# First minute strictly after `after`, seconds/microseconds ignored.
start = after.replace(second=0, microsecond=0) + timedelta(minutes=1)
# Search day-by-day. Upper bound generous enough for leap-year-only
# schedules (Feb 29) while avoiding an infinite loop for impossible
# schedules such as Feb 31.
# 20 years of days is cheap when iterating per-day.
start_date = start.date()
for offset in range(366 * 20 + 2):
day = start_date + timedelta(days=offset)
if day.month not in months:
continue
dom_match = day.day in dom
# Python: Monday=0 .. Sunday=6 ; cron: Sunday=0 .. Saturday=6
cron_dow = (day.weekday() + 1) % 7
dow_match = cron_dow in dow
if both_restricted:
test_cron.Cron.test_14) ... ok test_15 (test_cron.Cron.test_15) ... ok test_16 (test_cron.Cron.test_16) ... ok test_17 (test_cron.Cron.test_17) ... ok test_18 (test_cron.Cron.test_18) ... ok test_19 (test_cron.Cron.test_19) ... ok test_20 (test_cron.Cron.test_20) ... ok test_invalid (test_cron.Cron.test_invalid) ... ok ---------------------------------------------------------------------- Ran 21 tests in 0.005s OK
3 turns of 1221 s$0.065 final: 21 of 21 tests pass
"""Minimal 5-field cron evaluator (standard library only)."""
from datetime import datetime, timedelta
_RANGES = [(0, 59), (0, 23), (1, 31), (1, 12), (0, 6)]
def _num(s, lo, hi):
if not s.isdigit():
raise ValueError(f"bad number: {s!r}")
v = int(s)
if not lo <= v <= hi:
raise ValueError(f"out of range: {v}")
return v
def _field(text, lo, hi):
out = set()
for part in text.split(','):
if not part:
raise ValueError("empty token")
base, step = part, 1
if '/' in part:
base, st = part.split('/', 1)
if not st.isdigit() or int(st) == 0:
raise ValueError(f"bad step: {st!r}")
step = int(st)
if base == '*':
a, b = lo, hi
elif '-' in base:
x, y = base.split('-', 1)
a, b = _num(x, lo, hi), _num(y, lo, hi)
if a > b:
raise ValueError("reversed range")
else:
a = _num(base, lo, hi)
b = hi if '/' in part else a
out.update(range(a, b + 1, step))
return out
def next_run(expr: str, after: datetime) -> datetime:
fields = expr.split()
if len(fields) != 5:
raise ValueError("need 5 fields")
mins, hours, doms, mons, dows = (_field(f, *r) for f, r in zip(fields, _RANGES))
dom_star, dow_star = fields[2].startswith('*'), fields[4].startswith('*')
def day_ok(d):
dm = d.day in doms
dw = (d.isoweekday() % 7) in dows
if not dom_star and not dow_star:
return dm or dw
return dm and dw
t = after.replace(second=0, microsecond=0) + timedelta(minutes=1)
limit = t.replace(year=t.year + 9) if not (t.month == 2 and t.day == 29) else t + timedelta(days=9 * 366)
while t < limit:
if t.month not in mons:
y, m = (t.year + 1, 1) if t.month == 12 else (t.year, t.month + 1)
t = datetime(y, m, 1)
continue
if not day_ok(t):
t = datetime(t.year, t.month, t.day) + timedelta(days=1)
continue
if t.hour not in hours:
t = datetime(t.year, t.month, t.day, t.hour) + timedelta(hours=1)
continue
if t.minute not in mins:
t += timedelta(minutes=1)
continue
return t
raise ValueError("no matching time found")
test_cron.Cron.test_14) ... ok test_15 (test_cron.Cron.test_15) ... ok test_16 (test_cron.Cron.test_16) ... ok test_17 (test_cron.Cron.test_17) ... ok test_18 (test_cron.Cron.test_18) ... ok test_19 (test_cron.Cron.test_19) ... ok test_20 (test_cron.Cron.test_20) ... ok test_invalid (test_cron.Cron.test_invalid) ... ok ---------------------------------------------------------------------- Ran 21 tests in 0.004s OK