diff --git a/src/google/appengine/api/modules/modules.py b/src/google/appengine/api/modules/modules.py index a96e1fb..5387a57 100755 --- a/src/google/appengine/api/modules/modules.py +++ b/src/google/appengine/api/modules/modules.py @@ -22,6 +22,20 @@ https://cloud.google.com/appengine/docs/standard/python/using-the-modules-api. """ +import logging +import os +import threading + +from google.appengine.api import apiproxy_stub_map +from google.appengine.api.modules import modules_service_pb2 +from google.appengine.runtime import apiproxy_errors +from googleapiclient import discovery, errors, http +from google_auth_httplib2 import AuthorizedHttp +import google.auth +import six +import httplib2 + + __all__ = [ 'Error', 'InvalidModuleError', @@ -46,16 +60,6 @@ 'get_hostname' ] -import logging -import os - -import six - -from google.appengine.api import apiproxy_stub_map -from google.appengine.api.modules import modules_service_pb2 -from google.appengine.runtime import apiproxy_errors - - class Error(Exception): """Base-class for errors in this module.""" @@ -79,6 +83,28 @@ class UnexpectedStateError(Error): class TransientError(Error): """A transient error was encountered, retry the operation.""" +def _has_opted_in(): + return (os.environ.get('MODULES_USE_ADMIN_API', 'false').lower() == 'true') + +def _raise_error(e): + # Translate HTTP errors to the exceptions expected by the API + if e.resp.status == 400: + raise InvalidInstancesError(e) from e + elif e.resp.status == 404: + raise InvalidVersionError(e) from e + elif e.resp.status >= 500: + raise TransientError(e) from e + else: + raise Error(e) from e + +def _get_project_id(): + project_id = os.environ.get('GAE_PROJECT') or os.environ.get( + 'GOOGLE_CLOUD_PROJECT' + ) + if project_id is None: + app_id = os.environ.get('GAE_APPLICATION') + project_id = app_id.split('~', 1)[1] + return project_id def get_current_module_name(): """Returns the module name of the current instance. @@ -120,16 +146,44 @@ def get_current_instance_id(): return os.environ.get('GAE_INSTANCE') or os.environ.get('INSTANCE_ID', None) +class _ThreadedRpc: + """A class to emulate the UserRPC object for threaded operations.""" + + def __init__(self, target): + self.thread = threading.Thread(target=self._run_target, args=(target,)) + self.exception = None + self.done = threading.Event() + self.thread.start() + + def _run_target(self, target): + try: + target() + except Exception as e: + self.exception = e + finally: + self.done.set() + + def wait(self): + self.done.wait() + + def check_success(self): + if self.exception: + raise self.exception + + def get_result(self): + self.wait() + self.check_success() + return None + + def _GetRpc(): return apiproxy_stub_map.UserRPC('modules') - def _MakeAsyncCall(method, request, response, get_result_hook): rpc = _GetRpc() rpc.make_call(method, request, response, get_result_hook) return rpc - _MODULE_SERVICE_ERROR_MAP = { modules_service_pb2.ModulesServiceError.INVALID_INSTANCES: InvalidInstancesError, @@ -143,7 +197,6 @@ def _MakeAsyncCall(method, request, response, get_result_hook): UnexpectedStateError } - def _CheckAsyncResult(rpc, expected_application_errors, ignored_application_errors): try: @@ -158,6 +211,14 @@ def _CheckAsyncResult(rpc, expected_application_errors, raise mapped_error() raise Error(e) +def _get_admin_api_client_with_useragent(methodName): + userAgent = 'appengine-modules-api-python-client/' + methodName + http_client = httplib2.Http(timeout=60) + http_client = http.set_user_agent(http_client, userAgent) + credentials,_ = google.auth.default() + authorized_http = AuthorizedHttp(credentials, http=http_client) + client = discovery.build('appengine', 'v1', http=authorized_http) + return client def get_modules(): """Returns a list of all modules for the application. @@ -167,8 +228,29 @@ def get_modules(): application. The 'default' module will be included if it exists, as will the name of the module that is associated with the instance that calls this function. + + Raises: + Error: If the configured project ID is invalid. + TransientError: If there is an issue fetching the information. """ + if not _has_opted_in(): + return get_modules_legacy() + + project_id = _get_project_id() + client = _get_admin_api_client_with_useragent('get_modules') + request = client.apps().services().list(appsId=project_id) + + try: + response = request.execute() + except errors.HttpError as e: + if e.resp.status == 404: + raise Error(f"Project '{project_id}' not found.") from e + _raise_error(e) + + return [service['id'] for service in response.get('services', [])] +#Legacy get_modules implementation +def get_modules_legacy(): def _ResultHook(rpc): _CheckAsyncResult(rpc, [], {}) @@ -181,6 +263,7 @@ def _ResultHook(rpc): _ResultHook).get_result() + def get_versions(module=None): """Returns a list of versions for a given module. @@ -196,7 +279,27 @@ def get_versions(module=None): `InvalidModuleError` if the given module isn't valid, `TransientError` if there is an issue fetching the information. """ + if not _has_opted_in(): + return get_versions_legacy(module=module) + + if not module: + module = os.environ.get('GAE_SERVICE', 'default') + + project_id = _get_project_id() + client = _get_admin_api_client_with_useragent('get_versions') + request = client.apps().services().versions().list( + appsId=project_id, servicesId=module, view='FULL' + ) + try: + response = request.execute() + except errors.HttpError as e: + if e.resp.status == 404: + raise InvalidModuleError(f"Module '{module}' not found.") from e + _raise_error(e) + + return [version['id'] for version in response.get('versions', [])] +def get_versions_legacy(module=None): def _ResultHook(rpc): mapped_errors = [ modules_service_pb2.ModulesServiceError.INVALID_MODULE, @@ -212,7 +315,8 @@ def _ResultHook(rpc): request.module = module response = modules_service_pb2.GetVersionsResponse() return _MakeAsyncCall('GetVersions', request, response, - _ResultHook).get_result() + _ResultHook).get_result() + def get_default_version(module=None): @@ -230,6 +334,46 @@ def get_default_version(module=None): if no default version could be found. """ + if not _has_opted_in(): + return get_default_version_legacy(module=module) + + if not module: + module = os.environ.get('GAE_SERVICE', 'default') + project = _get_project_id() + client = _get_admin_api_client_with_useragent('get_default_version') + request = client.apps().services().get( + appsId=project, servicesId=module) + + try: + response = request.execute() + except errors.HttpError as e: + if e.resp.status == 404: + raise InvalidModuleError(f"Module '{module}' not found.") from e + _raise_error(e) + + allocations = response.get('split', {}).get('allocations') + maxAlloc = -1 + retVersion = None + + if allocations: + for version, allocation in allocations.items(): + if allocation == 1.0: + retVersion = version + break + + if allocation > maxAlloc: + retVersion = version + maxAlloc = allocation + elif allocation == maxAlloc: + if version < retVersion: + retVersion = version + + if retVersion is None: + raise InvalidVersionError(f"Could not determine default version for module '{module}'.") + + return retVersion + +def get_default_version_legacy(module): def _ResultHook(rpc): mapped_errors = [ modules_service_pb2.ModulesServiceError.INVALID_MODULE, @@ -269,7 +413,31 @@ def get_num_instances( Raises: `InvalidVersionError` on invalid input. """ + if not _has_opted_in(): + return get_num_instances_legacy(module=module, version=version) + + if module is None: + module = get_current_module_name() + if version is None: + version = get_current_version_name() + + project_id = _get_project_id() + client = _get_admin_api_client_with_useragent('get_num_instances') + request = client.apps().services().versions().get( + appsId=project_id, servicesId=module, versionsId=version) + + try: + response = request.execute() + except errors.HttpError as e: + _raise_error(e) + + if 'manualScaling' in response: + return response['manualScaling'].get('instances') + + return 0 + +def get_num_instances_legacy(module, version): def _ResultHook(rpc): mapped_errors = [modules_service_pb2.ModulesServiceError.INVALID_VERSION] _CheckAsyncResult(rpc, mapped_errors, {}) @@ -285,6 +453,23 @@ def _ResultHook(rpc): _ResultHook).get_result() +def _admin_api_version_patch(project_id, module, version, body, update_mask): + methodName = '' + if 'manualScaling' in body: + methodName = 'set_num_instances' + elif 'servingStatus' in body and body['servingStatus'] == 'SERVING': + methodName = 'start_version' + elif 'servingStatus' in body and body['servingStatus'] == 'STOPPED': + methodName = 'stop_version' + + client = _get_admin_api_client_with_useragent(methodName) + client.apps().services().versions().patch( + appsId=project_id, + servicesId=module, + versionsId=version, + updateMask=update_mask, + body=body).execute() + def set_num_instances( instances, module=None, @@ -324,6 +509,33 @@ def set_num_instances_async( A `UserRPC` to set the number of instances on the module version. """ + if not _has_opted_in(): + return set_num_instances_async_legacy(instances=instances, module=module, version=version) + + if not isinstance(instances, six.integer_types): + raise TypeError("'instances' arg must be of type long or int.") + + project_id = _get_project_id() + if module is None: + module = get_current_module_name() + if version is None: + version = get_current_version_name() + + def run_request(): + """This function will be executed in a separate thread.""" + try: + body = { + 'manualScaling': { + 'instances': instances + } + } + _admin_api_version_patch(project_id, module, version, body, 'manualScaling.instances') + except errors.HttpError as e: + _raise_error(e) + + return _ThreadedRpc(target=run_request) + +def set_num_instances_async_legacy(instances, module, version): def _ResultHook(rpc): mapped_errors = [ modules_service_pb2.ModulesServiceError.INVALID_VERSION, @@ -370,7 +582,28 @@ def start_version_async( Returns: A `UserRPC` to start all instances for the given module version. """ - + if not _has_opted_in(): + return start_version_async_legacy(module=module, version=version) + + if module is None: + module = get_current_module_name() + + if version is None: + version = get_current_version_name() + project_id = _get_project_id() + def run_request(): + """This function will be executed in a separate thread.""" + try: + body = { + 'servingStatus': 'SERVING' + } + _admin_api_version_patch(project_id, module, version, body, 'servingStatus') + except errors.HttpError as e: + _raise_error(e) + + return _ThreadedRpc(target=run_request) + +def start_version_async_legacy(module, version): def _ResultHook(rpc): mapped_errors = [ modules_service_pb2.ModulesServiceError.INVALID_VERSION, @@ -389,7 +622,6 @@ def _ResultHook(rpc): response = modules_service_pb2.StartModuleResponse() return _MakeAsyncCall('StartModule', request, response, _ResultHook) - def stop_version( module=None, version=None): @@ -422,6 +654,28 @@ def stop_version_async( A `UserRPC` to stop all instances for the given module version. """ + if not _has_opted_in(): + return stop_version_async_legacy(module=module, version=version) + + if module is None: + module = get_current_module_name() + + if version is None: + version = get_current_version_name() + project_id = _get_project_id() + def run_request(): + """This function will be executed in a separate thread.""" + try: + body = { + 'servingStatus': 'STOPPED' + } + _admin_api_version_patch(project_id, module, version, body, 'servingStatus') + except errors.HttpError as e: + _raise_error(e) + + return _ThreadedRpc(target=run_request) + +def stop_version_async_legacy(module, version): def _ResultHook(rpc): mapped_errors = [ modules_service_pb2.ModulesServiceError.INVALID_VERSION, @@ -433,7 +687,7 @@ def _ResultHook(rpc): (module, version) } _CheckAsyncResult(rpc, mapped_errors, expected_errors) - + request = modules_service_pb2.StopModuleRequest() if module: request.module = module @@ -443,6 +697,10 @@ def _ResultHook(rpc): return _MakeAsyncCall('StopModule', request, response, _ResultHook) +def _construct_hostname(*hostname_parts): + """Constructs a hostname for the given module, version, and instance.""" + return ".".join(hostname_parts) + def get_hostname( module=None, version=None, @@ -467,7 +725,93 @@ def get_hostname( InvalidInstancesError: if the given instance value is invalid. TypeError: if the given instance type is invalid. """ + if not _has_opted_in(): + return get_hostname_legacy(module=module, version=version, instance=instance) + + if instance is not None: + try: + instance_id = int(instance) + if instance_id < 0: + raise ValueError + except (ValueError, TypeError) as e: + raise InvalidInstancesError("Instance must be a non-negative integer.") from e + project_id = _get_project_id() + + + req_module = module or get_current_module_name() + req_version = version or get_current_version_name() + + try: + services = get_modules() + client = _get_admin_api_client_with_useragent('get_hostname') + request = client.apps().get(appsId=project_id) + + response = request.execute() + default_hostname = response.get('defaultHostname') + + except errors.HttpError as e: + _raise_error(e) + + # Legacy Applications (Without "Engines") + if len(services) == 1 and services[0] == 'default': + if req_module != 'default': + raise InvalidModuleError(f"Module '{req_module}' not found.") + hostname_parts = [req_version, default_hostname] + if instance: + return _construct_hostname(instance, req_version, default_hostname) + return _construct_hostname(req_version, default_hostname) + + if instance is not None: + try: + # Get version details to check scaling and instance count + version_request = discovery.build('appengine', 'v1').apps().services().versions().get( + appsId=project_id, servicesId=req_module, versionsId=req_version, view='FULL') + version_details = version_request.execute() + + if 'manualScaling' not in version_details: + raise InvalidInstancesError( + "Instance-specific hostnames are only available for manually scaled services.") + + num_instances = version_details['manualScaling'].get('instances', 0) + if int(instance) >= num_instances: + raise InvalidInstancesError( + "The specified instance does not exist for this module/version.") + + return _construct_hostname(instance, req_version, req_module, default_hostname) + + except errors.HttpError as e: + if e.resp.status == 404: + raise InvalidModuleError( + f"Module '{req_module}' or version '{req_version}' not found.") from e + _raise_error(e) + + # Request with no explicit version and no instance. + if version is None: + try: + # Get all versions for the target module. + versions_list = get_versions(module=req_module) + + # Create a set of version IDs for efficient lookup. + existing_version_ids = set(versions_list) + + # Check if the version from the current context exists in the target module. + if req_version in existing_version_ids: + return _construct_hostname(req_version, req_module, default_hostname) + else: + # If the current version does not exist on the target module, + # return a hostname without a version. + return _construct_hostname(req_module, default_hostname) + + except errors.HttpError as e: + if e.resp.status == 404: + raise InvalidModuleError(f"Module '{req_module}' not found.") from e + _raise_error(e) + + # Request with a version but no instance + return _construct_hostname(version, req_module, default_hostname) + +def get_hostname_legacy(module, version, instance): def _ResultHook(rpc): mapped_errors = [ modules_service_pb2.ModulesServiceError.INVALID_MODULE, @@ -488,3 +832,4 @@ def _ResultHook(rpc): response = modules_service_pb2.GetHostnameResponse() return _MakeAsyncCall('GetHostname', request, response, _ResultHook).get_result() + diff --git a/tests/google/appengine/api/modules/modules_test.py b/tests/google/appengine/api/modules/modules_test.py index 2c2209a..7de738b 100755 --- a/tests/google/appengine/api/modules/modules_test.py +++ b/tests/google/appengine/api/modules/modules_test.py @@ -6,7 +6,7 @@ # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # -# http://www.apache.org/licenses/LICENSE-2.0 +# http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, @@ -25,9 +25,13 @@ from google.appengine.api.modules import modules_service_pb2 from google.appengine.runtime import apiproxy_errors from google.appengine.runtime.context import ctx_test_util +import google.auth +import google_auth_httplib2 +from googleapiclient import discovery import mox from absl.testing import absltest +from googleapiclient import errors @ctx_test_util.isolated_context() @@ -36,66 +40,84 @@ class ModulesTest(absltest.TestCase): def setUp(self): """Setup testing environment.""" self.mox = mox.Mox() + self.mock_admin_api_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, 'discovery') + + # Environment variables are cleared in tearDown + os.environ['GAE_APPLICATION'] = 's~project' + os.environ['GOOGLE_CLOUD_PROJECT'] = 'project' + os.environ['GAE_SERVICE'] = 'default' + os.environ['GAE_VERSION'] = 'v1' + os.environ['CURRENT_MODULE_ID'] = 'default' + os.environ['CURRENT_VERSION_ID'] = 'v1.123' def tearDown(self): """Tear down testing environment.""" - self.mox.VerifyAll() self.mox.UnsetStubs() + self.mox.VerifyAll() - def testGetCurrentModuleName_DefaultModule(self): - """Test get_current_module_name for default engine.""" - os.environ['CURRENT_MODULE_ID'] = 'default' - os.environ['CURRENT_VERSION_ID'] = 'v1.123' - self.assertEqual('default', modules.get_current_module_name()) + # Clear environment variables that were set in tests + for var in [ + 'GAE_SERVICE', 'GAE_VERSION', 'CURRENT_MODULE_ID', 'CURRENT_VERSION_ID', + 'INSTANCE_ID', 'GAE_INSTANCE', 'GOOGLE_CLOUD_PROJECT', 'GAE_APPLICATION' + ]: + if var in os.environ: + del os.environ[var] - def testGetCurrentModuleName_NonDefaultModule(self): - """Test get_current_module_name for a non default engine.""" - os.environ['CURRENT_MODULE_ID'] = 'module1' - os.environ['CURRENT_VERSION_ID'] = 'v1.123' - self.assertEqual('module1', modules.get_current_module_name()) + def _SetupAdminApiMocks(self, project='project'): + modules.discovery.build('appengine', + 'v1').AndReturn(self.mock_admin_api_client) + + def _CreateHttpError(self, status, reason='Error'): + resp = self.mox.CreateMockAnything() + resp.status = status + resp.reason = reason + return errors.HttpError(resp, b'') + + # --- Tests for Get/Set Current Module, Version, Instance --- - def testGetCurrentModuleName_GaeService(self): - """Test get_current_module_name from GAE_SERVICE.""" + def testGetCurrentModuleName(self): os.environ['GAE_SERVICE'] = 'module1' - os.environ['GAE_VERSION'] = 'v1' self.assertEqual('module1', modules.get_current_module_name()) - def testGetCurrentVersionName_DefaultModule(self): - """Test get_current_version_name for default engine.""" - os.environ['CURRENT_VERSION_ID'] = 'v1.123' - self.assertEqual('v1', modules.get_current_version_name()) - - def testGetCurrentVersionName_NonDefaultModule(self): - """Test get_current_version_name for a non default engine.""" - os.environ['CURRENT_MODULE_ID'] = 'module1' - os.environ['CURRENT_VERSION_ID'] = 'v1.123' - self.assertEqual('v1', modules.get_current_version_name()) + def testGetCurrentModuleName_Fallback(self): + if 'GAE_SERVICE' in os.environ: + del os.environ['GAE_SERVICE'] + os.environ['CURRENT_MODULE_ID'] = 'module2' + self.assertEqual('module2', modules.get_current_module_name()) - def testGetCurrentVersionName_VersionIdContainsNone(self): - """Test get_current_version_name when 'None' is in version id.""" - os.environ['CURRENT_MODULE_ID'] = 'module1' - os.environ['CURRENT_VERSION_ID'] = 'None.123' - self.assertEqual(None, modules.get_current_version_name()) + def testGetCurrentVersionName(self): + os.environ['GAE_VERSION'] = 'v2' + self.assertEqual('v2', modules.get_current_version_name()) - def testGetCurrentVersionName_GaeVersion(self): - """Test get_current_module_name from GAE_SERVICE.""" - os.environ['GAE_SERVICE'] = 'module1' - os.environ['GAE_VERSION'] = 'v1' - self.assertEqual('v1', modules.get_current_version_name()) + def testGetCurrentVersionName_Fallback(self): + if 'GAE_VERSION' in os.environ: + del os.environ['GAE_VERSION'] + os.environ['CURRENT_VERSION_ID'] = 'v3.456' + self.assertEqual('v3', modules.get_current_version_name()) - def testGetCurrentInstanceId_Empty(self): - """Test get_current_instance_id when none has been set in the environ.""" - self.assertEqual(None, modules.get_current_instance_id()) + def testGetCurrentVersionName_None(self): + if 'GAE_VERSION' in os.environ: + del os.environ['GAE_VERSION'] + os.environ['CURRENT_VERSION_ID'] = 'None.456' + self.assertIsNone(modules.get_current_version_name()) def testGetCurrentInstanceId(self): - """Test get_current_instance_id.""" - os.environ['INSTANCE_ID'] = '123' - self.assertEqual('123', modules.get_current_instance_id()) - - def testGetCurrentInstanceId_GaeInstance(self): - """Test get_current_instance_id.""" - os.environ['GAE_INSTANCE'] = '123' - self.assertEqual('123', modules.get_current_instance_id()) + os.environ['GAE_INSTANCE'] = 'instance1' + self.assertEqual('instance1', modules.get_current_instance_id()) + + def testGetCurrentInstanceId_Fallback(self): + if 'GAE_INSTANCE' in os.environ: + del os.environ['GAE_INSTANCE'] + os.environ['INSTANCE_ID'] = 'instance2' + self.assertEqual('instance2', modules.get_current_instance_id()) + + def testGetCurrentInstanceId_None(self): + if 'GAE_INSTANCE' in os.environ: + del os.environ['GAE_INSTANCE'] + if 'INSTANCE_ID' in os.environ: + del os.environ['INSTANCE_ID'] + self.assertIsNone(modules.get_current_instance_id()) def SetSuccessExpectations(self, method, expected_request, service_response): rpc = MockRpc(method, expected_request, service_response) @@ -110,7 +132,49 @@ def SetExceptionExpectations(self, method, expected_request, modules._GetRpc().AndReturn(rpc) self.mox.ReplayAll() + # --- Tests for updated get_modules --- + def testGetModules(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent('get_modules').AndReturn(mock_client) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + + mock_apps = self.mox.CreateMockAnything() + mock_services = self.mox.CreateMockAnything() + mock_request = self.mox.CreateMockAnything() + mock_client.apps().AndReturn(mock_apps) + mock_apps.services().AndReturn(mock_services) + mock_services.list(appsId='project').AndReturn(mock_request) + mock_request.execute().AndReturn( + {'services': [{'id': 'module1'}, {'id': 'default'}]}) + self.mox.ReplayAll() + self.assertEqual(['module1', 'default'], modules.get_modules()) + + def testGetModules_InvalidProject(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent('get_modules').AndReturn(mock_client) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + + mock_apps = self.mox.CreateMockAnything() + mock_services = self.mox.CreateMockAnything() + mock_request = self.mox.CreateMockAnything() + mock_client.apps().AndReturn(mock_apps) + mock_apps.services().AndReturn(mock_services) + mock_services.list(appsId='project').AndReturn(mock_request) + mock_request.execute().AndRaise(self._CreateHttpError(404)) + self.mox.ReplayAll() + with self.assertRaisesRegex(modules.Error, "Project 'project' not found."): + modules.get_modules() + + # --- Tests for legacy get_modules --- + + def testGetModulesLegacy(self): """Test we return the expected results.""" service_response = modules_service_pb2.GetModulesResponse() service_response.module.append('module1') @@ -120,7 +184,55 @@ def testGetModules(self): service_response) self.assertEqual(['module1', 'module2'], modules.get_modules()) + # --- Tests for updated get_versions --- + def testGetVersions(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent('get_versions').AndReturn(mock_client) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + + mock_apps = self.mox.CreateMockAnything() + mock_services = self.mox.CreateMockAnything() + mock_versions = self.mox.CreateMockAnything() + mock_request = self.mox.CreateMockAnything() + mock_client.apps().AndReturn(mock_apps) + mock_apps.services().AndReturn(mock_services) + mock_services.versions().AndReturn(mock_versions) + mock_versions.list( + appsId='project', servicesId='default', view='FULL').AndReturn( + mock_request) + mock_request.execute().AndReturn({'versions': [{'id': 'v1'}, {'id': 'v2'}]}) + self.mox.ReplayAll() + self.assertEqual(['v1', 'v2'], modules.get_versions()) + + def testGetVersions_InvalidModule(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent('get_versions').AndReturn(mock_client) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + mock_apps = self.mox.CreateMockAnything() + mock_services = self.mox.CreateMockAnything() + mock_versions = self.mox.CreateMockAnything() + mock_request = self.mox.CreateMockAnything() + mock_client.apps().AndReturn(mock_apps) + mock_apps.services().AndReturn(mock_services) + mock_services.versions().AndReturn(mock_versions) + mock_versions.list( + appsId='project', servicesId='foo', view='FULL').AndReturn(mock_request) + mock_request.execute().AndRaise(self._CreateHttpError(404)) + self.mox.ReplayAll() + with self.assertRaisesRegex(modules.InvalidModuleError, + "Module 'foo' not found."): + modules.get_versions(module='foo') + + # --- Tests for Legacy get_versions --- + + def testGetVersionsLegacy(self): """Test we return the expected results.""" expected_request = modules_service_pb2.GetVersionsRequest() expected_request.module = 'module1' @@ -132,7 +244,7 @@ def testGetVersions(self): service_response) self.assertEqual(['v1', 'v2'], modules.get_versions('module1')) - def testGetVersions_NoModule(self): + def testGetVersionsLegacy_NoModule(self): """Test we return the expected results when no module is passed.""" expected_request = modules_service_pb2.GetVersionsRequest() service_response = modules_service_pb2.GetVersionsResponse() @@ -143,21 +255,123 @@ def testGetVersions_NoModule(self): service_response) self.assertEqual(['v1', 'v2'], modules.get_versions()) - def testGetVersions_InvalidModuleError(self): + def testGetVersionsLegacy_InvalidModuleError(self): """Test we raise the right error when the given module is invalid.""" self.SetExceptionExpectations( 'GetVersions', modules_service_pb2.GetVersionsRequest(), modules_service_pb2.ModulesServiceError.INVALID_MODULE) self.assertRaises(modules.InvalidModuleError, modules.get_versions) - def testGetVersions_TransientError(self): + def testGetVersionsLegacy_TransientError(self): """Test we raise the right error when a transient error is encountered.""" self.SetExceptionExpectations( 'GetVersions', modules_service_pb2.GetVersionsRequest(), modules_service_pb2.ModulesServiceError.TRANSIENT_ERROR) self.assertRaises(modules.TransientError, modules.get_versions) + # --- Tests for updated get_default_version --- + def testGetDefaultVersion(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + + mock_admin_api_client = self.mox.CreateMockAnything() + + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent( + 'get_default_version').AndReturn(mock_admin_api_client) + + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + mock_apps = self.mox.CreateMockAnything() + mock_services = self.mox.CreateMockAnything() + mock_request = self.mox.CreateMockAnything() + + mock_admin_api_client.apps().AndReturn(mock_apps) + mock_apps.services().AndReturn(mock_services) + mock_services.get(appsId='project', + servicesId='default').AndReturn(mock_request) + mock_request.execute().AndReturn( + {'split': {'allocations': {'v1': 0.5, 'v2': 0.5}}}) + + self.mox.ReplayAll() + + # The assertion remains the same + self.assertEqual('v1', modules.get_default_version()) + + def testGetDefaultVersion_Lexicographical(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_admin_api_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent( + 'get_default_version').AndReturn(mock_admin_api_client) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + mock_apps = self.mox.CreateMockAnything() + mock_services = self.mox.CreateMockAnything() + mock_request = self.mox.CreateMockAnything() + mock_admin_api_client.apps().AndReturn(mock_apps) + mock_apps.services().AndReturn(mock_services) + mock_services.get(appsId='project', + servicesId='default').AndReturn(mock_request) + mock_request.execute().AndReturn( + {'split': {'allocations': {'v2-beta': 0.5, 'v1-stable': 0.5}}}) + self.mox.ReplayAll() + self.assertEqual('v1-stable', modules.get_default_version()) + + + def testGetDefaultVersion_NoDefaultVersion(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_admin_api_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent( + 'get_default_version').AndReturn(mock_admin_api_client) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + mock_apps = self.mox.CreateMockAnything() + mock_services = self.mox.CreateMockAnything() + mock_request = self.mox.CreateMockAnything() + mock_admin_api_client.apps().AndReturn(mock_apps) + mock_apps.services().AndReturn(mock_services) + mock_services.get(appsId='project', + servicesId='default').AndReturn(mock_request) + mock_request.execute().AndReturn({}) + self.mox.ReplayAll() + with self.assertRaisesRegex(modules.InvalidVersionError, + 'Could not determine default version'): + modules.get_default_version() + + def testGetDefaultVersion_InvalidModule(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + + mock_admin_api_client = self.mox.CreateMockAnything() + + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent( + 'get_default_version').AndReturn(mock_admin_api_client) + + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + + mock_apps = self.mox.CreateMockAnything() + mock_services = self.mox.CreateMockAnything() + mock_request = self.mox.CreateMockAnything() + + mock_admin_api_client.apps().AndReturn(mock_apps) + mock_apps.services().AndReturn(mock_services) + mock_services.get(appsId='project', + servicesId='foo').AndReturn(mock_request) + + mock_request.execute().AndRaise(self._CreateHttpError(404)) + + self.mox.ReplayAll() + + with self.assertRaisesRegex(modules.InvalidModuleError, + "Module 'foo' not found."): + modules.get_default_version(module='foo') + + # --- Tests for legacy get_default_version --- + + def testGetDefaultVersionLegacy(self): """Test we return the expected results.""" expected_request = modules_service_pb2.GetDefaultVersionRequest() expected_request.module = 'module1' @@ -168,7 +382,7 @@ def testGetDefaultVersion(self): service_response) self.assertEqual('v1', modules.get_default_version('module1')) - def testGetDefaultVersion_NoModule(self): + def testGetDefaultVersionLegacy_NoModule(self): """Test we return the expected results when no module is passed.""" expected_request = modules_service_pb2.GetDefaultVersionRequest() service_response = modules_service_pb2.GetDefaultVersionResponse() @@ -178,21 +392,99 @@ def testGetDefaultVersion_NoModule(self): service_response) self.assertEqual('v1', modules.get_default_version()) - def testGetDefaultVersion_InvalidModuleError(self): + def testGetDefaultVersionLegacy_InvalidModuleError(self): """Test we raise an error when one is received from the lower API.""" self.SetExceptionExpectations( 'GetDefaultVersion', modules_service_pb2.GetDefaultVersionRequest(), modules_service_pb2.ModulesServiceError.INVALID_MODULE) self.assertRaises(modules.InvalidModuleError, modules.get_default_version) - def testGetDefaultVersion_InvalidVersionError(self): + def testGetDefaultVersionLegacy_InvalidVersionError(self): """Test we raise an error when one is received from the lower API.""" self.SetExceptionExpectations( 'GetDefaultVersion', modules_service_pb2.GetDefaultVersionRequest(), modules_service_pb2.ModulesServiceError.INVALID_VERSION) self.assertRaises(modules.InvalidVersionError, modules.get_default_version) + + # --- Tests for updated get_num_instances --- def testGetNumInstances(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent('get_num_instances').AndReturn(mock_client) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + self.mox.StubOutWithMock(modules, 'get_current_module_name') + modules.get_current_module_name().AndReturn('default') + self.mox.StubOutWithMock(modules, 'get_current_version_name') + modules.get_current_version_name().AndReturn('v1') + + mock_apps = self.mox.CreateMockAnything() + mock_services = self.mox.CreateMockAnything() + mock_versions = self.mox.CreateMockAnything() + mock_request = self.mox.CreateMockAnything() + mock_client.apps().AndReturn(mock_apps) + mock_apps.services().AndReturn(mock_services) + mock_services.versions().AndReturn(mock_versions) + mock_versions.get(appsId='project', servicesId='default', + versionsId='v1').AndReturn(mock_request) + mock_request.execute().AndReturn({'manualScaling': {'instances': 5}}) + self.mox.ReplayAll() + self.assertEqual(5, modules.get_num_instances()) + + def testGetNumInstances_NoManualScaling(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent('get_num_instances').AndReturn(mock_client) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + self.mox.StubOutWithMock(modules, 'get_current_module_name') + modules.get_current_module_name().AndReturn('default') + self.mox.StubOutWithMock(modules, 'get_current_version_name') + modules.get_current_version_name().AndReturn('v1') + + mock_apps = self.mox.CreateMockAnything() + mock_services = self.mox.CreateMockAnything() + mock_versions = self.mox.CreateMockAnything() + mock_request = self.mox.CreateMockAnything() + mock_client.apps().AndReturn(mock_apps) + mock_apps.services().AndReturn(mock_services) + mock_services.versions().AndReturn(mock_versions) + mock_versions.get(appsId='project', servicesId='default', + versionsId='v1').AndReturn(mock_request) + mock_request.execute().AndReturn({'automaticScaling': {}}) + self.mox.ReplayAll() + self.assertEqual(0, modules.get_num_instances()) + + def testGetNumInstances_InvalidVersion(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent('get_num_instances').AndReturn(mock_client) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + self.mox.StubOutWithMock(modules, 'get_current_module_name') + modules.get_current_module_name().AndReturn('default') + + mock_apps = self.mox.CreateMockAnything() + mock_services = self.mox.CreateMockAnything() + mock_versions = self.mox.CreateMockAnything() + mock_request = self.mox.CreateMockAnything() + mock_client.apps().AndReturn(mock_apps) + mock_apps.services().AndReturn(mock_services) + mock_services.versions().AndReturn(mock_versions) + mock_versions.get(appsId='project', servicesId='default', + versionsId='v-bad').AndReturn(mock_request) + mock_request.execute().AndRaise(self._CreateHttpError(404)) + self.mox.ReplayAll() + with self.assertRaises(modules.InvalidVersionError): + modules.get_num_instances(version='v-bad') + + # --- Tests for updated get_num_instances --- + + def testGetNumInstancesLegacy(self): """Test we return the expected results.""" expected_request = modules_service_pb2.GetNumInstancesRequest() expected_request.module = 'module1' @@ -204,7 +496,7 @@ def testGetNumInstances(self): service_response) self.assertEqual(11, modules.get_num_instances('module1', 'v1')) - def testGetNumInstances_NoVersion(self): + def testGetNumInstancesLegacy_NoVersion(self): """Test we return the expected results when no version is passed.""" expected_request = modules_service_pb2.GetNumInstancesRequest() expected_request.module = 'module1' @@ -215,7 +507,7 @@ def testGetNumInstances_NoVersion(self): service_response) self.assertEqual(11, modules.get_num_instances('module1')) - def testGetNumInstances_NoModule(self): + def testGetNumInstancesLegacy_NoModule(self): """Test we return the expected results when no module is passed.""" expected_request = modules_service_pb2.GetNumInstancesRequest() expected_request.version = 'v1' @@ -226,7 +518,7 @@ def testGetNumInstances_NoModule(self): service_response) self.assertEqual(11, modules.get_num_instances(version='v1')) - def testGetNumInstances_AllDefaults(self): + def testGetNumInstancesLegacy_AllDefaults(self): """Test we return the expected results when no args are passed.""" expected_request = modules_service_pb2.GetNumInstancesRequest() service_response = modules_service_pb2.GetNumInstancesResponse() @@ -236,7 +528,7 @@ def testGetNumInstances_AllDefaults(self): service_response) self.assertEqual(11, modules.get_num_instances()) - def testGetNumInstances_InvalidVersionError(self): + def testGetNumInstancesLegacy_InvalidVersionError(self): """Test we raise the expected error when the API call fails.""" expected_request = modules_service_pb2.GetNumInstancesRequest() expected_request.module = 'module1' @@ -245,9 +537,77 @@ def testGetNumInstances_InvalidVersionError(self): 'GetNumInstances', expected_request, modules_service_pb2.ModulesServiceError.INVALID_VERSION) self.assertRaises(modules.InvalidVersionError, - modules.get_num_instances, 'module1', 'v1') + modules.get_num_instances, 'module1', 'v1') + + # --- Tests for updated set_num_instances--- def testSetNumInstances(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent('set_num_instances').AndReturn(mock_client) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + self.mox.StubOutWithMock(modules, 'get_current_module_name') + modules.get_current_module_name().AndReturn('default') + self.mox.StubOutWithMock(modules, 'get_current_version_name') + modules.get_current_version_name().AndReturn('v1') + + mock_apps = self.mox.CreateMockAnything() + mock_services = self.mox.CreateMockAnything() + mock_versions = self.mox.CreateMockAnything() + mock_request = self.mox.CreateMockAnything() + mock_client.apps().AndReturn(mock_apps) + mock_apps.services().AndReturn(mock_services) + mock_services.versions().AndReturn(mock_versions) + mock_versions.patch( + appsId='project', + servicesId='default', + versionsId='v1', + updateMask='manualScaling.instances', + body={'manualScaling': {'instances': 10}}).AndReturn(mock_request) + mock_request.execute() + self.mox.ReplayAll() + modules.set_num_instances(10) + + def testSetNumInstances_TypeError(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + with self.assertRaises(TypeError): + modules.set_num_instances('not-an-int') + + def testSetNumInstances_InvalidInstancesError(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent('set_num_instances').AndReturn(mock_client) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + self.mox.StubOutWithMock(modules, 'get_current_module_name') + modules.get_current_module_name().AndReturn('default') + self.mox.StubOutWithMock(modules, 'get_current_version_name') + modules.get_current_version_name().AndReturn('v1') + + mock_apps = self.mox.CreateMockAnything() + mock_services = self.mox.CreateMockAnything() + mock_versions = self.mox.CreateMockAnything() + mock_request = self.mox.CreateMockAnything() + mock_client.apps().AndReturn(mock_apps) + mock_apps.services().AndReturn(mock_services) + mock_services.versions().AndReturn(mock_versions) + mock_versions.patch( + appsId='project', + servicesId='default', + versionsId='v1', + updateMask='manualScaling.instances', + body={'manualScaling': {'instances': -1}}).AndReturn(mock_request) + mock_request.execute().AndRaise(self._CreateHttpError(400)) + self.mox.ReplayAll() + with self.assertRaises(modules.InvalidInstancesError): + modules.set_num_instances(-1) + + # --- Tests for legacy set_num_instances--- + + def testSetNumInstancesLegacy(self): """Test we return the expected results.""" expected_request = modules_service_pb2.SetNumInstancesRequest() expected_request.module = 'module1' @@ -259,7 +619,7 @@ def testSetNumInstances(self): service_response) modules.set_num_instances(12, 'module1', 'v1') - def testSetNumInstances_NoVersion(self): + def testSetNumInstancesLegacy_NoVersion(self): """Test we return the expected results when no version is passed.""" expected_request = modules_service_pb2.SetNumInstancesRequest() expected_request.module = 'module1' @@ -270,7 +630,7 @@ def testSetNumInstances_NoVersion(self): service_response) modules.set_num_instances(13, 'module1') - def testSetNumInstances_NoModule(self): + def testSetNumInstancesLegacy_NoModule(self): """Test we return the expected results when no module is passed.""" expected_request = modules_service_pb2.SetNumInstancesRequest() expected_request.version = 'v1' @@ -281,7 +641,7 @@ def testSetNumInstances_NoModule(self): service_response) modules.set_num_instances(14, version='v1') - def testSetNumInstances_AllDefaults(self): + def testSetNumInstancesLegacy_AllDefaults(self): """Test we return the expected results when no args are passed.""" expected_request = modules_service_pb2.SetNumInstancesRequest() expected_request.instances = 15 @@ -291,11 +651,11 @@ def testSetNumInstances_AllDefaults(self): service_response) modules.set_num_instances(15) - def testSetNumInstances_BadInstancesType(self): + def testSetNumInstancesLegacy_BadInstancesType(self): """Test we raise an error when we receive a bad instances type.""" self.assertRaises(TypeError, modules.set_num_instances, 'no good') - def testSetNumInstances_InvalidVersionError(self): + def testSetNumInstancesLegacy_InvalidVersionError(self): """Test we raise an error when we receive on from the underlying API.""" expected_request = modules_service_pb2.SetNumInstancesRequest() expected_request.instances = 23 @@ -305,7 +665,7 @@ def testSetNumInstances_InvalidVersionError(self): self.assertRaises(modules.InvalidVersionError, modules.set_num_instances, 23) - def testSetNumInstances_TransientError(self): + def testSetNumInstancesLegacy_TransientError(self): """Test we raise an error when we receive on from the underlying API.""" expected_request = modules_service_pb2.SetNumInstancesRequest() expected_request.instances = 23 @@ -314,7 +674,92 @@ def testSetNumInstances_TransientError(self): modules_service_pb2.ModulesServiceError.TRANSIENT_ERROR) self.assertRaises(modules.TransientError, modules.set_num_instances, 23) + # --- Tests for updated start_version--- + def testStartVersion(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent('start_version').AndReturn(mock_client) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + + mock_apps = self.mox.CreateMockAnything() + mock_services = self.mox.CreateMockAnything() + mock_versions = self.mox.CreateMockAnything() + mock_request = self.mox.CreateMockAnything() + mock_client.apps().AndReturn(mock_apps) + mock_apps.services().AndReturn(mock_services) + mock_services.versions().AndReturn(mock_versions) + mock_versions.patch( + appsId='project', + servicesId='default', + versionsId='v1', + updateMask='servingStatus', + body={'servingStatus': 'SERVING'}).AndReturn(mock_request) + mock_request.execute() + self.mox.ReplayAll() + modules.start_version('default', 'v1') + + def testStartVersion_InvalidVersion(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent('start_version').AndReturn(mock_client) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + + mock_apps = self.mox.CreateMockAnything() + mock_services = self.mox.CreateMockAnything() + mock_versions = self.mox.CreateMockAnything() + mock_request = self.mox.CreateMockAnything() + mock_client.apps().AndReturn(mock_apps) + mock_apps.services().AndReturn(mock_services) + mock_services.versions().AndReturn(mock_versions) + mock_versions.patch( + appsId='project', + servicesId='default', + versionsId='v-bad', + updateMask='servingStatus', + body={'servingStatus': 'SERVING'}).AndReturn(mock_request) + mock_request.execute().AndRaise(self._CreateHttpError(404)) + self.mox.ReplayAll() + with self.assertRaises(modules.InvalidVersionError): + modules.start_version('default', 'v-bad') + + def testStartVersionAsync_NoneArgs(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent('start_version').AndReturn(mock_client) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + self.mox.StubOutWithMock(modules, 'get_current_module_name') + modules.get_current_module_name().AndReturn('default') + self.mox.StubOutWithMock(modules, 'get_current_version_name') + modules.get_current_version_name().AndReturn('v1') + + mock_apps = self.mox.CreateMockAnything() + mock_services = self.mox.CreateMockAnything() + mock_versions = self.mox.CreateMockAnything() + mock_request = self.mox.CreateMockAnything() + mock_client.apps().AndReturn(mock_apps) + mock_apps.services().AndReturn(mock_services) + mock_services.versions().AndReturn(mock_versions) + mock_versions.patch( + appsId='project', + servicesId='default', + versionsId='v1', + updateMask='servingStatus', + body={'servingStatus': 'SERVING'}).AndReturn(mock_request) + mock_request.execute() + self.mox.ReplayAll() + rpc = modules.start_version_async(None, None) + rpc.get_result() + + # --- Tests for legacy start_version--- + + def testStartVersionLegacy(self): """Test we pass through the expected args.""" expected_request = modules_service_pb2.StartModuleRequest() expected_request.module = 'module1' @@ -325,7 +770,7 @@ def testStartVersion(self): service_response) modules.start_version('module1', 'v1') - def testStartVersion_InvalidVersionError(self): + def testStartVersionLegacy_InvalidVersionError(self): """Test we raise an error when we receive one from the API.""" expected_request = modules_service_pb2.StartModuleRequest() expected_request.module = 'module1' @@ -338,7 +783,7 @@ def testStartVersion_InvalidVersionError(self): 'module1', 'v1') - def testStartVersion_UnexpectedStateError(self): + def testStartVersionLegacy_UnexpectedStateError(self): """Test we don't raise an error if the version is already started.""" expected_request = modules_service_pb2.StartModuleRequest() expected_request.module = 'module1' @@ -351,7 +796,7 @@ def testStartVersion_UnexpectedStateError(self): modules_service_pb2.ModulesServiceError.UNEXPECTED_STATE) modules.start_version('module1', 'v1') - def testStartVersion_TransientError(self): + def testStartVersionLegacy_TransientError(self): """Test we raise an error when we receive one from the API.""" expected_request = modules_service_pb2.StartModuleRequest() expected_request.module = 'module1' @@ -363,19 +808,99 @@ def testStartVersion_TransientError(self): modules.start_version, 'module1', 'v1') + + # --- Tests for updated stop_version--- def testStopVersion(self): - """Test we pass through the expected args.""" - expected_request = modules_service_pb2.StopModuleRequest() - expected_request.module = 'module1' - expected_request.version = 'v1' - service_response = modules_service_pb2.StopModuleResponse() - self.SetSuccessExpectations('StopModule', - expected_request, - service_response) - modules.stop_version('module1', 'v1') + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent('stop_version').AndReturn(mock_client) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + self.mox.StubOutWithMock(modules, 'get_current_module_name') + modules.get_current_module_name().AndReturn('default') + self.mox.StubOutWithMock(modules, 'get_current_version_name') + modules.get_current_version_name().AndReturn('v1') + + mock_apps = self.mox.CreateMockAnything() + mock_services = self.mox.CreateMockAnything() + mock_versions = self.mox.CreateMockAnything() + mock_request = self.mox.CreateMockAnything() + mock_client.apps().AndReturn(mock_apps) + mock_apps.services().AndReturn(mock_services) + mock_services.versions().AndReturn(mock_versions) + mock_versions.patch( + appsId='project', + servicesId='default', + versionsId='v1', + updateMask='servingStatus', + body={'servingStatus': 'STOPPED'}).AndReturn(mock_request) + mock_request.execute() + self.mox.ReplayAll() + modules.stop_version() + + def testStopVersion_InvalidVersion(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent('stop_version').AndReturn(mock_client) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + self.mox.StubOutWithMock(modules, 'get_current_module_name') + modules.get_current_module_name().AndReturn('default') + + mock_apps = self.mox.CreateMockAnything() + mock_services = self.mox.CreateMockAnything() + mock_versions = self.mox.CreateMockAnything() + mock_request = self.mox.CreateMockAnything() + mock_client.apps().AndReturn(mock_apps) + mock_apps.services().AndReturn(mock_services) + mock_services.versions().AndReturn(mock_versions) + mock_versions.patch( + appsId='project', + servicesId='default', + versionsId='v-bad', + updateMask='servingStatus', + body={'servingStatus': 'STOPPED'}).AndReturn(mock_request) + mock_request.execute().AndRaise(self._CreateHttpError(404)) + self.mox.ReplayAll() + with self.assertRaises(modules.InvalidVersionError): + modules.stop_version(version='v-bad') - def testStopVersion_NoModule(self): + def testStopVersion_TransientError(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent('stop_version').AndReturn(mock_client) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + self.mox.StubOutWithMock(modules, 'get_current_module_name') + modules.get_current_module_name().AndReturn('default') + self.mox.StubOutWithMock(modules, 'get_current_version_name') + modules.get_current_version_name().AndReturn('v1') + + mock_apps = self.mox.CreateMockAnything() + mock_services = self.mox.CreateMockAnything() + mock_versions = self.mox.CreateMockAnything() + mock_request = self.mox.CreateMockAnything() + mock_client.apps().AndReturn(mock_apps) + mock_apps.services().AndReturn(mock_services) + mock_services.versions().AndReturn(mock_versions) + mock_versions.patch( + appsId='project', + servicesId='default', + versionsId='v1', + updateMask='servingStatus', + body={'servingStatus': 'STOPPED'}).AndReturn(mock_request) + mock_request.execute().AndRaise(self._CreateHttpError(500)) + self.mox.ReplayAll() + with self.assertRaises(modules.TransientError): + modules.stop_version() + + # --- Tests for legacy stop_version-- + + def testStopVersionLegacy_NoModule(self): """Test we pass through the expected args.""" expected_request = modules_service_pb2.StopModuleRequest() expected_request.version = 'v1' @@ -385,7 +910,7 @@ def testStopVersion_NoModule(self): service_response) modules.stop_version(version='v1') - def testStopVersion_NoVersion(self): + def testStopVersionLegacy_NoVersion(self): """Test we pass through the expected args.""" expected_request = modules_service_pb2.StopModuleRequest() expected_request.module = 'module1' @@ -395,7 +920,7 @@ def testStopVersion_NoVersion(self): service_response) modules.stop_version('module1') - def testStopVersion_InvalidVersionError(self): + def testStopVersionLegacy_InvalidVersionError(self): """Test we raise an error when we receive one from the API.""" expected_request = modules_service_pb2.StopModuleRequest() expected_request.module = 'module1' @@ -408,7 +933,7 @@ def testStopVersion_InvalidVersionError(self): 'module1', 'v1') - def testStopVersion_AlreadyStopped(self): + def testStopVersionLegacy_AlreadyStopped(self): """Test we don't raise an error if the version is already stopped.""" expected_request = modules_service_pb2.StopModuleRequest() expected_request.module = 'module1' @@ -421,14 +946,287 @@ def testStopVersion_AlreadyStopped(self): modules_service_pb2.ModulesServiceError.UNEXPECTED_STATE) modules.stop_version('module1', 'v1') - def testStopVersion_TransientError(self): + def testStopVersionLegacy_TransientError(self): """Test we raise an error when we receive one from the API.""" self.SetExceptionExpectations( 'StopModule', modules_service_pb2.StopModuleRequest(), modules_service_pb2.ModulesServiceError.TRANSIENT_ERROR) self.assertRaises(modules.TransientError, modules.stop_version) - def testGetHostname(self): + def testRaiseError_Generic(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent(mox.IsA(str)).AndReturn(mock_client) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + self.mox.StubOutWithMock(modules, 'get_current_module_name') + modules.get_current_module_name().AndReturn('default') + self.mox.StubOutWithMock(modules, 'get_current_version_name') + modules.get_current_version_name().AndReturn('v1') + + mock_apps = self.mox.CreateMockAnything() + mock_services = self.mox.CreateMockAnything() + mock_versions = self.mox.CreateMockAnything() + mock_request = self.mox.CreateMockAnything() + mock_client.apps().AndReturn(mock_apps) + mock_apps.services().AndReturn(mock_services) + mock_services.versions().AndReturn(mock_versions) + mock_versions.patch( + appsId='project', + servicesId='default', + versionsId='v1', + updateMask='servingStatus', + body={'servingStatus': 'STOPPED'}).AndReturn(mock_request) + mock_request.execute().AndRaise(self._CreateHttpError(401)) # Unauthorized + self.mox.ReplayAll() + with self.assertRaises(modules.Error): + modules.stop_version() + + # --- Tests for updated get_hostname --- + + def testGetHostname_WithVersion_NoInstance(self): + """Tests the simple case with an explicit module and version.""" + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_admin_api_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent( + 'get_hostname').AndReturn(mock_admin_api_client) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + self.mox.StubOutWithMock(modules, 'get_modules') + modules.get_modules().AndReturn(['default', 'other']) + mock_apps = self.mox.CreateMockAnything() + mock_get_request = self.mox.CreateMockAnything() + mock_admin_api_client.apps().AndReturn(mock_apps) + mock_apps.get(appsId='project').AndReturn(mock_get_request) + mock_get_request.execute().AndReturn( + {'defaultHostname': 'project.appspot.com'}) + self.mox.ReplayAll() + self.assertEqual('v2.foo.project.appspot.com', + modules.get_hostname(module='foo', version='v2')) + + def testGetHostname_Instance_Success(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_client_1 = self.mox.CreateMockAnything() + mock_client_2 = self.mox.CreateMockAnything() + + # Mock the two main dependencies of get_hostname + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent( + 'get_hostname').AndReturn(mock_client_1) + self.mox.StubOutWithMock(modules.discovery, 'build') + modules.discovery.build('appengine', 'v1').AndReturn(mock_client_2) + + # Mock the helper functions + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + self.mox.StubOutWithMock(modules, 'get_modules') + modules.get_modules().AndReturn(['default', 'other']) + self.mox.StubOutWithMock(modules, 'get_current_module_name') + modules.get_current_module_name().AndReturn('default') + self.mox.StubOutWithMock(modules, 'get_current_version_name') + modules.get_current_version_name().AndReturn('v1') + + # Set up expectations for the first client call + mock_apps_1 = self.mox.CreateMockAnything() + mock_get_request = self.mox.CreateMockAnything() + mock_client_1.apps().AndReturn(mock_apps_1) + mock_apps_1.get(appsId='project').AndReturn(mock_get_request) + mock_get_request.execute().AndReturn( + {'defaultHostname': 'project.appspot.com'}) + + # Set up expectations for the second client call + mock_apps_2 = self.mox.CreateMockAnything() + mock_services_2 = self.mox.CreateMockAnything() + mock_versions_2 = self.mox.CreateMockAnything() + mock_version_request = self.mox.CreateMockAnything() + mock_client_2.apps().AndReturn(mock_apps_2) + mock_apps_2.services().AndReturn(mock_services_2) + mock_services_2.versions().AndReturn(mock_versions_2) + mock_versions_2.get( + appsId='project', servicesId='default', versionsId='v1', + view='FULL').AndReturn(mock_version_request) + mock_version_request.execute().AndReturn( + {'manualScaling': {'instances': 5}}) + + self.mox.ReplayAll() + + self.assertEqual('2.v1.default.project.appspot.com', + modules.get_hostname(instance='2')) + + + def testGetHostname_Instance_NoManualScaling(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_client_1 = self.mox.CreateMockAnything() + mock_client_2 = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent('get_hostname').AndReturn(mock_client_1) + self.mox.StubOutWithMock(modules.discovery, 'build') + modules.discovery.build('appengine', 'v1').AndReturn(mock_client_2) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + self.mox.StubOutWithMock(modules, 'get_modules') + modules.get_modules().AndReturn(['default', 'other']) + self.mox.StubOutWithMock(modules, 'get_current_module_name') + modules.get_current_module_name().AndReturn('default') + self.mox.StubOutWithMock(modules, 'get_current_version_name') + modules.get_current_version_name().AndReturn('v1') + mock_apps_1 = self.mox.CreateMockAnything() + mock_get_request = self.mox.CreateMockAnything() + mock_client_1.apps().AndReturn(mock_apps_1) + mock_apps_1.get(appsId='project').AndReturn(mock_get_request) + mock_get_request.execute().AndReturn( + {'defaultHostname': 'project.appspot.com'}) + mock_apps_2 = self.mox.CreateMockAnything() + mock_services_2 = self.mox.CreateMockAnything() + mock_versions_2 = self.mox.CreateMockAnything() + mock_version_request = self.mox.CreateMockAnything() + mock_client_2.apps().AndReturn(mock_apps_2) + mock_apps_2.services().AndReturn(mock_services_2) + mock_services_2.versions().AndReturn(mock_versions_2) + mock_versions_2.get( + appsId='project', servicesId='default', versionsId='v1', + view='FULL').AndReturn(mock_version_request) + mock_version_request.execute().AndReturn({'automaticScaling': {}}) + self.mox.ReplayAll() + with self.assertRaisesRegex( + modules.InvalidInstancesError, + 'Instance-specific hostnames are only available for manually scaled ' + 'services.'): + modules.get_hostname(instance='1') + + def testGetHostname_Instance_OutOfBounds(self): + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_api_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(google.auth, 'default') + google.auth.default().AndReturn((None, 'project')) + + self.mox.StubOutWithMock(modules.discovery, 'build') + modules.discovery.build('appengine', 'v1', http=mox.IsA(object)).AndReturn(mock_api_client) + modules.discovery.build('appengine', 'v1').AndReturn(mock_api_client) + + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + self.mox.StubOutWithMock(modules, 'get_modules') + modules.get_modules().AndReturn(['default', 'other']) + self.mox.StubOutWithMock(modules, 'get_current_module_name') + modules.get_current_module_name().AndReturn('default') + self.mox.StubOutWithMock(modules, 'get_current_version_name') + modules.get_current_version_name().AndReturn('v1') + mock_apps = self.mox.CreateMockAnything() + mock_get_request = self.mox.CreateMockAnything() + mock_services = self.mox.CreateMockAnything() + mock_versions = self.mox.CreateMockAnything() + mock_version_request = self.mox.CreateMockAnything() + mock_api_client.apps().AndReturn(mock_apps) + mock_apps.get(appsId='project').AndReturn(mock_get_request) + mock_get_request.execute().AndReturn( + {'defaultHostname': 'project.appspot.com'}) + mock_api_client.apps().AndReturn(mock_apps) + mock_apps.services().AndReturn(mock_services) + mock_services.versions().AndReturn(mock_versions) + mock_versions.get( + appsId='project', servicesId='default', versionsId='v1', + view='FULL').AndReturn(mock_version_request) + mock_version_request.execute().AndReturn( + {'manualScaling': {'instances': 5}}) + self.mox.ReplayAll() + with self.assertRaisesRegex( + modules.InvalidInstancesError, + 'The specified instance does not exist for this module/version.'): + modules.get_hostname(instance='5') + + def testGetHostname_Instance_InvalidValue(self): + """Tests instance request with an invalid non-integer instance value.""" + os.environ['MODULES_USE_ADMIN_API'] = 'true' + with self.assertRaisesRegex( + modules.InvalidInstancesError, + 'Instance must be a non-negative integer.'): + modules.get_hostname(instance='foo') + + def testGetHostname_NoVersion_VersionExistsOnTarget(self): + """Tests no-version call where the current version exists on the target.""" + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent('get_hostname').AndReturn(mock_client) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + self.mox.StubOutWithMock(modules, 'get_modules') + modules.get_modules().AndReturn(['default', 'module1']) + self.mox.StubOutWithMock(modules, 'get_current_version_name') + modules.get_current_version_name().AndReturn('v1') + self.mox.StubOutWithMock(modules, 'get_versions') + modules.get_versions(module='module1').AndReturn(['v1', 'v2']) + mock_apps = self.mox.CreateMockAnything() + mock_get_request = self.mox.CreateMockAnything() + mock_client.apps().AndReturn(mock_apps) + mock_apps.get(appsId='project').AndReturn(mock_get_request) + mock_get_request.execute().AndReturn( + {'defaultHostname': 'project.appspot.com'}) + self.mox.ReplayAll() + self.assertEqual('v1.module1.project.appspot.com', + modules.get_hostname(module='module1')) + + def testGetHostname_NoVersion_VersionDoesNotExistOnTarget(self): + """Tests no-version call where the current version is not on the target.""" + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent('get_hostname').AndReturn(mock_client) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + self.mox.StubOutWithMock(modules, 'get_modules') + modules.get_modules().AndReturn(['default', 'module1']) + self.mox.StubOutWithMock(modules, 'get_current_version_name') + modules.get_current_version_name().AndReturn('v1') + self.mox.StubOutWithMock(modules, 'get_versions') + modules.get_versions(module='module1').AndReturn(['v2', 'v3']) + mock_apps = self.mox.CreateMockAnything() + mock_get_request = self.mox.CreateMockAnything() + mock_client.apps().AndReturn(mock_apps) + mock_apps.get(appsId='project').AndReturn(mock_get_request) + mock_get_request.execute().AndReturn( + {'defaultHostname': 'project.appspot.com'}) + self.mox.ReplayAll() + self.assertEqual('module1.project.appspot.com', + modules.get_hostname(module='module1')) + + def testGetHostname_LegacyApp_Success(self): + """Tests a hostname request for a legacy app without engines.""" + os.environ['MODULES_USE_ADMIN_API'] = 'true' + mock_client = self.mox.CreateMockAnything() + self.mox.StubOutWithMock(modules, '_get_admin_api_client_with_useragent') + modules._get_admin_api_client_with_useragent('get_hostname').AndReturn(mock_client) + self.mox.StubOutWithMock(modules, '_get_project_id') + modules._get_project_id().AndReturn('project') + self.mox.StubOutWithMock(modules, 'get_modules') + modules.get_modules().AndReturn(['default']) + self.mox.StubOutWithMock(modules, 'get_current_module_name') + modules.get_current_module_name().AndReturn('default') + self.mox.StubOutWithMock(modules, 'get_current_version_name') + modules.get_current_version_name().AndReturn('v1') + mock_apps = self.mox.CreateMockAnything() + mock_get_request = self.mox.CreateMockAnything() + mock_client.apps().AndReturn(mock_apps) + mock_apps.get(appsId='project').AndReturn(mock_get_request) + mock_get_request.execute().AndReturn( + {'defaultHostname': 'project.appspot.com'}) + self.mox.ReplayAll() + self.assertEqual('v1.project.appspot.com', modules.get_hostname()) + + def testGetHostname_LegacyApp_WithInstance(self): + """Tests a legacy app request with an invalid non-integer instance.""" + os.environ['MODULES_USE_ADMIN_API'] = 'true' + with self.assertRaisesRegex( + modules.InvalidInstancesError, + 'Instance must be a non-negative integer.'): + modules.get_hostname(instance='i') + + # --- Tests for Legacy get_hostname --- + + def testGetHostnameLegacy(self): """Test we pass through the expected args.""" expected_request = modules_service_pb2.GetHostnameRequest() expected_request.module = 'module1' @@ -441,7 +1239,7 @@ def testGetHostname(self): service_response) self.assertEqual('abc', modules.get_hostname('module1', 'v1', '3')) - def testGetHostname_NoModule(self): + def testGetHostnameLegacy_NoModule(self): """Test we pass through the expected args when no module is specified.""" expected_request = modules_service_pb2.GetHostnameRequest() expected_request.version = 'v1' @@ -453,7 +1251,7 @@ def testGetHostname_NoModule(self): service_response) self.assertEqual('abc', modules.get_hostname(version='v1', instance='3')) - def testGetHostname_NoVersion(self): + def testGetHostnameLegacy_NoVersion(self): """Test we pass through the expected args when no version is specified.""" expected_request = modules_service_pb2.GetHostnameRequest() expected_request.module = 'module1' @@ -466,7 +1264,7 @@ def testGetHostname_NoVersion(self): self.assertEqual('abc', modules.get_hostname(module='module1', instance='3')) - def testGetHostname_IntInstance(self): + def testGetHostnameLegacy_IntInstance(self): """Test we pass through the expected args when an int instance is given.""" expected_request = modules_service_pb2.GetHostnameRequest() expected_request.module = 'module1' @@ -478,7 +1276,7 @@ def testGetHostname_IntInstance(self): service_response) self.assertEqual('abc', modules.get_hostname(module='module1', instance=3)) - def testGetHostname_InstanceZero(self): + def testGetHostnameLegacy_InstanceZero(self): """Test we pass through the expected args when instance zero is given.""" expected_request = modules_service_pb2.GetHostnameRequest() expected_request.module = 'module1' @@ -490,7 +1288,7 @@ def testGetHostname_InstanceZero(self): service_response) self.assertEqual('abc', modules.get_hostname(module='module1', instance=0)) - def testGetHostname_NoArgs(self): + def testGetHostnameLegacy_NoArgs(self): """Test we pass through the expected args when none are given.""" expected_request = modules_service_pb2.GetHostnameRequest() service_response = modules_service_pb2.GetHostnameResponse() @@ -500,7 +1298,7 @@ def testGetHostname_NoArgs(self): service_response) self.assertEqual('abc', modules.get_hostname()) - def testGetHostname_BadInstanceType(self): + def testGetHostnameLegacy_BadInstanceType(self): """Test get_hostname throws a TypeError when passed a float for instance.""" self.assertRaises(TypeError, modules.get_hostname, @@ -508,7 +1306,7 @@ def testGetHostname_BadInstanceType(self): 'v1', 1.2) - def testGetHostname_InvalidModuleError(self): + def testGetHostnameLegacy_InvalidModuleError(self): """Test we raise an error when we receive one from the API.""" expected_request = modules_service_pb2.GetHostnameRequest() expected_request.module = 'module1' @@ -521,14 +1319,14 @@ def testGetHostname_InvalidModuleError(self): 'module1', 'v1') - def testGetHostname_InvalidInstancesError(self): + def testGetHostnameLegacy_InvalidInstancesError(self): """Test we raise an error when we receive one from the API.""" self.SetExceptionExpectations( 'GetHostname', modules_service_pb2.GetHostnameRequest(), modules_service_pb2.ModulesServiceError.INVALID_INSTANCES) self.assertRaises(modules.InvalidInstancesError, modules.get_hostname) - def testGetHostname_UnKnownError(self): + def testGetHostnameLegacy_UnKnownError(self): """Test we raise an error when we receive one from the API.""" expected_request = modules_service_pb2.GetHostnameRequest() expected_request.module = 'module1' @@ -541,7 +1339,7 @@ def testGetHostname_UnKnownError(self): 'module1', 'v1') - def testGetHostname_UnMappedError(self): + def testGetHostnameLegacy_UnMappedError(self): """Test we raise an error when we receive one from the API.""" expected_request = modules_service_pb2.GetHostnameRequest() expected_request.module = 'module1' @@ -557,7 +1355,6 @@ def testGetHostname_UnMappedError(self): 'module1', 'v1') - class MockRpc(object): """Mock UserRPC class.""" @@ -595,7 +1392,6 @@ def make_call(self, method, self._hook = get_result_hook self.user_data = user_data - if __name__ == '__main__': absltest.main() diff --git a/tox.ini b/tox.ini index 3e65358..831ccf0 100644 --- a/tox.ini +++ b/tox.ini @@ -30,4 +30,5 @@ deps = ruamel.yaml < 0.18 six urllib3 + google-api-python-client commands = pytest --cov=google.appengine {posargs}