From ab4f0bccffe38582907bde875320bbc60d8d33e8 Mon Sep 17 00:00:00 2001
From: Dr Alex Mitre <30060514+mitre88@users.noreply.github.com>
Date: Sun, 26 Jul 2026 18:28:49 -0600
Subject: [PATCH] Validate GCSToLocalFilesystemOperator arguments after
 template rendering
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

filename and store_to_xcom_key are template fields, but their mutual-exclusion
check ran in __init__, before Jinja rendering — templated values were rejected
at Dag parse time based on the raw expressions. Validate in execute() instead.
---
 .../google/cloud/transfers/gcs_to_local.py          |  4 ++--
 .../google/cloud/transfers/test_gcs_to_local.py     | 13 +++++++++++++
 .../ci/prek/validate_operators_init_exemptions.txt  |  1 -
 3 files changed, 15 insertions(+), 3 deletions(-)

diff --git a/providers/google/src/airflow/providers/google/cloud/transfers/gcs_to_local.py b/providers/google/src/airflow/providers/google/cloud/transfers/gcs_to_local.py
index a229a949e9af8..8a280fa136701 100644
--- a/providers/google/src/airflow/providers/google/cloud/transfers/gcs_to_local.py
+++ b/providers/google/src/airflow/providers/google/cloud/transfers/gcs_to_local.py
@@ -88,8 +88,6 @@ def __init__(
         file_encoding: str = "utf-8",
         **kwargs,
     ) -> None:
-        if filename is not None and store_to_xcom_key is not None:
-            raise ValueError("Either filename or store_to_xcom_key can be set")
         super().__init__(**kwargs)
         self.bucket = bucket
         self.filename = filename
@@ -100,6 +98,8 @@ def __init__(
         self.file_encoding = file_encoding
 
     def execute(self, context: Context):
+        if self.filename is not None and self.store_to_xcom_key is not None:
+            raise ValueError("Either filename or store_to_xcom_key can be set")
         self.log.info("Executing download: %s, %s, %s", self.bucket, self.object_name, self.filename)
         hook = GCSHook(
             gcp_conn_id=self.gcp_conn_id,
diff --git a/providers/google/tests/unit/google/cloud/transfers/test_gcs_to_local.py b/providers/google/tests/unit/google/cloud/transfers/test_gcs_to_local.py
index 27e1adca93690..d58284b5ef318 100644
--- a/providers/google/tests/unit/google/cloud/transfers/test_gcs_to_local.py
+++ b/providers/google/tests/unit/google/cloud/transfers/test_gcs_to_local.py
@@ -55,6 +55,19 @@ def test_execute(self, mock_hook):
             bucket_name=TEST_BUCKET, object_name=TEST_OBJECT, filename=LOCAL_FILE_PATH
         )
 
+    @mock.patch("airflow.providers.google.cloud.transfers.gcs_to_local.GCSHook")
+    def test_filename_and_xcom_key_fails_at_execute_time(self, mock_hook):
+        operator = GCSToLocalFilesystemOperator(
+            task_id=TASK_ID,
+            bucket=TEST_BUCKET,
+            object_name=TEST_OBJECT,
+            filename=LOCAL_FILE_PATH,
+            store_to_xcom_key=XCOM_KEY,
+        )
+        with pytest.raises(ValueError, match="Either filename or store_to_xcom_key can be set"):
+            operator.execute(None)
+        mock_hook.return_value.download.assert_not_called()
+
     @mock.patch("airflow.providers.google.cloud.transfers.gcs_to_local.GCSHook")
     def test_size_lt_max_xcom_size(self, mock_hook):
         operator = GCSToLocalFilesystemOperator(
diff --git a/scripts/ci/prek/validate_operators_init_exemptions.txt b/scripts/ci/prek/validate_operators_init_exemptions.txt
index e192ef70bafb8..16883ac736446 100644
--- a/scripts/ci/prek/validate_operators_init_exemptions.txt
+++ b/scripts/ci/prek/validate_operators_init_exemptions.txt
@@ -34,7 +34,6 @@ providers/google/src/airflow/providers/google/cloud/transfers/azure_fileshare_to
 providers/google/src/airflow/providers/google/cloud/transfers/bigquery_to_mssql.py::BigQueryToMsSqlOperator
 providers/google/src/airflow/providers/google/cloud/transfers/gcs_to_bigquery.py::GCSToBigQueryOperator
 providers/google/src/airflow/providers/google/cloud/transfers/gcs_to_gcs.py::GCSToGCSOperator
-providers/google/src/airflow/providers/google/cloud/transfers/gcs_to_local.py::GCSToLocalFilesystemOperator
 providers/google/src/airflow/providers/google/marketing_platform/operators/campaign_manager.py::GoogleCampaignManagerDeleteReportOperator
 providers/microsoft/azure/src/airflow/providers/microsoft/azure/transfers/gcs_to_wasb.py::GCSToAzureBlobStorageOperator
 providers/microsoft/azure/src/airflow/providers/microsoft/azure/transfers/oracle_to_azure_data_lake.py::OracleToAzureDataLakeOperator
