Skip to content

Commit b315593

Browse files
authored
feat(spanner): add emulator support (#14)
* emulator support implementation * facilitate running system test against an emulator * add tests * formatting * remove brittle error string checks * add skips for tests when emulator support is used * fix lint errors
1 parent 250f19e commit b315593

File tree

7 files changed

+202
-33
lines changed

7 files changed

+202
-33
lines changed

google/cloud/spanner_v1/client.py

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,21 @@
2323
* a :class:`~google.cloud.spanner_v1.instance.Instance` owns a
2424
:class:`~google.cloud.spanner_v1.database.Database`
2525
"""
26+
import grpc
27+
import os
2628
import warnings
2729

2830
from google.api_core.gapic_v1 import client_info
2931
import google.api_core.client_options
3032

33+
from google.cloud.spanner_admin_instance_v1.gapic.transports import (
34+
instance_admin_grpc_transport,
35+
)
36+
37+
from google.cloud.spanner_admin_database_v1.gapic.transports import (
38+
database_admin_grpc_transport,
39+
)
40+
3141
# pylint: disable=line-too-long
3242
from google.cloud.spanner_admin_database_v1.gapic.database_admin_client import ( # noqa
3343
DatabaseAdminClient,
@@ -45,13 +55,23 @@
4555
from google.cloud.spanner_v1.instance import Instance
4656

4757
_CLIENT_INFO = client_info.ClientInfo(client_library_version=__version__)
58+
EMULATOR_ENV_VAR = "SPANNER_EMULATOR_HOST"
59+
_EMULATOR_HOST_HTTP_SCHEME = (
60+
"%s contains a http scheme. When used with a scheme it may cause gRPC's "
61+
"DNS resolver to endlessly attempt to resolve. %s is intended to be used "
62+
"without a scheme: ex %s=localhost:8080."
63+
) % ((EMULATOR_ENV_VAR,) * 3)
4864
SPANNER_ADMIN_SCOPE = "https://www.googleapis.com/auth/spanner.admin"
4965
_USER_AGENT_DEPRECATED = (
5066
"The 'user_agent' argument to 'Client' is deprecated / unused. "
5167
"Please pass an appropriate 'client_info' instead."
5268
)
5369

5470

71+
def _get_spanner_emulator_host():
72+
return os.getenv(EMULATOR_ENV_VAR)
73+
74+
5575
class InstanceConfig(object):
5676
"""Named configurations for Spanner instances.
5777
@@ -156,6 +176,12 @@ def __init__(
156176
warnings.warn(_USER_AGENT_DEPRECATED, DeprecationWarning, stacklevel=2)
157177
self.user_agent = user_agent
158178

179+
if _get_spanner_emulator_host() is not None and (
180+
"http://" in _get_spanner_emulator_host()
181+
or "https://" in _get_spanner_emulator_host()
182+
):
183+
warnings.warn(_EMULATOR_HOST_HTTP_SCHEME)
184+
159185
@property
160186
def credentials(self):
161187
"""Getter for client's credentials.
@@ -189,22 +215,42 @@ def project_name(self):
189215
def instance_admin_api(self):
190216
"""Helper for session-related API calls."""
191217
if self._instance_admin_api is None:
192-
self._instance_admin_api = InstanceAdminClient(
193-
credentials=self.credentials,
194-
client_info=self._client_info,
195-
client_options=self._client_options,
196-
)
218+
if _get_spanner_emulator_host() is not None:
219+
transport = instance_admin_grpc_transport.InstanceAdminGrpcTransport(
220+
channel=grpc.insecure_channel(_get_spanner_emulator_host())
221+
)
222+
self._instance_admin_api = InstanceAdminClient(
223+
client_info=self._client_info,
224+
client_options=self._client_options,
225+
transport=transport,
226+
)
227+
else:
228+
self._instance_admin_api = InstanceAdminClient(
229+
credentials=self.credentials,
230+
client_info=self._client_info,
231+
client_options=self._client_options,
232+
)
197233
return self._instance_admin_api
198234

199235
@property
200236
def database_admin_api(self):
201237
"""Helper for session-related API calls."""
202238
if self._database_admin_api is None:
203-
self._database_admin_api = DatabaseAdminClient(
204-
credentials=self.credentials,
205-
client_info=self._client_info,
206-
client_options=self._client_options,
207-
)
239+
if _get_spanner_emulator_host() is not None:
240+
transport = database_admin_grpc_transport.DatabaseAdminGrpcTransport(
241+
channel=grpc.insecure_channel(_get_spanner_emulator_host())
242+
)
243+
self._database_admin_api = DatabaseAdminClient(
244+
client_info=self._client_info,
245+
client_options=self._client_options,
246+
transport=transport,
247+
)
248+
else:
249+
self._database_admin_api = DatabaseAdminClient(
250+
credentials=self.credentials,
251+
client_info=self._client_info,
252+
client_options=self._client_options,
253+
)
208254
return self._database_admin_api
209255

210256
def copy(self):
@@ -288,7 +334,14 @@ def instance(
288334
:rtype: :class:`~google.cloud.spanner_v1.instance.Instance`
289335
:returns: an instance owned by this client.
290336
"""
291-
return Instance(instance_id, self, configuration_name, node_count, display_name)
337+
return Instance(
338+
instance_id,
339+
self,
340+
configuration_name,
341+
node_count,
342+
display_name,
343+
_get_spanner_emulator_host(),
344+
)
292345

293346
def list_instances(self, filter_="", page_size=None, page_token=None):
294347
"""List instances for the client's project.

google/cloud/spanner_v1/database.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import copy
1818
import functools
19+
import grpc
1920
import os
2021
import re
2122
import threading
@@ -33,6 +34,7 @@
3334
from google.cloud.spanner_v1._helpers import _metadata_with_prefix
3435
from google.cloud.spanner_v1.batch import Batch
3536
from google.cloud.spanner_v1.gapic.spanner_client import SpannerClient
37+
from google.cloud.spanner_v1.gapic.transports import spanner_grpc_transport
3638
from google.cloud.spanner_v1.keyset import KeySet
3739
from google.cloud.spanner_v1.pool import BurstyPool
3840
from google.cloud.spanner_v1.pool import SessionCheckout
@@ -190,11 +192,21 @@ def ddl_statements(self):
190192
def spanner_api(self):
191193
"""Helper for session-related API calls."""
192194
if self._spanner_api is None:
195+
client_info = self._instance._client._client_info
196+
client_options = self._instance._client._client_options
197+
if self._instance.emulator_host is not None:
198+
transport = spanner_grpc_transport.SpannerGrpcTransport(
199+
channel=grpc.insecure_channel(self._instance.emulator_host)
200+
)
201+
self._spanner_api = SpannerClient(
202+
client_info=client_info,
203+
client_options=client_options,
204+
transport=transport,
205+
)
206+
return self._spanner_api
193207
credentials = self._instance._client.credentials
194208
if isinstance(credentials, google.auth.credentials.Scoped):
195209
credentials = credentials.with_scopes((SPANNER_DATA_SCOPE,))
196-
client_info = self._instance._client._client_info
197-
client_options = self._instance._client._client_options
198210
if (
199211
os.getenv("GOOGLE_CLOUD_SPANNER_ENABLE_RESOURCE_BASED_ROUTING")
200212
== "true"

google/cloud/spanner_v1/instance.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,14 @@ def __init__(
7676
configuration_name=None,
7777
node_count=DEFAULT_NODE_COUNT,
7878
display_name=None,
79+
emulator_host=None,
7980
):
8081
self.instance_id = instance_id
8182
self._client = client
8283
self.configuration_name = configuration_name
8384
self.node_count = node_count
8485
self.display_name = display_name or instance_id
86+
self.emulator_host = emulator_host
8587

8688
def _update_from_pb(self, instance_pb):
8789
"""Refresh self from the server-provided protobuf.

noxfile.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,13 @@ def system(session):
9494
"""Run the system test suite."""
9595
system_test_path = os.path.join("tests", "system.py")
9696
system_test_folder_path = os.path.join("tests", "system")
97-
# Sanity check: Only run tests if the environment variable is set.
98-
if not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS", ""):
99-
session.skip("Credentials must be set via environment variable")
97+
# Sanity check: Only run tests if either credentials or emulator host is set.
98+
if not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS", "") and not os.environ.get(
99+
"SPANNER_EMULATOR_HOST", ""
100+
):
101+
session.skip(
102+
"Credentials or emulator host must be set via environment variable"
103+
)
100104

101105
system_test_exists = os.path.exists(system_test_path)
102106
system_test_folder_exists = os.path.exists(system_test_folder_path)

tests/system/test_system.py

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656

5757

5858
CREATE_INSTANCE = os.getenv("GOOGLE_CLOUD_TESTS_CREATE_SPANNER_INSTANCE") is not None
59+
USE_EMULATOR = os.getenv("SPANNER_EMULATOR_HOST") is not None
5960
USE_RESOURCE_ROUTING = (
6061
os.getenv("GOOGLE_CLOUD_SPANNER_ENABLE_RESOURCE_BASED_ROUTING") == "true"
6162
)
@@ -105,10 +106,10 @@ def setUpModule():
105106
EXISTING_INSTANCES[:] = instances
106107

107108
if CREATE_INSTANCE:
108-
109-
# Defend against back-end returning configs for regions we aren't
110-
# actually allowed to use.
111-
configs = [config for config in configs if "-us-" in config.name]
109+
if not USE_EMULATOR:
110+
# Defend against back-end returning configs for regions we aren't
111+
# actually allowed to use.
112+
configs = [config for config in configs if "-us-" in config.name]
112113

113114
if not configs:
114115
raise ValueError("List instance configs failed in module set up.")
@@ -185,6 +186,7 @@ def test_create_instance(self):
185186
self.assertEqual(instance, instance_alt)
186187
self.assertEqual(instance.display_name, instance_alt.display_name)
187188

189+
@unittest.skipIf(USE_EMULATOR, "Skipping updating instance")
188190
def test_update_instance(self):
189191
OLD_DISPLAY_NAME = Config.INSTANCE.display_name
190192
NEW_DISPLAY_NAME = "Foo Bar Baz"
@@ -382,12 +384,9 @@ def test_table_not_found(self):
382384
temp_db_id, ddl_statements=[create_table, index]
383385
)
384386
self.to_delete.append(temp_db)
385-
with self.assertRaises(exceptions.NotFound) as exc_info:
387+
with self.assertRaises(exceptions.NotFound):
386388
temp_db.create()
387389

388-
expected = "Table not found: {0}".format(incorrect_table)
389-
self.assertEqual(exc_info.exception.args, (expected,))
390-
391390
@pytest.mark.skip(
392391
reason=(
393392
"update_dataset_ddl() has a flaky timeout"
@@ -993,6 +992,7 @@ def test_transaction_batch_update_wo_statements(self):
993992
with self.assertRaises(InvalidArgument):
994993
transaction.batch_update([])
995994

995+
@unittest.skipIf(USE_EMULATOR, "Skipping partitioned DML")
996996
def test_execute_partitioned_dml(self):
997997
# [START spanner_test_dml_partioned_dml_update]
998998
retry = RetryInstanceState(_has_all_ddl)
@@ -1625,6 +1625,7 @@ def test_read_with_range_keys_and_index_open_open(self):
16251625
expected = [data[keyrow]] + data[start + 1 : end]
16261626
self.assertEqual(rows, expected)
16271627

1628+
@unittest.skipIf(USE_EMULATOR, "Skipping partitioned reads")
16281629
def test_partition_read_w_index(self):
16291630
row_count = 10
16301631
columns = self.COLUMNS[1], self.COLUMNS[2]
@@ -1724,16 +1725,11 @@ def test_invalid_type(self):
17241725
batch.insert(table, columns, valid_input)
17251726

17261727
invalid_input = ((0, ""),)
1727-
with self.assertRaises(exceptions.FailedPrecondition) as exc_info:
1728+
with self.assertRaises(exceptions.FailedPrecondition):
17281729
with self._db.batch() as batch:
17291730
batch.delete(table, self.ALL)
17301731
batch.insert(table, columns, invalid_input)
17311732

1732-
error_msg = (
1733-
"Invalid value for column value in table " "counters: Expected INT64."
1734-
)
1735-
self.assertIn(error_msg, str(exc_info.exception))
1736-
17371733
def test_execute_sql_select_1(self):
17381734

17391735
self._db.snapshot(multi_use=True)
@@ -2111,6 +2107,7 @@ def test_execute_sql_returning_transfinite_floats(self):
21112107
# NaNs cannot be searched for by equality.
21122108
self.assertTrue(math.isnan(float_array[2]))
21132109

2110+
@unittest.skipIf(USE_EMULATOR, "Skipping partitioned queries")
21142111
def test_partition_query(self):
21152112
row_count = 40
21162113
sql = "SELECT * FROM {}".format(self.TABLE)

0 commit comments

Comments
 (0)