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

weilee 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 94febf24170 Add options_mapping support to HITLBranchOperator (#55093)
94febf24170 is described below

commit 94febf24170771204b3effd0fcaad3164d9da854
Author: Constance Martineau <[email protected]>
AuthorDate: Mon Sep 1 10:39:52 2025 -0400

    Add options_mapping support to HITLBranchOperator (#55093)
    
    Co-authored-by: Wei Lee <[email protected]>
---
 .../airflow/providers/standard/operators/hitl.py   |  48 +++++++++
 .../tests/unit/standard/operators/test_hitl.py     | 110 ++++++++++++++++++++-
 2 files changed, 157 insertions(+), 1 deletion(-)

diff --git 
a/providers/standard/src/airflow/providers/standard/operators/hitl.py 
b/providers/standard/src/airflow/providers/standard/operators/hitl.py
index 75588f8cbeb..440abf9a516 100644
--- a/providers/standard/src/airflow/providers/standard/operators/hitl.py
+++ b/providers/standard/src/airflow/providers/standard/operators/hitl.py
@@ -350,9 +350,57 @@ class HITLBranchOperator(HITLOperator, BranchMixIn):
 
     inherits_from_skipmixin = True
 
+    def __init__(self, *, options_mapping: dict[str, str] | None = None, 
**kwargs) -> None:
+        """
+        Initialize HITLBranchOperator.
+
+        Args:
+            options_mapping:
+                A dictionary mapping option labels (must match entries in 
`self.options`)
+                to string values (e.g., task IDs). Defaults to an empty dict 
if not provided.
+
+        Raises:
+            ValueError:
+                - If `options_mapping` contains keys not present in 
`self.options`.
+                - If any value in `options_mapping` is not a string.
+        """
+        super().__init__(**kwargs)
+        self.options_mapping = options_mapping or {}
+        self.validate_options_mapping()
+
+    def validate_options_mapping(self) -> None:
+        """
+        Validate that `options_mapping` keys match `self.options` and all 
values are strings.
+
+        Raises:
+            ValueError: If any key is not in `self.options` or any value is 
not a string.
+        """
+        if not self.options_mapping:
+            return
+
+        # Validate that the choice options are keys in the mapping are the same
+        invalid_keys = set(self.options_mapping.keys()) - set(self.options)
+        if invalid_keys:
+            raise ValueError(
+                f"`options_mapping` contains keys that are not in `options`: 
{sorted(invalid_keys)}"
+            )
+
+        # validate that all values are strings
+        invalid_entries = {
+            k: (v, type(v).__name__) for k, v in self.options_mapping.items() 
if not isinstance(v, str)
+        }
+        if invalid_entries:
+            raise ValueError(
+                f"`options_mapping` values must be strings 
(task_ids).\nInvalid entries: {invalid_entries}"
+            )
+
     def execute_complete(self, context: Context, event: dict[str, Any]) -> Any:
+        """Execute the operator and branch based on chosen options."""
         ret = super().execute_complete(context=context, event=event)
         chosen_options = ret["chosen_options"]
+
+        # 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]
         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 2257c4e6198..ca0b0ae9f4f 100644
--- a/providers/standard/tests/unit/standard/operators/test_hitl.py
+++ b/providers/standard/tests/unit/standard/operators/test_hitl.py
@@ -31,7 +31,7 @@ from urllib.parse import parse_qs, urlparse
 import pytest
 from sqlalchemy import select
 
-from airflow.exceptions import DownstreamTasksSkipped
+from airflow.exceptions import AirflowException, DownstreamTasksSkipped
 from airflow.models import TaskInstance, Trigger
 from airflow.models.hitl import HITLDetail
 from airflow.providers.standard.operators.empty import EmptyOperator
@@ -505,3 +505,111 @@ class TestHITLBranchOperator:
                 },
             )
         assert set(exc_info.value.tasks) == set((f"branch_{i}", -1) for i in 
range(4, 6))
+
+    def test_mapping_applies_for_single_choice(self, dag_maker):
+        # ["Approve"]; map -> "publish"
+        with dag_maker("hitl_map_dag", serialized=True):
+            op = HITLBranchOperator(
+                task_id="choose",
+                subject="S",
+                options=["Approve", "Reject"],
+                options_mapping={"Approve": "publish"},
+            )
+            op >> [EmptyOperator(task_id="publish"), 
EmptyOperator(task_id="archive")]
+
+        dr = dag_maker.create_dagrun()
+        ti = dr.get_task_instance("choose")
+
+        with pytest.raises(DownstreamTasksSkipped) as exc:
+            op.execute_complete(
+                context={"ti": ti, "task": ti.task},
+                event={"chosen_options": ["Approve"], "params_input": {}},
+            )
+        # checks to see that the "archive" task was skipped
+        assert set(exc.value.tasks) == {("archive", -1)}
+
+    def test_mapping_with_multiple_choices(self, dag_maker):
+        # multiple=True; mapping applied per option; no dedup implied
+        with dag_maker("hitl_map_dag", serialized=True):
+            op = HITLBranchOperator(
+                task_id="choose",
+                subject="S",
+                multiple=True,
+                options=["Approve", "KeepAsIs"],
+                options_mapping={"Approve": "publish", "KeepAsIs": "keep"},
+            )
+            op >> [
+                EmptyOperator(task_id="publish"),
+                EmptyOperator(task_id="keep"),
+                EmptyOperator(task_id="other"),
+            ]
+
+        dr = dag_maker.create_dagrun()
+        ti = dr.get_task_instance("choose")
+
+        with pytest.raises(DownstreamTasksSkipped) as exc:
+            op.execute_complete(
+                context={"ti": ti, "task": ti.task},
+                event={"chosen_options": ["Approve", "KeepAsIs"], 
"params_input": {}},
+            )
+        # publish + keep chosen → only "other" skipped
+        assert set(exc.value.tasks) == {("other", -1)}
+
+    def test_fallback_to_option_when_not_mapped(self, dag_maker):
+        # No mapping: option must match downstream task_id
+        with dag_maker("hitl_map_dag", serialized=True):
+            op = HITLBranchOperator(
+                task_id="choose",
+                subject="S",
+                options=["branch_1", "branch_2"],  # no mapping for branch_2
+            )
+            op >> [EmptyOperator(task_id="branch_1"), 
EmptyOperator(task_id="branch_2")]
+
+        dr = dag_maker.create_dagrun()
+        ti = dr.get_task_instance("choose")
+
+        with pytest.raises(DownstreamTasksSkipped) as exc:
+            op.execute_complete(
+                context={"ti": ti, "task": ti.task},
+                event={"chosen_options": ["branch_2"], "params_input": {}},
+            )
+        assert set(exc.value.tasks) == {("branch_1", -1)}
+
+    def test_error_if_mapped_branch_not_direct_downstream(self, dag_maker):
+        # Don't add the mapped task downstream → expect a clean error
+        with dag_maker("hitl_map_dag", serialized=True):
+            op = HITLBranchOperator(
+                task_id="choose",
+                subject="S",
+                options=["Approve"],
+                options_mapping={"Approve": "not_a_downstream"},
+            )
+            # Intentionally no downstream "not_a_downstream"
+
+        dr = dag_maker.create_dagrun()
+        ti = dr.get_task_instance("choose")
+
+        with pytest.raises(AirflowException, match="downstream|not found"):
+            op.execute_complete(
+                context={"ti": ti, "task": ti.task},
+                event={"chosen_options": ["Approve"], "params_input": {}},
+            )
+
+    @pytest.mark.parametrize("bad", [123, ["publish"], {"x": "y"}, b"publish"])
+    def test_options_mapping_non_string_value_raises(self, bad):
+        with pytest.raises(ValueError, match=r"values must be strings 
\(task_ids\)"):
+            HITLBranchOperator(
+                task_id="choose",
+                subject="S",
+                options=["Approve"],
+                options_mapping={"Approve": bad},
+            )
+
+    def test_options_mapping_key_not_in_options_raises(self):
+        with pytest.raises(ValueError, match="contains keys that are not in 
`options`"):
+            HITLBranchOperator(
+                task_id="choose",
+                subject="S",
+                options=["Approve", "Reject"],
+                options_mapping={"NotAnOption": "publish"},
+            )

Reply via email to