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 d3e7d369587 Return list of destination URIs from S3ToGCSOperator
(#64851)
d3e7d369587 is described below
commit d3e7d369587005959bc63c7f329176b19fdf37df
Author: Yuseok Jo <[email protected]>
AuthorDate: Mon Apr 20 23:29:51 2026 +0900
Return list of destination URIs from S3ToGCSOperator (#64851)
* Return list of destination URIs from S3ToGCSOperator
* Add return_gcs_uris to S3ToGCSOperator
---
.../providers/google/cloud/transfers/s3_to_gcs.py | 44 +++++++-
.../unit/google/cloud/transfers/test_s3_to_gcs.py | 115 +++++++++++++++++++--
2 files changed, 148 insertions(+), 11 deletions(-)
diff --git
a/providers/google/src/airflow/providers/google/cloud/transfers/s3_to_gcs.py
b/providers/google/src/airflow/providers/google/cloud/transfers/s3_to_gcs.py
index 9ac46273532..7b44556c044 100644
--- a/providers/google/src/airflow/providers/google/cloud/transfers/s3_to_gcs.py
+++ b/providers/google/src/airflow/providers/google/cloud/transfers/s3_to_gcs.py
@@ -17,6 +17,7 @@
# under the License.
from __future__ import annotations
+import warnings
from collections.abc import Sequence
from datetime import datetime, timezone
from tempfile import NamedTemporaryFile
@@ -58,6 +59,13 @@ except ImportError:
if TYPE_CHECKING:
from airflow.providers.common.compat.sdk import Context
+_RETURN_GCS_URIS_FALSE_DEPRECATION_MSG = (
+ "S3ToGCSOperator currently returns a list of S3 object keys to preserve
backward "
+ "compatibility. This default will change in a future release: the operator
will return "
+ "a list of destination 'gs://' URIs instead. Pass 'return_gcs_uris=True'
to opt into "
+ "the new behavior now and silence this warning."
+)
+
class S3ToGCSOperator(S3ListOperator):
"""
@@ -108,6 +116,11 @@ class S3ToGCSOperator(S3ListOperator):
:param deferrable: Run operator in the deferrable mode
:param poll_interval: time in seconds between polling for job completion.
The value is considered only when running in deferrable mode. Must be
greater than 0.
+ :param return_gcs_uris: If ``True``, ``execute`` and deferrable
``execute_complete`` return a list of
+ destination ``gs://`` URIs for copied objects. Defaults to ``False``
for backward
+ compatibility, which returns the raw S3 object keys and emits a
deprecation warning;
+ this default will change to ``True`` in a future release. Set
``return_gcs_uris=True``
+ to opt into the new behavior now and silence the warning.
**Example**:
@@ -154,6 +167,7 @@ class S3ToGCSOperator(S3ListOperator):
google_impersonation_chain: str | Sequence[str] | None = None,
deferrable=conf.getboolean("operators", "default_deferrable",
fallback=False),
poll_interval: int = 10,
+ return_gcs_uris: bool = False,
**kwargs,
):
super().__init__(bucket=bucket, prefix=prefix, delimiter=delimiter,
aws_conn_id=aws_conn_id, **kwargs)
@@ -168,6 +182,13 @@ class S3ToGCSOperator(S3ListOperator):
if poll_interval <= 0:
raise ValueError("Invalid value for poll_interval. Expected value
greater than 0")
self.poll_interval = poll_interval
+ self.return_gcs_uris = return_gcs_uris
+ if not return_gcs_uris:
+ warnings.warn(
+ _RETURN_GCS_URIS_FALSE_DEPRECATION_MSG,
+ FutureWarning,
+ stacklevel=2,
+ )
def _check_inputs(self) -> None:
if self.dest_gcs and not gcs_object_is_directory(self.dest_gcs):
@@ -180,6 +201,11 @@ class S3ToGCSOperator(S3ListOperator):
'The destination Google Cloud Storage path must end with a
slash "/" or be empty.'
)
+ def _destination_uris_for_s3_keys(self, s3_keys: list[str]) -> list[str]:
+ """Build ``gs://`` URIs for each transferred S3 object key."""
+ gcs_bucket, _ = _parse_gcs_url(self.dest_gcs)
+ return [f"gs://{gcs_bucket}/{self.s3_to_gcs_object(s3_object=k)}" for
k in s3_keys]
+
def _get_files(self, context: Context, gcs_hook: GCSHook) -> list[str]:
# use the super method to list all the files in an S3 bucket/key
s3_objects = super().execute(context)
@@ -189,7 +215,7 @@ class S3ToGCSOperator(S3ListOperator):
return s3_objects
- def execute(self, context: Context):
+ def execute(self, context: Context) -> list[str]:
self._check_inputs()
gcs_hook = GCSHook(
gcp_conn_id=self.gcp_conn_id,
@@ -206,6 +232,8 @@ class S3ToGCSOperator(S3ListOperator):
else:
self.transfer_files(s3_objects, gcs_hook, s3_hook)
+ if self.return_gcs_uris:
+ return self._destination_uris_for_s3_keys(s3_objects)
return s3_objects
def exclude_existing_objects(self, s3_objects: list[str], gcs_hook:
GCSHook) -> list[str]:
@@ -339,14 +367,20 @@ class S3ToGCSOperator(S3ListOperator):
"""
Handle the trigger callback when transfer jobs complete.
- Returns the list of copied file paths when available (deferrable mode
with
- files passed via trigger), so subsequent tasks can consume them via
XCom.
- Returns None when event does not contain files (e.g. legacy triggers).
+ Returns a list of copied files when available (deferrable mode with
files passed via trigger),
+ so subsequent tasks can consume them via XCom. When
``return_gcs_uris`` is ``True``, the list
+ contains destination ``gs://`` URIs; otherwise it contains the S3
object keys transferred.
+ Returns ``None`` when the event does not contain files (e.g. legacy
triggers).
"""
if event["status"] == "error":
raise AirflowException(event["message"])
self.log.info("%s completed with response %s ", self.task_id,
event["message"])
- return event.get("files")
+ files = event.get("files")
+ if files is None:
+ return None
+ if self.return_gcs_uris:
+ return self._destination_uris_for_s3_keys(files)
+ return files
def get_transfer_hook(self):
return CloudDataTransferServiceHook(
diff --git
a/providers/google/tests/unit/google/cloud/transfers/test_s3_to_gcs.py
b/providers/google/tests/unit/google/cloud/transfers/test_s3_to_gcs.py
index 1e17364b695..580123a69cf 100644
--- a/providers/google/tests/unit/google/cloud/transfers/test_s3_to_gcs.py
+++ b/providers/google/tests/unit/google/cloud/transfers/test_s3_to_gcs.py
@@ -25,7 +25,11 @@ import time_machine
from airflow.providers.common.compat.sdk import AirflowException, TaskDeferred
from airflow.providers.google.cloud.hooks.cloud_storage_transfer_service
import CloudDataTransferServiceHook
-from airflow.providers.google.cloud.transfers.s3_to_gcs import S3ToGCSOperator
+from airflow.providers.google.cloud.hooks.gcs import _parse_gcs_url
+from airflow.providers.google.cloud.transfers.s3_to_gcs import (
+ _RETURN_GCS_URIS_FALSE_DEPRECATION_MSG,
+ S3ToGCSOperator,
+)
from airflow.utils.timezone import utcnow
PROJECT_ID = "test-project-id"
@@ -84,6 +88,7 @@ class TestS3ToGoogleCloudStorageOperator:
apply_gcs_prefix=APPLY_GCS_PREFIX,
deferrable=DEFERRABLE,
poll_interval=POLL_INTERVAL,
+ return_gcs_uris=True,
)
assert operator.task_id == TASK_ID
@@ -96,6 +101,7 @@ class TestS3ToGoogleCloudStorageOperator:
assert operator.apply_gcs_prefix == APPLY_GCS_PREFIX
assert operator.deferrable == DEFERRABLE
assert operator.poll_interval == POLL_INTERVAL
+ assert operator.return_gcs_uris is True
@mock.patch("airflow.providers.google.cloud.transfers.s3_to_gcs.S3Hook")
@mock.patch("airflow.providers.google.cloud.transfers.s3_to_gcs.GCSHook")
@@ -110,6 +116,7 @@ class TestS3ToGoogleCloudStorageOperator:
gcp_conn_id=GCS_CONN_ID,
dest_gcs=GCS_PATH_PREFIX,
google_impersonation_chain=IMPERSONATION_CHAIN,
+ return_gcs_uris=True,
)
operator.hook = mock.MagicMock()
@@ -132,8 +139,71 @@ class TestS3ToGoogleCloudStorageOperator:
impersonation_chain=IMPERSONATION_CHAIN,
)
- # we expect MOCK_FILES to be uploaded
- assert sorted(MOCK_FILES) == sorted(uploaded_files)
+ expected_uris = [f"gs://{GCS_BUCKET}/{GCS_PREFIX}{f}" for f in
MOCK_FILES]
+ assert sorted(expected_uris) == sorted(uploaded_files)
+
+ @mock.patch("airflow.providers.google.cloud.transfers.s3_to_gcs.S3Hook")
+ @mock.patch("airflow.providers.google.cloud.transfers.s3_to_gcs.GCSHook")
+ def test_execute_legacy_returns_s3_keys(self, gcs_mock_hook, s3_mock_hook):
+ """When return_gcs_uris=False, execute returns raw S3 keys for
backward compatibility."""
+ with pytest.warns(FutureWarning, match="S3ToGCSOperator"):
+ operator = S3ToGCSOperator(
+ task_id=TASK_ID,
+ bucket=S3_BUCKET,
+ prefix=S3_PREFIX,
+ delimiter=S3_DELIMITER,
+ gcp_conn_id=GCS_CONN_ID,
+ dest_gcs=GCS_PATH_PREFIX,
+ google_impersonation_chain=IMPERSONATION_CHAIN,
+ return_gcs_uris=False,
+ )
+ operator.hook = mock.MagicMock()
+ operator.hook.list_keys.return_value = MOCK_FILES
+
+ uploaded_files = operator.execute(context={})
+ assert sorted(uploaded_files) == sorted(MOCK_FILES)
+
+ def
test_init_emits_deprecation_warning_when_return_gcs_uris_false_default(self):
+ """``return_gcs_uris`` defaults to ``False`` and emits a deprecation
warning at init."""
+ with pytest.warns(FutureWarning, match="S3ToGCSOperator"):
+ operator = S3ToGCSOperator(
+ task_id=TASK_ID,
+ bucket=S3_BUCKET,
+ prefix=S3_PREFIX,
+ delimiter=S3_DELIMITER,
+ gcp_conn_id=GCS_CONN_ID,
+ dest_gcs=GCS_PATH_PREFIX,
+ )
+ assert operator.return_gcs_uris is False
+
+ def
test_init_emits_deprecation_warning_when_return_gcs_uris_false_explicit(self):
+ """Explicit ``return_gcs_uris=False`` still emits the deprecation
warning."""
+ with pytest.warns(FutureWarning, match="S3ToGCSOperator"):
+ S3ToGCSOperator(
+ task_id=TASK_ID,
+ bucket=S3_BUCKET,
+ prefix=S3_PREFIX,
+ delimiter=S3_DELIMITER,
+ gcp_conn_id=GCS_CONN_ID,
+ dest_gcs=GCS_PATH_PREFIX,
+ return_gcs_uris=False,
+ )
+
+ def test_init_no_warning_when_return_gcs_uris_true(self, recwarn):
+ """``return_gcs_uris=True`` opts into the new behavior and silences
the warning."""
+ S3ToGCSOperator(
+ task_id=TASK_ID,
+ bucket=S3_BUCKET,
+ prefix=S3_PREFIX,
+ delimiter=S3_DELIMITER,
+ gcp_conn_id=GCS_CONN_ID,
+ dest_gcs=GCS_PATH_PREFIX,
+ return_gcs_uris=True,
+ )
+ assert not any(
+ issubclass(w.category, FutureWarning) and
_RETURN_GCS_URIS_FALSE_DEPRECATION_MSG in str(w.message)
+ for w in recwarn
+ )
@mock.patch("airflow.providers.google.cloud.transfers.s3_to_gcs.S3Hook")
@mock.patch("airflow.providers.google.cloud.transfers.s3_to_gcs.GCSHook")
@@ -245,6 +315,7 @@ class TestS3ToGoogleCloudStorageOperator:
dest_gcs=gcs_destination,
google_impersonation_chain=IMPERSONATION_CHAIN,
apply_gcs_prefix=apply_gcs_prefix,
+ return_gcs_uris=True,
)
operator.hook = mock.MagicMock()
operator.hook.list_keys.return_value = [s3_prefix + s3_object]
@@ -264,7 +335,9 @@ class TestS3ToGoogleCloudStorageOperator:
impersonation_chain=IMPERSONATION_CHAIN,
)
- assert sorted([s3_prefix + s3_object]) == sorted(uploaded_files)
+ dest_bucket, _ = _parse_gcs_url(gcs_destination)
+ expected_uri = f"gs://{dest_bucket}/{gcs_object}"
+ assert uploaded_files == [expected_uri]
@pytest.mark.parametrize(
("s3_prefix", "gcs_destination", "apply_gcs_prefix", "expected_input",
"expected_output"),
@@ -499,14 +572,44 @@ class TestS3ToGoogleCloudStorageOperatorDeferrable:
"airflow.providers.google.cloud.transfers.s3_to_gcs.S3ToGCSOperator.log",
new_callable=PropertyMock
)
def test_execute_complete_success_returns_copied_files(self, mock_log):
- """Deferrable mode returns list of copied files for use in subsequent
tasks via XCom."""
+ """Deferrable mode returns destination GCS URIs for XCom when
return_gcs_uris=True."""
expected_files = [MOCK_FILE_1, MOCK_FILE_2]
event = {
"status": "success",
"message": "Transfer completed",
"files": expected_files,
}
- operator = S3ToGCSOperator(task_id=TASK_ID, bucket=S3_BUCKET)
+ operator = S3ToGCSOperator(
+ task_id=TASK_ID,
+ bucket=S3_BUCKET,
+ prefix=S3_PREFIX,
+ dest_gcs=GCS_PATH_PREFIX,
+ return_gcs_uris=True,
+ )
+ result = operator.execute_complete(context={}, event=event)
+
+ expected_uris = [f"gs://{GCS_BUCKET}/{GCS_PREFIX}{f}" for f in
expected_files]
+ assert result == expected_uris
+
+ @mock.patch(
+
"airflow.providers.google.cloud.transfers.s3_to_gcs.S3ToGCSOperator.log",
new_callable=PropertyMock
+ )
+ def test_execute_complete_legacy_returns_s3_keys(self, mock_log):
+ """When return_gcs_uris is False, execute_complete returns raw S3 keys
for backward compatibility."""
+ expected_files = [MOCK_FILE_1, MOCK_FILE_2]
+ event = {
+ "status": "success",
+ "message": "Transfer completed",
+ "files": expected_files,
+ }
+ with pytest.warns(FutureWarning, match="S3ToGCSOperator"):
+ operator = S3ToGCSOperator(
+ task_id=TASK_ID,
+ bucket=S3_BUCKET,
+ prefix=S3_PREFIX,
+ dest_gcs=GCS_PATH_PREFIX,
+ return_gcs_uris=False,
+ )
result = operator.execute_complete(context={}, event=event)
assert result == expected_files