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