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()

Reply via email to