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 5a8409cc527 Add S3CopyPrefixOperator to copy all objects under a 
prefix (#68946)
5a8409cc527 is described below

commit 5a8409cc5275cc41978cd409a609f1fa9de04246
Author: bentorb <[email protected]>
AuthorDate: Sat Jun 27 01:33:37 2026 +0200

    Add S3CopyPrefixOperator to copy all objects under a prefix (#68946)
    
    S3CopyObjectOperator handles a single object at a time. Users who need
    to copy all objects sharing a prefix must implement their own pagination,
    error handling, and encryption support. This operator encapsulates that
    pattern so it can be used directly in a Dag.
---
 providers/amazon/docs/operators/s3/s3.rst          |  18 ++
 .../airflow/providers/amazon/aws/operators/s3.py   | 155 +++++++++++++
 .../amazon/tests/system/amazon/aws/example_s3.py   |  12 +
 .../tests/unit/amazon/aws/operators/test_s3.py     | 245 +++++++++++++++++++++
 4 files changed, 430 insertions(+)

diff --git a/providers/amazon/docs/operators/s3/s3.rst 
b/providers/amazon/docs/operators/s3/s3.rst
index d5bf4879606..5dec957b5ce 100644
--- a/providers/amazon/docs/operators/s3/s3.rst
+++ b/providers/amazon/docs/operators/s3/s3.rst
@@ -132,6 +132,24 @@ Ensure the role or user has the necessary permissions to 
use the key.
     :start-after: [START howto_operator_s3_copy_object]
     :end-before: [END howto_operator_s3_copy_object]
 
+.. _howto/operator:S3CopyPrefixOperator:
+
+Copy Amazon S3 objects by prefix
+================================
+
+To copy all Amazon S3 objects under a prefix from one bucket to another you 
can use
+:class:`~airflow.providers.amazon.aws.operators.s3.S3CopyPrefixOperator`.
+The Amazon S3 connection used here needs to have access to both source and 
destination bucket/prefix.
+You can also specify server-side encryption using AWS KMS if you do not want 
to use destination buckets default key.
+When using KMS, you must provide both the ``kms_key_id`` and 
``kms_encryption_type`` parameters.
+Ensure the role or user has the necessary permissions to use the key.
+
+.. exampleinclude:: /../../amazon/tests/system/amazon/aws/example_s3.py
+    :language: python
+    :dedent: 4
+    :start-after: [START howto_operator_s3_copy_prefix]
+    :end-before: [END howto_operator_s3_copy_prefix]
+
 .. _howto/operator:S3DeleteObjectsOperator:
 
 Delete Amazon S3 objects
diff --git a/providers/amazon/src/airflow/providers/amazon/aws/operators/s3.py 
b/providers/amazon/src/airflow/providers/amazon/aws/operators/s3.py
index 2a5b12052b6..b74fb60a1b7 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/operators/s3.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/operators/s3.py
@@ -385,6 +385,161 @@ class S3CopyObjectOperator(AwsBaseOperator[S3Hook]):
         )
 
 
+class S3CopyPrefixOperator(AwsBaseOperator[S3Hook]):
+    """
+    Creates a copy of all objects under a prefix already stored in S3.
+
+    Note: the S3 connection used here needs to have access to both
+    source and destination bucket/prefix.
+
+    .. seealso::
+        For more information on how to use this operator, take a look at the 
guide:
+        :ref:`howto/operator:S3CopyPrefixOperator`
+
+    :param source_bucket_prefix: The prefix in the source bucket. (templated)
+        It can be either full s3:// style url or relative path from root level.
+        When it's specified as a full s3:// url, please omit 
source_bucket_name.
+    :param dest_bucket_prefix: The prefix in the destination to copy to. 
(templated)
+        The convention to specify `dest_bucket_prefix` is the same as 
`source_bucket_prefix`.
+    :param source_bucket_name: Name of the S3 bucket where the source objects 
are in. (templated)
+        It should be omitted when `source_bucket_prefix` is provided as a full 
s3:// url.
+    :param dest_bucket_name: Name of the S3 bucket to where the objects are 
copied. (templated)
+        It should be omitted when `dest_bucket_prefix` is provided as a full 
s3:// url.
+    :param kms_key_id: The ARN, id or alias of the AWS KMS key to use for 
encrypting the destination object.
+        Required if using KMS-based server-side encryption with a non-default 
key. (templated)
+    :param kms_encryption_type: Type of KMS encryption to use for the object.
+        Can be either "aws:kms" (standard KMS) or "aws:kms:dsse" 
(double-shielded KMS).
+    :param continue_on_failure: If False, stop and fail the task on the first 
copy error.
+        If True, try to copy every object in the prefix and then fail the task 
on any error.
+        Default is False.
+    :param acl_policy: String specifying the canned ACL policy for the file 
being
+        uploaded to the S3 bucket.
+    :param meta_data_directive: Whether to `COPY` the metadata from the source 
object or `REPLACE` it with
+        metadata that's provided in the request.
+    :param aws_conn_id: The Airflow connection used for AWS credentials.
+        If this is ``None`` or empty then the default boto3 behaviour is used. 
If
+        running Airflow in a distributed manner and aws_conn_id is None or
+        empty, then default boto3 configuration would be used (and must be
+        maintained on each worker node).
+    :param region_name: AWS region_name. If not specified then the default 
boto3 behaviour is used.
+    :param verify: Whether or not to verify SSL certificates. See:
+        
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/core/session.html
+    :param botocore_config: Configuration dictionary (key-values) for botocore 
client. See:
+        
https://botocore.amazonaws.com/v1/documentation/api/latest/reference/config.html
+    """
+
+    template_fields: Sequence[str] = aws_template_fields(
+        "source_bucket_prefix",
+        "dest_bucket_prefix",
+        "source_bucket_name",
+        "dest_bucket_name",
+        "kms_key_id",
+    )
+    aws_hook_class = S3Hook
+
+    def __init__(
+        self,
+        *,
+        source_bucket_prefix: str,
+        dest_bucket_prefix: str,
+        source_bucket_name: str | None = None,
+        dest_bucket_name: str | None = None,
+        kms_key_id: str | None = None,
+        kms_encryption_type: str | None = None,
+        continue_on_failure: bool = False,
+        acl_policy: str | None = None,
+        meta_data_directive: str | None = None,
+        **kwargs,
+    ):
+        super().__init__(**kwargs)
+
+        self.source_bucket_prefix = source_bucket_prefix
+        self.dest_bucket_prefix = dest_bucket_prefix
+        self.source_bucket_name = source_bucket_name
+        self.dest_bucket_name = dest_bucket_name
+        self.kms_key_id = kms_key_id
+        self.kms_encryption_type = kms_encryption_type
+        self.continue_on_failure = continue_on_failure
+        self.acl_policy = acl_policy
+        self.meta_data_directive = meta_data_directive
+
+    def execute(self, context: Context):
+        source_bucket_name, source_bucket_prefix = self.hook.get_s3_bucket_key(
+            self.source_bucket_name, self.source_bucket_prefix, 
"source_bucket_name", "source_bucket_prefix"
+        )
+
+        dest_bucket_name, dest_bucket_prefix = self.hook.get_s3_bucket_key(
+            self.dest_bucket_name, self.dest_bucket_prefix, 
"dest_bucket_name", "dest_bucket_prefix"
+        )
+
+        s3_client = self.hook.get_conn()
+
+        paginator = s3_client.get_paginator("list_objects_v2")
+        pages = paginator.paginate(
+            Bucket=source_bucket_name,
+            Prefix=source_bucket_prefix,
+        )
+
+        copied_object_count = 0
+        failed_object_count = 0
+        for page in pages:
+            if "Contents" in page:
+                for obj in page["Contents"]:
+                    source_key = obj["Key"]
+                    dest_key = dest_bucket_prefix + 
source_key[len(source_bucket_prefix) :]
+
+                    try:
+                        self.hook.copy_object(
+                            source_bucket_key=source_key,
+                            dest_bucket_key=dest_key,
+                            source_bucket_name=source_bucket_name,
+                            dest_bucket_name=dest_bucket_name,
+                            kms_key_id=self.kms_key_id,
+                            kms_encryption_type=self.kms_encryption_type,
+                            acl_policy=self.acl_policy,
+                            meta_data_directive=self.meta_data_directive,
+                        )
+
+                        copied_object_count += 1
+                    except Exception as e:
+                        if self.continue_on_failure:
+                            self.log.error("Failed to copy %s: %s", 
source_key, e)
+                            failed_object_count += 1
+                        else:
+                            raise RuntimeError(f"Failed to copy {source_key}: 
{e}") from e
+
+        self.log.info("Successfully copied %s object(s)", copied_object_count)
+
+        if failed_object_count > 0:
+            raise RuntimeError(f"Failed to copy {failed_object_count} 
object(s)")
+
+    def get_openlineage_facets_on_start(self):
+        from airflow.providers.common.compat.openlineage.facet import Dataset
+        from airflow.providers.openlineage.extractors import OperatorLineage
+
+        source_bucket_name, source_bucket_prefix = self.hook.get_s3_bucket_key(
+            self.source_bucket_name, self.source_bucket_prefix, 
"source_bucket_name", "source_bucket_prefix"
+        )
+
+        dest_bucket_name, dest_bucket_prefix = self.hook.get_s3_bucket_key(
+            self.dest_bucket_name, self.dest_bucket_prefix, 
"dest_bucket_name", "dest_bucket_prefix"
+        )
+
+        input_dataset = Dataset(
+            namespace=f"s3://{source_bucket_name}",
+            name=source_bucket_prefix,
+        )
+        output_dataset = Dataset(
+            namespace=f"s3://{dest_bucket_name}",
+            name=dest_bucket_prefix,
+        )
+
+        return OperatorLineage(
+            inputs=[input_dataset],
+            outputs=[output_dataset],
+        )
+
+
 class S3CreateObjectOperator(AwsBaseOperator[S3Hook]):
     """
     Creates a new object from `data` as string or bytes.
diff --git a/providers/amazon/tests/system/amazon/aws/example_s3.py 
b/providers/amazon/tests/system/amazon/aws/example_s3.py
index 184211e630d..19769a28696 100644
--- a/providers/amazon/tests/system/amazon/aws/example_s3.py
+++ b/providers/amazon/tests/system/amazon/aws/example_s3.py
@@ -20,6 +20,7 @@ from datetime import datetime
 
 from airflow.providers.amazon.aws.operators.s3 import (
     S3CopyObjectOperator,
+    S3CopyPrefixOperator,
     S3CreateBucketOperator,
     S3CreateObjectOperator,
     S3DeleteBucketOperator,
@@ -254,6 +255,16 @@ with DAG(
     )
     # [END howto_operator_s3_copy_object]
 
+    # [START howto_operator_s3_copy_prefix]
+    copy_prefix = S3CopyPrefixOperator(
+        task_id="copy_prefix",
+        source_bucket_name=bucket_name,
+        source_bucket_prefix=f"{env_id}-",
+        dest_bucket_name=bucket_name_2,
+        dest_bucket_prefix=f"{env_id}-copied-",
+    )
+    # [END howto_operator_s3_copy_prefix]
+
     # [START howto_operator_s3_file_transform]
     file_transform = S3FileTransformOperator(
         task_id="file_transform",
@@ -321,6 +332,7 @@ with DAG(
             sensor_key_with_regex_deferrable,
         ],
         copy_object,
+        copy_prefix,
         file_transform,
         sensor_keys_unchanged,
         # TEST TEARDOWN
diff --git a/providers/amazon/tests/unit/amazon/aws/operators/test_s3.py 
b/providers/amazon/tests/unit/amazon/aws/operators/test_s3.py
index f3a74a4dd3f..cd907171927 100644
--- a/providers/amazon/tests/unit/amazon/aws/operators/test_s3.py
+++ b/providers/amazon/tests/unit/amazon/aws/operators/test_s3.py
@@ -36,6 +36,7 @@ from airflow.models.taskinstance import TaskInstance
 from airflow.providers.amazon.aws.hooks.s3 import S3Hook
 from airflow.providers.amazon.aws.operators.s3 import (
     S3CopyObjectOperator,
+    S3CopyPrefixOperator,
     S3CreateBucketOperator,
     S3CreateObjectOperator,
     S3DeleteBucketOperator,
@@ -658,6 +659,250 @@ class TestS3CopyObjectOperator:
         assert objects_in_dest_bucket["Contents"][0]["Key"] == self.dest_key
 
 
+class TestS3CopyPrefixOperator:
+    def setup_method(self):
+        self.source_bucket = "source-bucket"
+        self.source_prefix = "data/logs/"
+        self.dest_bucket = "dest-bucket"
+        self.dest_prefix = "backup/logs/"
+
+        self.source_s3_url = f"s3://{self.source_bucket}/{self.source_prefix}"
+        self.dest_s3_url = f"s3://{self.dest_bucket}/{self.dest_prefix}"
+
+    @staticmethod
+    def _create_s3_client():
+        return boto3.client("s3", region_name="us-east-1")
+
+    def _create_buckets(self, s3_client):
+        s3_client.create_bucket(Bucket=self.source_bucket)
+        s3_client.create_bucket(Bucket=self.dest_bucket)
+
+    def _upload_test_objects(self, s3_client, keys):
+        for key in keys:
+            s3_client.upload_fileobj(Bucket=self.source_bucket, Key=key, 
Fileobj=BytesIO(b"test-content"))
+
+    @mock_aws
+    def test_s3_copy_prefix_basic(self):
+        s3_client = self._create_s3_client()
+        self._create_buckets(s3_client)
+        self._upload_test_objects(
+            s3_client,
+            [
+                f"{self.source_prefix}file1.txt",
+                f"{self.source_prefix}file2.txt",
+                f"{self.source_prefix}subdir/file3.txt",
+            ],
+        )
+
+        dest_objects = s3_client.list_objects_v2(Bucket=self.dest_bucket, 
Prefix=self.dest_prefix)
+        assert "Contents" not in dest_objects
+
+        op = S3CopyPrefixOperator(
+            task_id="test_copy_prefix",
+            source_bucket_name=self.source_bucket,
+            source_bucket_prefix=self.source_prefix,
+            dest_bucket_name=self.dest_bucket,
+            dest_bucket_prefix=self.dest_prefix,
+        )
+
+        op.execute(None)
+
+        dest_objects = s3_client.list_objects_v2(Bucket=self.dest_bucket, 
Prefix=self.dest_prefix)
+        assert len(dest_objects["Contents"]) == 3
+
+        copied_keys = [obj["Key"] for obj in dest_objects["Contents"]]
+        assert "backup/logs/file1.txt" in copied_keys
+        assert "backup/logs/file2.txt" in copied_keys
+        assert "backup/logs/subdir/file3.txt" in copied_keys
+
+    @mock_aws
+    def test_s3_copy_prefix_selective_copying(self):
+        s3_client = self._create_s3_client()
+        self._create_buckets(s3_client)
+        self._upload_test_objects(
+            s3_client,
+            [
+                f"{self.source_prefix}file1.txt",
+                f"{self.source_prefix}subdir/file2.txt",
+                "data/metrics/file3.txt",
+                "other/logs/file4.txt",
+                "archive/data/file5.txt",
+            ],
+        )
+
+        op = S3CopyPrefixOperator(
+            task_id="test_copy_prefix_selective",
+            source_bucket_name=self.source_bucket,
+            source_bucket_prefix=self.source_prefix,
+            dest_bucket_name=self.dest_bucket,
+            dest_bucket_prefix=self.dest_prefix,
+        )
+
+        op.execute(None)
+
+        dest_objects = s3_client.list_objects_v2(Bucket=self.dest_bucket, 
Prefix=self.dest_prefix)
+        assert len(dest_objects["Contents"]) == 2
+
+        copied_keys = [obj["Key"] for obj in dest_objects["Contents"]]
+        assert "backup/logs/file1.txt" in copied_keys
+        assert "backup/logs/subdir/file2.txt" in copied_keys
+
+    @mock_aws
+    def test_s3_copy_prefix_s3_urls(self):
+        s3_client = self._create_s3_client()
+        self._create_buckets(s3_client)
+        self._upload_test_objects(
+            s3_client, [f"{self.source_prefix}file1.txt", 
f"{self.source_prefix}file2.txt"]
+        )
+
+        op = S3CopyPrefixOperator(
+            task_id="test_copy_prefix_urls",
+            source_bucket_prefix=self.source_s3_url,
+            dest_bucket_prefix=self.dest_s3_url,
+        )
+
+        op.execute(None)
+
+        dest_objects = s3_client.list_objects_v2(Bucket=self.dest_bucket, 
Prefix=self.dest_prefix)
+        assert len(dest_objects["Contents"]) == 2
+
+    def test_invalid_combination_bucket_with_s3_url(self):
+        op = S3CopyPrefixOperator(
+            task_id="test_invalid",
+            source_bucket_name=self.source_bucket,
+            
source_bucket_prefix=f"s3://{self.source_bucket}/{self.source_prefix}",
+            dest_bucket_name=self.dest_bucket,
+            dest_bucket_prefix=self.dest_prefix,
+        )
+
+        with pytest.raises(TypeError, match="should be a relative path"):
+            op.execute(None)
+
+    @mock_aws
+    def test_s3_copy_prefix_same_bucket(self):
+        s3_client = self._create_s3_client()
+        s3_client.create_bucket(Bucket=self.source_bucket)
+        self._upload_test_objects(s3_client, 
[f"{self.source_prefix}file1.txt"])
+
+        op = S3CopyPrefixOperator(
+            task_id="test_copy_prefix_same_bucket",
+            source_bucket_name=self.source_bucket,
+            source_bucket_prefix=self.source_prefix,
+            dest_bucket_name=self.source_bucket,
+            dest_bucket_prefix="archive/logs/",
+        )
+
+        op.execute(None)
+
+        all_objects = s3_client.list_objects_v2(Bucket=self.source_bucket)
+        keys = [obj["Key"] for obj in all_objects["Contents"]]
+        assert f"{self.source_prefix}file1.txt" in keys
+        assert "archive/logs/file1.txt" in keys
+
+    @mock_aws
+    def test_s3_copy_prefix_empty_result(self):
+        s3_client = self._create_s3_client()
+        self._create_buckets(s3_client)
+
+        op = S3CopyPrefixOperator(
+            task_id="test_copy_prefix_empty",
+            source_bucket_name=self.source_bucket,
+            source_bucket_prefix=self.source_prefix,
+            dest_bucket_name=self.dest_bucket,
+            dest_bucket_prefix=self.dest_prefix,
+        )
+
+        op.execute(None)
+
+        dest_objects = s3_client.list_objects_v2(Bucket=self.dest_bucket, 
Prefix=self.dest_prefix)
+        assert "Contents" not in dest_objects
+
+    @mock_aws
+    def test_continue_on_failure_false(self):
+        s3_client = self._create_s3_client()
+        self._create_buckets(s3_client)
+        self._upload_test_objects(s3_client, 
[f"{self.source_prefix}file1.txt"])
+
+        op = S3CopyPrefixOperator(
+            task_id="test_copy_prefix_fail_fast",
+            source_bucket_name=self.source_bucket,
+            source_bucket_prefix=self.source_prefix,
+            dest_bucket_name=self.dest_bucket,
+            dest_bucket_prefix=self.dest_prefix,
+            continue_on_failure=False,
+        )
+
+        with mock.patch.object(op.hook, "copy_object", 
side_effect=Exception("Copy failed")):
+            with pytest.raises(
+                RuntimeError, match=f"Failed to copy 
{self.source_prefix}file1.txt: Copy failed"
+            ):
+                op.execute(None)
+
+    @mock_aws
+    def test_continue_on_failure_true(self):
+        s3_client = self._create_s3_client()
+        self._create_buckets(s3_client)
+        self._upload_test_objects(
+            s3_client, [f"{self.source_prefix}file1.txt", 
f"{self.source_prefix}file2.txt"]
+        )
+
+        op = S3CopyPrefixOperator(
+            task_id="test_copy_prefix_continue",
+            source_bucket_name=self.source_bucket,
+            source_bucket_prefix=self.source_prefix,
+            dest_bucket_name=self.dest_bucket,
+            dest_bucket_prefix=self.dest_prefix,
+            continue_on_failure=True,
+        )
+
+        def mock_copy_object(*args, **kwargs):
+            if "file1.txt" in kwargs.get("source_bucket_key", ""):
+                raise Exception("Copy failed for file1")
+            return None
+
+        with mock.patch.object(op.hook, "copy_object", 
side_effect=mock_copy_object) as mock_copy:
+            with pytest.raises(RuntimeError, match=r"Failed to copy 1 
object\(s\)"):
+                op.execute(None)
+
+        attempted_keys = [call.kwargs["source_bucket_key"] for call in 
mock_copy.call_args_list]
+        assert f"{self.source_prefix}file2.txt" in attempted_keys
+
+    @pytest.mark.parametrize(
+        "op_kwargs",
+        [
+            {
+                "source_bucket_name": "source-bucket",
+                "source_bucket_prefix": "data/logs/",
+                "dest_bucket_name": "dest-bucket",
+                "dest_bucket_prefix": "backup/logs/",
+            },
+            {
+                "source_bucket_prefix": "s3://source-bucket/data/logs/",
+                "dest_bucket_prefix": "s3://dest-bucket/backup/logs/",
+            },
+        ],
+        ids=["bucket_and_prefix", "s3_urls"],
+    )
+    def test_get_openlineage_facets_on_start(self, op_kwargs):
+        from airflow.providers.common.compat.openlineage.facet import Dataset
+
+        op = S3CopyPrefixOperator(task_id="test", **op_kwargs)
+
+        lineage = op.get_openlineage_facets_on_start()
+        assert lineage.inputs == [Dataset(namespace="s3://source-bucket", 
name="data/logs/")]
+        assert lineage.outputs == [Dataset(namespace="s3://dest-bucket", 
name="backup/logs/")]
+
+    def test_template_fields(self):
+        operator = S3CopyPrefixOperator(
+            task_id="test_copy_prefix",
+            source_bucket_name=self.source_bucket,
+            source_bucket_prefix=self.source_prefix,
+            dest_bucket_name=self.dest_bucket,
+            dest_bucket_prefix=self.dest_prefix,
+        )
+        validate_template_fields(operator)
+
+
 @mock_aws
 class TestS3DeleteObjectsOperator:
     def test_s3_delete_single_object(self):

Reply via email to