From 72cd352d2ae1ee953e94329fec96d2adb3eef3c0 Mon Sep 17 00:00:00 2001 From: Ethan Stoner Date: Sun, 30 Aug 2026 03:11:53 -0700 Subject: [PATCH] Fix cron description treating "?" as a day-of-month/day-of-week conflict croniter accepts "?" in the day-of-month and day-of-week fields and expands it to "*", so "0 0 ? * MON" schedules exactly like "0 0 * * MON". The DOM/DOW conflict check compared the two fields against "*" only, so a "?" counted as a restriction and the generated description was split into a bogus "or" clause: 0 0 ? * MON -> "At 00:00 (or) At 00:00, only on Monday" 0 0 1 * ? -> "At 00:00, on day 1 of the month (or) At 00:00" The extra clause claims a daily midnight run that never happens. This description is stored as DagModel.timetable_description and shown in the UI, the REST API and "airflow dags details". Treat "?" as unrestricted alongside "*". Genuine DOM/DOW conflicts such as "0 0 15 * 1" keep the existing "or" explanation. Signed-off-by: Ethan Stoner --- airflow-core/src/airflow/timetables/_cron.py | 8 +++++++- .../tests/unit/timetables/test_cron_mixin.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/airflow-core/src/airflow/timetables/_cron.py b/airflow-core/src/airflow/timetables/_cron.py index ed0b3c76bb786..dc40a9eea5deb 100644 --- a/airflow-core/src/airflow/timetables/_cron.py +++ b/airflow-core/src/airflow/timetables/_cron.py @@ -31,6 +31,12 @@ from pendulum.tz.timezone import FixedTimezone, Timezone +# croniter accepts ``?`` in the day-of-month and day-of-week fields and expands +# it to ``*``, so it must not count as a restriction when deciding whether the +# DOM/DOW pair needs the "or" explanation. +_UNRESTRICTED_DAY_FIELDS = frozenset({"*", "?"}) + + def _covers_every_hour(cron: croniter) -> bool: """ Check whether the given cron runs at least once an hour. @@ -97,7 +103,7 @@ def _describe_with_dom_dow_fix(self, expression: str) -> str: dom = cron_fields[2] dow = cron_fields[4] - if dom != "*" and dow != "*": + if dom not in _UNRESTRICTED_DAY_FIELDS and dow not in _UNRESTRICTED_DAY_FIELDS: # Case: conflict → DOM OR DOW cron_fields_dom = cron_fields.copy() cron_fields_dom[4] = "*" diff --git a/airflow-core/tests/unit/timetables/test_cron_mixin.py b/airflow-core/tests/unit/timetables/test_cron_mixin.py index d8d5ff44df30b..06a54f8d332ee 100644 --- a/airflow-core/tests/unit/timetables/test_cron_mixin.py +++ b/airflow-core/tests/unit/timetables/test_cron_mixin.py @@ -16,6 +16,8 @@ # under the License. from __future__ import annotations +import pytest + from airflow.timetables._cron import CronMixin SAMPLE_TZ = "UTC" @@ -39,3 +41,20 @@ def test_dom_and_dow_conflict(): assert "(or)" in desc assert "Every minute, on day 1 of the month" in desc assert "Every minute, only on Monday" in desc + + +@pytest.mark.parametrize( + ("expression", "equivalent"), + [ + pytest.param("0 0 ? * MON", "0 0 * * MON", id="question-mark-day-of-month"), + pytest.param("0 0 1 * ?", "0 0 1 * *", id="question-mark-day-of-week"), + pytest.param("0 0 ? * ?", "0 0 * * *", id="question-mark-both"), + ], +) +def test_question_mark_is_not_a_dom_dow_conflict(expression, equivalent): + # croniter expands "?" to "*", so it must describe the same as the "*" form + # instead of being reported as a day-of-month/day-of-week conflict. + desc = CronMixin(expression, SAMPLE_TZ).description + + assert "(or)" not in desc + assert desc == CronMixin(equivalent, SAMPLE_TZ).description