This is an automated email from the ASF dual-hosted git repository.

dabla 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 a3cd2a59c23 Add an async upload to S3Hook: aload_bytes (#75242)
a3cd2a59c23 is described below

commit a3cd2a59c234ebd9c5b0e9533b7441b14101b1ad
Author: David Blain <[email protected]>
AuthorDate: Thu Oct 22 14:31:52 2026 +0200

    Add an async upload to S3Hook: aload_bytes (#75242)
    
    An async task or trigger that writes to S3 has to hand load_bytes() to a
    thread, because S3Hook only has async methods to read and check keys. A
    native coroutine on the aiobotocore client of get_async_conn() lets it
    upload without blocking its event loop or leaving it.
    
    The new method does not reuse _upload_file_obj(), which adds the per-call
    encryption and ACL settings to the hook's own extra_args, so that they do
    not leak into later uploads through the same hook.
---
 .../src/airflow/providers/amazon/aws/hooks/s3.py   | 52 +++++++++++++++
 .../amazon/tests/unit/amazon/aws/hooks/test_s3.py  | 77 ++++++++++++++++++++++
 2 files changed, 129 insertions(+)

diff --git a/providers/amazon/src/airflow/providers/amazon/aws/hooks/s3.py 
b/providers/amazon/src/airflow/providers/amazon/aws/hooks/s3.py
index bba4ac7399f..e9089f95bfc 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/hooks/s3.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/hooks/s3.py
@@ -1333,6 +1333,58 @@ class S3Hook(AwsBaseHook):
         with BytesIO(bytes_data) as f:
             self._upload_file_obj(f, key, bucket_name, replace, encrypt, 
acl_policy)
 
+    @unify_bucket_name_and_key
+    @provide_bucket_name
+    async def aload_bytes(
+        self,
+        bytes_data: bytes,
+        key: str,
+        bucket_name: str | None = None,
+        replace: bool = False,
+        encrypt: bool = False,
+        acl_policy: str | None = None,
+    ) -> None:
+        """
+        Load bytes to S3 asynchronously.
+
+        The async counterpart of :meth:`load_bytes`, on the aiobotocore client 
of
+        :meth:`get_async_conn`, so an async task or trigger can upload without 
blocking its
+        event loop. It sends the object in a single ``PutObject`` request: 
there is no
+        multipart transfer and ``transfer_config`` is not used, so it is meant 
for objects
+        below the 5 GB limit of a single upload.
+
+        .. seealso::
+            - :external+boto3:py:meth:`S3.Client.put_object`
+
+        :param bytes_data: bytes to set as content for the key.
+        :param key: S3 key that will point to the file
+        :param bucket_name: Name of the bucket in which to store the file
+        :param replace: A flag to decide whether or not to overwrite the key
+            if it already exists
+        :param encrypt: If True, the file will be encrypted on the server-side
+            by S3 and will be stored in an encrypted form while at rest in S3.
+        :param acl_policy: The string to specify the canned ACL policy for the
+            object to be uploaded
+        """
+        async with await self.get_async_conn() as client:
+            if not replace and await self.get_head_object_async(client, key, 
bucket_name):
+                raise ValueError(f"The key {key} already exists.")
+
+            # A copy, so the hook's own extra_args keep no per-call settings.
+            extra_args = dict(self.extra_args)
+            if encrypt:
+                extra_args["ServerSideEncryption"] = "AES256"
+            if acl_policy:
+                extra_args["ACL"] = acl_policy
+            if self._requester_pays:
+                extra_args["RequestPayer"] = "requester"
+
+            await client.put_object(Bucket=bucket_name, Key=key, 
Body=bytes_data, **extra_args)
+
+        get_hook_lineage_collector().add_output_asset(
+            context=self, scheme="s3", asset_kwargs={"bucket": bucket_name, 
"key": key}
+        )
+
     @unify_bucket_name_and_key
     @provide_bucket_name
     def load_file_obj(
diff --git a/providers/amazon/tests/unit/amazon/aws/hooks/test_s3.py 
b/providers/amazon/tests/unit/amazon/aws/hooks/test_s3.py
index 2bc0a03bcfe..c5f373a4e28 100644
--- a/providers/amazon/tests/unit/amazon/aws/hooks/test_s3.py
+++ b/providers/amazon/tests/unit/amazon/aws/hooks/test_s3.py
@@ -1310,6 +1310,83 @@ class TestAwsS3Hook:
         assert response["Grants"][1]["Permission"] == "READ"
         assert response["Grants"][0]["Permission"] == "FULL_CONTROL"
 
+    @staticmethod
+    def _mock_async_conn(mock_get_async_conn, head_object=None):
+        """Make ``get_async_conn()`` yield an aiobotocore client mock, as 
``async with await`` uses it."""
+
+        class S3Client:
+            """The aiobotocore S3 client methods aload_bytes calls."""
+
+            async def head_object(self, **kwargs): ...
+
+            async def put_object(self, **kwargs): ...
+
+        client = AsyncMock(spec=S3Client)
+        client.head_object.return_value = head_object
+
+        class AsyncConn:
+            async def __aenter__(self):
+                return client
+
+            async def __aexit__(self, *exc_info):
+                return None
+
+        mock_get_async_conn.return_value = AsyncConn()
+        return client
+
+    @pytest.mark.asyncio
+    
@async_mock.patch("airflow.providers.amazon.aws.hooks.s3.S3Hook.get_async_conn",
 autospec=True)
+    async def test_aload_bytes(self, mock_get_async_conn, 
hook_lineage_collector):
+        client = self._mock_async_conn(mock_get_async_conn)
+        hook = S3Hook(aws_conn_id=None)
+
+        await hook.aload_bytes(b"Content", "my_key", "my_bucket")
+
+        client.head_object.assert_awaited_once_with(Bucket="my_bucket", 
Key="my_key")
+        client.put_object.assert_awaited_once_with(Bucket="my_bucket", 
Key="my_key", Body=b"Content")
+        assert len(hook_lineage_collector.collected_assets.outputs) == 1
+        assert hook_lineage_collector.collected_assets.outputs[0].asset == 
Asset(uri="s3://my_bucket/my_key")
+
+    @pytest.mark.asyncio
+    
@async_mock.patch("airflow.providers.amazon.aws.hooks.s3.S3Hook.get_async_conn",
 autospec=True)
+    async def test_aload_bytes_takes_the_bucket_from_an_s3_url(self, 
mock_get_async_conn):
+        client = self._mock_async_conn(mock_get_async_conn)
+
+        await S3Hook(aws_conn_id=None).aload_bytes(b"Content", 
"s3://my_bucket/dir/my_key", replace=True)
+
+        client.head_object.assert_not_called()
+        client.put_object.assert_awaited_once_with(Bucket="my_bucket", 
Key="dir/my_key", Body=b"Content")
+
+    @pytest.mark.asyncio
+    
@async_mock.patch("airflow.providers.amazon.aws.hooks.s3.S3Hook.get_async_conn",
 autospec=True)
+    async def test_aload_bytes_refuses_an_existing_key_without_replace(self, 
mock_get_async_conn):
+        client = self._mock_async_conn(mock_get_async_conn, 
head_object={"ContentLength": 7})
+
+        with pytest.raises(ValueError, match="The key my_key already exists."):
+            await S3Hook(aws_conn_id=None).aload_bytes(b"Content", "my_key", 
"my_bucket")
+        client.put_object.assert_not_called()
+
+    @pytest.mark.asyncio
+    
@async_mock.patch("airflow.providers.amazon.aws.hooks.s3.S3Hook.get_async_conn",
 autospec=True)
+    async def test_aload_bytes_extra_args_do_not_leak_into_the_hook(self, 
mock_get_async_conn):
+        client = self._mock_async_conn(mock_get_async_conn)
+        hook = S3Hook(aws_conn_id=None, extra_args={"ContentType": 
"text/plain"}, requester_pays=True)
+
+        await hook.aload_bytes(
+            b"Content", "my_key", "my_bucket", replace=True, encrypt=True, 
acl_policy="public-read"
+        )
+
+        client.put_object.assert_awaited_once_with(
+            Bucket="my_bucket",
+            Key="my_key",
+            Body=b"Content",
+            ContentType="text/plain",
+            ServerSideEncryption="AES256",
+            ACL="public-read",
+            RequestPayer="requester",
+        )
+        assert hook.extra_args == {"ContentType": "text/plain"}
+
     def test_load_fileobj(self, s3_bucket, tmp_path):
         hook = S3Hook()
         path = tmp_path / "testfile"

Reply via email to