From f13a1f384489f1b492522f3bbeac81092c3ec25e Mon Sep 17 00:00:00 2001 From: Abhisek Behera <123497213+abhisek343@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:39:19 +0530 Subject: [PATCH 1/2] Fix Cloud Build operator template preparation --- .../providers/google/cloud/operators/cloud_build.py | 11 +++++------ .../ci/prek/validate_operators_init_exemptions.txt | 1 - 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/providers/google/src/airflow/providers/google/cloud/operators/cloud_build.py b/providers/google/src/airflow/providers/google/cloud/operators/cloud_build.py index 85963e81e4711..ef4084fa44e77 100644 --- a/providers/google/src/airflow/providers/google/cloud/operators/cloud_build.py +++ b/providers/google/src/airflow/providers/google/cloud/operators/cloud_build.py @@ -190,8 +190,6 @@ def __init__( ) -> None: super().__init__(**kwargs) self.build = build - # Not template fields to keep original value - self.build_raw = build self.project_id = project_id self.wait = wait self.retry = retry @@ -205,12 +203,13 @@ def __init__( def prepare_template(self) -> None: # if no file is specified, skip - if not isinstance(self.build_raw, str): + if not isinstance(self.build, str): return - with open(self.build_raw) as file: - if self.build_raw.endswith((".yaml", ".yml")): + build_path = self.build + with open(build_path) as file: + if build_path.endswith((".yaml", ".yml")): self.build = yaml.safe_load(file.read()) - if self.build_raw.endswith(".json"): + if build_path.endswith(".json"): self.build = json.loads(file.read()) @property diff --git a/scripts/ci/prek/validate_operators_init_exemptions.txt b/scripts/ci/prek/validate_operators_init_exemptions.txt index ada2c47676665..a4d2e7f04ae14 100644 --- a/scripts/ci/prek/validate_operators_init_exemptions.txt +++ b/scripts/ci/prek/validate_operators_init_exemptions.txt @@ -7,5 +7,4 @@ # Burn-down tracked at https://github.com/apache/airflow/issues/70296 providers/amazon/src/airflow/providers/amazon/aws/operators/neptune.py::NeptuneStartDbClusterOperator providers/amazon/src/airflow/providers/amazon/aws/operators/neptune.py::NeptuneStopDbClusterOperator -providers/google/src/airflow/providers/google/cloud/operators/cloud_build.py::CloudBuildCreateBuildOperator providers/google/src/airflow/providers/google/cloud/operators/dataproc.py::DataprocCreateClusterOperator From e6b2e9066bb8c2c4be793db2ce85ee767e120ffc Mon Sep 17 00:00:00 2001 From: Shahar Epstein <60007259+shahar1@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:11:53 +0300 Subject: [PATCH 2/2] Cover CloudBuildCreateBuildOperator template preparation with tests prepare_template() now reads self.build instead of the removed build_raw copy, so a second call short-circuits on the already-parsed dict instead of re-reading the build file from disk. Add a test that constructing the operator keeps no second attribute referencing the build argument, and a test that calling prepare_template() twice after the backing file is gone still leaves operator.build populated with the parsed content from the first call. The duplicate-attribute test matches on object identity rather than on the removed build_raw name, so it also catches a copy reintroduced under any other attribute name. The operator leaves template_ext empty, so resolve_template_files() never replaces self.build with the file's contents before prepare_template() runs; prepare_template() is the only place that reads the file, which is what makes the second call idempotent. --- .../google/cloud/operators/cloud_build.py | 1 + .../cloud/operators/test_cloud_build.py | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/providers/google/src/airflow/providers/google/cloud/operators/cloud_build.py b/providers/google/src/airflow/providers/google/cloud/operators/cloud_build.py index ef4084fa44e77..14d500731ac84 100644 --- a/providers/google/src/airflow/providers/google/cloud/operators/cloud_build.py +++ b/providers/google/src/airflow/providers/google/cloud/operators/cloud_build.py @@ -205,6 +205,7 @@ def prepare_template(self) -> None: # if no file is specified, skip if not isinstance(self.build, str): return + # Keep the path in a local: the branches below replace self.build with a dict. build_path = self.build with open(build_path) as file: if build_path.endswith((".yaml", ".yml")): diff --git a/providers/google/tests/unit/google/cloud/operators/test_cloud_build.py b/providers/google/tests/unit/google/cloud/operators/test_cloud_build.py index 854cc97923bf1..8726a7c835027 100644 --- a/providers/google/tests/unit/google/cloud/operators/test_cloud_build.py +++ b/providers/google/tests/unit/google/cloud/operators/test_cloud_build.py @@ -165,6 +165,28 @@ def test_load_templated(self, file_type, file_content): expected_body = {"steps": [{"name": "ubuntu", "args": ["echo", "Hello {{ params.name }}!"]}]} assert expected_body == operator.build + def test_init_does_not_duplicate_build(self): + build_path = "path/to/build.json" + operator = CloudBuildCreateBuildOperator(build=build_path, task_id="task-id") + assert operator.build == build_path + # Any second attribute referencing the same object would be a copy prepare_template() + # could read instead of self.build, whatever it is named. + assert [name for name, value in vars(operator).items() if value is build_path] == ["build"] + + def test_prepare_template_second_call_is_no_op(self, tmp_path): + expected_body = {"steps": [{"name": "ubuntu", "args": ["echo", "Hello {{ params.name }}!"]}]} + build_file = tmp_path / "build.json" + build_file.write_text(json.dumps(expected_body)) + + operator = CloudBuildCreateBuildOperator(build=str(build_file), task_id="task-id") + operator.prepare_template() + assert expected_body == operator.build + + build_file.unlink() + + operator.prepare_template() + assert expected_body == operator.build + @mock.patch(CLOUD_BUILD_HOOK_PATH) def test_create_build_trigger(self, mock_hook): mock_hook.return_value.create_build_trigger.return_value = BuildTrigger()