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

shahar1 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 52e27183bf7 Read HITLOperator subject/body into the summary lazily 
(#70345)
52e27183bf7 is described below

commit 52e27183bf761536e1df526364311337ca357406
Author: Stefan Wang <[email protected]>
AuthorDate: Fri Jul 24 02:15:48 2026 -0700

    Read HITLOperator subject/body into the summary lazily (#70345)
    
    * Read HITLOperator subject/body into the summary lazily
    
    subject and body are template fields, rendered after __init__ runs. The
    constructor built hitl_summary with self.subject/self.body, so a templated
    subject or body was captured as its un-rendered Jinja expression -- and the
    OpenLineage task START event, which reads hitl_summary, emitted the raw
    template instead of the rendered value.
    
    Make hitl_summary a property so subject/body are read at access time, after
    rendering. Runtime and subclass additions go through a private
    _hitl_summary_extra dict that the property merges in.
    
    related: #70296
---
 .../airflow/providers/standard/operators/hitl.py   | 33 +++++++++++++++-------
 .../tests/unit/standard/operators/test_hitl.py     | 18 +++++++++++-
 .../ci/prek/validate_operators_init_exemptions.txt |  1 -
 3 files changed, 40 insertions(+), 12 deletions(-)

diff --git 
a/providers/standard/src/airflow/providers/standard/operators/hitl.py 
b/providers/standard/src/airflow/providers/standard/operators/hitl.py
index ce42221e7d9..ce4f4d4a878 100644
--- a/providers/standard/src/airflow/providers/standard/operators/hitl.py
+++ b/providers/standard/src/airflow/providers/standard/operators/hitl.py
@@ -132,8 +132,18 @@ class HITLOperator(BaseOperator):
         self.validate_params()
         self.validate_defaults()
 
-        # HITL summary for the use of listeners; subclasses can extend it.
-        self.hitl_summary: dict[str, Any] = {
+        # Runtime/subclass additions to the summary; config-derived entries 
live in the property.
+        self._hitl_summary_extra: dict[str, Any] = {}
+
+    @property
+    def hitl_summary(self) -> dict[str, Any]:
+        """
+        Summary of the Human-in-the-loop request, for listeners/observability.
+
+        A property so the ``subject``/``body`` template fields are read after 
rendering, not
+        captured as un-rendered Jinja in ``__init__``.
+        """
+        return {
             "subject": self.subject,
             "body": self.body,
             "options": self.options,
@@ -141,6 +151,7 @@ class HITLOperator(BaseOperator):
             "multiple": self.multiple,
             "assigned_users": self.assigned_users,
             "serialized_params": self.serialized_params or None,
+            **self._hitl_summary_extra,
         }
 
     def validate_options(self) -> None:
@@ -210,7 +221,9 @@ class HITLOperator(BaseOperator):
             timeout_datetime = None
 
         # Enrich summary with runtime info
-        self.hitl_summary["timeout_datetime"] = timeout_datetime.isoformat() 
if timeout_datetime else None
+        self._hitl_summary_extra["timeout_datetime"] = (
+            timeout_datetime.isoformat() if timeout_datetime else None
+        )
 
         self.log.info("Waiting for response")
         for notifier in self.notifiers:
@@ -246,7 +259,7 @@ class HITLOperator(BaseOperator):
 
     def execute_complete(self, context: Context, event: dict[str, Any]) -> Any:
         if "error" in event:
-            self.hitl_summary["error_type"] = event["error_type"]
+            self._hitl_summary_extra["error_type"] = event["error_type"]
             self.process_trigger_event_error(event)
 
         chosen_options = event["chosen_options"]
@@ -254,7 +267,7 @@ class HITLOperator(BaseOperator):
         self.validate_chosen_options(chosen_options)
         self.validate_params_input(params_input)
 
-        self.hitl_summary.update(
+        self._hitl_summary_extra.update(
             {
                 "chosen_options": chosen_options,
                 "params_input": params_input,
@@ -432,14 +445,14 @@ class ApprovalOperator(HITLOperator, SkipMixin):
             **kwargs,
         )
 
-        self.hitl_summary["ignore_downstream_trigger_rules"] = 
self.ignore_downstream_trigger_rules
-        self.hitl_summary["fail_on_reject"] = self.fail_on_reject
+        self._hitl_summary_extra["ignore_downstream_trigger_rules"] = 
self.ignore_downstream_trigger_rules
+        self._hitl_summary_extra["fail_on_reject"] = self.fail_on_reject
 
     def execute_complete(self, context: Context, event: dict[str, Any]) -> Any:
         ret = super().execute_complete(context=context, event=event)
 
         chosen_option = ret["chosen_options"][0]
-        self.hitl_summary["approved"] = chosen_option == self.APPROVE
+        self._hitl_summary_extra["approved"] = chosen_option == self.APPROVE
         if chosen_option == self.APPROVE:
             self.log.info("Approved. Proceeding with downstream tasks...")
             return ret
@@ -493,7 +506,7 @@ class HITLBranchOperator(HITLOperator, BranchMixIn):
         super().__init__(**kwargs)
         self.options_mapping = options_mapping or {}
         self.validate_options_mapping()
-        self.hitl_summary["options_mapping"] = self.options_mapping
+        self._hitl_summary_extra["options_mapping"] = self.options_mapping
 
     def validate_options_mapping(self) -> None:
         """
@@ -528,7 +541,7 @@ class HITLBranchOperator(HITLOperator, BranchMixIn):
 
         # Map options to task IDs using the mapping, fallback to original 
option
         chosen_options = [self.options_mapping.get(option, option) for option 
in chosen_options]
-        self.hitl_summary["branches_to_execute"] = chosen_options
+        self._hitl_summary_extra["branches_to_execute"] = chosen_options
         return self.do_branch(context=context, 
branches_to_execute=chosen_options)
 
 
diff --git a/providers/standard/tests/unit/standard/operators/test_hitl.py 
b/providers/standard/tests/unit/standard/operators/test_hitl.py
index 7b7d5941b60..e83d3c91446 100644
--- a/providers/standard/tests/unit/standard/operators/test_hitl.py
+++ b/providers/standard/tests/unit/standard/operators/test_hitl.py
@@ -974,6 +974,21 @@ class TestHITLSummaryForListeners:
             "serialized_params": None,
         }
 
+    def test_summary_reflects_rendered_subject_body(self) -> None:
+        """The summary reads subject/body live, so it reflects rendered values 
(guards #70296)."""
+        op = HITLOperator(
+            task_id="test",
+            subject="Review for {{ ds }}",
+            body="Deploy {{ ds }}?",
+            options=["Yes", "No"],
+        )
+        # Airflow renders template fields in place before execute.
+        op.subject = "Review for 2020-01-01"
+        op.body = "Deploy 2020-01-01?"
+
+        assert op.hitl_summary["subject"] == "Review for 2020-01-01"
+        assert op.hitl_summary["body"] == "Deploy 2020-01-01?"
+
     def test_approval_operator_init_summary(self) -> None:
         """ApprovalOperator hitl_summary includes base + approval-specific 
fields."""
         op = ApprovalOperator(
@@ -1382,7 +1397,8 @@ class TestHITLSummaryForListeners:
             },
         )
 
-        assert s == {
+        # hitl_summary is a property, so re-read it to see the 
execute_complete additions.
+        assert op.hitl_summary == {
             "subject": "Release v2.0?",
             "body": "Please approve the production deployment.",
             "options": ["Approve", "Reject"],
diff --git a/scripts/ci/prek/validate_operators_init_exemptions.txt 
b/scripts/ci/prek/validate_operators_init_exemptions.txt
index 7f5f7bc618c..a4846a7da2e 100644
--- a/scripts/ci/prek/validate_operators_init_exemptions.txt
+++ b/scripts/ci/prek/validate_operators_init_exemptions.txt
@@ -68,7 +68,6 @@ 
providers/papermill/src/airflow/providers/papermill/operators/papermill.py::Pape
 providers/ssh/src/airflow/providers/ssh/operators/ssh.py::SSHOperator
 
providers/ssh/src/airflow/providers/ssh/operators/ssh_remote_job.py::SSHRemoteJobOperator
 
providers/standard/src/airflow/providers/standard/operators/bash.py::BashOperator
-providers/standard/src/airflow/providers/standard/operators/hitl.py::HITLOperator
 
providers/standard/src/airflow/providers/standard/operators/trigger_dagrun.py::TriggerDagRunOperator
 
providers/standard/src/airflow/providers/standard/sensors/date_time.py::DateTimeSensor
 
providers/teradata/src/airflow/providers/teradata/transfers/teradata_to_teradata.py::TeradataToTeradataOperator

Reply via email to