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 d746d747ef3 [Python] Fix GCS client creation in non-default universes
(#40674)
d746d747ef3 is described below
commit d746d747ef3fe88c1591d74bc8e0659fdc107368
Author: Shunping Huang <[email protected]>
AuthorDate: Tue Oct 20 09:36:24 2026 -0400
[Python] Fix GCS client creation in non-default universes (#40674)
---
sdks/python/apache_beam/io/gcp/gcsio.py | 21 ++++++++-
sdks/python/apache_beam/io/gcp/gcsio_test.py | 46 +++++++++++++++++++
.../python/apache_beam/options/pipeline_options.py | 8 ++--
.../apache_beam/options/pipeline_options_test.py | 52 ++++++++++++++++++++++
4 files changed, 123 insertions(+), 4 deletions(-)
diff --git a/sdks/python/apache_beam/io/gcp/gcsio.py
b/sdks/python/apache_beam/io/gcp/gcsio.py
index a673845811d..2bdc4ca7d7e 100644
--- a/sdks/python/apache_beam/io/gcp/gcsio.py
+++ b/sdks/python/apache_beam/io/gcp/gcsio.py
@@ -30,6 +30,7 @@ https://github.com/apache/beam/blob/master/sdks/python/OWNERS
# pytype: skip-file
import logging
+import os
import re
import time
from typing import Optional
@@ -69,6 +70,8 @@ _LOGGER = logging.getLogger(__name__)
DEFAULT_READ_BUFFER_SIZE = 16 * 1024 * 1024
DEFAULT_WRITE_BUFFER_SIZE = 16 * 1024 * 1024
+_DEFAULT_UNIVERSE_DOMAIN = 'googleapis.com'
+
# Writes are performed as resumable uploads, which require the chunk size to be
# a multiple of 256 KiB.
WRITE_BUFFER_SIZE_MULTIPLE = 256 * 1024
@@ -205,10 +208,26 @@ def create_storage_client(pipeline_options,
use_credentials=True):
if google_cloud_options.gcs_custom_audit_entries is not None:
extra_headers.update(google_cloud_options.gcs_custom_audit_entries)
+ google_auth_credentials = credentials.get_google_auth_credentials()
+
+ # storage.Client only reads the universe domain from client_options; it
+ # ignores GOOGLE_CLOUD_UNIVERSE_DOMAIN and the credentials' universe
+ # domain, and fails if the latter differs from its default
+ # (googleapis.com). Set it explicitly to support non-default universes
+ # such as Trusted Partner Cloud (TPC).
+ universe_domain = (
+ os.environ.get('GOOGLE_CLOUD_UNIVERSE_DOMAIN') or
+ getattr(google_auth_credentials, 'universe_domain', None))
+ client_options = None
+ if (isinstance(universe_domain, str) and
+ universe_domain != _DEFAULT_UNIVERSE_DOMAIN):
+ client_options = {'universe_domain': universe_domain}
+
return storage.Client(
- credentials=credentials.get_google_auth_credentials(),
+ credentials=google_auth_credentials,
project=google_cloud_options.project,
client_info=beam_client_info,
+ client_options=client_options,
extra_headers=extra_headers)
else:
return storage.Client.create_anonymous_client()
diff --git a/sdks/python/apache_beam/io/gcp/gcsio_test.py
b/sdks/python/apache_beam/io/gcp/gcsio_test.py
index ddb94f9c17f..30e330e3afb 100644
--- a/sdks/python/apache_beam/io/gcp/gcsio_test.py
+++ b/sdks/python/apache_beam/io/gcp/gcsio_test.py
@@ -959,6 +959,52 @@ class TestGCSIO(unittest.TestCase):
self.gcs.is_soft_delete_enabled("gs://beam_without_soft_delete/tmp"))
+_TPC_UNIVERSE_DOMAIN = "example-tpc.goog"
+
+
[email protected](NotFound is None, 'GCP dependencies are not installed')
+# Disable mTLS auto-detection, which is incompatible with non-default
+# universe domains and may be enabled on developer machines.
[email protected](os.environ, {"GOOGLE_API_USE_CLIENT_CERTIFICATE": "false"})
+class TestCreateStorageClientUniverseDomain(unittest.TestCase):
+ """Tests for Trusted Partner Cloud (TPC) universe domain support."""
+ def _create_client(self, mock_get_service_credentials, universe_domain):
+ from apache_beam.internal.gcp.auth import _ApitoolsCredentialsAdapter
+ mock_get_service_credentials.return_value = _ApitoolsCredentialsAdapter(
+ _make_credentials("test-project", universe_domain=universe_domain))
+ return gcsio.create_storage_client(
+ PipelineOptions(["--project=test-project"]))
+
+ @mock.patch('apache_beam.internal.gcp.auth.get_service_credentials')
+ def test_default_universe_domain(self, mock_get_service_credentials):
+ os.environ.pop("GOOGLE_CLOUD_UNIVERSE_DOMAIN", None)
+ client = self._create_client(
+ mock_get_service_credentials, _DEFAULT_UNIVERSE_DOMAIN)
+ self.assertEqual(client.universe_domain, _DEFAULT_UNIVERSE_DOMAIN)
+ self.assertEqual(
+ client._connection.API_BASE_URL, "https://storage.googleapis.com")
+
+ @mock.patch('apache_beam.internal.gcp.auth.get_service_credentials')
+ def test_universe_domain_from_env_var(self, mock_get_service_credentials):
+ os.environ["GOOGLE_CLOUD_UNIVERSE_DOMAIN"] = _TPC_UNIVERSE_DOMAIN
+ client = self._create_client(
+ mock_get_service_credentials, _TPC_UNIVERSE_DOMAIN)
+ self.assertEqual(client.universe_domain, _TPC_UNIVERSE_DOMAIN)
+ self.assertEqual(
+ client._connection.API_BASE_URL,
+ "https://storage.%s" % _TPC_UNIVERSE_DOMAIN)
+
+ @mock.patch('apache_beam.internal.gcp.auth.get_service_credentials')
+ def test_universe_domain_from_credentials(self,
mock_get_service_credentials):
+ os.environ.pop("GOOGLE_CLOUD_UNIVERSE_DOMAIN", None)
+ client = self._create_client(
+ mock_get_service_credentials, _TPC_UNIVERSE_DOMAIN)
+ self.assertEqual(client.universe_domain, _TPC_UNIVERSE_DOMAIN)
+ self.assertEqual(
+ client._connection.API_BASE_URL,
+ "https://storage.%s" % _TPC_UNIVERSE_DOMAIN)
+
+
if __name__ == '__main__':
logging.getLogger().setLevel(logging.INFO)
unittest.main()
diff --git a/sdks/python/apache_beam/options/pipeline_options.py
b/sdks/python/apache_beam/options/pipeline_options.py
index f0406e2505b..dca07da5146 100644
--- a/sdks/python/apache_beam/options/pipeline_options.py
+++ b/sdks/python/apache_beam/options/pipeline_options.py
@@ -1275,7 +1275,7 @@ class GoogleCloudOptions(PipelineOptions):
'Unable to check soft delete policy because GCP dependencies are '
'not installed.')
return
- if gcsio.GcsIO().is_soft_delete_enabled(gcs_path):
+ if gcsio.GcsIO(pipeline_options=self).is_soft_delete_enabled(gcs_path):
logger.log_first_n(
logging.WARN,
"Bucket %s used as %s has soft-delete policy enabled."
@@ -1289,8 +1289,10 @@ class GoogleCloudOptions(PipelineOptions):
arg_name,
n=1,
key="message")
- except ImportError:
- _LOGGER.warning('Unable to check soft delete policy due to import
error.')
+ except Exception as e: # pylint: disable=broad-except
+ # This check only emits a warning, so it must not block pipeline
+ # submission.
+ _LOGGER.warning('Unable to check soft delete policy: %s', e)
# If either temp or staging location has an issue, we use the valid one for
# both locations. If both are bad we return an error.
diff --git a/sdks/python/apache_beam/options/pipeline_options_test.py
b/sdks/python/apache_beam/options/pipeline_options_test.py
index 493e42d5301..2814995b067 100644
--- a/sdks/python/apache_beam/options/pipeline_options_test.py
+++ b/sdks/python/apache_beam/options/pipeline_options_test.py
@@ -930,6 +930,58 @@ class PipelineOptionsTest(unittest.TestCase):
errors = options._handle_temp_and_staging_locations(validator)
self.assertEqual(errors, expected)
+ @unittest.skipIf(not has_gcsio, 'GCP dependencies are not installed')
+ @mock.patch.dict(
+ os.environ,
+ {
+ "GOOGLE_CLOUD_UNIVERSE_DOMAIN": "example-tpc.goog",
+ # mTLS auto-detection is incompatible with non-default universes.
+ "GOOGLE_API_USE_CLIENT_CERTIFICATE": "false",
+ })
+ @mock.patch('apache_beam.io.gcp.gcsio.GcsIO.get_bucket')
+ @mock.patch('apache_beam.internal.gcp.auth.get_service_credentials')
+ def test_soft_delete_check_with_tpc_universe_domain(
+ self, mock_get_service_credentials, mock_get_bucket):
+ import google.auth.credentials
+
+ from apache_beam.internal.gcp.auth import _ApitoolsCredentialsAdapter
+ mock_get_service_credentials.return_value = _ApitoolsCredentialsAdapter(
+ mock.Mock(
+ spec=google.auth.credentials.Credentials,
+ universe_domain="example-tpc.goog"))
+ soft_delete_policy = mock_get_bucket.return_value.soft_delete_policy
+ soft_delete_policy.retention_duration_seconds = 0
+
+ options = GoogleCloudOptions([
+ '--project=myproject',
+ '--impersonate_service_account=s...@myproject.iam.gserviceaccount.com',
+ '--temp_location=gs://beam/tmp'
+ ])
+ # Must not raise a universe domain mismatch ValueError.
+ options._warn_if_soft_delete_policy_enabled('temp_location')
+
+ # The GCS client must be built from the user's pipeline options so that
+ # project and impersonation settings are honored.
+ used_options = mock_get_service_credentials.call_args[0][0].view_as(
+ GoogleCloudOptions)
+ self.assertEqual(used_options.project, 'myproject')
+ self.assertEqual(
+ used_options.impersonate_service_account,
+ '[email protected]')
+
+ @unittest.skipIf(not has_gcsio, 'GCP dependencies are not installed')
+ @mock.patch(
+ 'apache_beam.io.gcp.gcsio.GcsIO.__init__',
+ side_effect=ValueError('universe domain mismatch'))
+ def test_soft_delete_check_does_not_raise(self, unused_mock_init):
+ options = GoogleCloudOptions(
+ ['--project=myproject', '--temp_location=gs://beam/tmp'])
+ # The soft delete check only emits a warning; failures must not block
+ # pipeline submission.
+ with self.assertLogs('apache_beam.options.pipeline_options',
+ level='WARNING'):
+ options._warn_if_soft_delete_policy_enabled('temp_location')
+
def test_validation_good_stg_good_temp(self):
runner = MockRunners.DataflowRunner()
options = GoogleCloudOptions([