This is an automated email from the ASF dual-hosted git repository.
potiuk 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 ed68491d8be Prepare Cloud Batch jobs before template rendering (#70835)
ed68491d8be is described below
commit ed68491d8be71d18e1100db9708a852b7eea360c
Author: Shivam Rastogi <[email protected]>
AuthorDate: Tue Aug 18 12:41:44 2026 -0700
Prepare Cloud Batch jobs before template rendering (#70835)
---
.../google/cloud/operators/cloud_batch.py | 10 ++--
.../google/cloud/operators/test_cloud_batch.py | 63 ++++++++++++++++++++--
.../ci/prek/validate_operators_init_exemptions.txt | 1 -
3 files changed, 66 insertions(+), 8 deletions(-)
diff --git
a/providers/google/src/airflow/providers/google/cloud/operators/cloud_batch.py
b/providers/google/src/airflow/providers/google/cloud/operators/cloud_batch.py
index 914559070b7..0e6d8a9491a 100644
---
a/providers/google/src/airflow/providers/google/cloud/operators/cloud_batch.py
+++
b/providers/google/src/airflow/providers/google/cloud/operators/cloud_batch.py
@@ -78,10 +78,6 @@ class CloudBatchSubmitJobOperator(GoogleCloudBaseOperator):
self.region = region
self.job_name = job_name
self.job = job
- # Normalize Job protobuf to dict so Airflow's template renderer can
descend
- # into nested fields (e.g. runnable.container.commands). See #37217.
- if isinstance(job, Job):
- self.job = Job.to_dict(job)
self.polling_period_seconds = polling_period_seconds
self.timeout_seconds = timeout_seconds
self.gcp_conn_id = gcp_conn_id
@@ -89,6 +85,12 @@ class CloudBatchSubmitJobOperator(GoogleCloudBaseOperator):
self.deferrable = deferrable
self.polling_period_seconds = polling_period_seconds
+ def prepare_template(self) -> None:
+ # Normalize Job protobuf to dict so Airflow's template renderer can
descend
+ # into nested fields (e.g. runnable.container.commands). See #37217.
+ if isinstance(self.job, Job):
+ self.job = Job.to_dict(self.job)
+
def execute(self, context: Context):
hook: CloudBatchHook = CloudBatchHook(self.gcp_conn_id,
self.impersonation_chain)
job = hook.submit_batch_job(
diff --git
a/providers/google/tests/unit/google/cloud/operators/test_cloud_batch.py
b/providers/google/tests/unit/google/cloud/operators/test_cloud_batch.py
index 1b688bb65df..b231907ab59 100644
--- a/providers/google/tests/unit/google/cloud/operators/test_cloud_batch.py
+++ b/providers/google/tests/unit/google/cloud/operators/test_cloud_batch.py
@@ -32,6 +32,8 @@ from airflow.providers.google.cloud.operators.cloud_batch
import (
CloudBatchSubmitJobOperator,
)
+from tests_common.test_utils.compat import DagSerialization
+
CLOUD_BATCH_HOOK_PATH =
"airflow.providers.google.cloud.operators.cloud_batch.CloudBatchHook"
TASK_ID = "test"
PROJECT_ID = "testproject"
@@ -54,7 +56,7 @@ class TestCloudBatchSubmitJobOperator:
assert completed_job["name"] == JOB_NAME
mock.return_value.submit_batch_job.assert_called_with(
- job_name=JOB_NAME, job=batch_v1.Job.to_dict(JOB), region=REGION,
project_id=PROJECT_ID
+ job_name=JOB_NAME, job=JOB, region=REGION, project_id=PROJECT_ID
)
mock.return_value.wait_for_job.assert_called()
@@ -119,6 +121,32 @@ class TestCloudBatchSubmitJobOperatorTemplating:
def test_template_fields_includes_job(self):
assert "job" in CloudBatchSubmitJobOperator.template_fields
+ @pytest.mark.parametrize(
+ ("job_input_factory", "job_input_type"),
+ [
+ pytest.param(lambda d: d, dict, id="dict"),
+ pytest.param(lambda d: batch_v1.Job.from_json(json.dumps(d)),
batch_v1.Job, id="protobuf-Job"),
+ ],
+ )
+ def test_job_is_unchanged_until_template_preparation(self,
job_input_factory, job_input_type):
+ job = job_input_factory(_job_dict_with_template())
+ operator = CloudBatchSubmitJobOperator(
+ task_id=TASK_ID,
+ project_id=PROJECT_ID,
+ region=REGION,
+ job_name=JOB_NAME,
+ job=job,
+ )
+
+ assert operator.job is job
+ assert isinstance(operator.job, job_input_type)
+
+ operator.resolve_template_files()
+ assert isinstance(operator.job, dict)
+ prepared_job = operator.job
+ operator.resolve_template_files()
+ assert operator.job is prepared_job
+
@pytest.mark.db_test
@pytest.mark.parametrize(
"job_input_factory",
@@ -127,7 +155,10 @@ class TestCloudBatchSubmitJobOperatorTemplating:
pytest.param(lambda d: batch_v1.Job.from_json(json.dumps(d)),
id="protobuf-Job"),
],
)
- def test_jinja_in_job_commands_is_rendered(self,
create_task_instance_of_operator, job_input_factory):
+ def test_jinja_in_job_commands_is_rendered(
+ self, dag_maker, create_task_instance_of_operator, job_input_factory
+ ):
+ job = job_input_factory(_job_dict_with_template())
ti = create_task_instance_of_operator(
CloudBatchSubmitJobOperator,
dag_id="test_cloud_batch_render",
@@ -135,9 +166,12 @@ class TestCloudBatchSubmitJobOperatorTemplating:
project_id=PROJECT_ID,
region=REGION,
job_name=JOB_NAME,
- job=job_input_factory(_job_dict_with_template()),
+ job=job,
logical_date=datetime(2026, 1, 15),
)
+ operator = dag_maker.dag.get_task(TASK_ID)
+ assert isinstance(operator.job, dict)
+
task = ti.render_templates()
assert isinstance(task.job, dict)
@@ -146,6 +180,29 @@ class TestCloudBatchSubmitJobOperatorTemplating:
# dag_maker's default run_id is "test"; the point is {{ run_id }} got
substituted at all.
assert task.job["labels"]["run_id"] == "test"
+ @pytest.mark.db_test
+ @pytest.mark.need_serialized_dag
+ def test_protobuf_job_is_prepared_before_serialization(self, dag_maker):
+ job = batch_v1.Job.from_json(json.dumps(_job_dict_with_template()))
+
+ with dag_maker(dag_id="test_cloud_batch_serialization"):
+ operator = CloudBatchSubmitJobOperator(
+ task_id=TASK_ID,
+ project_id=PROJECT_ID,
+ region=REGION,
+ job_name=JOB_NAME,
+ job=job,
+ )
+ assert operator.job is job
+ dag_maker.dag.resolve_template_files()
+
+ serialized_dag =
DagSerialization.deserialize_dag(dag_maker.get_serialized_data()["dag"])
+ serialized_job = serialized_dag.get_task(TASK_ID).job
+
+ assert isinstance(serialized_job, dict)
+ command =
serialized_job["task_groups"][0]["task_spec"]["runnables"][0]["container"]["commands"][1]
+ assert command == "echo {{ ds }}"
+
class TestCloudBatchDeleteJobOperator:
@mock.patch(CLOUD_BATCH_HOOK_PATH)
diff --git a/scripts/ci/prek/validate_operators_init_exemptions.txt
b/scripts/ci/prek/validate_operators_init_exemptions.txt
index b7182ebdda3..73dd45c9731 100644
--- a/scripts/ci/prek/validate_operators_init_exemptions.txt
+++ b/scripts/ci/prek/validate_operators_init_exemptions.txt
@@ -9,7 +9,6 @@
providers/amazon/src/airflow/providers/amazon/aws/operators/neptune.py::NeptuneS
providers/amazon/src/airflow/providers/amazon/aws/operators/neptune.py::NeptuneStopDbClusterOperator
providers/amazon/src/airflow/providers/amazon/aws/transfers/gcs_to_s3.py::GCSToS3Operator
providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/pod.py::KubernetesPodOperator
-providers/google/src/airflow/providers/google/cloud/operators/cloud_batch.py::CloudBatchSubmitJobOperator
providers/google/src/airflow/providers/google/cloud/operators/cloud_build.py::CloudBuildCreateBuildOperator
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