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 dc939331f4c Add `S3ReadObjectOperator` (#65789)
dc939331f4c is described below

commit dc939331f4cd90892fd201931c466dffff977a4f
Author: John Jackson <[email protected]>
AuthorDate: Fri Apr 24 11:57:15 2026 -0700

    Add `S3ReadObjectOperator` (#65789)
    
    Add a new operator that reads an S3 object and returns its content
    as a UTF-8 string. This is the read counterpart to the existing
    `S3CreateObjectOperator`.
    
    The operator:
    - Extends `AwsBaseOperator[S3Hook]` following the standard pattern
    - Supports both explicit `s3_bucket`/`s3_key` and full `s3://` URLs
    - Delegates to `S3Hook.read_key()` for the actual read
    - Returns the content as a string (auto-pushed to XCom)
    - Includes OpenLineage support (input dataset facet)
    
    Includes:
    - Unit tests (moto integration + mock-based)
    - System test DAG with infrastructure create/teardown
    - Documentation with example snippets
---
 providers/amazon/docs/operators/s3/s3.rst          | 14 +++++
 .../airflow/providers/amazon/aws/operators/s3.py   | 64 ++++++++++++++++++++++
 .../amazon/tests/system/amazon/aws/example_s3.py   | 10 ++++
 .../tests/unit/amazon/aws/operators/test_s3.py     | 56 +++++++++++++++++++
 4 files changed, 144 insertions(+)

diff --git a/providers/amazon/docs/operators/s3/s3.rst 
b/providers/amazon/docs/operators/s3/s3.rst
index 2602eda66d8..d5bf4879606 100644
--- a/providers/amazon/docs/operators/s3/s3.rst
+++ b/providers/amazon/docs/operators/s3/s3.rst
@@ -178,6 +178,20 @@ for more information about Amazon S3 prefixes.
     :start-after: [START howto_operator_s3_list_prefixes]
     :end-before: [END howto_operator_s3_list_prefixes]
 
+.. _howto/operator:S3ReadObjectOperator:
+
+Read an Amazon S3 object
+========================
+
+To read the content of an Amazon S3 object and return it as a string you can 
use
+:class:`~airflow.providers.amazon.aws.operators.s3.S3ReadObjectOperator`.
+
+.. exampleinclude:: /../../amazon/tests/system/amazon/aws/example_s3.py
+    :language: python
+    :dedent: 4
+    :start-after: [START howto_operator_s3_read_object]
+    :end-before: [END howto_operator_s3_read_object]
+
 .. _howto/operator:S3ListOperator:
 
 List 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 50c1ac07983..2a5b12052b6 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/operators/s3.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/operators/s3.py
@@ -930,3 +930,67 @@ class S3ListPrefixesOperator(AwsBaseOperator[S3Hook]):
         )
 
         return self.hook.list_prefixes(bucket_name=self.bucket, 
prefix=self.prefix, delimiter=self.delimiter)
+
+
+class S3ReadObjectOperator(AwsBaseOperator[S3Hook]):
+    """
+    Read an S3 object and return its content as a string.
+
+    Uses ``S3Hook.read_key`` to fetch the object body, decode it as UTF-8,
+    and return the resulting string. The return value is automatically pushed
+    to XCom so downstream tasks can consume it.
+
+    .. seealso::
+        For more information on how to use this operator, take a look at the 
guide:
+        :ref:`howto/operator:S3ReadObjectOperator`
+
+    :param s3_bucket: Name of the S3 bucket. (templated)
+        It should be omitted when ``s3_key`` is provided as a full ``s3://`` 
URL.
+    :param s3_key: The key of the object to read. (templated)
+        It can be either a full ``s3://`` style URL or a relative path from 
root level.
+        When specified as a full ``s3://`` URL, omit ``s3_bucket``.
+    :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("s3_bucket", "s3_key")
+    aws_hook_class = S3Hook
+
+    def __init__(
+        self,
+        *,
+        s3_bucket: str | None = None,
+        s3_key: str,
+        **kwargs,
+    ) -> None:
+        super().__init__(**kwargs)
+        self.s3_bucket = s3_bucket
+        self.s3_key = s3_key
+
+    def execute(self, context: Context) -> str:
+        bucket, key = self.hook.get_s3_bucket_key(self.s3_bucket, self.s3_key, 
"s3_bucket", "s3_key")
+        self.log.info("Reading s3://%s/%s", bucket, key)
+        return self.hook.read_key(key=key, bucket_name=bucket)
+
+    def get_openlineage_facets_on_start(self):
+        from airflow.providers.common.compat.openlineage.facet import Dataset
+        from airflow.providers.openlineage.extractors import OperatorLineage
+
+        bucket, key = self.hook.get_s3_bucket_key(self.s3_bucket, self.s3_key, 
"s3_bucket", "s3_key")
+
+        input_dataset = Dataset(
+            namespace=f"s3://{bucket}",
+            name=key,
+        )
+
+        return OperatorLineage(
+            inputs=[input_dataset],
+        )
diff --git a/providers/amazon/tests/system/amazon/aws/example_s3.py 
b/providers/amazon/tests/system/amazon/aws/example_s3.py
index 6ddf6d95bbd..184211e630d 100644
--- a/providers/amazon/tests/system/amazon/aws/example_s3.py
+++ b/providers/amazon/tests/system/amazon/aws/example_s3.py
@@ -30,6 +30,7 @@ from airflow.providers.amazon.aws.operators.s3 import (
     S3ListOperator,
     S3ListPrefixesOperator,
     S3PutBucketTaggingOperator,
+    S3ReadObjectOperator,
 )
 from airflow.providers.amazon.aws.sensors.s3 import S3KeySensor, 
S3KeysUnchangedSensor
 from airflow.providers.common.compat.sdk import DAG, chain
@@ -141,6 +142,14 @@ with DAG(
         replace=True,
     )
 
+    # [START howto_operator_s3_read_object]
+    read_object = S3ReadObjectOperator(
+        task_id="read_object",
+        s3_bucket=bucket_name,
+        s3_key=key,
+    )
+    # [END howto_operator_s3_read_object]
+
     # [START howto_operator_s3_list_prefixes]
     list_prefixes = S3ListPrefixesOperator(
         task_id="list_prefixes",
@@ -301,6 +310,7 @@ with DAG(
         delete_tagging,
         create_object,
         create_object_2,
+        read_object,
         list_prefixes,
         list_keys,
         [sensor_one_key, sensor_two_keys, sensor_key_with_function, 
sensor_key_with_regex],
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 a7501ee4bbf..f3a74a4dd3f 100644
--- a/providers/amazon/tests/unit/amazon/aws/operators/test_s3.py
+++ b/providers/amazon/tests/unit/amazon/aws/operators/test_s3.py
@@ -46,6 +46,7 @@ from airflow.providers.amazon.aws.operators.s3 import (
     S3ListOperator,
     S3ListPrefixesOperator,
     S3PutBucketTaggingOperator,
+    S3ReadObjectOperator,
 )
 from airflow.providers.common.compat.openlineage.facet import (
     Dataset,
@@ -1083,3 +1084,58 @@ class TestS3CreateObjectOperator:
     def test_template_fields(self):
         operator = S3CreateObjectOperator(task_id="test", s3_bucket="bucket", 
s3_key="key", data="test")
         validate_template_fields(operator)
+
+
+class TestS3ReadObjectOperator:
+    def setup_method(self):
+        self.operator = S3ReadObjectOperator(
+            task_id="test-s3-read-object",
+            s3_bucket=BUCKET_NAME,
+            s3_key=S3_KEY,
+        )
+
+    @mock_aws
+    def test_execute_reads_object(self):
+        conn = boto3.client("s3", region_name="us-east-1")
+        conn.create_bucket(Bucket=BUCKET_NAME)
+        conn.put_object(Bucket=BUCKET_NAME, Key=S3_KEY, Body=b"hello world 
from s3")
+
+        result = self.operator.execute({})
+        assert result == "hello world from s3"
+
+    @mock_aws
+    def test_execute_with_s3_url(self):
+        conn = boto3.client("s3", region_name="us-east-1")
+        conn.create_bucket(Bucket=BUCKET_NAME)
+        conn.put_object(Bucket=BUCKET_NAME, Key=S3_KEY, Body=b"url-content")
+
+        op = S3ReadObjectOperator(
+            task_id="test-s3-url",
+            s3_key=f"s3://{BUCKET_NAME}/{S3_KEY}",
+        )
+        result = op.execute({})
+        assert result == "url-content"
+
+    @mock_aws
+    def test_execute_empty_object(self):
+        conn = boto3.client("s3", region_name="us-east-1")
+        conn.create_bucket(Bucket=BUCKET_NAME)
+        conn.put_object(Bucket=BUCKET_NAME, Key=S3_KEY, Body=b"")
+
+        result = self.operator.execute({})
+        assert result == ""
+
+    @pytest.mark.parametrize(("bucket", "key"), (("bucket", "file.txt"), 
(None, "s3://bucket/file.txt")))
+    def test_get_openlineage_facets_on_start(self, bucket, key):
+        expected_input = Dataset(
+            namespace="s3://bucket",
+            name="file.txt",
+        )
+        op = S3ReadObjectOperator(task_id="test", s3_bucket=bucket, s3_key=key)
+        lineage = op.get_openlineage_facets_on_start()
+        assert len(lineage.outputs) == 0
+        assert len(lineage.inputs) == 1
+        assert lineage.inputs[0] == expected_input
+
+    def test_template_fields(self):
+        validate_template_fields(self.operator)

Reply via email to