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

shunping pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new d85fbc50cf9 [Python] Add `AwsOptions` and deprecate `S3Options` 
(#40529)
d85fbc50cf9 is described below

commit d85fbc50cf91fa5b181b33664563dd32cd0364db
Author: Shunping Huang <[email protected]>
AuthorDate: Mon Oct 12 12:32:10 2026 -0400

    [Python] Add `AwsOptions` and deprecate `S3Options` (#40529)
---
 CHANGES.md                                         |   3 +-
 .../apache_beam/io/aws/clients/s3/boto3_client.py  |  29 +++---
 .../io/aws/clients/s3/boto3_client_test.py         | 111 +++++++++++++++++++++
 .../apache_beam/io/aws/clients/s3/client_test.py   |   2 +-
 sdks/python/apache_beam/io/aws/s3filesystem.py     |   2 +-
 sdks/python/apache_beam/io/aws/s3io_test.py        |   2 +-
 .../python/apache_beam/options/pipeline_options.py | 106 ++++++++++++++++----
 .../apache_beam/options/pipeline_options_test.py   |  54 ++++++++++
 8 files changed, 270 insertions(+), 39 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index cbda51c7c60..0525ff92792 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -73,6 +73,7 @@
 * The `disableCounterMetrics`, `disableStringSetMetrics` and 
`disableBoundedTrieMetrics` experiments are now honored by the Python SDK, as 
they already were in Java (Python) 
([#38746](https://github.com/apache/beam/issues/38746)).
 * (Go) Pipeline-level `--resource_hints` are now forwarded to expansion 
services, so they apply to cross-language transforms as they do in the Java and 
Python SDKs. The `max_active_bundles_per_worker` hint name is now also accepted 
by `--resource_hints` ([#23893](https://github.com/apache/beam/issues/23893)).
 * ReadFromBigQuery now supports Lakehouse runtime catalog (BigLake metastore) 
tables with `method=DIRECT_READ`, using 4-part 
`project.catalog.namespace.table` identifiers. Previously 
`project:catalog.namespace.table` was silently mis-parsed and tables that 
report no `numBytes` failed to split (Python) 
([#39597](https://github.com/apache/beam/issues/39597)).
+* (Python) Added `AwsOptions` with `--aws_*` pipeline options (e.g. 
`--aws_access_key_id`, `--aws_region_name`, `--aws_endpoint_url`) for 
configuring AWS service clients such as S3 
([#40528](https://github.com/apache/beam/issues/40528)).
 
 ## Breaking Changes
 
@@ -84,7 +85,7 @@
 ## Deprecations
 
 * Support for Python 3.10 was removed following its end-of-life 
([#40250](https://github.com/apache/beam/issues/40250)).
-
+* (Python) `S3Options` and its `--s3_*` pipeline options are deprecated in 
favor of `AwsOptions` and the equivalent `--aws_*` options. The `--s3_*` 
options still work and are used when the corresponding `--aws_*` option is not 
set ([#40528](https://github.com/apache/beam/issues/40528)).
 * X behavior is deprecated and will be removed in X versions 
([#X](https://github.com/apache/beam/issues/X)).
 
 ## Bugfixes
diff --git a/sdks/python/apache_beam/io/aws/clients/s3/boto3_client.py 
b/sdks/python/apache_beam/io/aws/clients/s3/boto3_client.py
index aee24ac2c05..9182c1d7df8 100644
--- a/sdks/python/apache_beam/io/aws/clients/s3/boto3_client.py
+++ b/sdks/python/apache_beam/io/aws/clients/s3/boto3_client.py
@@ -43,24 +43,19 @@ class Client(object):
   def __init__(self, options):
     assert boto3 is not None, 'Missing boto3 requirement'
     if isinstance(options, pipeline_options.PipelineOptions):
-      s3_options = options.view_as(pipeline_options.S3Options)
-      access_key_id = s3_options.s3_access_key_id
-      secret_access_key = s3_options.s3_secret_access_key
-      session_token = s3_options.s3_session_token
-      endpoint_url = s3_options.s3_endpoint_url
-      use_ssl = not s3_options.s3_disable_ssl
-      region_name = s3_options.s3_region_name
-      api_version = s3_options.s3_api_version
-      verify = s3_options.s3_verify
+      aws_options = options.view_as(pipeline_options.AwsOptions)
     else:
-      access_key_id = options.get('s3_access_key_id')
-      secret_access_key = options.get('s3_secret_access_key')
-      session_token = options.get('s3_session_token')
-      endpoint_url = options.get('s3_endpoint_url')
-      use_ssl = not options.get('s3_disable_ssl', False)
-      region_name = options.get('s3_region_name')
-      api_version = options.get('s3_api_version')
-      verify = options.get('s3_verify')
+      # A plain dict of option names (aws_* or the deprecated s3_*) to values.
+      aws_options = pipeline_options.AwsOptions(flags=[], **options)
+
+    access_key_id = aws_options.aws_access_key_id
+    secret_access_key = aws_options.aws_secret_access_key
+    session_token = aws_options.aws_session_token
+    endpoint_url = aws_options.aws_endpoint_url
+    use_ssl = not aws_options.aws_disable_ssl
+    region_name = aws_options.aws_region_name
+    api_version = aws_options.aws_api_version
+    verify = aws_options.aws_verify
 
     session = boto3.session.Session()
     self.client = session.client(
diff --git a/sdks/python/apache_beam/io/aws/clients/s3/boto3_client_test.py 
b/sdks/python/apache_beam/io/aws/clients/s3/boto3_client_test.py
new file mode 100644
index 00000000000..ccea8a3c664
--- /dev/null
+++ b/sdks/python/apache_beam/io/aws/clients/s3/boto3_client_test.py
@@ -0,0 +1,111 @@
+#
+# 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.
+#
+
+"""Tests for option handling in the boto3 S3 client."""
+
+import unittest
+from unittest import mock
+
+from apache_beam.options.pipeline_options import PipelineOptions
+
+try:
+  import boto3  # pylint: disable=unused-import
+
+  from apache_beam.io.aws.clients.s3 import boto3_client
+except ImportError:
+  boto3_client = None
+
+
[email protected](boto3_client is None, 'boto3 is not installed')
+class Boto3ClientOptionsTest(unittest.TestCase):
+  def setUp(self):
+    patcher = mock.patch('boto3.session.Session')
+    self.mock_session = patcher.start().return_value
+    self.addCleanup(patcher.stop)
+
+  def _client_kwargs(self):
+    return self.mock_session.client.call_args.kwargs
+
+  def test_aws_options_passed_to_client(self):
+    boto3_client.Client(
+        PipelineOptions([
+            '--aws_access_key_id=AKIDEXAMPLE',
+            '--aws_secret_access_key=SECRETEXAMPLE',
+            '--aws_session_token=TOKEN',
+            '--aws_endpoint_url=http://localhost:4566',
+            '--aws_region_name=us-east-1',
+            '--aws_api_version=2006-03-01',
+            '--aws_verify=False',
+            '--aws_disable_ssl',
+        ]))
+    self.assertEqual(
+        self._client_kwargs(),
+        {
+            'service_name': 's3',
+            'region_name': 'us-east-1',
+            'api_version': '2006-03-01',
+            'use_ssl': False,
+            'verify': 'False',
+            'endpoint_url': 'http://localhost:4566',
+            'aws_access_key_id': 'AKIDEXAMPLE',
+            'aws_secret_access_key': 'SECRETEXAMPLE',
+            'aws_session_token': 'TOKEN',
+        })
+
+  def test_deprecated_s3_options_still_work(self):
+    boto3_client.Client(
+        PipelineOptions([
+            '--s3_access_key_id=AKIDEXAMPLE',
+            '--s3_secret_access_key=SECRETEXAMPLE',
+            '--s3_region_name=us-west-2',
+            '--s3_disable_ssl',
+        ]))
+    kwargs = self._client_kwargs()
+    self.assertEqual(kwargs['aws_access_key_id'], 'AKIDEXAMPLE')
+    self.assertEqual(kwargs['aws_secret_access_key'], 'SECRETEXAMPLE')
+    self.assertEqual(kwargs['region_name'], 'us-west-2')
+    self.assertFalse(kwargs['use_ssl'])
+
+  def test_aws_options_take_precedence_over_s3_options(self):
+    boto3_client.Client(
+        PipelineOptions([
+            '--s3_region_name=us-west-2',
+            '--aws_region_name=us-east-1',
+        ]))
+    self.assertEqual(self._client_kwargs()['region_name'], 'us-east-1')
+
+  def test_dict_options(self):
+    boto3_client.Client({
+        'aws_access_key_id': 'AKIDEXAMPLE',
+        's3_secret_access_key': 'SECRETEXAMPLE',
+        'aws_disable_ssl': True,
+    })
+    kwargs = self._client_kwargs()
+    self.assertEqual(kwargs['aws_access_key_id'], 'AKIDEXAMPLE')
+    self.assertEqual(kwargs['aws_secret_access_key'], 'SECRETEXAMPLE')
+    self.assertFalse(kwargs['use_ssl'])
+
+  def test_no_options_uses_boto3_defaults(self):
+    boto3_client.Client(PipelineOptions([]))
+    kwargs = self._client_kwargs()
+    self.assertIsNone(kwargs['aws_access_key_id'])
+    self.assertIsNone(kwargs['aws_secret_access_key'])
+    self.assertTrue(kwargs['use_ssl'])
+
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/sdks/python/apache_beam/io/aws/clients/s3/client_test.py 
b/sdks/python/apache_beam/io/aws/clients/s3/client_test.py
index 67797b1736b..ea110ea9b5d 100644
--- a/sdks/python/apache_beam/io/aws/clients/s3/client_test.py
+++ b/sdks/python/apache_beam/io/aws/clients/s3/client_test.py
@@ -47,7 +47,7 @@ class ClientErrorTest(unittest.TestCase):
       self.client.known_buckets.add(test_data_bucket)
       self.aws = s3io.S3IO(self.client)
     else:
-      self.aws = s3io.S3IO(options=pipeline_options.S3Options())
+      self.aws = s3io.S3IO(options=pipeline_options.AwsOptions())
 
   def test_get_object_metadata(self):
 
diff --git a/sdks/python/apache_beam/io/aws/s3filesystem.py 
b/sdks/python/apache_beam/io/aws/s3filesystem.py
index 584263ec241..f7ec8a28d2b 100644
--- a/sdks/python/apache_beam/io/aws/s3filesystem.py
+++ b/sdks/python/apache_beam/io/aws/s3filesystem.py
@@ -41,7 +41,7 @@ class S3FileSystem(FileSystem):
     """Initializes a connection to S3.
 
     Connection configuration is done by passing pipeline options.
-    See :class:`~apache_beam.options.pipeline_options.S3Options`.
+    See :class:`~apache_beam.options.pipeline_options.AwsOptions`.
     """
     super().__init__(pipeline_options)
     self._options = pipeline_options
diff --git a/sdks/python/apache_beam/io/aws/s3io_test.py 
b/sdks/python/apache_beam/io/aws/s3io_test.py
index 09df3da7674..ee871be9d82 100644
--- a/sdks/python/apache_beam/io/aws/s3io_test.py
+++ b/sdks/python/apache_beam/io/aws/s3io_test.py
@@ -98,7 +98,7 @@ class TestS3IO(unittest.TestCase):
       self.aws = s3io.S3IO(self.client)
 
     else:
-      self.aws = s3io.S3IO(options=pipeline_options.S3Options())
+      self.aws = s3io.S3IO(options=pipeline_options.AwsOptions())
       self.client = self.aws.client
 
   def test_size(self):
diff --git a/sdks/python/apache_beam/options/pipeline_options.py 
b/sdks/python/apache_beam/options/pipeline_options.py
index 743ab967b04..37a98b63820 100644
--- a/sdks/python/apache_beam/options/pipeline_options.py
+++ b/sdks/python/apache_beam/options/pipeline_options.py
@@ -51,6 +51,7 @@ __all__ = [
     'ProfilingOptions',
     'SetupOptions',
     'TestOptions',
+    'AwsOptions',
     'S3Options'
 ]
 
@@ -2398,42 +2399,111 @@ class OptionsContext(object):
     return options
 
 
-class S3Options(PipelineOptions):
+# Deprecated S3Options option names, mapped to the AwsOptions options that
+# replace them.
+_DEPRECATED_S3_TO_AWS_OPTIONS = {
+    's3_access_key_id': 'aws_access_key_id',
+    's3_secret_access_key': 'aws_secret_access_key',
+    's3_session_token': 'aws_session_token',
+    's3_endpoint_url': 'aws_endpoint_url',
+    's3_region_name': 'aws_region_name',
+    's3_api_version': 'aws_api_version',
+    's3_verify': 'aws_verify',
+    's3_disable_ssl': 'aws_disable_ssl',
+}
+_AWS_TO_DEPRECATED_S3_OPTIONS = {
+    aws: s3
+    for s3, aws in _DEPRECATED_S3_TO_AWS_OPTIONS.items()
+}
+
+
+class AwsOptions(PipelineOptions):
+  """Options used to create AWS service clients, e.g. for S3.
+
+  The deprecated ``--s3_*`` flags from :class:`S3Options` are still accepted.
+  Each ``aws_*`` option falls back to its ``s3_*`` counterpart when it is not
+  set.
+  """
   @classmethod
   def _add_argparse_args(cls, parser):
-    # These options are passed to the S3 IO Client
     parser.add_argument(
-        '--s3_access_key_id',
+        '--aws_access_key_id',
         default=None,
-        help='The secret key to use when creating the s3 client.')
+        help='The access key ID to use when creating AWS clients.')
     parser.add_argument(
-        '--s3_secret_access_key',
+        '--aws_secret_access_key',
         default=None,
-        help='The secret key to use when creating the s3 client.')
+        help='The secret access key to use when creating AWS clients.')
     parser.add_argument(
-        '--s3_session_token',
+        '--aws_session_token',
         default=None,
-        help='The session token to use when creating the s3 client.')
+        help='The session token to use when creating AWS clients.')
     parser.add_argument(
-        '--s3_endpoint_url',
+        '--aws_endpoint_url',
         default=None,
-        help='The complete URL to use for the constructed s3 client.')
+        help='The complete URL to use for the constructed AWS client.')
     parser.add_argument(
-        '--s3_region_name',
+        '--aws_region_name',
         default=None,
-        help='The name of the region associated with the s3 client.')
+        help='The name of the region associated with the AWS client.')
     parser.add_argument(
-        '--s3_api_version',
+        '--aws_api_version',
         default=None,
-        help='The API version to use with the s3 client.')
+        help='The API version to use with the AWS client.')
     parser.add_argument(
-        '--s3_verify',
+        '--aws_verify',
         default=None,
-        help='Whether or not to verify SSL certificates with the s3 client.')
+        help='Whether or not to verify SSL certificates with the AWS client.')
     parser.add_argument(
-        '--s3_disable_ssl',
+        '--aws_disable_ssl',
         default=False,
         action='store_true',
         help=(
-            'Whether or not to use SSL with the s3 client. '
+            'Whether or not to use SSL with the AWS client. '
             'By default, SSL is used.'))
+
+    # Deprecated S3Options flags, kept for backward compatibility. They are
+    # registered here (not on S3Options) so that both flag sets are parsed by
+    # every AwsOptions view and are never registered twice.
+    for s3_name, aws_name in _DEPRECATED_S3_TO_AWS_OPTIONS.items():
+      if s3_name == 's3_disable_ssl':
+        kwargs = {'default': False, 'action': 'store_true'}
+      else:
+        kwargs = {'default': None}
+      parser.add_argument(
+          '--' + s3_name,
+          help='Deprecated, use --%s instead.' % aws_name,
+          **kwargs)
+
+  def __getattr__(self, name):
+    value = super().__getattr__(name)
+    s3_name = _AWS_TO_DEPRECATED_S3_OPTIONS.get(name)
+    if s3_name and not value:
+      s3_value = self._all_options.get(s3_name)
+      if s3_value:
+        logger.log_first_n(
+            logging.WARN,
+            '--%s is deprecated, use --%s instead.' % (s3_name, name),
+            key='message')
+        return s3_value
+    return value
+
+
+class S3Options(AwsOptions):
+  """Deprecated, use :class:`AwsOptions` instead.
+
+  The ``s3_*`` attributes delegate to the corresponding ``aws_*`` options, so
+  both the old and new flags are honored.
+  """
+  def __init__(self, flags: Optional[Sequence[str]] = None, **kwargs) -> None:
+    logger.log_first_n(
+        logging.WARN,
+        'S3Options is deprecated, use AwsOptions instead.',
+        key='message')
+    super().__init__(flags, **kwargs)
+
+  def __getattr__(self, name):
+    aws_name = _DEPRECATED_S3_TO_AWS_OPTIONS.get(name)
+    if aws_name:
+      return getattr(self, aws_name)
+    return super().__getattr__(name)
diff --git a/sdks/python/apache_beam/options/pipeline_options_test.py 
b/sdks/python/apache_beam/options/pipeline_options_test.py
index d9e1d850ec7..f02ec10a69a 100644
--- a/sdks/python/apache_beam/options/pipeline_options_test.py
+++ b/sdks/python/apache_beam/options/pipeline_options_test.py
@@ -28,12 +28,14 @@ import hamcrest as hc
 import mock
 from parameterized import parameterized
 
+from apache_beam.options.pipeline_options import AwsOptions
 from apache_beam.options.pipeline_options import CrossLanguageOptions
 from apache_beam.options.pipeline_options import DebugOptions
 from apache_beam.options.pipeline_options import GoogleCloudOptions
 from apache_beam.options.pipeline_options import JobServerOptions
 from apache_beam.options.pipeline_options import PipelineOptions
 from apache_beam.options.pipeline_options import ProfilingOptions
+from apache_beam.options.pipeline_options import S3Options
 from apache_beam.options.pipeline_options import StandardOptions
 from apache_beam.options.pipeline_options import TypeOptions
 from apache_beam.options.pipeline_options import WorkerOptions
@@ -1121,6 +1123,58 @@ class CompatVersionTest(unittest.TestCase):
         msg="Should return False when update_compatibility_version is not set")
 
 
+class AwsOptionsTest(unittest.TestCase):
+  def test_aws_options(self):
+    options = PipelineOptions([
+        '--aws_access_key_id=key',
+        '--aws_region_name=us-east-1',
+        '--aws_disable_ssl',
+    ]).view_as(AwsOptions)
+    self.assertEqual(options.aws_access_key_id, 'key')
+    self.assertEqual(options.aws_region_name, 'us-east-1')
+    self.assertTrue(options.aws_disable_ssl)
+    self.assertIsNone(options.aws_endpoint_url)
+
+  def test_aws_options_fall_back_to_deprecated_s3_flags(self):
+    options = PipelineOptions([
+        '--s3_access_key_id=key',
+        '--s3_region_name=us-west-2',
+        '--s3_disable_ssl',
+    ]).view_as(AwsOptions)
+    self.assertEqual(options.aws_access_key_id, 'key')
+    self.assertEqual(options.aws_region_name, 'us-west-2')
+    self.assertTrue(options.aws_disable_ssl)
+
+  def test_aws_flags_take_precedence_over_s3_flags(self):
+    options = PipelineOptions([
+        '--s3_region_name=us-west-2',
+        '--aws_region_name=us-east-1',
+    ]).view_as(AwsOptions)
+    self.assertEqual(options.aws_region_name, 'us-east-1')
+
+  def test_aws_options_fall_back_to_deprecated_s3_kwargs(self):
+    options = PipelineOptions(s3_endpoint_url='http://localhost:4566')
+    self.assertEqual(
+        options.view_as(AwsOptions).aws_endpoint_url, 'http://localhost:4566')
+
+  def test_s3_options_delegate_to_aws_options(self):
+    options = PipelineOptions([
+        '--aws_access_key_id=key',
+        '--s3_region_name=us-west-2',
+    ]).view_as(S3Options)
+    self.assertIsInstance(options, AwsOptions)
+    self.assertEqual(options.s3_access_key_id, 'key')
+    self.assertEqual(options.s3_region_name, 'us-west-2')
+    self.assertEqual(options.aws_region_name, 'us-west-2')
+    self.assertFalse(options.s3_disable_ssl)
+
+  def test_options_are_registered_once(self):
+    all_options = PipelineOptions(['--aws_region_name=us-east-1'
+                                   ]).get_all_options()
+    self.assertEqual(all_options['aws_region_name'], 'us-east-1')
+    self.assertIn('s3_region_name', all_options)
+
+
 if __name__ == '__main__':
   logging.getLogger().setLevel(logging.INFO)
   unittest.main()

Reply via email to