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 b9c52951378 Create GCS folder markers as directories in 
GCSToSFTPOperator (#73880)
b9c52951378 is described below

commit b9c529513784531304b859c9bcf37adb6ce1aa6b
Author: Yuseok Jo <[email protected]>
AuthorDate: Sat Oct 3 16:21:47 2026 +0900

    Create GCS folder markers as directories in GCSToSFTPOperator (#73880)
---
 providers/google/docs/changelog.rst                |  9 +++
 .../google/cloud/transfers/gcs_to_sftp.py          | 55 ++++++++++++-----
 .../google/cloud/transfers/test_gcs_to_sftp.py     | 72 ++++++++++++++++++++++
 3 files changed, 119 insertions(+), 17 deletions(-)

diff --git a/providers/google/docs/changelog.rst 
b/providers/google/docs/changelog.rst
index be32f9edc07..e0b19f6049b 100644
--- a/providers/google/docs/changelog.rst
+++ b/providers/google/docs/changelog.rst
@@ -27,6 +27,15 @@
 Changelog
 ---------
 
+.. warning::
+    ``GCSToSFTPOperator`` no longer writes GCS folder markers, the objects 
whose name ends with
+    ``/``, as files. They were written as an empty file at the folder's path, 
so the folder's
+    contents could not be transferred after them. With 
``create_intermediate_dirs=True``, the
+    default, each marker is now created as a directory on the SFTP server, and 
deleted from the
+    bucket when ``move_object=True``. With ``create_intermediate_dirs=False``, 
markers are skipped
+    and left in the bucket. An empty file that an earlier version wrote at a 
marker's path blocks
+    the directory and fails the task; remove it from the SFTP server before 
the next run.
+
 22.6.0
 ......
 
diff --git 
a/providers/google/src/airflow/providers/google/cloud/transfers/gcs_to_sftp.py 
b/providers/google/src/airflow/providers/google/cloud/transfers/gcs_to_sftp.py
index 521125b610b..bc08bb6dc0b 100644
--- 
a/providers/google/src/airflow/providers/google/cloud/transfers/gcs_to_sftp.py
+++ 
b/providers/google/src/airflow/providers/google/cloud/transfers/gcs_to_sftp.py
@@ -82,7 +82,9 @@ class GCSToSFTPOperator(BaseOperator):
     :param keep_directory_structure: (Optional) When set to False the path of 
the file
         on the bucket is recreated within path passed in destination_path.
     :param create_intermediate_dirs: (Optional) When set to True the 
intermediate directories
-        in the specified file path will be created.
+        in the specified file path will be created. It also controls folder 
markers (objects whose
+        name ends with ``/``): when True, each marker is created as a 
directory on the SFTP server;
+        when False, it is skipped and left in the bucket, even if 
``move_object`` is True.
     :param move_object: When move object is True, the object is moved instead
         of copied to the new location. This is the equivalent of a mv command
         as opposed to a cp command.
@@ -209,25 +211,44 @@ class GCSToSFTPOperator(BaseOperator):
         destination_path: str,
     ) -> None:
         """Copy single object."""
-        self.log.info(
-            "Executing copy of gs://%s/%s to %s",
-            self.source_bucket,
-            source_object,
-            destination_path,
-        )
+        # ``_resolve_destination_path`` strips the trailing slash, so storing 
a folder marker
+        # would write a file at the path its children need as a directory.
+        if source_object.endswith("/"):
+            if not self.create_intermediate_dirs:
+                self.log.info(
+                    "Skipping folder marker gs://%s/%s because 
create_intermediate_dirs is False",
+                    self.source_bucket,
+                    source_object,
+                )
+                return
+
+            self.log.info(
+                "Creating directory %s for folder marker gs://%s/%s",
+                destination_path,
+                self.source_bucket,
+                source_object,
+            )
+            sftp_hook.create_directory(destination_path)
+        else:
+            self.log.info(
+                "Executing copy of gs://%s/%s to %s",
+                self.source_bucket,
+                source_object,
+                destination_path,
+            )
 
-        dir_path = os.path.dirname(destination_path)
+            dir_path = os.path.dirname(destination_path)
 
-        if self.create_intermediate_dirs:
-            sftp_hook.create_directory(dir_path)
+            if self.create_intermediate_dirs:
+                sftp_hook.create_directory(dir_path)
 
-        with NamedTemporaryFile("w") as tmp:
-            gcs_hook.download(
-                bucket_name=self.source_bucket,
-                object_name=source_object,
-                filename=tmp.name,
-            )
-            sftp_hook.store_file(destination_path, tmp.name)
+            with NamedTemporaryFile("w") as tmp:
+                gcs_hook.download(
+                    bucket_name=self.source_bucket,
+                    object_name=source_object,
+                    filename=tmp.name,
+                )
+                sftp_hook.store_file(destination_path, tmp.name)
 
         if self.move_object:
             self.log.info("Executing delete of gs://%s/%s", 
self.source_bucket, source_object)
diff --git 
a/providers/google/tests/unit/google/cloud/transfers/test_gcs_to_sftp.py 
b/providers/google/tests/unit/google/cloud/transfers/test_gcs_to_sftp.py
index 8bc93419338..7955d3c2d49 100644
--- a/providers/google/tests/unit/google/cloud/transfers/test_gcs_to_sftp.py
+++ b/providers/google/tests/unit/google/cloud/transfers/test_gcs_to_sftp.py
@@ -467,6 +467,78 @@ class TestGoogleCloudStorageToSFTPOperator:
 
         sftp_hook_mock.return_value.create_directory.assert_not_called()
 
+    @pytest.mark.parametrize(
+        ("move_object", "expected_deleted"),
+        [
+            pytest.param(False, [], id="copy"),
+            pytest.param(True, ["data/empty/", "data/folder/", 
"data/folder/file.txt"], id="move"),
+        ],
+    )
+    
@mock.patch("airflow.providers.google.cloud.transfers.gcs_to_sftp.GCSHook", 
autospec=True)
+    
@mock.patch("airflow.providers.google.cloud.transfers.gcs_to_sftp.SFTPHook", 
autospec=True)
+    def test_folder_markers_are_created_as_directories(
+        self, sftp_hook_mock, gcs_hook_mock, move_object, expected_deleted
+    ):
+        gcs_hook = gcs_hook_mock.return_value
+        sftp_hook = sftp_hook_mock.return_value
+        gcs_hook.list.return_value = ["data/empty/", "data/folder/", 
"data/folder/file.txt"]
+        operator = GCSToSFTPOperator(
+            task_id=TASK_ID,
+            source_bucket=TEST_BUCKET,
+            source_object="data/*",
+            destination_path=DESTINATION_SFTP,
+            create_intermediate_dirs=True,
+            move_object=move_object,
+            gcp_conn_id=GCP_CONN_ID,
+            sftp_conn_id=SFTP_CONN_ID,
+        )
+        operator.execute(None)
+
+        
sftp_hook.create_directory.assert_any_call(os.path.join(DESTINATION_SFTP, 
"data", "empty"))
+        sftp_hook.store_file.assert_called_once_with(
+            os.path.join(DESTINATION_SFTP, "data", "folder", "file.txt"), 
mock.ANY
+        )
+        gcs_hook.download.assert_called_once_with(
+            bucket_name=TEST_BUCKET, object_name="data/folder/file.txt", 
filename=mock.ANY
+        )
+        assert gcs_hook.delete.call_args_list == [mock.call(TEST_BUCKET, obj) 
for obj in expected_deleted]
+
+    @pytest.mark.parametrize(
+        ("move_object", "expected_deleted"),
+        [
+            pytest.param(False, [], id="copy"),
+            pytest.param(True, ["data/folder/file.txt"], id="move"),
+        ],
+    )
+    
@mock.patch("airflow.providers.google.cloud.transfers.gcs_to_sftp.GCSHook", 
autospec=True)
+    
@mock.patch("airflow.providers.google.cloud.transfers.gcs_to_sftp.SFTPHook", 
autospec=True)
+    def test_folder_markers_are_skipped_without_intermediate_dirs(
+        self, sftp_hook_mock, gcs_hook_mock, move_object, expected_deleted
+    ):
+        gcs_hook = gcs_hook_mock.return_value
+        sftp_hook = sftp_hook_mock.return_value
+        gcs_hook.list.return_value = ["data/empty/", "data/folder/", 
"data/folder/file.txt"]
+        operator = GCSToSFTPOperator(
+            task_id=TASK_ID,
+            source_bucket=TEST_BUCKET,
+            source_object="data/*",
+            destination_path=DESTINATION_SFTP,
+            create_intermediate_dirs=False,
+            move_object=move_object,
+            gcp_conn_id=GCP_CONN_ID,
+            sftp_conn_id=SFTP_CONN_ID,
+        )
+        operator.execute(None)
+
+        sftp_hook.create_directory.assert_not_called()
+        sftp_hook.store_file.assert_called_once_with(
+            os.path.join(DESTINATION_SFTP, "data", "folder", "file.txt"), 
mock.ANY
+        )
+        gcs_hook.download.assert_called_once_with(
+            bucket_name=TEST_BUCKET, object_name="data/folder/file.txt", 
filename=mock.ANY
+        )
+        assert gcs_hook.delete.call_args_list == [mock.call(TEST_BUCKET, obj) 
for obj in expected_deleted]
+
     @pytest.mark.parametrize(
         "source_object",
         [

Reply via email to