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 3ec3db07aa1 [Python] Make `--aws_*` options defaults and `--s3_*` 
options S3 overrides (#40532)
3ec3db07aa1 is described below

commit 3ec3db07aa1b65da98c09129476abe23f1962fa8
Author: Shunping Huang <[email protected]>
AuthorDate: Mon Oct 12 21:53:40 2026 -0400

    [Python] Make `--aws_*` options defaults and `--s3_*` options S3 overrides 
(#40532)
    
    * Make --aws_* options defaults and --s3_* options S3 overrides
    
    * Reformat
---
 CHANGES.md                                         |   3 +-
 .../apache_beam/io/aws/clients/s3/boto3_client.py  |  33 ++--
 .../io/aws/clients/s3/boto3_client_test.py         |  17 ++-
 .../python/apache_beam/options/pipeline_options.py | 169 ++++++++++++---------
 .../apache_beam/options/pipeline_options_test.py   |  84 +++++++---
 5 files changed, 193 insertions(+), 113 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index 354f971acc6..fda4c9edc21 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -73,7 +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)).
+* (Python) Added `AwsOptions` with `--aws_*` pipeline options (e.g. 
`--aws_access_key_id`, `--aws_region_name`, `--aws_endpoint_url`) that set 
defaults for all AWS service clients. The existing `--s3_*` options in 
`S3Options` override these defaults for the S3 client only 
([#40528](https://github.com/apache/beam/issues/40528)).
 
 ## Breaking Changes
 
@@ -86,7 +86,6 @@
 ## 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 9182c1d7df8..57c7c83f3ca 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
@@ -41,33 +41,32 @@ class Client(object):
   Wrapper for boto3 library
   """
   def __init__(self, options):
+    """
+    Args:
+      options: PipelineOptions, or a dict of option names (aws_* and s3_*) to
+        values. --s3_* options override the --aws_* defaults for S3.
+    """
     assert boto3 is not None, 'Missing boto3 requirement'
     if isinstance(options, pipeline_options.PipelineOptions):
       aws_options = options.view_as(pipeline_options.AwsOptions)
+      s3_options = options.view_as(pipeline_options.S3Options)
     else:
-      # A plain dict of option names (aws_* or the deprecated s3_*) to values.
       aws_options = pipeline_options.AwsOptions(flags=[], **options)
+      s3_options = pipeline_options.S3Options(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
+    settings = aws_options.get_client_settings(s3_options)
 
     session = boto3.session.Session()
     self.client = session.client(
         service_name='s3',
-        region_name=region_name,
-        api_version=api_version,
-        use_ssl=use_ssl,
-        verify=verify,
-        endpoint_url=endpoint_url,
-        aws_access_key_id=access_key_id,
-        aws_secret_access_key=secret_access_key,
-        aws_session_token=session_token)
+        region_name=settings['region_name'],
+        api_version=s3_options.s3_api_version,
+        use_ssl=not settings['disable_ssl'],
+        verify=settings['verify'],
+        endpoint_url=settings['endpoint_url'],
+        aws_access_key_id=settings['access_key_id'],
+        aws_secret_access_key=settings['secret_access_key'],
+        aws_session_token=settings['session_token'])
 
     self._download_request = None
     self._download_stream = None
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
index ccea8a3c664..490d7747294 100644
--- 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
@@ -48,7 +48,7 @@ class Boto3ClientOptionsTest(unittest.TestCase):
             '--aws_session_token=TOKEN',
             '--aws_endpoint_url=http://localhost:4566',
             '--aws_region_name=us-east-1',
-            '--aws_api_version=2006-03-01',
+            '--s3_api_version=2006-03-01',
             '--aws_verify=False',
             '--aws_disable_ssl',
         ]))
@@ -66,7 +66,7 @@ class Boto3ClientOptionsTest(unittest.TestCase):
             'aws_session_token': 'TOKEN',
         })
 
-  def test_deprecated_s3_options_still_work(self):
+  def test_s3_options_without_aws_options(self):
     boto3_client.Client(
         PipelineOptions([
             '--s3_access_key_id=AKIDEXAMPLE',
@@ -80,13 +80,20 @@ class Boto3ClientOptionsTest(unittest.TestCase):
     self.assertEqual(kwargs['region_name'], 'us-west-2')
     self.assertFalse(kwargs['use_ssl'])
 
-  def test_aws_options_take_precedence_over_s3_options(self):
+  def test_s3_options_override_aws_options(self):
     boto3_client.Client(
         PipelineOptions([
-            '--s3_region_name=us-west-2',
+            '--aws_access_key_id=AKIDEXAMPLE',
             '--aws_region_name=us-east-1',
+            '--aws_endpoint_url=https://vpce.example.com',
+            '--s3_region_name=us-west-2',
+            '--s3_endpoint_url=http://localhost:9000',
         ]))
-    self.assertEqual(self._client_kwargs()['region_name'], 'us-east-1')
+    kwargs = self._client_kwargs()
+    self.assertEqual(kwargs['region_name'], 'us-west-2')
+    self.assertEqual(kwargs['endpoint_url'], 'http://localhost:9000')
+    # Settings without an S3 override use the --aws_* default.
+    self.assertEqual(kwargs['aws_access_key_id'], 'AKIDEXAMPLE')
 
   def test_dict_options(self):
     boto3_client.Client({
diff --git a/sdks/python/apache_beam/options/pipeline_options.py 
b/sdks/python/apache_beam/options/pipeline_options.py
index 37a98b63820..24abcae4d3e 100644
--- a/sdks/python/apache_beam/options/pipeline_options.py
+++ b/sdks/python/apache_beam/options/pipeline_options.py
@@ -2399,30 +2399,27 @@ class OptionsContext(object):
     return options
 
 
-# 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()
-}
+# Client settings shared by all AWS clients. Each is configured in AwsOptions 
as
+# --aws_<setting>, and can be overridden for one service by a service options
+# class (see AwsOptions.get_client_settings), e.g. --s3_<setting> in S3Options.
+_AWS_CLIENT_SETTINGS = (
+    'access_key_id',
+    'secret_access_key',
+    'session_token',
+    'endpoint_url',
+    'region_name',
+    'verify',
+    'disable_ssl',
+)
 
 
 class AwsOptions(PipelineOptions):
-  """Options used to create AWS service clients, e.g. for S3.
+  """Default options for creating AWS service clients.
 
-  The deprecated ``--s3_*`` flags from :class:`S3Options` are still accepted.
-  Each ``aws_*`` option falls back to its ``s3_*`` counterpart when it is not
-  set.
+  These settings apply to every AWS client Beam creates. A service options
+  class can override any of them for its own client, e.g. ``--s3_endpoint_url``
+  in :class:`S3Options` overrides ``--aws_endpoint_url`` for S3 only. Use
+  :meth:`get_client_settings` to get the effective settings for a service.
   """
   @classmethod
   def _add_argparse_args(cls, parser):
@@ -2441,69 +2438,105 @@ class AwsOptions(PipelineOptions):
     parser.add_argument(
         '--aws_endpoint_url',
         default=None,
-        help='The complete URL to use for the constructed AWS client.')
+        help='The complete URL to use for the constructed AWS clients.')
     parser.add_argument(
         '--aws_region_name',
         default=None,
-        help='The name of the region associated with the AWS client.')
-    parser.add_argument(
-        '--aws_api_version',
-        default=None,
-        help='The API version to use with the AWS client.')
+        help='The name of the region associated with the AWS clients.')
     parser.add_argument(
         '--aws_verify',
         default=None,
-        help='Whether or not to verify SSL certificates with the AWS client.')
+        help='Whether or not to verify SSL certificates with the AWS clients.')
     parser.add_argument(
         '--aws_disable_ssl',
         default=False,
         action='store_true',
         help=(
-            'Whether or not to use SSL with the AWS client. '
+            'Whether or not to use SSL with the AWS clients. '
             '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
+  def get_client_settings(
+      self,
+      service_options: Optional['PipelineOptions'] = None) -> dict[str, Any]:
+    """Returns the effective client settings for an AWS service.
 
+    Starts from the ``--aws_*`` defaults and applies the overrides set in
+    ``service_options``. A service options class opts in by defining
+    ``AWS_CLIENT_SETTINGS_PREFIX`` (e.g. ``'s3'``) and options named
+    ``<prefix>_<setting>`` for the settings it can override (e.g.
+    ``s3_endpoint_url``). An override applies when it is set (not None/False).
 
-class S3Options(AwsOptions):
-  """Deprecated, use :class:`AwsOptions` instead.
+    Args:
+      service_options: Optional options view for a specific service, e.g.
+        ``options.view_as(S3Options)``.
 
-  The ``s3_*`` attributes delegate to the corresponding ``aws_*`` options, so
-  both the old and new flags are honored.
+    Returns:
+      A dict keyed by setting name (``access_key_id``, ``secret_access_key``,
+      ``session_token``, ``endpoint_url``, ``region_name``, ``verify``,
+      ``disable_ssl``).
+    """
+    settings = {
+        name: getattr(self, 'aws_' + name)
+        for name in _AWS_CLIENT_SETTINGS
+    }
+    prefix = getattr(service_options, 'AWS_CLIENT_SETTINGS_PREFIX', None)
+    if prefix:
+      for name in _AWS_CLIENT_SETTINGS:
+        override = getattr(service_options, '%s_%s' % (prefix, name), None)
+        if override:
+          settings[name] = override
+    return settings
+
+
+class S3Options(PipelineOptions):
+  """Options for the S3 client.
+
+  ``--s3_api_version`` is S3-specific. The other options override the
+  corresponding ``--aws_*`` default in :class:`AwsOptions` for S3 only, e.g. to
+  point S3 at an S3-compatible endpoint while other AWS clients use the
+  defaults.
   """
-  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)
+  AWS_CLIENT_SETTINGS_PREFIX = 's3'
 
-  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)
+  @classmethod
+  def _add_argparse_args(cls, parser):
+    parser.add_argument(
+        '--s3_access_key_id',
+        default=None,
+        help='The access key ID for the S3 client. Overrides '
+        '--aws_access_key_id.')
+    parser.add_argument(
+        '--s3_secret_access_key',
+        default=None,
+        help='The secret access key for the S3 client. Overrides '
+        '--aws_secret_access_key.')
+    parser.add_argument(
+        '--s3_session_token',
+        default=None,
+        help='The session token for the S3 client. Overrides '
+        '--aws_session_token.')
+    parser.add_argument(
+        '--s3_endpoint_url',
+        default=None,
+        help='The complete URL to use for the S3 client. Overrides '
+        '--aws_endpoint_url.')
+    parser.add_argument(
+        '--s3_region_name',
+        default=None,
+        help='The name of the region associated with the S3 client. '
+        'Overrides --aws_region_name.')
+    parser.add_argument(
+        '--s3_verify',
+        default=None,
+        help='Whether or not to verify SSL certificates with the S3 client. '
+        'Overrides --aws_verify.')
+    parser.add_argument(
+        '--s3_disable_ssl',
+        default=False,
+        action='store_true',
+        help='Disable SSL for the S3 client, even if --aws_disable_ssl is not '
+        'set.')
+    parser.add_argument(
+        '--s3_api_version',
+        default=None,
+        help='The API version to use with the S3 client.')
diff --git a/sdks/python/apache_beam/options/pipeline_options_test.py 
b/sdks/python/apache_beam/options/pipeline_options_test.py
index f02ec10a69a..493e42d5301 100644
--- a/sdks/python/apache_beam/options/pipeline_options_test.py
+++ b/sdks/python/apache_beam/options/pipeline_options_test.py
@@ -1135,44 +1135,86 @@ class AwsOptionsTest(unittest.TestCase):
     self.assertTrue(options.aws_disable_ssl)
     self.assertIsNone(options.aws_endpoint_url)
 
-  def test_aws_options_fall_back_to_deprecated_s3_flags(self):
+  def test_s3_options(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)
+        '--s3_api_version=2006-03-01',
+    ]).view_as(S3Options)
+    self.assertEqual(options.s3_access_key_id, 'key')
+    self.assertEqual(options.s3_region_name, 'us-west-2')
+    self.assertEqual(options.s3_api_version, '2006-03-01')
+    self.assertFalse(options.s3_disable_ssl)
 
-  def test_aws_flags_take_precedence_over_s3_flags(self):
+  def test_client_settings_without_service_options(self):
     options = PipelineOptions([
-        '--s3_region_name=us-west-2',
+        '--aws_access_key_id=key',
         '--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')
+        options.view_as(AwsOptions).get_client_settings(),
+        {
+            'access_key_id': 'key',
+            'secret_access_key': None,
+            'session_token': None,
+            'endpoint_url': None,
+            'region_name': 'us-east-1',
+            'verify': None,
+            'disable_ssl': False,
+        })
 
-  def test_s3_options_delegate_to_aws_options(self):
+  def test_s3_options_override_aws_defaults(self):
     options = PipelineOptions([
         '--aws_access_key_id=key',
+        '--aws_region_name=us-east-1',
+        '--aws_endpoint_url=https://vpce.example.com',
         '--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)
+        '--s3_endpoint_url=http://localhost:9000',
+        '--s3_disable_ssl',
+    ])
+    settings = options.view_as(AwsOptions).get_client_settings(
+        options.view_as(S3Options))
+    self.assertEqual(settings['region_name'], 'us-west-2')
+    self.assertEqual(settings['endpoint_url'], 'http://localhost:9000')
+    self.assertTrue(settings['disable_ssl'])
+    # Not overridden for S3, so the --aws_* default applies.
+    self.assertEqual(settings['access_key_id'], 'key')
+
+  def test_s3_options_from_kwargs(self):
+    options = PipelineOptions(s3_endpoint_url='http://localhost:4566')
+    settings = options.view_as(AwsOptions).get_client_settings(
+        options.view_as(S3Options))
+    self.assertEqual(settings['endpoint_url'], 'http://localhost:4566')
+
+  def test_other_services_can_override_aws_defaults(self):
+    # Any service options class can override the defaults by declaring a
+    # prefix and <prefix>_<setting> options, e.g. a future
+    # SecretsManagerOptions with --secretsmanager_endpoint_url.
+    class FakeServiceOptions(object):
+      AWS_CLIENT_SETTINGS_PREFIX = 'fake'
+      fake_endpoint_url = 'https://fake.example.com'
+      fake_region_name = None
+
+    aws_options = PipelineOptions([
+        '--aws_region_name=us-east-1',
+        '--aws_endpoint_url=https://default.example.com',
+    ]).view_as(AwsOptions)
+    settings = aws_options.get_client_settings(FakeServiceOptions())
+    self.assertEqual(settings['endpoint_url'], 'https://fake.example.com')
+    self.assertEqual(settings['region_name'], 'us-east-1')
+
+  def test_s3_api_version_is_s3_only(self):
+    options = PipelineOptions(['--s3_api_version=2006-03-01'])
+    self.assertNotIn('aws_api_version', options.get_all_options())
+    self.assertNotIn(
+        'api_version', options.view_as(AwsOptions).get_client_settings())
 
   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)
+    self.assertIn('s3_api_version', all_options)
 
 
 if __name__ == '__main__':

Reply via email to