This is an automated email from the ASF dual-hosted git repository.
jscheffl pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 4b407142e59 Fix CronMixin in task-sdk not resolving cron presets
before validation (#66102)
4b407142e59 is described below
commit 4b407142e59fae4f14b0ddcf7b4c8c4b2e634575
Author: Shashwati Bhattacharyaa <[email protected]>
AuthorDate: Sun Jun 7 23:39:26 2026 +0530
Fix CronMixin in task-sdk not resolving cron presets before validation
(#66102)
* Fix CronMixin not resolving cron presets before validation
* Fix ruff: use tuple for pytest.mark.parametrize first argument
* Address review: add sync comment, explain post_init, static test table,
add timetable test
* Make airflow-core re-export CRON_PRESETS from SDK as single source of
truth
* Fix ruff removing re-export import by adding noqa: F401
* Revert re-export: restore cron_presets dict in core, add sync comments
* Move timetable tests to timetables/test__cron.py to match module structure
---------
Co-authored-by: Shashwati <[email protected]>
---
airflow-core/src/airflow/utils/dates.py | 2 +
.../airflow/sdk/definitions/timetables/_cron.py | 18 ++++++
.../task_sdk/definitions/timetables/__init__.py} | 25 ---------
.../task_sdk/definitions/timetables/test__cron.py | 64 ++++++++++++++++++++++
4 files changed, 84 insertions(+), 25 deletions(-)
diff --git a/airflow-core/src/airflow/utils/dates.py
b/airflow-core/src/airflow/utils/dates.py
index 422c662f936..c2fb08f388b 100644
--- a/airflow-core/src/airflow/utils/dates.py
+++ b/airflow-core/src/airflow/utils/dates.py
@@ -19,6 +19,8 @@ from __future__ import annotations
import calendar
+# NOTE: Keep in sync with CRON_PRESETS in
task-sdk/src/airflow/sdk/definitions/timetables/_cron.py
+# The SDK cannot import from core, so both dicts must be updated together.
cron_presets: dict[str, str] = {
"@hourly": "0 * * * *",
"@daily": "0 0 * * *",
diff --git a/task-sdk/src/airflow/sdk/definitions/timetables/_cron.py
b/task-sdk/src/airflow/sdk/definitions/timetables/_cron.py
index e0fa7c26966..8d53f1fe666 100644
--- a/task-sdk/src/airflow/sdk/definitions/timetables/_cron.py
+++ b/task-sdk/src/airflow/sdk/definitions/timetables/_cron.py
@@ -27,6 +27,18 @@ if TYPE_CHECKING:
from pendulum.tz.timezone import FixedTimezone, Timezone
+# NOTE: Keep in sync with cron_presets in
airflow-core/src/airflow/utils/dates.py
+# Core cannot be imported from the SDK, so both dicts must be updated together.
+CRON_PRESETS: dict[str, str] = {
+ "@hourly": "0 * * * *",
+ "@daily": "0 0 * * *",
+ "@weekly": "0 0 * * 0",
+ "@monthly": "0 0 1 * *",
+ "@quarterly": "0 0 1 */3 *",
+ "@yearly": "0 0 1 1 *",
+}
+
+
@attrs.define
class CronMixin:
"""Mixin to provide interface to work with croniter."""
@@ -34,6 +46,12 @@ class CronMixin:
expression: str
timezone: str | Timezone | FixedTimezone
+ def __attrs_post_init__(self) -> None:
+ # Resolve preset aliases (e.g. "@quarterly") to their cron expressions
+ # in-place. After this point the original preset string is lost;
+ # attrs.evolve, equality, and serialisation all see the resolved form.
+ self.expression = CRON_PRESETS.get(self.expression, self.expression)
+
def validate(self) -> None:
try:
croniter(self.expression)
diff --git a/task-sdk/src/airflow/sdk/definitions/timetables/_cron.py
b/task-sdk/tests/task_sdk/definitions/timetables/__init__.py
similarity index 54%
copy from task-sdk/src/airflow/sdk/definitions/timetables/_cron.py
copy to task-sdk/tests/task_sdk/definitions/timetables/__init__.py
index e0fa7c26966..13a83393a91 100644
--- a/task-sdk/src/airflow/sdk/definitions/timetables/_cron.py
+++ b/task-sdk/tests/task_sdk/definitions/timetables/__init__.py
@@ -14,28 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-
-import attrs
-from croniter import CroniterBadCronError, CroniterBadDateError, croniter
-
-from airflow.sdk.exceptions import AirflowTimetableInvalid
-
-if TYPE_CHECKING:
- from pendulum.tz.timezone import FixedTimezone, Timezone
-
-
[email protected]
-class CronMixin:
- """Mixin to provide interface to work with croniter."""
-
- expression: str
- timezone: str | Timezone | FixedTimezone
-
- def validate(self) -> None:
- try:
- croniter(self.expression)
- except (CroniterBadCronError, CroniterBadDateError) as e:
- raise AirflowTimetableInvalid(str(e))
diff --git a/task-sdk/tests/task_sdk/definitions/timetables/test__cron.py
b/task-sdk/tests/task_sdk/definitions/timetables/test__cron.py
new file mode 100644
index 00000000000..064110b9bed
--- /dev/null
+++ b/task-sdk/tests/task_sdk/definitions/timetables/test__cron.py
@@ -0,0 +1,64 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import pytest
+
+from airflow.sdk.definitions.timetables._cron import CronMixin
+from airflow.sdk.definitions.timetables.interval import
CronDataIntervalTimetable
+from airflow.sdk.exceptions import AirflowTimetableInvalid
+
+SAMPLE_TZ = "UTC"
+
+# Static table so a typo in CRON_PRESETS is caught by the test.
+PRESET_CASES = [
+ ("@hourly", "0 * * * *"),
+ ("@daily", "0 0 * * *"),
+ ("@weekly", "0 0 * * 0"),
+ ("@monthly", "0 0 1 * *"),
+ ("@quarterly", "0 0 1 */3 *"),
+ ("@yearly", "0 0 1 1 *"),
+]
+
+
[email protected](("preset", "expected"), PRESET_CASES)
+def test_cron_preset_resolved(preset, expected):
+ cm = CronMixin(expression=preset, timezone=SAMPLE_TZ)
+ assert cm.expression == expected
+
+
+def test_cron_preset_validate_does_not_raise():
+ cm = CronMixin(expression="@quarterly", timezone=SAMPLE_TZ)
+ cm.validate()
+
+
+def test_invalid_cron_expression_raises():
+ cm = CronMixin(expression="invalid", timezone=SAMPLE_TZ)
+ with pytest.raises(AirflowTimetableInvalid):
+ cm.validate()
+
+
+def test_valid_cron_expression_does_not_raise():
+ cm = CronMixin(expression="0 0 * * *", timezone=SAMPLE_TZ)
+ cm.validate()
+
+
+def test_cron_data_interval_timetable_quarterly_preset():
+ """Regression test for #66101: CronDataIntervalTimetable must accept
@quarterly."""
+ timetable = CronDataIntervalTimetable(expression="@quarterly",
timezone=SAMPLE_TZ)
+ assert timetable.expression == "0 0 1 */3 *"
+ timetable.validate()