dabla commented on code in PR #73928:
URL: https://github.com/apache/airflow/pull/73928#discussion_r4179003733


##########
providers/standard/src/airflow/providers/standard/sensors/date_time.py:
##########
@@ -115,39 +117,53 @@ def __init__(
     ) -> None:
         super().__init__(**kwargs)
         self.end_from_trigger = end_from_trigger
-
-        # A templated target is rendered after Dag parsing, so it cannot be 
used to
-        # construct the trigger arguments at task initialization time.
-        if (
-            start_from_trigger
-            and isinstance(self.target_time, str)
-            and any(delimiter in self.target_time for delimiter in ("{{", 
"{%", "{#"))
-        ):
-            start_from_trigger = False
-
         self.start_from_trigger = start_from_trigger
+
         if self.start_from_trigger:
-            # Replaced rather than mutated: ``start_trigger_args`` is a class 
attribute, so
-            # assigning through it would overwrite the arguments of every 
other task built
-            # from this operator.
-            self.start_trigger_args = dataclasses.replace(
-                self.start_trigger_args,
-                trigger_kwargs=dict(
-                    moment=self._moment,
-                    end_from_trigger=self.end_from_trigger,
-                ),
-            )
+            if AIRFLOW_V_3_3_PLUS:
+                self.start_trigger_args = dataclasses.replace(
+                    self.start_trigger_args,
+                    trigger_kwargs={
+                        "target_time": self.target_time,
+                        "end_from_trigger": self.end_from_trigger,
+                    },
+                )
+            elif isinstance(self.target_time, str) and any(
+                delimiter in self.target_time for delimiter in ("{{", "{%", 
"{#")
+            ):
+                warnings.warn(
+                    f"start_from_trigger=True with a templated target_time 
({self.target_time!r}) requires "
+                    "Airflow >= 3.3. Disabling start_from_trigger, the task 
will defer from the worker instead.",
+                    UserWarning,
+                    stacklevel=2,
+                )
+                self.start_from_trigger = False
+            else:
+                self.start_trigger_args = dataclasses.replace(
+                    self.start_trigger_args,
+                    trigger_kwargs={
+                        "moment": self._moment,
+                        "end_from_trigger": self.end_from_trigger,
+                    },
+                )
 
     def execute(self, context: Context) -> NoReturn:
-        self.defer(
-            method_name="execute_complete",
-            trigger=DateTimeTrigger(
+
+        if AIRFLOW_V_3_3_PLUS:
+            trigger = DateTimeTrigger(
+                target_time=self.target_time,

Review Comment:
   **[blocker]** `execute()` branches on the wrong condition: it should check 
`self.start_from_trigger`, not the Airflow version, and keep building the 
trigger from `moment`.
   
   There are two separate paths:
   
   - **Triggerer path** (`start_from_trigger=True`): the scheduler defers the 
task directly from `start_trigger_args`, and the triggerer renders the raw 
`target_time` from `trigger_kwargs`. `execute()` is not involved.
   - **Worker path** (`start_from_trigger=False`): the worker has already 
rendered `self.target_time` when `execute()` runs, so the trigger can be built 
from `moment=self._moment` exactly as on `main`.
   
   Selecting `target_time=` in `execute()` on 3.3+ applies the triggerer-path 
mechanism to the worker path, which has two costs:
   
   - It ships the rendered value with whatever type it has. A `datetime` 
(passed directly, or rendered natively) reaches the trigger as `target_time` 
and fails there, see my comment on `DateTimeTrigger.moment`.
   - Plain deferral now depends on the triggerer running this provider version. 
An older `DateTimeTrigger` rejects the unknown `target_time` kwarg, while 
`moment` works with every version.
   
   Suggestion:
   
   ```python
   def execute(self, context: Context) -> None:
       if not self.start_from_trigger:
           if AIRFLOW_V_3_0_PLUS:
               trigger = DateTimeTrigger(moment=self._moment, 
end_from_trigger=self.end_from_trigger)
           else:
               trigger = DateTimeTrigger(moment=self._moment)
           self.defer(method_name="execute_complete", trigger=trigger)
   ```
   
   With that, the changes to `test_supervisor.py` and `test_task_runner.py` are 
no longer needed.



##########
providers/standard/src/airflow/providers/standard/triggers/temporal.py:
##########
@@ -31,31 +31,55 @@ class DateTimeTrigger(BaseTrigger):
     """
     Trigger based on a datetime.
 
-    A trigger that fires exactly once, at the given datetime, give or take
-    a few seconds.
-
-    The provided datetime MUST be in UTC.
+    Pass either ``moment`` (a tz-aware datetime) or ``target_time`` (a string, 
possibly a Jinja
+    template). ``target_time`` is listed in ``template_fields`` so that, with 
``start_from_trigger``,
+    the triggerer renders it in place before ``run()`` and it is parsed into 
``moment`` on first use.
 
     :param moment: when to yield event
+    :param target_time: raw (possibly templated) datetime string, an 
alternative to ``moment``
     :param end_from_trigger: whether the trigger should mark the task 
successful after time condition
         reached or resume the task after time condition reached.
     """
 
-    def __init__(self, moment: datetime.datetime, *, end_from_trigger: bool = 
False) -> None:
+    template_fields = ("target_time",)
+
+    def __init__(
+        self,
+        moment: datetime.datetime | None = None,
+        *,
+        target_time: datetime.datetime | str | None = None,
+        end_from_trigger: bool = False,
+    ) -> None:
         super().__init__()
-        if not isinstance(moment, datetime.datetime):
-            raise TypeError(f"Expected datetime.datetime type for moment. Got 
{type(moment)}")
-        # Make sure it's in UTC
-        if moment.tzinfo is None:
-            raise ValueError("You cannot pass naive datetimes")
-        self.moment: pendulum.DateTime = timezone.convert_to_utc(moment)
+        if (moment is None) == (target_time is None):
+            raise TypeError("DateTimeTrigger requires exactly one of 'moment' 
or 'target_time'")
+        self.target_time = target_time
+        self._moment: pendulum.DateTime | None = None
+        if moment is not None:
+            if not isinstance(moment, datetime.datetime):
+                raise TypeError(f"Expected datetime.datetime type for moment. 
Got {type(moment)}")
+            # Make sure it's in UTC
+            if moment.tzinfo is None:
+                raise ValueError("You cannot pass naive datetimes")
+            self._moment = timezone.convert_to_utc(moment)
         self.end_from_trigger = end_from_trigger
 
+    @property
+    def moment(self) -> pendulum.DateTime:
+        if self._moment is None:
+            # Resolved lazily: by now the triggerer has rendered target_time 
in place.
+            if not isinstance(self.target_time, str) or not self.target_time:

Review Comment:
   **[blocker]** A `datetime` `target_time` is accepted by `__init__` but makes 
the trigger fail at run time.
   
   The signature allows `target_time: datetime.datetime | str | None`, but 
`moment` only resolves strings. Reproduced against this branch:
   
   ```python
   trigger = DateTimeTrigger(target_time=datetime.datetime(2020, 1, 1, 
tzinfo=datetime.timezone.utc))
   await trigger.run().__anext__()
   # TypeError: DateTimeTrigger has neither a 'moment' nor a usable 
'target_time'
   ```
   
   On Airflow 3.3+ the sensor now sends a `datetime` this way in cases that 
work on `main`:
   
   - `DateTimeSensorAsync(target_time=<datetime>, start_from_trigger=True)`: 
`__init__` puts the raw value in `trigger_kwargs`. The updated 
`test_async_start_from_trigger_localizes_naive_datetime` asserts exactly that 
(a naive `datetime` under `target_time`).
   - `DateTimeSensorAsync(target_time=<datetime>)` through `execute()`: the 
updated expectation in `test_supervisor.py` shows a `datetime.datetime` being 
sent as `target_time`.
   - A template that renders to a native `datetime` 
(`render_template_as_native_obj=True`) would end up in the same place.
   
   `DateTimeSensor._moment` already handles both types, so the trigger can 
mirror it:
   
   ```python
   @property
   def moment(self) -> pendulum.DateTime:
       if self._moment is None:
           target_time = self.target_time
           if isinstance(target_time, datetime.datetime):
               target_time = target_time.isoformat()
           if not isinstance(target_time, str) or not target_time:
               raise TypeError(f"Expected str or datetime.datetime type for 
target_time. Got {type(target_time)}")
           self._moment = timezone.convert_to_utc(timezone.parse(target_time))
       return self._moment
   ```
   
   `timezone.parse` also localizes naive values, which restores the behaviour 
the test name promises. Please add trigger tests in `triggers/test_temporal.py` 
for `target_time` as `str`, aware `datetime` and naive `datetime`, for the 
"exactly one of" validation, and for the `serialize()` round trip.



##########
providers/standard/src/airflow/providers/standard/triggers/temporal.py:
##########
@@ -31,31 +31,55 @@ class DateTimeTrigger(BaseTrigger):
     """
     Trigger based on a datetime.
 
-    A trigger that fires exactly once, at the given datetime, give or take
-    a few seconds.
-
-    The provided datetime MUST be in UTC.
+    Pass either ``moment`` (a tz-aware datetime) or ``target_time`` (a string, 
possibly a Jinja
+    template). ``target_time`` is listed in ``template_fields`` so that, with 
``start_from_trigger``,
+    the triggerer renders it in place before ``run()`` and it is parsed into 
``moment`` on first use.
 
     :param moment: when to yield event
+    :param target_time: raw (possibly templated) datetime string, an 
alternative to ``moment``
     :param end_from_trigger: whether the trigger should mark the task 
successful after time condition
         reached or resume the task after time condition reached.
     """
 
-    def __init__(self, moment: datetime.datetime, *, end_from_trigger: bool = 
False) -> None:
+    template_fields = ("target_time",)

Review Comment:
   **[nit]** This class attribute has no effect, and the docstring above 
describes a mechanism that is not the one in use.
   
   `BaseTrigger.__init__` sets `self.template_fields = ()` on the instance, 
which shadows the class attribute:
   
   ```python
   >>> DateTimeTrigger(target_time="{{ ds }}").template_fields
   ()
   ```
   
   The fields that get rendered are set by the `task_instance` setter in 
`BaseTrigger`: the operator's `template_fields` that are also keys of 
`start_trigger_args.trigger_kwargs` and attributes of the trigger. So rendering 
works here because `DateTimeSensor.template_fields` contains `target_time` and 
the sensor puts `target_time` in `trigger_kwargs`. I would drop this line and 
reword the docstring accordingly.



##########
providers/standard/tests/unit/standard/sensors/test_date_time.py:
##########
@@ -134,19 +135,60 @@ def test_poke_with_natively_rendered_datetime(self, 
mock_utcnow):
         assert isinstance(op.target_time, datetime.datetime)
         assert op.poke(None) is True
 
+    @pytest.mark.asyncio
+    @pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="Test only for AF < 
3.2")
+    async def test_full_run_worker_path_templated_past_target_time(self):

Review Comment:
   **[warning]** This test exercises a flow that does not occur in production, 
so it does not cover the new feature.
   
   It calls `op.execute(ctx)` without rendering the operator first (the worker 
always renders before `execute`), then renders the template on the trigger 
through a `SimpleNamespace` task instance. With `start_from_trigger=True` the 
scheduler defers the task directly and `execute()` is never called; without it, 
the triggerer does not render anything.
   
   A test closer to the real path would build the trigger the way the triggerer 
does:
   
   ```python
   trigger = DateTimeTrigger(**op.start_trigger_args.trigger_kwargs)
   trigger.task_instance = ...  # object exposing task_id and task=op
   trigger.render_template_fields(ctx)
   event = await trigger.run().__anext__()
   ```
   
   Smaller points in this file:
   
   - The `skipif` reasons do not match the conditions: `not AIRFLOW_V_3_3_PLUS` 
with "Test only for AF < 3.2" skips on < 3.3, and the `AIRFLOW_V_3_3_PLUS` one 
below should read "< 3.3".
   - `TaskDeferred` and `SimpleNamespace` should be imported at the top of the 
module.
   - `test_async_start_from_trigger_molocalment` looks like an accidental 
rename.
   - The new `UserWarning` on Airflow < 3.3 has no test (`pytest.warns`).



##########
providers/standard/tests/unit/standard/sensors/test_date_time.py:
##########
@@ -156,8 +198,13 @@ def 
test_async_start_from_trigger_localizes_naive_datetime(self):
             start_from_trigger=True,
             dag=self.dag,
         )
-        assert op.start_trigger_args.trigger_kwargs["moment"] == 
pendulum.datetime(2020, 1, 1, tz="UTC")
 
+        if AIRFLOW_V_3_3_PLUS:
+            assert op.start_trigger_args.trigger_kwargs["target_time"] == 
datetime.datetime(2020, 1, 1, 0, 0)

Review Comment:
   **[warning]** This assertion pins the bug described on 
`DateTimeTrigger.moment`.
   
   The test is named `..._localizes_naive_datetime`, but on 3.3+ it now asserts 
that the naive `datetime` is passed through unchanged. The trigger then rejects 
it at run time. Once the trigger handles `datetime`, I would extend this test 
to check the resolved value, for example 
`DateTimeTrigger(**op.start_trigger_args.trigger_kwargs).moment == 
pendulum.datetime(2020, 1, 1, tz="UTC")`.



##########
task-sdk/tests/task_sdk/execution_time/test_supervisor.py:
##########
@@ -810,15 +811,22 @@ def mock_monotonic():
         # Validate calls to the client
         mock_client.task_instances.start.assert_called_once_with(ti.id, 
mocker.ANY, mocker.ANY)
         mock_client.task_instances.heartbeat.assert_called_once_with(ti.id, 
pid=mocker.ANY)
+
+        trigger_kwargs_key_name = "moment"
+        trigger_kwargs_class_name = "pendulum.datetime.DateTime"
+        if AIRFLOW_V_3_3_PLUS:

Review Comment:
   **[warning]** A change in the standard provider should not need edits in the 
task-sdk tests.
   
   These two tests only use `DateTimeSensorAsync` as a sample deferrable task 
and assert the trigger kwargs it sends. Their expectations had to change 
because `execute()` now sends `target_time` instead of `moment`. Once 
`execute()` builds the trigger from `moment` again (see my comment there), both 
`test_supervisor.py` and `test_task_runner.py` can be reverted.
   
   The `AIRFLOW_V_3_3_PLUS` branches added here are also dead code: the 
task-sdk tests only run against the current `main` (3.4.0), never against older 
Airflow versions.



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