From 57ad23c5dcf9a481f21b19ef3e6a1853e2002a99 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:43:39 -0700 Subject: [PATCH] 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. --- cron/jobs.py | 33 +++++++++++++++++++++++++++------ tests/cron/test_jobs.py | 22 ++++++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/cron/jobs.py b/cron/jobs.py index 2d9862e6f3..3c55f01d40 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -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:] diff --git a/tests/cron/test_jobs.py b/tests/cron/test_jobs.py index d72d1d76df..4dffbcc6f2 100644 --- a/tests/cron/test_jobs.py +++ b/tests/cron/test_jobs.py @@ -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.