potiuk commented on code in PR #73294:
URL: https://github.com/apache/airflow/pull/73294#discussion_r4066457568


##########
task-sdk/src/airflow/sdk/definitions/deadline.py:
##########
@@ -154,14 +154,22 @@ def __init__(
         callback: Callback,
         name: str | None = None,
     ):
+        if not isinstance(interval, (timedelta, VariableInterval)):

Review Comment:
   A bare number currently round-trips end to end, and this guard rejects it.
   
   `_decode_deadline_interval` in 
`airflow-core/src/airflow/serialization/decoders.py` has an explicit 
backward-compat branch that turns an `int`/`float` into 
`timedelta(seconds=...)`. On `main` today:
   
   ```python
   DeadlineAlert(reference=..., interval=3600, callback=SyncCallback(...))
   # serialized interval   -> 3600
   # deserialized interval -> datetime.timedelta(seconds=3600)
   ```
   
   After this change that Dag stops parsing. `int` was never documented or 
type-hinted, so tightening it is defensible — but it is a user-visible 
behaviour change, and this PR carries `backport-to-v3-3-test`, which would land 
it in a patch line.
   
   Please make the call explicitly before merge, either:
   
   - accept numbers and normalise them (`timedelta(seconds=interval)`), keeping 
the authoring surface consistent with the decoder's back-compat branch; or
   - keep the rejection, drop `backport-to-v3-3-test`, and add an 
`airflow-core/newsfragments/` entry so the break is announced.



##########
task-sdk/src/airflow/sdk/definitions/deadline.py:
##########
@@ -154,14 +154,22 @@ def __init__(
         callback: Callback,
         name: str | None = None,
     ):
+        if not isinstance(interval, (timedelta, VariableInterval)):
+            raise ValueError(
+                f"Interval must be a `timedelta` or a `VariableInterval`, 
received {type(interval).__name__}."
+            )
+
+        # Serializing blocks subclasses for security reasons, so isinstance is 
too loose.
+        if type(callback) not in (AsyncCallback, SyncCallback):

Review Comment:
   This tuple is now the second copy of the permitted-callback set. The first 
is `permitted` in `_decode_deadline_callback` 
(`airflow-core/src/airflow/serialization/decoders.py`), which builds its 
allow-list from `(AsyncCallback, SyncCallback)` too.
   
   The comment above justifies this check precisely by mirroring that 
allow-list, so the two drifting apart quietly invalidates the justification: 
add a third callback type later and this guard rejects it at authoring time 
even though the decoder would happily have accepted it — with no test anywhere 
that would notice.
   
   Please tie them to one source: export something like 
`DEADLINE_CALLBACK_TYPES = (AsyncCallback, SyncCallback)` from 
`airflow.sdk.definitions.callback` and build both sites from it. Core already 
imports the SDK, so the dependency direction works.



##########
task-sdk/src/airflow/sdk/definitions/deadline.py:
##########
@@ -154,14 +154,22 @@ def __init__(
         callback: Callback,

Review Comment:
   Please narrow this annotation to `AsyncCallback | SyncCallback`.
   
   After the check below, `Callback` is wider than what `__init__` actually 
accepts: a `Callback` subclass type-checks cleanly and then raises at runtime. 
The narrower annotation states the real contract, and lets mypy catch the 
subclass case at static-check time instead of at Dag-parse time — which is the 
same "fail earlier" goal this PR is pursuing.



##########
task-sdk/tests/task_sdk/definitions/test_deadline.py:
##########
@@ -142,30 +147,101 @@ def test_deadline_alert_in_set(self):
         assert len(alert_set) == 1
 
     @pytest.mark.parametrize(
-        ("callback_class"),
+        ("test_callback", "expected_name", "expected_pass"),
         [
-            pytest.param(AsyncCallback, id="async_callback"),
-            pytest.param(SyncCallback, id="sync_callback"),
+            pytest.param(
+                SyncCallback(TEST_CALLBACK_PATH),
+                "SyncCallback",
+                True,
+                id="sync_callback_passes",
+            ),
+            pytest.param(
+                AsyncCallback(TEST_CALLBACK_PATH),
+                "AsyncCallback",
+                True,
+                id="async_callback_passes",
+            ),
+            pytest.param(
+                type(TEST_CALLBACK_PATH),
+                "type",
+                False,
+                id="non_callback_callable_fails",
+            ),
+            pytest.param(
+                SubclassedCallback(TEST_CALLBACK_PATH),
+                "SubclassedCallback",
+                False,
+                id="subclassed_callback_fails",
+            ),
+            pytest.param(
+                "not_a_callback",
+                "str",
+                False,
+                id="non_callback_fails",
+            ),
+            pytest.param(
+                None,
+                "NoneType",
+                False,
+                id="can_not_be_none",
+            ),
         ],
     )
-    def test_deadline_alert_accepts_all_callbacks(self, callback_class):
-        alert = DeadlineAlert(
-            reference=DeadlineReference.DAGRUN_QUEUED_AT,
-            interval=timedelta(hours=1),
-            callback=callback_class(TEST_CALLBACK_PATH),
-        )
-        assert alert.callback is not None
-        assert isinstance(alert.callback, callback_class)
-
-    def test_deadline_alert_rejects_invalid_callback(self):
-        """Test that DeadlineAlert rejects non-callback types."""
-        with pytest.raises(ValueError, match="Callbacks of type str are not 
currently supported"):
-            DeadlineAlert(
+    def test_deadline_init_callback_type_checks(self, test_callback, 
expected_name, expected_pass):
+        if expected_pass:
+            alert = DeadlineAlert(
                 reference=DeadlineReference.DAGRUN_QUEUED_AT,
                 interval=timedelta(hours=1),
-                callback="not_a_callback",  # type: ignore
+                callback=test_callback,
+            )
+
+            assert alert.callback is test_callback
+            assert type(alert.callback).__name__ == expected_name
+        else:
+            with pytest.raises(
+                ValueError,
+                match=f"Callbacks must be `AsyncCallback` or `SyncCallback`, 
received {expected_name}",
+            ):
+                DeadlineAlert(
+                    reference=DeadlineReference.DAGRUN_QUEUED_AT,
+                    interval=timedelta(hours=1),
+                    callback=test_callback,
+                )
+
+    @pytest.mark.parametrize(
+        ("test_interval", "expected_pass"),
+        [
+            pytest.param(timedelta(1), True, id="positive_timedelta_passes"),
+            pytest.param(timedelta(-1), True, id="negative_timedelta_passes"),
+            pytest.param(timedelta(0), True, id="zero_timedelta_passes"),
+            pytest.param(VariableInterval("var"), True, 
id="VariableInterval_passes"),
+            pytest.param(1, False, id="int_fails"),
+            pytest.param(0.1, False, id="float_fails"),
+            pytest.param(True, False, id="bool_fails"),
+            pytest.param("str", False, id="string_fails"),
+            pytest.param(None, False, id="can_not_be_none"),
+        ],
+    )
+    def test_deadline_init_interval_type_checks(self, test_interval, 
expected_pass):
+        if expected_pass:
+            alert = DeadlineAlert(
+                reference=DeadlineReference.DAGRUN_QUEUED_AT,
+                interval=test_interval,
+                callback=TEST_DEADLINE_CALLBACK,
             )
 
+            assert alert.interval == test_interval
+            assert type(alert.interval) is type(test_interval)
+        else:
+            with pytest.raises(
+                ValueError, match="Interval must be a `timedelta` or a 
`VariableInterval`, received"

Review Comment:
   Please assert the reported type name here, the way 
`test_deadline_init_callback_type_checks` does above.
   
   As written, `match` stops at `received`, so the part of the message that 
names the offending type is never exercised — the test would still pass if the 
f-string reported the wrong type, or dropped it entirely. Given the whole point 
of the new message is telling the Dag author *what* they passed, that's the 
half worth pinning.
   
   The params already carry what's needed; adding an `expected_name` column 
alongside `expected_pass` mirrors the callback test exactly.



##########
task-sdk/src/airflow/sdk/definitions/deadline.py:
##########
@@ -154,14 +154,22 @@ def __init__(
         callback: Callback,
         name: str | None = None,
     ):
+        if not isinstance(interval, (timedelta, VariableInterval)):
+            raise ValueError(
+                f"Interval must be a `timedelta` or a `VariableInterval`, 
received {type(interval).__name__}."
+            )
+
+        # Serializing blocks subclasses for security reasons, so isinstance is 
too loose.

Review Comment:
   This comment states the principle, and the interval check two lines above 
doesn't follow it.
   
   `isinstance(interval, timedelta)` accepts `timedelta` subclasses, and the 
one Dag authors actually reach for is `pendulum.duration(...)` — Pendulum is 
Airflow's own datetime library, and `pendulum.Duration` subclasses 
`datetime.timedelta`. It passes this guard and then fails later:
   
   ```text
   TypeError: Cannot serialize object of type <class 
'pendulum.duration.Duration'>.
   Give it a `serialize()` method and a `deserialize(data, version)` 
staticmethod, ...
   ```
   
   That is exactly the deferred, opaque failure this PR exists to move earlier 
— so the interval guard doesn't yet do its job for the most likely wrong-ish 
input.
   
   Please fix by normalising rather than rejecting, which has the bonus of 
making pendulum durations actually work:
   
   ```python
   if isinstance(interval, timedelta) and type(interval) is not timedelta:
       interval = timedelta(seconds=interval.total_seconds())
   ```
   
   Rejecting instead is a fine answer too — but then it should be an exact-type 
check matching the callback one below, so the two guards tell the same story.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to