fix(cron): accept weekday lists and no-'every' natural schedules (#51975)

Widens _natural_every_to_cron to consume comma/'and'-separated weekday
lists ('Monday, Wednesday at 9am' -> '0 9 * * 1,3') and applies the same
helper to schedules without the 'every' prefix, matching the exact forms
the Desktop dialog advertises in the #51975 repro.
This commit is contained in:
Teknium
2026-08-29 18:43:39 -07:00
parent 77fd6db4c6
commit 57ad23c5dc
2 changed files with 49 additions and 6 deletions
+27 -6
View File
@@ -861,19 +861,40 @@ def _natural_every_to_cron(rest: str) -> Optional[str]:
"monday 9am" -> "0 9 * * 1"
"day at 9am" -> "0 9 * * *"
"weekday at 9am" -> "0 9 * * 1-5"
"monday, wednesday at 9am" -> "0 9 * * 1,3"
Returning None lets ``parse_schedule`` fall back to the interval
(``every 30m``) path, so existing duration schedules are unaffected.
"""
tokens = rest.lower().split()
tokens = rest.lower().replace(",", " ").split()
if not tokens:
return None
day_token = tokens[0]
dow = _WEEKDAY_TO_CRON_DOW.get(day_token) or _DAYSPEC_TO_CRON_DOW.get(day_token)
if dow is None:
return None
time_tokens = tokens[1:]
# Consume one or more leading day tokens: a keyword spec ("weekdays"),
# a single weekday, or a comma/"and"-separated weekday list
# ("monday, wednesday at 9am").
day_token = tokens[0]
dow = _DAYSPEC_TO_CRON_DOW.get(day_token)
idx = 1
if dow is None:
days = []
while idx <= len(tokens):
tok = tokens[idx - 1]
if tok == "and":
idx += 1
continue
mapped = _WEEKDAY_TO_CRON_DOW.get(tok)
if mapped is None:
break
if mapped not in days:
days.append(mapped)
idx += 1
if not days:
return None
dow = ",".join(days)
idx -= 1
time_tokens = tokens[idx:]
# Optional "at" separator: "every day at 9am".
if time_tokens and time_tokens[0] == "at":
time_tokens = time_tokens[1:]
+22
View File
@@ -129,6 +129,28 @@ class TestParseSchedule:
pytest.importorskip("croniter")
assert parse_schedule("every weekend at 10am")["expr"] == "0 10 * * 0,6"
def test_no_every_prefix_natural_forms(self):
# The Desktop dialog advertises these WITHOUT the "every" prefix
# (#51975 repro): they must parse identically.
pytest.importorskip("croniter")
assert parse_schedule("weekdays at 9am")["expr"] == "0 9 * * 1-5"
assert parse_schedule("monday at 9:30")["expr"] == "30 9 * * 1"
assert parse_schedule("daily at 7am")["expr"] == "0 7 * * *"
def test_weekday_list_forms(self):
# Comma/"and"-separated day lists from the #51975 repro.
pytest.importorskip("croniter")
assert parse_schedule("Monday, Wednesday at 9am")["expr"] == "0 9 * * 1,3"
assert parse_schedule("monday and friday at 5pm")["expr"] == "0 17 * * 1,5"
assert parse_schedule("every tue, thu 8am")["expr"] == "0 8 * * 2,4"
def test_natural_form_negatives_still_reject(self):
pytest.importorskip("croniter")
with pytest.raises(ValueError):
parse_schedule("monday banana at 9am")
with pytest.raises(ValueError):
parse_schedule("funday at 9am")
def test_every_time_formats(self):
pytest.importorskip("croniter")
# 24-hour, explicit minutes, noon/midnight, bare hour.