From b22630b8e2b543207c6f4d9a13e2925e8692c8c5 Mon Sep 17 00:00:00 2001 From: larkee <31196561+larkee@users.noreply.github.com> Date: Tue, 26 May 2020 17:10:52 +1200 Subject: [PATCH] feat: add support for using the emulator programatically (#87) * feat: add support for using the emulator programatically * always set credentials when SPANNER_EMULATOR_HOST is set * address PR comments Co-authored-by: larkee --- google/cloud/spanner_v1/client.py | 36 ++++++++----- google/cloud/spanner_v1/database.py | 4 +- tests/unit/test_client.py | 84 ++++++++++++++++++++++++++--- 3 files changed, 99 insertions(+), 25 deletions(-) diff --git a/google/cloud/spanner_v1/client.py b/google/cloud/spanner_v1/client.py index 89ab490cff..0759fcff23 100644 --- a/google/cloud/spanner_v1/client.py +++ b/google/cloud/spanner_v1/client.py @@ -28,6 +28,7 @@ import warnings from google.api_core.gapic_v1 import client_info +from google.auth.credentials import AnonymousCredentials import google.api_core.client_options from google.cloud.spanner_admin_instance_v1.gapic.transports import ( @@ -173,6 +174,20 @@ def __init__( client_options=None, query_options=None, ): + self._emulator_host = _get_spanner_emulator_host() + + if client_options and type(client_options) == dict: + self._client_options = google.api_core.client_options.from_dict( + client_options + ) + else: + self._client_options = client_options + + if self._emulator_host: + credentials = AnonymousCredentials() + elif isinstance(credentials, AnonymousCredentials): + self._emulator_host = self._client_options.api_endpoint + # NOTE: This API has no use for the _http argument, but sending it # will have no impact since the _http() @property only lazily # creates a working HTTP object. @@ -180,12 +195,6 @@ def __init__( project=project, credentials=credentials, _http=None ) self._client_info = client_info - if client_options and type(client_options) == dict: - self._client_options = google.api_core.client_options.from_dict( - client_options - ) - else: - self._client_options = client_options env_query_options = ExecuteSqlRequest.QueryOptions( optimizer_version=_get_spanner_optimizer_version() @@ -198,9 +207,8 @@ def __init__( warnings.warn(_USER_AGENT_DEPRECATED, DeprecationWarning, stacklevel=2) self.user_agent = user_agent - if _get_spanner_emulator_host() is not None and ( - "http://" in _get_spanner_emulator_host() - or "https://" in _get_spanner_emulator_host() + if self._emulator_host is not None and ( + "http://" in self._emulator_host or "https://" in self._emulator_host ): warnings.warn(_EMULATOR_HOST_HTTP_SCHEME) @@ -237,9 +245,9 @@ def project_name(self): def instance_admin_api(self): """Helper for session-related API calls.""" if self._instance_admin_api is None: - if _get_spanner_emulator_host() is not None: + if self._emulator_host is not None: transport = instance_admin_grpc_transport.InstanceAdminGrpcTransport( - channel=grpc.insecure_channel(_get_spanner_emulator_host()) + channel=grpc.insecure_channel(target=self._emulator_host) ) self._instance_admin_api = InstanceAdminClient( client_info=self._client_info, @@ -258,9 +266,9 @@ def instance_admin_api(self): def database_admin_api(self): """Helper for session-related API calls.""" if self._database_admin_api is None: - if _get_spanner_emulator_host() is not None: + if self._emulator_host is not None: transport = database_admin_grpc_transport.DatabaseAdminGrpcTransport( - channel=grpc.insecure_channel(_get_spanner_emulator_host()) + channel=grpc.insecure_channel(target=self._emulator_host) ) self._database_admin_api = DatabaseAdminClient( client_info=self._client_info, @@ -363,7 +371,7 @@ def instance( configuration_name, node_count, display_name, - _get_spanner_emulator_host(), + self._emulator_host, ) def list_instances(self, filter_="", page_size=None, page_token=None): diff --git a/google/cloud/spanner_v1/database.py b/google/cloud/spanner_v1/database.py index e7f6de3724..8ece803847 100644 --- a/google/cloud/spanner_v1/database.py +++ b/google/cloud/spanner_v1/database.py @@ -223,9 +223,7 @@ def spanner_api(self): channel=grpc.insecure_channel(self._instance.emulator_host) ) self._spanner_api = SpannerClient( - client_info=client_info, - client_options=client_options, - transport=transport, + client_info=client_info, transport=transport ) return self._spanner_api credentials = self._instance._client.credentials diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index b9446fd867..614bf4bde6 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -110,11 +110,14 @@ def _constructor_test_helper( @mock.patch("warnings.warn") def test_constructor_emulator_host_warning(self, mock_warn, mock_em): from google.cloud.spanner_v1 import client as MUT + from google.auth.credentials import AnonymousCredentials - expected_scopes = (MUT.SPANNER_ADMIN_SCOPE,) + expected_scopes = None creds = _make_credentials() mock_em.return_value = "http://emulator.host.com" - self._constructor_test_helper(expected_scopes, creds) + with mock.patch("google.cloud.spanner_v1.client.AnonymousCredentials") as patch: + expected_creds = patch.return_value = AnonymousCredentials() + self._constructor_test_helper(expected_scopes, creds, expected_creds) mock_warn.assert_called_once_with(MUT._EMULATOR_HOST_HTTP_SCHEME) def test_constructor_default_scopes(self): @@ -219,6 +222,8 @@ def test_constructor_custom_query_options_env_config(self, mock_ver): def test_instance_admin_api(self, mock_em): from google.cloud.spanner_v1.client import SPANNER_ADMIN_SCOPE + mock_em.return_value = None + credentials = _make_credentials() client_info = mock.Mock() client_options = mock.Mock() @@ -230,7 +235,6 @@ def test_instance_admin_api(self, mock_em): ) expected_scopes = (SPANNER_ADMIN_SCOPE,) - mock_em.return_value = None inst_module = "google.cloud.spanner_v1.client.InstanceAdminClient" with mock.patch(inst_module) as instance_admin_client: api = client.instance_admin_api @@ -250,7 +254,8 @@ def test_instance_admin_api(self, mock_em): credentials.with_scopes.assert_called_once_with(expected_scopes) @mock.patch("google.cloud.spanner_v1.client._get_spanner_emulator_host") - def test_instance_admin_api_emulator(self, mock_em): + def test_instance_admin_api_emulator_env(self, mock_em): + mock_em.return_value = "emulator.host" credentials = _make_credentials() client_info = mock.Mock() client_options = mock.Mock() @@ -261,7 +266,38 @@ def test_instance_admin_api_emulator(self, mock_em): client_options=client_options, ) - mock_em.return_value = "true" + inst_module = "google.cloud.spanner_v1.client.InstanceAdminClient" + with mock.patch(inst_module) as instance_admin_client: + api = client.instance_admin_api + + self.assertIs(api, instance_admin_client.return_value) + + # API instance is cached + again = client.instance_admin_api + self.assertIs(again, api) + + self.assertEqual(len(instance_admin_client.call_args_list), 1) + called_args, called_kw = instance_admin_client.call_args + self.assertEqual(called_args, ()) + self.assertEqual(called_kw["client_info"], client_info) + self.assertEqual(called_kw["client_options"], client_options) + self.assertIn("transport", called_kw) + self.assertNotIn("credentials", called_kw) + + def test_instance_admin_api_emulator_code(self): + from google.auth.credentials import AnonymousCredentials + from google.api_core.client_options import ClientOptions + + credentials = AnonymousCredentials() + client_info = mock.Mock() + client_options = ClientOptions(api_endpoint="emulator.host") + client = self._make_one( + project=self.PROJECT, + credentials=credentials, + client_info=client_info, + client_options=client_options, + ) + inst_module = "google.cloud.spanner_v1.client.InstanceAdminClient" with mock.patch(inst_module) as instance_admin_client: api = client.instance_admin_api @@ -284,6 +320,7 @@ def test_instance_admin_api_emulator(self, mock_em): def test_database_admin_api(self, mock_em): from google.cloud.spanner_v1.client import SPANNER_ADMIN_SCOPE + mock_em.return_value = None credentials = _make_credentials() client_info = mock.Mock() client_options = mock.Mock() @@ -295,7 +332,6 @@ def test_database_admin_api(self, mock_em): ) expected_scopes = (SPANNER_ADMIN_SCOPE,) - mock_em.return_value = None db_module = "google.cloud.spanner_v1.client.DatabaseAdminClient" with mock.patch(db_module) as database_admin_client: api = client.database_admin_api @@ -315,7 +351,8 @@ def test_database_admin_api(self, mock_em): credentials.with_scopes.assert_called_once_with(expected_scopes) @mock.patch("google.cloud.spanner_v1.client._get_spanner_emulator_host") - def test_database_admin_api_emulator(self, mock_em): + def test_database_admin_api_emulator_env(self, mock_em): + mock_em.return_value = "host:port" credentials = _make_credentials() client_info = mock.Mock() client_options = mock.Mock() @@ -326,7 +363,38 @@ def test_database_admin_api_emulator(self, mock_em): client_options=client_options, ) - mock_em.return_value = "host:port" + db_module = "google.cloud.spanner_v1.client.DatabaseAdminClient" + with mock.patch(db_module) as database_admin_client: + api = client.database_admin_api + + self.assertIs(api, database_admin_client.return_value) + + # API instance is cached + again = client.database_admin_api + self.assertIs(again, api) + + self.assertEqual(len(database_admin_client.call_args_list), 1) + called_args, called_kw = database_admin_client.call_args + self.assertEqual(called_args, ()) + self.assertEqual(called_kw["client_info"], client_info) + self.assertEqual(called_kw["client_options"], client_options) + self.assertIn("transport", called_kw) + self.assertNotIn("credentials", called_kw) + + def test_database_admin_api_emulator_code(self): + from google.auth.credentials import AnonymousCredentials + from google.api_core.client_options import ClientOptions + + credentials = AnonymousCredentials() + client_info = mock.Mock() + client_options = ClientOptions(api_endpoint="emulator.host") + client = self._make_one( + project=self.PROJECT, + credentials=credentials, + client_info=client_info, + client_options=client_options, + ) + db_module = "google.cloud.spanner_v1.client.DatabaseAdminClient" with mock.patch(db_module) as database_admin_client: api = client.database_admin_api