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 9287e0c1d47 Move CloudComposerExternalTaskSensor task-id wrapping out
of __init__ (#73103)
9287e0c1d47 is described below
commit 9287e0c1d477a290bbd1afb9a4d7c45ae9fd7fe2
Author: Kailash Nelson <[email protected]>
AuthorDate: Tue Sep 22 23:56:45 2026 -0600
Move CloudComposerExternalTaskSensor task-id wrapping out of __init__
(#73103)
Template fields are rendered after the constructor, so wrapping a task id
into a list or treating an empty list as unset inspects the un-rendered
Jinja expression and keeps the class on the exemption list.
Reuse self._total_states instead of recalculating, and call
_normalize_external_task_ids() at the start of execute_complete() so
deferrable resume on a fresh instance raises ExternalTaskFailedError.
---
.../google/cloud/sensors/cloud_composer.py | 89 +++++----
.../google/cloud/sensors/test_cloud_composer.py | 202 +++++++++++++++++++++
.../ci/prek/validate_operators_init_exemptions.txt | 1 -
3 files changed, 254 insertions(+), 38 deletions(-)
diff --git
a/providers/google/src/airflow/providers/google/cloud/sensors/cloud_composer.py
b/providers/google/src/airflow/providers/google/cloud/sensors/cloud_composer.py
index 7698f3af4f2..8a307e7be75 100644
---
a/providers/google/src/airflow/providers/google/cloud/sensors/cloud_composer.py
+++
b/providers/google/src/airflow/providers/google/cloud/sensors/cloud_composer.py
@@ -398,18 +398,56 @@ class CloudComposerExternalTaskSensor(BaseSensorOperator):
self.skipped_states = list(skipped_states) if skipped_states else []
self.failed_states = list(failed_states) if failed_states else []
- total_states = set(self.allowed_states + self.skipped_states +
self.failed_states)
+ self._total_states = set(self.allowed_states + self.skipped_states +
self.failed_states)
- if len(total_states) != len(self.allowed_states) +
len(self.skipped_states) + len(self.failed_states):
+ if len(self._total_states) != len(self.allowed_states) +
len(self.skipped_states) + len(
+ self.failed_states
+ ):
raise DuplicateStateError(
"Duplicate values provided across allowed_states,
skipped_states and failed_states."
)
- # convert [] to None
+ self.execution_range = execution_range
+ self.composer_external_dag_id = composer_external_dag_id
+ self.composer_external_task_id = composer_external_task_id
+ self.composer_external_task_ids = composer_external_task_ids
+ self.composer_external_task_group_id = composer_external_task_group_id
+ self.gcp_conn_id = gcp_conn_id
+ self.impersonation_chain = impersonation_chain
+ self.deferrable = deferrable
+ self.poll_interval = poll_interval
+ self._external_task_ids_normalized = False
+
+ def _get_logical_dates(self, context) -> tuple[datetime, datetime]:
+ logical_date = context.get("logical_date", None)
+ if logical_date is None:
+ raise RuntimeError(
+ "logical_date is None. Please make sure the sensor is not used
in an asset-triggered Dag. "
+ "CloudComposerDAGRunSensor was designed to be used in
time-based scheduled Dags only, "
+ "and asset-triggered Dags do not have logical_date. "
+ )
+ if isinstance(self.execution_range, timedelta):
+ if self.execution_range < timedelta(0):
+ return logical_date, logical_date - self.execution_range
+ return logical_date - self.execution_range, logical_date
+ if isinstance(self.execution_range, list) and
len(self.execution_range) > 0:
+ return self.execution_range[0], self.execution_range[1] if len(
+ self.execution_range
+ ) > 1 else logical_date
+ return logical_date - timedelta(1), logical_date
+
+ def _normalize_external_task_ids(self) -> None:
+ """Wrap a single task id and validate the target type after templates
have been rendered."""
+ if self._external_task_ids_normalized:
+ return
+
+ composer_external_task_id = self.composer_external_task_id
+ composer_external_task_ids = self.composer_external_task_ids
+ composer_external_task_group_id = self.composer_external_task_group_id
+
if not composer_external_task_ids:
composer_external_task_ids = None
- # can't set both single task id and a list of task ids
if composer_external_task_id is not None and
composer_external_task_ids is not None:
raise ValueError(
"Only one of `composer_external_task_id` or
`composer_external_task_ids` may "
@@ -417,7 +455,6 @@ class CloudComposerExternalTaskSensor(BaseSensorOperator):
"use `composer_external_task_id` or
`composer_external_task_ids` or `composer_external_task_group_id`."
)
- # since both not set, convert the single id to a 1-elt list - from
here on, we only consider the list
if composer_external_task_id is not None:
composer_external_task_ids = [composer_external_task_id]
@@ -428,49 +465,25 @@ class CloudComposerExternalTaskSensor(BaseSensorOperator):
"use `composer_external_task_id` or
`composer_external_task_ids` or `composer_external_task_group_id`."
)
- # check the requested states are all valid states for the target type,
be it dag or task
if composer_external_task_ids or composer_external_task_group_id:
- if not total_states <= set(State.task_states):
+ if not self._total_states <= set(State.task_states):
raise ValueError(
"Valid values for `allowed_states`, `skipped_states` and
`failed_states` "
- "when `composer_external_task_id` or
`composer_external_task_ids` or `composer_external_task_group_id` "
- f"is not `None`: {State.task_states}"
+ "when `composer_external_task_id` or
`composer_external_task_ids` or "
+ f"`composer_external_task_group_id` is not `None`:
{State.task_states}"
)
- elif not total_states <= set(State.dag_states):
+ elif not self._total_states <= set(State.dag_states):
raise ValueError(
"Valid values for `allowed_states`, `skipped_states` and
`failed_states` "
- f"when `composer_external_task_id` and
`composer_external_task_group_id` is `None`: {State.dag_states}"
+ f"when `composer_external_task_id` and
`composer_external_task_group_id` is `None`: "
+ f"{State.dag_states}"
)
- self.execution_range = execution_range
- self.composer_external_dag_id = composer_external_dag_id
- self.composer_external_task_id = composer_external_task_id
self.composer_external_task_ids = composer_external_task_ids
- self.composer_external_task_group_id = composer_external_task_group_id
- self.gcp_conn_id = gcp_conn_id
- self.impersonation_chain = impersonation_chain
- self.deferrable = deferrable
- self.poll_interval = poll_interval
-
- def _get_logical_dates(self, context) -> tuple[datetime, datetime]:
- logical_date = context.get("logical_date", None)
- if logical_date is None:
- raise RuntimeError(
- "logical_date is None. Please make sure the sensor is not used
in an asset-triggered Dag. "
- "CloudComposerDAGRunSensor was designed to be used in
time-based scheduled Dags only, "
- "and asset-triggered Dags do not have logical_date. "
- )
- if isinstance(self.execution_range, timedelta):
- if self.execution_range < timedelta(0):
- return logical_date, logical_date - self.execution_range
- return logical_date - self.execution_range, logical_date
- if isinstance(self.execution_range, list) and
len(self.execution_range) > 0:
- return self.execution_range[0], self.execution_range[1] if len(
- self.execution_range
- ) > 1 else logical_date
- return logical_date - timedelta(1), logical_date
+ self._external_task_ids_normalized = True
def poke(self, context: Context) -> bool:
+ self._normalize_external_task_ids()
start_date, end_date = self._get_logical_dates(context)
task_instances = self._get_task_instances(
@@ -634,6 +647,7 @@ class CloudComposerExternalTaskSensor(BaseSensorOperator):
)
def execute(self, context: Context) -> None:
+ self._normalize_external_task_ids()
self._composer_airflow_version = self._get_composer_airflow_version()
if self.composer_external_task_ids and
len(self.composer_external_task_ids) > len(
@@ -694,6 +708,7 @@ class CloudComposerExternalTaskSensor(BaseSensorOperator):
super().execute(context)
def execute_complete(self, context: Context, event: dict):
+ self._normalize_external_task_ids()
if event and event["status"] == "error":
raise AirflowException(event["message"])
if event and event["status"] == "failed":
diff --git
a/providers/google/tests/unit/google/cloud/sensors/test_cloud_composer.py
b/providers/google/tests/unit/google/cloud/sensors/test_cloud_composer.py
index 21a545a9dc3..f97b46151b2 100644
--- a/providers/google/tests/unit/google/cloud/sensors/test_cloud_composer.py
+++ b/providers/google/tests/unit/google/cloud/sensors/test_cloud_composer.py
@@ -28,6 +28,7 @@ from airflow.providers.google.cloud.sensors.cloud_composer
import (
CloudComposerDAGRunSensor,
CloudComposerExternalTaskSensor,
)
+from airflow.providers.standard.exceptions import ExternalTaskFailedError
TEST_PROJECT_ID = "test_project_id"
TEST_OPERATION_NAME = "test_operation_name"
@@ -350,3 +351,204 @@ class TestCloudComposerExternalTaskSensor:
task._composer_airflow_version = composer_airflow_version
assert not task.poke(context={"logical_date": datetime(2024, 5, 23, 0,
0, 0)})
+
+ def test_init_does_not_wrap_task_id_into_task_ids(self):
+ task = CloudComposerExternalTaskSensor(
+ task_id="task-id",
+ project_id=TEST_PROJECT_ID,
+ region=TEST_REGION,
+ environment_id=TEST_ENVIRONMENT_ID,
+ composer_external_dag_id="test_dag_id",
+ composer_external_task_id=TEST_COMPOSER_EXTERNAL_TASK_ID,
+ )
+
+ assert task.composer_external_task_id == TEST_COMPOSER_EXTERNAL_TASK_ID
+ assert task.composer_external_task_ids is None
+
+
@mock.patch("airflow.providers.google.cloud.sensors.cloud_composer.CloudComposerHook")
+ def test_poke_wraps_task_id_into_task_ids(self, mock_hook):
+ mock_hook.return_value.get_task_instances.return_value =
TEST_GET_TASK_INSTANCES_RESULT(
+ "success",
+ "logical_date",
+ TEST_COMPOSER_EXTERNAL_TASK_ID,
+ )
+ task = CloudComposerExternalTaskSensor(
+ task_id="task-id",
+ project_id=TEST_PROJECT_ID,
+ region=TEST_REGION,
+ environment_id=TEST_ENVIRONMENT_ID,
+ composer_external_dag_id="test_dag_id",
+ composer_external_task_id=TEST_COMPOSER_EXTERNAL_TASK_ID,
+ allowed_states=["success"],
+ )
+ task._composer_airflow_version = 3
+
+ context = {"logical_date": datetime(2024, 5, 23, 0, 0, 0)}
+ assert task.poke(context=context)
+ assert task.composer_external_task_ids ==
[TEST_COMPOSER_EXTERNAL_TASK_ID]
+ assert task.poke(context=context)
+ assert task.composer_external_task_ids ==
[TEST_COMPOSER_EXTERNAL_TASK_ID]
+
+ def test_init_keeps_empty_task_ids_until_poke(self):
+ task = CloudComposerExternalTaskSensor(
+ task_id="task-id",
+ project_id=TEST_PROJECT_ID,
+ region=TEST_REGION,
+ environment_id=TEST_ENVIRONMENT_ID,
+ composer_external_dag_id="test_dag_id",
+ composer_external_task_ids=[],
+ )
+
+ assert task.composer_external_task_ids == []
+
+
@mock.patch("airflow.providers.google.cloud.sensors.cloud_composer.CloudComposerHook")
+ def test_poke_treats_empty_task_ids_as_unset(self, mock_hook):
+ mock_hook.return_value.get_task_instances.return_value =
TEST_GET_TASK_INSTANCES_RESULT(
+ "success",
+ "logical_date",
+ TEST_COMPOSER_EXTERNAL_TASK_ID,
+ )
+ task = CloudComposerExternalTaskSensor(
+ task_id="task-id",
+ project_id=TEST_PROJECT_ID,
+ region=TEST_REGION,
+ environment_id=TEST_ENVIRONMENT_ID,
+ composer_external_dag_id="test_dag_id",
+ composer_external_task_ids=[],
+ allowed_states=["success"],
+ )
+ task._composer_airflow_version = 3
+
+ task.poke(context={"logical_date": datetime(2024, 5, 23, 0, 0, 0)})
+ assert task.composer_external_task_ids is None
+
+ def test_task_id_and_task_ids_are_exclusive_after_render(self):
+ task = CloudComposerExternalTaskSensor(
+ task_id="task-id",
+ project_id=TEST_PROJECT_ID,
+ region=TEST_REGION,
+ environment_id=TEST_ENVIRONMENT_ID,
+ composer_external_dag_id="test_dag_id",
+ composer_external_task_id=TEST_COMPOSER_EXTERNAL_TASK_ID,
+ composer_external_task_ids=[TEST_COMPOSER_EXTERNAL_TASK_ID],
+ )
+
+ with pytest.raises(ValueError, match="Only one of
`composer_external_task_id`"):
+ task.poke(context={"logical_date": datetime(2024, 5, 23, 0, 0, 0)})
+
+ def test_empty_task_ids_list_normalizes_before_exclusivity_check(self):
+ task = CloudComposerExternalTaskSensor(
+ task_id="task-id",
+ project_id=TEST_PROJECT_ID,
+ region=TEST_REGION,
+ environment_id=TEST_ENVIRONMENT_ID,
+ composer_external_dag_id="test_dag_id",
+
composer_external_task_group_id=TEST_COMPOSER_EXTERNAL_TASK_GROUP_ID,
+ composer_external_task_ids=[],
+ )
+
+ assert task.composer_external_task_ids == []
+
+ with (
+ mock.patch.object(task, "_get_composer_airflow_version",
return_value=3),
+ mock.patch.object(task, "poke", return_value=True),
+ ):
+ task.execute(context={})
+
+ assert task.composer_external_task_ids is None
+ assert task.composer_external_task_group_id ==
TEST_COMPOSER_EXTERNAL_TASK_GROUP_ID
+
+ def test_empty_task_ids_use_dag_state_validation_after_normalize(self):
+ task = CloudComposerExternalTaskSensor(
+ task_id="task-id",
+ project_id=TEST_PROJECT_ID,
+ region=TEST_REGION,
+ environment_id=TEST_ENVIRONMENT_ID,
+ composer_external_dag_id="test_dag_id",
+ composer_external_task_ids=[],
+ allowed_states=["up_for_retry"],
+ )
+
+ assert task.composer_external_task_ids == []
+ with pytest.raises(ValueError, match="composer_external_task_id.*is
`None`"):
+ task.poke(context={"logical_date": datetime(2024, 5, 23, 0, 0, 0)})
+
+ def test_templated_task_id_is_normalized_after_rendering(self):
+ task = CloudComposerExternalTaskSensor(
+ task_id="task-id",
+ project_id=TEST_PROJECT_ID,
+ region=TEST_REGION,
+ environment_id=TEST_ENVIRONMENT_ID,
+ composer_external_dag_id="test_dag_id",
+ composer_external_task_id="{{ target_task_id }}",
+ )
+
+ assert task.composer_external_task_ids is None
+ task.render_template_fields(context={"target_task_id":
TEST_COMPOSER_EXTERNAL_TASK_ID})
+ assert task.composer_external_task_id == TEST_COMPOSER_EXTERNAL_TASK_ID
+ assert task.composer_external_task_ids is None
+
+ with (
+ mock.patch.object(task, "_get_composer_airflow_version",
return_value=3),
+ mock.patch.object(task, "poke", return_value=True),
+ ):
+ task.execute(context={})
+
+ assert task.composer_external_task_ids ==
[TEST_COMPOSER_EXTERNAL_TASK_ID]
+
+ def test_state_validation_uses_rendered_target(self):
+ task = CloudComposerExternalTaskSensor(
+ task_id="task-id",
+ project_id=TEST_PROJECT_ID,
+ region=TEST_REGION,
+ environment_id=TEST_ENVIRONMENT_ID,
+ composer_external_dag_id="test_dag_id",
+ composer_external_task_id=lambda *, context, jinja_env: None,
+ allowed_states=["up_for_retry"],
+ )
+
+ task.render_template_fields(context={})
+ assert task.composer_external_task_id is None
+
+ with pytest.raises(ValueError, match="composer_external_task_id.*is
`None`"):
+ task.execute(context={})
+
+ def test_templated_empty_task_ids_do_not_conflict_with_task_group(self):
+ task = CloudComposerExternalTaskSensor(
+ task_id="task-id",
+ project_id=TEST_PROJECT_ID,
+ region=TEST_REGION,
+ environment_id=TEST_ENVIRONMENT_ID,
+ composer_external_dag_id="test_dag_id",
+
composer_external_task_group_id=TEST_COMPOSER_EXTERNAL_TASK_GROUP_ID,
+ composer_external_task_ids=lambda *, context, jinja_env: [],
+ )
+
+ task.render_template_fields(context={})
+ assert task.composer_external_task_ids == []
+
+ with (
+ mock.patch.object(task, "_get_composer_airflow_version",
return_value=3),
+ mock.patch.object(task, "poke", return_value=True),
+ ):
+ task.execute(context={})
+
+ assert task.composer_external_task_ids is None
+ assert task.composer_external_task_group_id ==
TEST_COMPOSER_EXTERNAL_TASK_GROUP_ID
+
+ def test_execute_complete_normalizes_single_task_id_for_failed_event(self):
+ """Deferrable resume hits execute_complete on a fresh instance;
normalize first."""
+ task = CloudComposerExternalTaskSensor(
+ task_id="task-id",
+ project_id=TEST_PROJECT_ID,
+ region=TEST_REGION,
+ environment_id=TEST_ENVIRONMENT_ID,
+ composer_external_dag_id="test_dag_id",
+ composer_external_task_id="task_a",
+ deferrable=True,
+ )
+
+ assert task.composer_external_task_ids is None
+ with pytest.raises(ExternalTaskFailedError):
+ task.execute_complete(context={}, event={"status": "failed"})
+ assert task.composer_external_task_ids == ["task_a"]
diff --git a/scripts/ci/prek/validate_operators_init_exemptions.txt
b/scripts/ci/prek/validate_operators_init_exemptions.txt
index c49a448fcd3..675813b6031 100644
--- a/scripts/ci/prek/validate_operators_init_exemptions.txt
+++ b/scripts/ci/prek/validate_operators_init_exemptions.txt
@@ -11,4 +11,3 @@
providers/google/src/airflow/providers/google/cloud/operators/cloud_build.py::Cl
providers/google/src/airflow/providers/google/cloud/operators/cloud_storage_transfer_service.py::CloudDataTransferServiceCreateJobOperator
providers/google/src/airflow/providers/google/cloud/operators/dataproc.py::DataprocCreateClusterOperator
providers/google/src/airflow/providers/google/cloud/operators/functions.py::CloudFunctionDeployFunctionOperator
-providers/google/src/airflow/providers/google/cloud/sensors/cloud_composer.py::CloudComposerExternalTaskSensor