This is an automated email from the ASF dual-hosted git repository.
o-nikolas 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 fb959c70741 Skip Azure Blob folder-marker keys in
AzureBlobStorageToS3Operator (#72892)
fb959c70741 is described below
commit fb959c707415a99314ef5123ca65fa2e03f0a4f4
Author: Yuseok Jo <[email protected]>
AuthorDate: Fri Sep 25 01:36:36 2026 +0900
Skip Azure Blob folder-marker keys in AzureBlobStorageToS3Operator (#72892)
Azure container listings include trailing-slash folder markers, which the
operator copied verbatim, leaving spurious empty keys in the destination
bucket and in the XCom output that downstream tasks consume. GCSToS3Operator
already applied an overlap-aware rule for this (#72497), so the two
transfers
disagreed on identical input; both now share it. Sorting the replace=False
path makes the returned key order independent of which keys already exist in
S3.
---
.../amazon/aws/transfers/azure_blob_to_s3.py | 11 +++++-
.../providers/amazon/aws/transfers/gcs_to_s3.py | 26 +------------
.../airflow/providers/amazon/aws/utils/transfer.py | 42 ++++++++++++++++++++
.../amazon/aws/transfers/test_azure_blob_to_s3.py | 21 ++++++++++
.../unit/amazon/aws/transfers/test_gcs_to_s3.py | 26 -------------
.../tests/unit/amazon/aws/utils/test_transfer.py | 45 ++++++++++++++++++++++
6 files changed, 120 insertions(+), 51 deletions(-)
diff --git
a/providers/amazon/src/airflow/providers/amazon/aws/transfers/azure_blob_to_s3.py
b/providers/amazon/src/airflow/providers/amazon/aws/transfers/azure_blob_to_s3.py
index adb82c42d38..bc2c8bcad6a 100644
---
a/providers/amazon/src/airflow/providers/amazon/aws/transfers/azure_blob_to_s3.py
+++
b/providers/amazon/src/airflow/providers/amazon/aws/transfers/azure_blob_to_s3.py
@@ -23,6 +23,7 @@ from collections.abc import Sequence
from typing import TYPE_CHECKING
from airflow.providers.amazon.aws.hooks.s3 import S3Hook
+from airflow.providers.amazon.aws.utils.transfer import
strip_overlapping_folder_markers
from airflow.providers.common.compat.sdk import BaseOperator
try:
@@ -139,6 +140,14 @@ class AzureBlobStorageToS3Operator(BaseOperator):
container_name=self.container_name, prefix=self.prefix,
endswith=self.delimiter
)
+ files, dropped_keys = strip_overlapping_folder_markers(files)
+ if dropped_keys:
+ self.log.info(
+ "Skipping %s Azure Blob folder-marker key(s) (omitted from
transfer and XCom output): %s",
+ len(dropped_keys),
+ dropped_keys,
+ )
+
if not self.replace:
# if we are not replacing -> list all files in the S3 bucket
# and only keep those files which are present in
@@ -151,7 +160,7 @@ class AzureBlobStorageToS3Operator(BaseOperator):
existing_files = existing_files or []
# remove the prefix for the existing files to allow the match
existing_files = [file.replace(f"{prefix}/", "", 1) for file in
existing_files]
- files = list(set(files) - set(existing_files))
+ files = sorted(set(files) - set(existing_files))
if files:
for file in files:
diff --git
a/providers/amazon/src/airflow/providers/amazon/aws/transfers/gcs_to_s3.py
b/providers/amazon/src/airflow/providers/amazon/aws/transfers/gcs_to_s3.py
index 85032ecd387..f5cccd2d52e 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/transfers/gcs_to_s3.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/transfers/gcs_to_s3.py
@@ -26,6 +26,7 @@ from typing import TYPE_CHECKING
from packaging.version import Version
from airflow.providers.amazon.aws.hooks.s3 import S3Hook
+from airflow.providers.amazon.aws.utils.transfer import
strip_overlapping_folder_markers
from airflow.providers.common.compat.sdk import BaseOperator
from airflow.providers.google.cloud.hooks.gcs import GCSHook
@@ -164,29 +165,6 @@ class GCSToS3Operator(BaseOperator):
return os.path.basename(file_path)
return file_path
- @staticmethod
- def _strip_overlapping_folder_markers(keys: list[str]) -> tuple[list[str],
list[str]]:
- """
- Drop trailing-slash keys that are strict prefixes of other listed keys.
-
- Treated as directory markers. A lone trailing-slash key with no overlap
- (e.g. ``lonely/``) is preserved, and a non-slash key that happens to
be a
- strict prefix of another (e.g. ``abc`` of ``abcdef``) is also
preserved.
- Returns ``(kept, dropped)``.
- """
- if not keys:
- return [], []
- ordered = sorted(set(keys))
- kept: list[str] = []
- dropped: list[str] = []
- for current, nxt in zip(ordered, ordered[1:]):
- if current.endswith("/") and nxt.startswith(current):
- dropped.append(current)
- else:
- kept.append(current)
- kept.append(ordered[-1])
- return kept, dropped
-
def execute(self, context: Context) -> list[str]:
# list all files in an Google Cloud Storage bucket
gcs_hook = GCSHook(
@@ -210,7 +188,7 @@ class GCSToS3Operator(BaseOperator):
gcs_files = gcs_hook.list(**list_kwargs) # type: ignore
- gcs_files, dropped_keys =
self._strip_overlapping_folder_markers(gcs_files)
+ gcs_files, dropped_keys = strip_overlapping_folder_markers(gcs_files)
if self.flatten_structure:
# A kept marker like lonely/ has no basename, so flattening would
hit the destination prefix.
dropped_keys += [file for file in gcs_files if not
self._transform_file_path(file)]
diff --git
a/providers/amazon/src/airflow/providers/amazon/aws/utils/transfer.py
b/providers/amazon/src/airflow/providers/amazon/aws/utils/transfer.py
new file mode 100644
index 00000000000..474ee5d5627
--- /dev/null
+++ b/providers/amazon/src/airflow/providers/amazon/aws/utils/transfer.py
@@ -0,0 +1,42 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Utilities shared by Amazon transfer operators."""
+
+from __future__ import annotations
+
+
+def strip_overlapping_folder_markers(keys: list[str]) -> tuple[list[str],
list[str]]:
+ """
+ Drop trailing-slash keys that are strict prefixes of other listed keys.
+
+ Treated as directory markers. A lone trailing-slash key with no overlap
+ (e.g. ``lonely/``) is preserved, and a non-slash key that happens to be a
+ strict prefix of another (e.g. ``abc`` of ``abcdef``) is also preserved.
+ Returns ``(kept, dropped)``.
+ """
+ if not keys:
+ return [], []
+ ordered = sorted(set(keys))
+ kept: list[str] = []
+ dropped: list[str] = []
+ for current, nxt in zip(ordered, ordered[1:]):
+ if current.endswith("/") and nxt.startswith(current):
+ dropped.append(current)
+ else:
+ kept.append(current)
+ kept.append(ordered[-1])
+ return kept, dropped
diff --git
a/providers/amazon/tests/unit/amazon/aws/transfers/test_azure_blob_to_s3.py
b/providers/amazon/tests/unit/amazon/aws/transfers/test_azure_blob_to_s3.py
index 38db8a87f38..227010c78de 100644
--- a/providers/amazon/tests/unit/amazon/aws/transfers/test_azure_blob_to_s3.py
+++ b/providers/amazon/tests/unit/amazon/aws/transfers/test_azure_blob_to_s3.py
@@ -20,6 +20,7 @@ from __future__ import annotations
from io import RawIOBase
from unittest import mock
+import pytest
from moto import mock_aws
from airflow.providers.amazon.aws.hooks.s3 import S3Hook
@@ -216,3 +217,23 @@ class TestAzureBlobToS3Operator:
replace=False,
acl_policy=s3_acl_policy,
)
+
+ @pytest.mark.parametrize("replace", [False, True])
+
@mock.patch("airflow.providers.amazon.aws.transfers.azure_blob_to_s3.WasbHook")
+ def test_execute_skips_overlapping_folder_markers(self, mock_hook,
replace):
+ mock_hook.return_value.get_blobs_list_recursive.return_value = [
+ "src/",
+ "src/file.txt",
+ "lonely/",
+ ]
+ operator = AzureBlobStorageToS3Operator(
+ task_id=TASK_ID,
+ container_name=CONTAINER_NAME,
+ dest_s3_key=S3_BUCKET,
+ replace=replace,
+ )
+ hook, _ = _create_test_bucket()
+
+ uploaded_files = operator.execute(None)
+ assert uploaded_files == ["lonely/", "src/file.txt"]
+ assert hook.list_keys("bucket") == ["lonely/", "src/file.txt"]
diff --git a/providers/amazon/tests/unit/amazon/aws/transfers/test_gcs_to_s3.py
b/providers/amazon/tests/unit/amazon/aws/transfers/test_gcs_to_s3.py
index feaca455f6b..fd4ead2088f 100644
--- a/providers/amazon/tests/unit/amazon/aws/transfers/test_gcs_to_s3.py
+++ b/providers/amazon/tests/unit/amazon/aws/transfers/test_gcs_to_s3.py
@@ -220,32 +220,6 @@ class TestGCSToS3Operator:
assert sorted(MOCK_FILES) == sorted(uploaded_files)
assert sorted(MOCK_FILES) == sorted(hook.list_keys("bucket",
delimiter="/"))
- @pytest.mark.parametrize(
- ("keys", "expected_kept", "expected_dropped"),
- [
- ([], [], []),
- (["a"], ["a"], []),
- (["a", "b"], ["a", "b"], []),
- # Non-slash prefix overlaps must NOT be treated as folder markers.
- (["a", "ax"], ["a", "ax"], []),
- (["abc", "abcdef"], ["abc", "abcdef"], []),
- (["foo/", "foo/bar.txt"], ["foo/bar.txt"], ["foo/"]),
- (
- ["data/", "data/sub/", "data/sub/file.txt"],
- ["data/sub/file.txt"],
- ["data/", "data/sub/"],
- ),
- # A lone trailing-slash key with no overlap is a real object and
stays.
- (["lonely/"], ["lonely/"], []),
- (["lonely/", "report.csv"], ["lonely/", "report.csv"], []),
- ],
- )
- def test_strip_overlapping_folder_markers(self, keys, expected_kept,
expected_dropped):
- """Folder-marker detection: requires both strict-prefix overlap AND
trailing slash."""
- kept, dropped = GCSToS3Operator._strip_overlapping_folder_markers(keys)
- assert kept == expected_kept
- assert dropped == expected_dropped
-
@mock.patch("airflow.providers.amazon.aws.transfers.gcs_to_s3.GCSHook")
def test_execute_skips_overlapping_folder_markers(self, mock_hook):
mock_hook.return_value.list.return_value = ["src/", "src/airflow.png",
"lonely/"]
diff --git a/providers/amazon/tests/unit/amazon/aws/utils/test_transfer.py
b/providers/amazon/tests/unit/amazon/aws/utils/test_transfer.py
new file mode 100644
index 00000000000..979b99db997
--- /dev/null
+++ b/providers/amazon/tests/unit/amazon/aws/utils/test_transfer.py
@@ -0,0 +1,45 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import pytest
+
+from airflow.providers.amazon.aws.utils.transfer import
strip_overlapping_folder_markers
+
+
[email protected](
+ ("keys", "expected_kept", "expected_dropped"),
+ [
+ ([], [], []),
+ (["a"], ["a"], []),
+ (["a", "b"], ["a", "b"], []),
+ (["a", "ax"], ["a", "ax"], []),
+ (["abc", "abcdef"], ["abc", "abcdef"], []),
+ (["foo/", "foo/bar.txt"], ["foo/bar.txt"], ["foo/"]),
+ (
+ ["data/", "data/sub/", "data/sub/file.txt"],
+ ["data/sub/file.txt"],
+ ["data/", "data/sub/"],
+ ),
+ (["lonely/"], ["lonely/"], []),
+ (["lonely/", "report.csv"], ["lonely/", "report.csv"], []),
+ ],
+)
+def test_strip_overlapping_folder_markers(keys, expected_kept,
expected_dropped):
+ kept, dropped = strip_overlapping_folder_markers(keys)
+ assert kept == expected_kept
+ assert dropped == expected_dropped