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 d593d389e0b [Python] Support custom BigQuery endpoints and universe
domains (#40675)
d593d389e0b is described below
commit d593d389e0b57a43d79ca816c00982519cfe05ad
Author: Shunping Huang <[email protected]>
AuthorDate: Thu Oct 22 18:40:58 2026 -0400
[Python] Support custom BigQuery endpoints and universe domains (#40675)
---
sdks/python/apache_beam/io/gcp/bigquery_tools.py | 97 ++++++++++++++--
.../apache_beam/io/gcp/bigquery_tools_test.py | 128 +++++++++++++++++++++
.../python/apache_beam/options/pipeline_options.py | 33 ++++++
.../apache_beam/options/pipeline_options_test.py | 67 +++++++++++
4 files changed, 317 insertions(+), 8 deletions(-)
diff --git a/sdks/python/apache_beam/io/gcp/bigquery_tools.py
b/sdks/python/apache_beam/io/gcp/bigquery_tools.py
index d1412d0f855..ea3aa1bbbd4 100644
--- a/sdks/python/apache_beam/io/gcp/bigquery_tools.py
+++ b/sdks/python/apache_beam/io/gcp/bigquery_tools.py
@@ -33,6 +33,7 @@ import decimal
import io
import json
import logging
+import os
import re
import sys
import time
@@ -60,6 +61,7 @@ from apache_beam.io.gcp.internal.clients import bigquery
from apache_beam.metrics import monitoring_infos
from apache_beam.metrics.metric import Metrics
from apache_beam.options import value_provider
+from apache_beam.options.pipeline_options import BigQueryOptions
from apache_beam.options.pipeline_options import GoogleCloudOptions
from apache_beam.options.pipeline_options import PipelineOptions
from apache_beam.transforms import DoFn
@@ -123,6 +125,77 @@ _PROJECT_NAME_SEGMENT_PATTERN = r'[-a-z0-9]*[a-z0-9]'
# For CI: temp dataset of this name are automatically deleted
_TEMP_DATASET_PREFIX = 'beam_temp_dataset_'
+_DEFAULT_UNIVERSE_DOMAIN = 'googleapis.com'
+_UNIVERSE_DOMAIN_ENV = 'GOOGLE_CLOUD_UNIVERSE_DOMAIN'
+_BIGQUERY_SERVICE_PATH = '/bigquery/v2'
+
+
+def _get_bigquery_endpoint(
+ pipeline_options: Optional[PipelineOptions] = None) -> Optional[str]:
+ """Returns the BigQuery root URL set via ``--bigquery_endpoint``, or None.
+
+ The given pipeline options are checked first. If they do not set the
+ endpoint, the options of the running pipeline are checked; these are
+ available on workers, where BigQuery clients are often created without
+ access to the pipeline options.
+
+ The returned URL always has a scheme and never has a trailing slash or the
+ ``/bigquery/v2`` service path.
+ """
+ endpoint = None
+ if pipeline_options is not None:
+ endpoint = pipeline_options.view_as(BigQueryOptions).big_query_endpoint
+ if not endpoint:
+ endpoint = value_provider.RuntimeValueProvider.get_value(
+ 'big_query_endpoint', str, None)
+ if not endpoint:
+ return None
+ endpoint = endpoint.strip().rstrip('/')
+ if endpoint.endswith(_BIGQUERY_SERVICE_PATH):
+ endpoint = endpoint[:-len(_BIGQUERY_SERVICE_PATH)]
+ if '://' not in endpoint:
+ endpoint = 'https://' + endpoint
+ return endpoint
+
+
+def get_bigquery_api_url(
+ pipeline_options: Optional[PipelineOptions] = None) -> str:
+ """Returns the base URL of the BigQuery v2 REST API.
+
+ The URL is resolved in the following order:
+
+ 1. ``--bigquery_endpoint``, if set.
+ 2. ``https://bigquery.<domain>``, if the ``GOOGLE_CLOUD_UNIVERSE_DOMAIN``
+ environment variable is set to a non-default universe domain. This
+ matches how the google-cloud-bigquery client library resolves its
+ endpoint.
+ 3. The default public URL.
+ """
+ endpoint = _get_bigquery_endpoint(pipeline_options)
+ if endpoint is None:
+ universe_domain = (
+ os.environ.get(_UNIVERSE_DOMAIN_ENV, '').strip() or
+ _DEFAULT_UNIVERSE_DOMAIN)
+ if universe_domain == _DEFAULT_UNIVERSE_DOMAIN:
+ return bigquery.BigqueryV2.BASE_URL
+ endpoint = 'https://bigquery.%s' % universe_domain
+ return endpoint + _BIGQUERY_SERVICE_PATH + '/'
+
+
+def get_bigquery_client_options(
+ pipeline_options: Optional[PipelineOptions] = None) -> Optional[dict]:
+ """Returns ``client_options`` for a ``google.cloud.bigquery.Client``.
+
+ Returns None when ``--bigquery_endpoint`` is not set, so that the client
+ library resolves the endpoint itself (it honors
+ ``GOOGLE_CLOUD_UNIVERSE_DOMAIN``).
+ """
+ endpoint = _get_bigquery_endpoint(pipeline_options)
+ if endpoint is None:
+ return None
+ return {'api_endpoint': endpoint}
+
+
# TODO(https://github.com/apache/beam/issues/25946): Add support for
# more Beam portable schema types as Python types
BIGQUERY_TYPE_TO_PYTHON_TYPE = {
@@ -510,6 +583,7 @@ class BigQueryWrapper(object):
pipeline_options=None):
self.quota_project_id = (
quota_project_id or _quota_project_id_from_options(pipeline_options))
+ self._pipeline_options = pipeline_options
self.client = client or BigQueryWrapper._bigquery_client(
pipeline_options or PipelineOptions(),
quota_project_id=self.quota_project_id)
@@ -556,7 +630,8 @@ class BigQueryWrapper(object):
"""
if self._gcp_bq_client is None:
self._gcp_bq_client = BigQueryWrapper._gcp_bigquery_client(
- quota_project_id=self.quota_project_id)
+ quota_project_id=self.quota_project_id,
+ pipeline_options=self._pipeline_options)
return self._gcp_bq_client
@property
@@ -1597,6 +1672,7 @@ class BigQueryWrapper(object):
# Use explicit quota_project_id if provided, otherwise get from options
quota_project_id = quota_project_id or _quota_project_id_from_options(
pipeline_options)
+ url = get_bigquery_api_url(pipeline_options)
http = get_new_http()
if quota_project_id:
if credentials is None:
@@ -1612,11 +1688,12 @@ class BigQueryWrapper(object):
http = _HttpWithHeaders(
http, {'x-goog-user-project': quota_project_id},
hosts=[
- urllib.parse.urlsplit(url).hostname for url in (
- bigquery.BigqueryV2.BASE_URL,
+ urllib.parse.urlsplit(u).hostname for u in (
+ url, bigquery.BigqueryV2.BASE_URL,
bigquery.BigqueryV2.MTLS_BASE_URL)
])
return bigquery.BigqueryV2(
+ url=url,
http=http,
credentials=credentials,
response_encoding='utf8',
@@ -1625,17 +1702,21 @@ class BigQueryWrapper(object):
})
@staticmethod
- def _gcp_bigquery_client(quota_project_id: str = None):
+ def _gcp_bigquery_client(
+ quota_project_id: str = None,
+ pipeline_options: Optional[PipelineOptions] = None):
"""Create a google-cloud-bigquery Client with optional quota project.
The client applies the quota project to the credentials it resolves
- itself, so no credentials are derived here.
+ itself, so no credentials are derived here. If ``--bigquery_endpoint`` is
+ set, it is used as the API endpoint; otherwise the client library resolves
+ the endpoint itself, honoring ``GOOGLE_CLOUD_UNIVERSE_DOMAIN``.
"""
- client_options = None
+ client_options = get_bigquery_client_options(pipeline_options) or {}
if quota_project_id:
- client_options = {'quota_project_id': quota_project_id}
+ client_options['quota_project_id'] = quota_project_id
return gcp_bigquery.Client(
- client_options=client_options,
+ client_options=client_options or None,
client_info=ClientInfo(
user_agent="apache-beam-%s" % apache_beam.__version__))
diff --git a/sdks/python/apache_beam/io/gcp/bigquery_tools_test.py
b/sdks/python/apache_beam/io/gcp/bigquery_tools_test.py
index 6df9f6c5c14..be5441c263b 100644
--- a/sdks/python/apache_beam/io/gcp/bigquery_tools_test.py
+++ b/sdks/python/apache_beam/io/gcp/bigquery_tools_test.py
@@ -53,6 +53,9 @@ from apache_beam.io.gcp.bigquery_tools import
parse_table_schema_from_json
from apache_beam.io.gcp.internal.clients import bigquery
from apache_beam.metrics import monitoring_infos
from apache_beam.metrics.execution import MetricsEnvironment
+from apache_beam.options.pipeline_options import BigQueryOptions
+from apache_beam.options.pipeline_options import PipelineOptions
+from apache_beam.options.value_provider import RuntimeValueProvider
from apache_beam.options.value_provider import StaticValueProvider
from apache_beam.typehints.row_type import RowTypeConstraint
from apache_beam.utils.timestamp import Timestamp
@@ -241,6 +244,131 @@ class TestTableReferenceParser(unittest.TestCase):
self.assertEqual(parsed_ref.tableId, tableId)
[email protected](HttpError is None, 'GCP dependencies are not installed')
+class TestBigQueryEndpoint(unittest.TestCase):
+ def setUp(self):
+ self._saved_runtime_options = RuntimeValueProvider.runtime_options
+ self._saved_experiments = RuntimeValueProvider.experiments
+ RuntimeValueProvider.runtime_options = None
+ env_patcher = mock.patch.dict(os.environ)
+ env_patcher.start()
+ self.addCleanup(env_patcher.stop)
+ os.environ.pop('GOOGLE_CLOUD_UNIVERSE_DOMAIN', None)
+
+ def tearDown(self):
+ RuntimeValueProvider.runtime_options = self._saved_runtime_options
+ RuntimeValueProvider.experiments = self._saved_experiments
+
+ def test_default_endpoint(self):
+ options = PipelineOptions([])
+ self.assertIsNone(options.view_as(BigQueryOptions).bigquery_endpoint)
+ self.assertEqual(
+ bigquery_tools.get_bigquery_api_url(options),
+ 'https://bigquery.googleapis.com/bigquery/v2/')
+ self.assertIsNone(bigquery_tools.get_bigquery_client_options(options))
+
+ def test_default_universe_domain_uses_default_endpoint(self):
+ os.environ['GOOGLE_CLOUD_UNIVERSE_DOMAIN'] = 'googleapis.com'
+ self.assertEqual(
+ bigquery_tools.get_bigquery_api_url(PipelineOptions([])),
+ 'https://bigquery.googleapis.com/bigquery/v2/')
+
+ def test_universe_domain_from_env(self):
+ os.environ['GOOGLE_CLOUD_UNIVERSE_DOMAIN'] = 'example-tpc.goog'
+ options = PipelineOptions([])
+ self.assertEqual(
+ bigquery_tools.get_bigquery_api_url(options),
+ 'https://bigquery.example-tpc.goog/bigquery/v2/')
+ # google-cloud-bigquery resolves the universe domain by itself.
+ self.assertIsNone(bigquery_tools.get_bigquery_client_options(options))
+
+ @parameterized.expand([
+ ('https://bq.example.com', ),
+ ('https://bq.example.com/', ),
+ ('https://bq.example.com/bigquery/v2', ),
+ ('https://bq.example.com/bigquery/v2/', ),
+ ('bq.example.com', ),
+ ])
+ def test_explicit_endpoint(self, endpoint):
+ options = PipelineOptions(['--bigquery_endpoint', endpoint])
+ self.assertEqual(
+ options.view_as(BigQueryOptions).bigquery_endpoint, endpoint)
+ self.assertEqual(
+ bigquery_tools.get_bigquery_api_url(options),
+ 'https://bq.example.com/bigquery/v2/')
+ self.assertEqual(
+ bigquery_tools.get_bigquery_client_options(options),
+ {'api_endpoint': 'https://bq.example.com'})
+
+ def test_explicit_endpoint_takes_precedence_over_universe_domain(self):
+ os.environ['GOOGLE_CLOUD_UNIVERSE_DOMAIN'] = 'example-tpc.goog'
+ options = PipelineOptions(['--bigquery_endpoint=http://localhost:9050'])
+ self.assertEqual(
+ bigquery_tools.get_bigquery_api_url(options),
+ 'http://localhost:9050/bigquery/v2/')
+ self.assertEqual(
+ bigquery_tools.get_bigquery_client_options(options),
+ {'api_endpoint': 'http://localhost:9050'})
+
+ def test_endpoint_from_runtime_options(self):
+ # On workers, clients may be created without the pipeline options.
+ RuntimeValueProvider.set_runtime_options(
+ {'big_query_endpoint': 'https://bq.example.com'})
+ self.assertEqual(
+ bigquery_tools.get_bigquery_api_url(PipelineOptions([])),
+ 'https://bq.example.com/bigquery/v2/')
+ self.assertEqual(
+ bigquery_tools.get_bigquery_client_options(None),
+ {'api_endpoint': 'https://bq.example.com'})
+
+ def test_endpoint_attribute_alias_setter(self):
+ bq_options = PipelineOptions([]).view_as(BigQueryOptions)
+ bq_options.bigquery_endpoint = 'https://bq.example.com'
+ self.assertEqual(
+ bigquery_tools.get_bigquery_api_url(bq_options),
+ 'https://bq.example.com/bigquery/v2/')
+
+ @mock.patch.object(bigquery_tools.auth, 'get_service_credentials')
+ def test_bigquery_client_uses_endpoint(self, unused_mock_credentials):
+ options = PipelineOptions(['--bigquery_endpoint=https://bq.example.com'])
+ client = bigquery_tools.BigQueryWrapper._bigquery_client(options)
+ self.assertEqual(client.url, 'https://bq.example.com/bigquery/v2/')
+
+ @mock.patch.object(bigquery_tools.auth, 'get_service_credentials')
+ @mock.patch.object(bigquery_tools.gcp_bigquery, 'Client')
+ def test_wrapper_applies_endpoint_to_both_clients(
+ self, mock_gcp_client, unused_mock_credentials):
+ options = PipelineOptions(['--bigquery_endpoint=https://bq.example.com'])
+ wrapper = bigquery_tools.BigQueryWrapper(pipeline_options=options)
+ self.assertEqual(wrapper.client.url, 'https://bq.example.com/bigquery/v2/')
+ # The google-cloud-bigquery client is created on first use.
+ wrapper.gcp_bq_client # pylint: disable=pointless-statement
+ self.assertEqual(
+ mock_gcp_client.call_args.kwargs['client_options'],
+ {'api_endpoint': 'https://bq.example.com'})
+
+ @mock.patch.object(bigquery_tools.gcp_bigquery, 'Client')
+ def test_gcp_client_merges_endpoint_and_quota_project(self, mock_gcp_client):
+ options = PipelineOptions(['--bigquery_endpoint=https://bq.example.com'])
+ bigquery_tools.BigQueryWrapper._gcp_bigquery_client(
+ quota_project_id='billing', pipeline_options=options)
+ self.assertEqual(
+ mock_gcp_client.call_args.kwargs['client_options'], {
+ 'api_endpoint': 'https://bq.example.com',
+ 'quota_project_id': 'billing'
+ })
+
+ @mock.patch.object(bigquery_tools.auth, 'get_service_credentials')
+ def test_quota_project_header_sent_to_custom_endpoint(self,
mock_credentials):
+ options = PipelineOptions(['--bigquery_endpoint=https://bq.example.com'])
+ bigquery_tools.BigQueryWrapper._bigquery_client(
+ options, quota_project_id='billing')
+ # apitools wraps the given http with credentials.authorize().
+ http = mock_credentials.return_value.authorize.call_args.args[0]
+ self.assertIsInstance(http, bigquery_tools._HttpWithHeaders)
+ self.assertIn('bq.example.com', http._hosts)
+
+
@unittest.skipIf(HttpError is None, 'GCP dependencies are not installed')
class TestBigQueryWrapper(unittest.TestCase):
def test_delete_non_existing_dataset(self):
diff --git a/sdks/python/apache_beam/options/pipeline_options.py
b/sdks/python/apache_beam/options/pipeline_options.py
index da3aae4e6a1..5b73b22476b 100644
--- a/sdks/python/apache_beam/options/pipeline_options.py
+++ b/sdks/python/apache_beam/options/pipeline_options.py
@@ -1388,6 +1388,39 @@ class GoogleCloudOptions(PipelineOptions):
return None
+class BigQueryOptions(PipelineOptions):
+ """BigQuery-specific options, mirroring Java's ``BigQueryOptions``.
+
+ Option names (``dest``) are chosen so that they map to the Java option names
+ when pipeline options are passed to Java, e.g. to cross-language BigQuery
+ transforms: Java converts ``big_query_endpoint`` to ``bigQueryEndpoint``.
+ """
+
+ # Alternative attribute names, mapped to the option names they stand for.
+ _ATTRIBUTE_ALIASES = {'bigquery_endpoint': 'big_query_endpoint'}
+
+ @classmethod
+ def _add_argparse_args(cls, parser):
+ parser.add_argument(
+ '--bigquery_endpoint',
+ '--big_query_endpoint',
+ dest='big_query_endpoint',
+ default=None,
+ help=(
+ 'The root URL for the BigQuery API, e.g. '
+ '"https://bigquery.googleapis.com". If not set, the endpoint is '
+ 'derived from the GOOGLE_CLOUD_UNIVERSE_DOMAIN environment '
+ 'variable when present, and otherwise the default public URL is '
+ 'used. Also passed to Java cross-language BigQuery transforms as '
+ 'bigQueryEndpoint.'))
+
+ def __getattr__(self, name):
+ return super().__getattr__(self._ATTRIBUTE_ALIASES.get(name, name))
+
+ def __setattr__(self, name, value):
+ super().__setattr__(self._ATTRIBUTE_ALIASES.get(name, name), value)
+
+
class AzureOptions(PipelineOptions):
"""Azure Blob Storage options."""
@classmethod
diff --git a/sdks/python/apache_beam/options/pipeline_options_test.py
b/sdks/python/apache_beam/options/pipeline_options_test.py
index 2814995b067..94083a55c6f 100644
--- a/sdks/python/apache_beam/options/pipeline_options_test.py
+++ b/sdks/python/apache_beam/options/pipeline_options_test.py
@@ -29,6 +29,7 @@ import mock
from parameterized import parameterized
from apache_beam.options.pipeline_options import AwsOptions
+from apache_beam.options.pipeline_options import BigQueryOptions
from apache_beam.options.pipeline_options import CrossLanguageOptions
from apache_beam.options.pipeline_options import DebugOptions
from apache_beam.options.pipeline_options import GoogleCloudOptions
@@ -1269,6 +1270,72 @@ class AwsOptionsTest(unittest.TestCase):
self.assertIn('s3_api_version', all_options)
+def _java_option_name(python_option_name):
+ """Converts a Python option name the way Java's PipelineOptionsTranslation
+ does (Guava CaseFormat.LOWER_UNDERSCORE to LOWER_CAMEL)."""
+ first, *rest = python_option_name.lower().split('_')
+ return first + ''.join(part.capitalize() for part in rest)
+
+
+class BigQueryOptionsTest(unittest.TestCase):
+ # Python option name (dest) -> property name in Java's
+ # org.apache.beam.sdk.io.gcp.bigquery.BigQueryOptions. Options are passed to
+ # Java (e.g. cross-language BigQuery transforms) as
+ # beam:option:<dest>:v1, and Java derives the property name from <dest>.
+ JAVA_OPTION_NAMES = {
+ 'big_query_endpoint': 'bigQueryEndpoint',
+ }
+
+ def test_option_names_map_to_java_option_names(self):
+ parser = _BeamArgumentParser()
+ BigQueryOptions._add_argparse_args(parser)
+ dests = {action.dest for action in parser._actions if action.dest !=
'help'}
+ # A new option must be added to JAVA_OPTION_NAMES, so that its name is
+ # checked against Java.
+ self.assertEqual(dests, set(self.JAVA_OPTION_NAMES))
+ for dest, java_name in self.JAVA_OPTION_NAMES.items():
+ self.assertEqual(_java_option_name(dest), java_name)
+
+ def test_options_serialized_with_java_compatible_names(self):
+ options = PipelineOptions(['--bigquery_endpoint=https://bq.example.com'])
+ fields = options.to_runner_api().fields
+ self.assertEqual(
+ fields['beam:option:big_query_endpoint:v1'].string_value,
+ 'https://bq.example.com')
+ self.assertNotIn('beam:option:bigquery_endpoint:v1', fields)
+
+ @parameterized.expand([
+ ('--bigquery_endpoint', ),
+ ('--big_query_endpoint', ),
+ ])
+ def test_endpoint_flags(self, flag):
+ options = PipelineOptions([flag, 'https://bq.example.com'])
+ self.assertEqual(
+ options.get_all_options()['big_query_endpoint'],
+ 'https://bq.example.com')
+ bq_options = options.view_as(BigQueryOptions)
+ self.assertEqual(bq_options.big_query_endpoint, 'https://bq.example.com')
+ self.assertEqual(bq_options.bigquery_endpoint, 'https://bq.example.com')
+
+ def test_endpoint_default(self):
+ bq_options = PipelineOptions([]).view_as(BigQueryOptions)
+ self.assertIsNone(bq_options.big_query_endpoint)
+ self.assertIsNone(bq_options.bigquery_endpoint)
+
+ def test_endpoint_attribute_alias_setter(self):
+ options = PipelineOptions([])
+ options.view_as(
+ BigQueryOptions).bigquery_endpoint = 'https://bq.example.com'
+ self.assertEqual(
+ options.view_as(BigQueryOptions).big_query_endpoint,
+ 'https://bq.example.com')
+ self.assertNotIn('bigquery_endpoint', options.get_all_options())
+
+ def test_unknown_attribute_still_raises(self):
+ with self.assertRaises(AttributeError):
+ getattr(PipelineOptions([]).view_as(BigQueryOptions), 'not_an_option')
+
+
if __name__ == '__main__':
logging.getLogger().setLevel(logging.INFO)
unittest.main()