This is an automated email from the ASF dual-hosted git repository.

dabla 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 4c39202d20e DateTimeSensorAsync support start_from_trigger and 
templated target_time (#73928)
4c39202d20e is described below

commit 4c39202d20ea75702e630bfeee00ed75e52a3d32
Author: raphaelauv <[email protected]>
AuthorDate: Wed Oct 14 11:07:03 2026 +0200

    DateTimeSensorAsync support start_from_trigger and templated target_time 
(#73928)
    
    * DateTimeSensorAsync support start_from_trigger for templated target_time
    
    ---------
    
    Co-authored-by: raphaelauv <[email protected]>
---
 .../providers/standard/sensors/date_time.py        | 54 ++++++++------
 .../providers/standard/triggers/temporal.py        | 56 ++++++++++----
 .../tests/unit/standard/sensors/test_date_time.py  | 86 ++++++++++++++++++----
 .../tests/unit/standard/triggers/test_temporal.py  | 42 +++++++++++
 4 files changed, 187 insertions(+), 51 deletions(-)

diff --git 
a/providers/standard/src/airflow/providers/standard/sensors/date_time.py 
b/providers/standard/src/airflow/providers/standard/sensors/date_time.py
index cc3485a906d..9efcd1095b1 100644
--- a/providers/standard/src/airflow/providers/standard/sensors/date_time.py
+++ b/providers/standard/src/airflow/providers/standard/sensors/date_time.py
@@ -19,12 +19,13 @@ from __future__ import annotations
 
 import dataclasses
 import datetime
+import warnings
 from collections.abc import Sequence
 from typing import TYPE_CHECKING, Any, NoReturn
 
 from airflow.providers.common.compat.sdk import BaseSensorOperator, timezone
 from airflow.providers.standard.triggers.temporal import DateTimeTrigger
-from airflow.providers.standard.version_compat import AIRFLOW_V_3_0_PLUS
+from airflow.providers.standard.version_compat import AIRFLOW_V_3_0_PLUS, 
AIRFLOW_V_3_3_PLUS
 from airflow.triggers.base import StartTriggerArgs
 
 if TYPE_CHECKING:
@@ -89,8 +90,8 @@ class DateTimeSensorAsync(DateTimeSensor):
 
     :param target_time: datetime after which the job succeeds. (templated)
     :param start_from_trigger: Start the task directly from the triggerer 
without going into the worker.
-        Ignored when ``target_time`` is a Jinja template: templates are only 
rendered on the worker, so the
-        task defers from the worker instead.
+        Ignored on Airflow < 3.3 when ``target_time`` is a Jinja template.
+
     :param trigger_kwargs: The keyword arguments passed to the trigger when 
start_from_trigger is set to True
         during dynamic task mapping. This argument is not used in standard 
usage.
     :param end_from_trigger: End the task directly from the triggerer without 
going into the worker.
@@ -115,28 +116,35 @@ class DateTimeSensorAsync(DateTimeSensor):
     ) -> 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(
diff --git 
a/providers/standard/src/airflow/providers/standard/triggers/temporal.py 
b/providers/standard/src/airflow/providers/standard/triggers/temporal.py
index cfc206ff313..f1d01b5401f 100644
--- a/providers/standard/src/airflow/providers/standard/triggers/temporal.py
+++ b/providers/standard/src/airflow/providers/standard/triggers/temporal.py
@@ -31,31 +31,57 @@ 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, or a datetime). With ``start_from_trigger``, the operator lists 
``target_time`` in its
+    own ``template_fields`` and puts it in 
``start_trigger_args.trigger_kwargs``, so the triggerer
+    renders it in place before ``run()``; it is then 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:
+    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.
+            target_time: Any = 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("DateTimeTrigger has neither a 'moment' nor a 
usable 'target_time'")
+            self._moment = timezone.convert_to_utc(timezone.parse(target_time))
+        return self._moment
+
     def serialize(self) -> tuple[str, dict[str, Any]]:
-        return (
-            "airflow.providers.standard.triggers.temporal.DateTimeTrigger",
-            {"moment": self.moment, "end_from_trigger": self.end_from_trigger},
-        )
+        if self._moment is None:
+            kwargs: dict[str, Any] = {"target_time": self.target_time}
+        else:
+            kwargs = {"moment": self._moment}
+        kwargs["end_from_trigger"] = self.end_from_trigger
+        return 
("airflow.providers.standard.triggers.temporal.DateTimeTrigger", kwargs)
 
     async def run(self) -> AsyncIterator[TriggerEvent]:
         """
diff --git a/providers/standard/tests/unit/standard/sensors/test_date_time.py 
b/providers/standard/tests/unit/standard/sensors/test_date_time.py
index 12818e27ea7..c860b524e18 100644
--- a/providers/standard/tests/unit/standard/sensors/test_date_time.py
+++ b/providers/standard/tests/unit/standard/sensors/test_date_time.py
@@ -17,7 +17,9 @@
 # under the License.
 from __future__ import annotations
 
+import asyncio
 import datetime
+from types import SimpleNamespace
 from unittest.mock import patch
 
 import pendulum
@@ -26,8 +28,9 @@ import pytest
 from airflow import macros
 from airflow.models.dag import DAG
 from airflow.providers.standard.sensors.date_time import DateTimeSensor, 
DateTimeSensorAsync
+from airflow.providers.standard.triggers.temporal import DateTimeTrigger
 
-from tests_common.test_utils.version_compat import timezone
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_3_PLUS, timezone
 
 DEFAULT_DATE = timezone.datetime(2015, 1, 1)
 
@@ -134,19 +137,53 @@ class TestDateTimeSensor:
         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.3")
+    async def test_full_run_triggerer_path_templated_past_target_time(self):
+        """Simulates the triggerer path: start_trigger_args carries the raw 
target_time, which
+        the triggerer renders in place (never going through the worker's 
execute())."""
+        dag = DAG(
+            dag_id="full_run_triggerer_path_dag",
+            start_date=pendulum.datetime(2020, 1, 1, tz="UTC"),
+            schedule=None,
+        )
+        op = DateTimeSensorAsync(
+            task_id="full_run_triggerer_path",
+            target_time="{{ data_interval_end }}",
+            start_from_trigger=True,
+            dag=dag,
+        )
+        ctx = {"data_interval_end": pendulum.datetime(2020, 1, 1, tz="UTC")}
+
+        trigger = DateTimeTrigger(**op.start_trigger_args.trigger_kwargs)
+        trigger.task_instance = SimpleNamespace(task_id=op.task_id, task=op)
+        trigger.render_template_fields(ctx)
+
+        assert trigger.target_time == "2020-01-01 00:00:00+00:00"
+
+        # Triggerer: run the trigger the task deferred to. The past moment 
fires immediately.
+        event = await asyncio.wait_for(trigger.run().__anext__(), timeout=5)
+
+        assert op.execute_complete(context=ctx, event=event.payload) is None
+
     def test_moment_localizes_naive_datetime(self):
         """A naive datetime target_time is localized to UTC via _moment 
(mirrors old isoformat())."""
         op = DateTimeSensor(task_id="naive", 
target_time=datetime.datetime(2020, 1, 1), dag=self.dag)
         assert op._moment == pendulum.datetime(2020, 1, 1, tz="UTC")
 
-    def test_async_start_from_trigger_moment(self):
+    def test_async_start_from_trigger_sets_trigger_kwargs(self):
         op = DateTimeSensorAsync(
             task_id="async",
             target_time="2020-01-01T00:00:00+00:00",
             start_from_trigger=True,
             dag=self.dag,
         )
-        assert op.start_trigger_args.trigger_kwargs["moment"] == 
pendulum.parse("2020-01-01T00:00:00+00:00")
+        if AIRFLOW_V_3_3_PLUS:
+            assert op.start_trigger_args.trigger_kwargs["target_time"] == 
"2020-01-01T00:00:00+00:00"
+        else:
+            assert op.start_trigger_args.trigger_kwargs["moment"] == 
pendulum.parse(
+                "2020-01-01T00:00:00+00:00"
+            )
 
     def test_async_start_from_trigger_localizes_naive_datetime(self):
         """DateTimeSensorAsync never pokes, so _moment must still localize a 
naive datetime."""
@@ -156,9 +193,28 @@ class TestDateTimeSensor:
             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)
+        else:
+            assert op.start_trigger_args.trigger_kwargs["moment"] == 
pendulum.datetime(2020, 1, 1, tz="UTC")
+
+    @pytest.mark.skipif(AIRFLOW_V_3_3_PLUS, reason="Test only for AF < 3.3")
     def test_async_start_from_trigger_skips_templated_target_time(self):
+        with pytest.warns(UserWarning, match="requires Airflow >= 3.3"):
+            op = DateTimeSensorAsync(
+                task_id="async_templated",
+                target_time="{{ data_interval_end }}",
+                start_from_trigger=True,
+                dag=self.dag,
+            )
+
+        assert op.start_from_trigger is False
+        # The class attribute must be left alone, not replaced with a copy 
built from the raw template.
+        assert op.start_trigger_args is DateTimeSensorAsync.start_trigger_args
+
+    @pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="Test only for AF >= 
3.3")
+    def test_async_start_from_trigger_templated_target_time(self):
         op = DateTimeSensorAsync(
             task_id="async_templated",
             target_time="{{ data_interval_end }}",
@@ -166,9 +222,8 @@ class TestDateTimeSensor:
             dag=self.dag,
         )
 
-        assert op.start_from_trigger is False
-        # The class attribute must be left alone, not replaced with a copy 
built from the raw template.
-        assert op.start_trigger_args is DateTimeSensorAsync.start_trigger_args
+        assert op.start_from_trigger is True
+        assert op.start_trigger_args.trigger_kwargs["target_time"] == "{{ 
data_interval_end }}"
 
     def test_start_trigger_args_are_not_shared_between_tasks(self):
         """Each task must carry its own trigger arguments.
@@ -190,12 +245,17 @@ class TestDateTimeSensor:
         )
 
         assert first.start_trigger_args is not second.start_trigger_args
-        assert first.start_trigger_args.trigger_kwargs["moment"] == 
pendulum.parse(
-            "2030-01-01T00:00:00+00:00"
-        )
-        assert second.start_trigger_args.trigger_kwargs["moment"] == 
pendulum.parse(
-            "2040-06-06T00:00:00+00:00"
-        )
+        if AIRFLOW_V_3_3_PLUS:
+            assert first.start_trigger_args.trigger_kwargs["target_time"] == 
"2030-01-01T00:00:00+00:00"
+            assert second.start_trigger_args.trigger_kwargs["target_time"] == 
"2040-06-06T00:00:00+00:00"
+        else:
+            assert first.start_trigger_args.trigger_kwargs["moment"] == 
pendulum.parse(
+                "2030-01-01T00:00:00+00:00"
+            )
+            assert second.start_trigger_args.trigger_kwargs["moment"] == 
pendulum.parse(
+                "2040-06-06T00:00:00+00:00"
+            )
+
         # the class level template must survive untouched for the next task 
built from it
         assert DateTimeSensorAsync.start_trigger_args.trigger_kwargs == {
             "moment": "",
diff --git a/providers/standard/tests/unit/standard/triggers/test_temporal.py 
b/providers/standard/tests/unit/standard/triggers/test_temporal.py
index 9a3b2c423d4..300f0a6be80 100644
--- a/providers/standard/tests/unit/standard/triggers/test_temporal.py
+++ b/providers/standard/tests/unit/standard/triggers/test_temporal.py
@@ -40,6 +40,11 @@ def test_input_validation():
         DateTimeTrigger("2012-01-01T03:03:03+00:00")
 
 
+def test_input_exactly_one_time():
+    with pytest.raises(TypeError, match="DateTimeTrigger requires exactly one 
of 'moment' or 'target_time'"):
+        DateTimeTrigger(moment="2012-01-01T03:03:03+00:00", 
target_time="2012-01-01T03:03:03+00:00")
+
+
 def test_input_validation_tz():
     """
     Tests that the DateTimeTrigger validates input to moment arg, it shouldn't 
accept naive datetime.
@@ -50,6 +55,37 @@ def test_input_validation_tz():
         DateTimeTrigger(moment)
 
 
+def test_target_time_resolves_to_utc_moment():
+    """
+    Tests that target_time as a naive or aware string or datetime resolves to 
a UTC moment
+    """
+
+    target_time = datetime.datetime(2020, 1, 1, 0, 0, 0)
+
+    assert DateTimeTrigger(target_time=target_time).moment == 
datetime.datetime(
+        2020, 1, 1, 0, 0, 0, tzinfo=datetime.UTC
+    )
+
+    tz = datetime.timezone(datetime.timedelta(hours=-2))
+    target_time = datetime.datetime(2020, 1, 1, 0, 0, 0, tzinfo=tz)
+
+    assert DateTimeTrigger(target_time=target_time).moment == 
datetime.datetime(
+        2020, 1, 1, 2, 0, 0, tzinfo=datetime.UTC
+    )
+
+    target_time = "2020-01-01T00:00:00"
+
+    assert DateTimeTrigger(target_time=target_time).moment == 
datetime.datetime(
+        2020, 1, 1, 0, 0, 0, tzinfo=datetime.UTC
+    )
+
+    target_time = "2020-01-01T00:00:00-02:00"
+
+    assert DateTimeTrigger(target_time=target_time).moment == 
datetime.datetime(
+        2020, 1, 1, 2, 0, 0, tzinfo=datetime.UTC
+    )
+
+
 def test_datetime_trigger_serialization():
     """
     Tests that the DateTimeTrigger correctly serializes its arguments
@@ -61,6 +97,12 @@ def test_datetime_trigger_serialization():
     assert classpath == 
"airflow.providers.standard.triggers.temporal.DateTimeTrigger"
     assert kwargs == {"moment": moment, "end_from_trigger": False}
 
+    moment = pendulum.instance(datetime.datetime(2020, 4, 1, 13, 0), 
pendulum.UTC)
+    trigger = DateTimeTrigger(target_time=moment)
+    classpath, kwargs = trigger.serialize()
+    assert classpath == 
"airflow.providers.standard.triggers.temporal.DateTimeTrigger"
+    assert kwargs == {"target_time": moment, "end_from_trigger": False}
+
 
 def test_timedelta_trigger_serialization():
     """

Reply via email to