From 119a43cd8ba2a94f5b43edc39172be9660715eaa Mon Sep 17 00:00:00 2001 From: Tim Swast Date: Fri, 30 Sep 2016 17:15:28 -0700 Subject: [PATCH] Add initial support for RuntimeConfig API. To keep the initial review small, this includes only a minimal set of features. It supports listing and getting variables, but not the other methods of the API. --- runtimeconfig/.coveragerc | 11 + runtimeconfig/MANIFEST.in | 4 + runtimeconfig/README.rst | 44 +++ runtimeconfig/google/__init__.py | 20 + runtimeconfig/google/cloud/__init__.py | 20 + .../google/cloud/runtimeconfig/__init__.py | 17 + .../google/cloud/runtimeconfig/_helpers.py | 70 ++++ .../google/cloud/runtimeconfig/client.py | 59 +++ .../google/cloud/runtimeconfig/config.py | 262 +++++++++++++ .../google/cloud/runtimeconfig/connection.py | 47 +++ .../google/cloud/runtimeconfig/variable.py | 233 ++++++++++++ runtimeconfig/setup.cfg | 2 + runtimeconfig/setup.py | 68 ++++ runtimeconfig/tox.ini | 30 ++ runtimeconfig/unit_tests/__init__.py | 13 + runtimeconfig/unit_tests/test__helpers.py | 73 ++++ runtimeconfig/unit_tests/test_client.py | 53 +++ runtimeconfig/unit_tests/test_config.py | 345 ++++++++++++++++++ runtimeconfig/unit_tests/test_connection.py | 44 +++ runtimeconfig/unit_tests/test_variable.py | 189 ++++++++++ 20 files changed, 1604 insertions(+) create mode 100644 runtimeconfig/.coveragerc create mode 100644 runtimeconfig/MANIFEST.in create mode 100644 runtimeconfig/README.rst create mode 100644 runtimeconfig/google/__init__.py create mode 100644 runtimeconfig/google/cloud/__init__.py create mode 100644 runtimeconfig/google/cloud/runtimeconfig/__init__.py create mode 100644 runtimeconfig/google/cloud/runtimeconfig/_helpers.py create mode 100644 runtimeconfig/google/cloud/runtimeconfig/client.py create mode 100644 runtimeconfig/google/cloud/runtimeconfig/config.py create mode 100644 runtimeconfig/google/cloud/runtimeconfig/connection.py create mode 100644 runtimeconfig/google/cloud/runtimeconfig/variable.py create mode 100644 runtimeconfig/setup.cfg create mode 100644 runtimeconfig/setup.py create mode 100644 runtimeconfig/tox.ini create mode 100644 runtimeconfig/unit_tests/__init__.py create mode 100644 runtimeconfig/unit_tests/test__helpers.py create mode 100644 runtimeconfig/unit_tests/test_client.py create mode 100644 runtimeconfig/unit_tests/test_config.py create mode 100644 runtimeconfig/unit_tests/test_connection.py create mode 100644 runtimeconfig/unit_tests/test_variable.py diff --git a/runtimeconfig/.coveragerc b/runtimeconfig/.coveragerc new file mode 100644 index 000000000000..a54b99aa14b7 --- /dev/null +++ b/runtimeconfig/.coveragerc @@ -0,0 +1,11 @@ +[run] +branch = True + +[report] +fail_under = 100 +show_missing = True +exclude_lines = + # Re-enable the standard pragma + pragma: NO COVER + # Ignore debug-only repr + def __repr__ diff --git a/runtimeconfig/MANIFEST.in b/runtimeconfig/MANIFEST.in new file mode 100644 index 000000000000..cb3a2b9ef4fa --- /dev/null +++ b/runtimeconfig/MANIFEST.in @@ -0,0 +1,4 @@ +include README.rst +graft google +graft unit_tests +global-exclude *.pyc diff --git a/runtimeconfig/README.rst b/runtimeconfig/README.rst new file mode 100644 index 000000000000..7baa4779d508 --- /dev/null +++ b/runtimeconfig/README.rst @@ -0,0 +1,44 @@ +Python Client for Google Cloud RuntimeConfig +============================================ + + Python idiomatic client for `Google Cloud RuntimeConfig`_ + +.. _Google Cloud RuntimeConfig: https://cloud.google.com/deployment-manager/runtime-configurator/ + +- `Documentation`_ + +.. _Documentation: http://googlecloudplatform.github.io/google-cloud-python/ + +Quick Start +----------- + +:: + + $ pip install --upgrade google-cloud-runtimeconfig + +Authentication +-------------- + +With ``google-cloud-python`` we try to make authentication as painless as +possible. Check out the `Authentication section`_ in our documentation to +learn more. You may also find the `authentication document`_ shared by all +the ``google-cloud-*`` libraries to be helpful. + +.. _Authentication section: http://google-cloud-python.readthedocs.io/en/latest/google-cloud-auth.html +.. _authentication document: https://github.com/GoogleCloudPlatform/gcloud-common/tree/master/authentication + +Using the API +------------- + +The Google Cloud `RuntimeConfig`_ (`RuntimeConfig API docs`_) API enables +developers to dynamically configure and expose variables through Google Cloud +Platform. In addition, you can also set Watchers and Waiters that will watch +for changes to your data and return based on certain conditions. + +.. _RuntimeConfig: https://cloud.google.com/deployment-manager/runtime-configurator/ +.. _RuntimeConfig API docs: https://cloud.google.com/deployment-manager/runtime-configurator/reference/rest/ + +See the ``google-cloud-python`` API `runtimeconfig documentation`_ to learn +how to interact with Cloud RuntimeConfig using this Client Library. + +.. _RuntimeConfig documentation: https://google-cloud-python.readthedocs.io/en/stable/runtimeconfig-usage.html diff --git a/runtimeconfig/google/__init__.py b/runtimeconfig/google/__init__.py new file mode 100644 index 000000000000..b2b833373882 --- /dev/null +++ b/runtimeconfig/google/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# 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 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +try: + import pkg_resources + pkg_resources.declare_namespace(__name__) +except ImportError: + import pkgutil + __path__ = pkgutil.extend_path(__path__, __name__) diff --git a/runtimeconfig/google/cloud/__init__.py b/runtimeconfig/google/cloud/__init__.py new file mode 100644 index 000000000000..b2b833373882 --- /dev/null +++ b/runtimeconfig/google/cloud/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# 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 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +try: + import pkg_resources + pkg_resources.declare_namespace(__name__) +except ImportError: + import pkgutil + __path__ = pkgutil.extend_path(__path__, __name__) diff --git a/runtimeconfig/google/cloud/runtimeconfig/__init__.py b/runtimeconfig/google/cloud/runtimeconfig/__init__.py new file mode 100644 index 000000000000..1ab5f83d202c --- /dev/null +++ b/runtimeconfig/google/cloud/runtimeconfig/__init__.py @@ -0,0 +1,17 @@ +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# 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 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Google Cloud Runtime Configurator API package.""" + +from google.cloud.runtimeconfig.client import Client diff --git a/runtimeconfig/google/cloud/runtimeconfig/_helpers.py b/runtimeconfig/google/cloud/runtimeconfig/_helpers.py new file mode 100644 index 000000000000..e03bb794f605 --- /dev/null +++ b/runtimeconfig/google/cloud/runtimeconfig/_helpers.py @@ -0,0 +1,70 @@ +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# 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 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Shared helper functions for RuntimeConfig API classes.""" + + +def config_name_from_full_name(full_name): + """Extract the config name from a full resource name. + + >>> config_name_from_full_name('projects/my-proj/configs/my-config') + "my-config" + + :type full_name: str + :param full_name: + The full resource name of a config. The full resource name looks like + ``projects/project-name/configs/config-name`` and is returned as the + ``name`` field of a config resource. See: + https://cloud.google.com/deployment-manager/runtime-configurator/reference/rest/v1beta1/projects.configs + + :rtype: str + :returns: The config's short name, given its full resource name. + :raises: :class:`ValueError` if ``full_name`` is not the expected format + """ + projects, _, configs, result = full_name.split('/') + if projects != 'projects' or configs != 'configs': + raise ValueError( + 'Unexpected format of resource', full_name, + 'Expected "projects/{proj}/configs/{cfg}"') + return result + + +def variable_name_from_full_name(full_name): + """Extract the variable name from a full resource name. + + >>> variable_name_from_full_name( + 'projects/my-proj/configs/my-config/variables/var-name') + "var-name" + >>> variable_name_from_full_name( + 'projects/my-proj/configs/my-config/variables/another/var/name') + "another/var/name" + + :type full_name: str + :param full_name: + The full resource name of a variable. The full resource name looks like + ``projects/prj-name/configs/cfg-name/variables/var-name`` and is + returned as the ``name`` field of a variable resource. See: + https://cloud.google.com/deployment-manager/runtime-configurator/reference/rest/v1beta1/projects.configs.variables + + :rtype: str + :returns: The variable's short name, given its full resource name. + :raises: :class:`ValueError` if ``full_name`` is not the expected format + """ + projects, _, configs, _, variables, result = full_name.split('/', 5) + if (projects != 'projects' or configs != 'configs' or + variables != 'variables'): + raise ValueError( + 'Unexpected format of resource', full_name, + 'Expected "projects/{proj}/configs/{cfg}/variables/..."') + return result diff --git a/runtimeconfig/google/cloud/runtimeconfig/client.py b/runtimeconfig/google/cloud/runtimeconfig/client.py new file mode 100644 index 000000000000..e6fd120f9ca3 --- /dev/null +++ b/runtimeconfig/google/cloud/runtimeconfig/client.py @@ -0,0 +1,59 @@ +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# 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 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Client for interacting with the Google Cloud RuntimeConfig API.""" + + +from google.cloud.client import JSONClient +from google.cloud.runtimeconfig.connection import Connection +from google.cloud.runtimeconfig.config import Config + + +class Client(JSONClient): + """Client to bundle configuration needed for API requests. + + :type project: str + :param project: + (Optional) The project which the client acts on behalf of. If not + passed, falls back to the default inferred from the environment. + + :type credentials: :class:`oauth2client.client.OAuth2Credentials` + :param credentials: + (Optional) The OAuth2 Credentials to use for the connection owned by + this client. If not passed (and if no ``http`` object is passed), falls + back to the default inferred from the environment. + + :type http: :class:`httplib2.Http` or class that defines ``request()``. + :param http: + (Optional) An HTTP object to make requests. If not passed, an ``http`` + object is created that is bound to the ``credentials`` for the current + object. + """ + + _connection_class = Connection + + def config(self, config_name): + """Factory constructor for config object. + + .. note:: + This will not make an HTTP request; it simply instantiates + a config object owned by this client. + + :type config_name: str + :param config_name: The name of the config to be instantiated. + + :rtype: :class:`google.cloud.runtimeconfig.config.Config` + :returns: The config object created. + """ + return Config(client=self, name=config_name) diff --git a/runtimeconfig/google/cloud/runtimeconfig/config.py b/runtimeconfig/google/cloud/runtimeconfig/config.py new file mode 100644 index 000000000000..b73897dd02b1 --- /dev/null +++ b/runtimeconfig/google/cloud/runtimeconfig/config.py @@ -0,0 +1,262 @@ +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# 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 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Create / interact with Google Cloud RuntimeConfig configs.""" + +from google.cloud.exceptions import NotFound +from google.cloud.runtimeconfig._helpers import config_name_from_full_name +from google.cloud.runtimeconfig.variable import Variable +from google.cloud.iterator import Iterator + + +class Config(object): + """A Config resource in the Cloud RuntimeConfig service. + + This consists of metadata and a hierarchy of variables. + + See: + https://cloud.google.com/deployment-manager/runtime-configurator/reference/rest/v1beta1/projects.configs + + :type client: :class:`google.cloud.runtimeconfig.client.Client` + :param client: A client which holds credentials and project configuration + for the config (which requires a project). + + :type name: str + :param name: The name of the config. + """ + + def __init__(self, client, name): + self._client = client + self.name = name + self._properties = {} + + def __repr__(self): + return '' % (self.name,) + + @property + def client(self): + """The client bound to this config.""" + return self._client + + @property + def description(self): + """Description of the config object. + + See: + https://cloud.google.com/deployment-manager/runtime-configurator/reference/rest/v1beta1/projects.configs#resource-runtimeconfig + + :rtype: str, or ``NoneType`` + :returns: the description (None until set from the server). + """ + return self._properties.get('description') + + @property + def project(self): + """Project bound to the config. + + :rtype: str + :returns: the project (derived from the client). + """ + return self._client.project + + @property + def full_name(self): + """Fully-qualified name of this variable. + + Example: + ``projects/my-project/configs/my-config`` + + :rtype: str + :returns: The full name based on project and config names. + + :raises: :class:`ValueError` if the config is missing a name. + """ + if not self.name: + raise ValueError('Missing config name.') + return 'projects/%s/configs/%s' % (self._client.project, self.name) + + @property + def path(self): + """URL path for the config's APIs. + + :rtype: str + :returns: The URL path based on project and config names. + """ + return '/%s' % (self.full_name,) + + def variable(self, variable_name): + """Factory constructor for variable object. + + .. note:: + This will not make an HTTP request; it simply instantiates + a variable object owned by this config. + + :type variable_name: str + :param variable_name: The name of the variable to be instantiated. + + :rtype: :class:`google.cloud.runtimeconfig.variable.Variable` + :returns: The variable object created. + """ + return Variable(name=variable_name, config=self) + + def _require_client(self, client): + """Check client or verify over-ride. + + :type client: :class:`google.cloud.runtimconfig.client.Client` + :param client: + (Optional) The client to use. If not passed, falls back to the + ``client`` stored on the current zone. + + :rtype: :class:`google.cloud.runtimeconfig.client.Client` + :returns: The client passed in or the currently bound client. + """ + if client is None: + client = self._client + return client + + def _set_properties(self, api_response): + """Update properties from resource in body of ``api_response`` + + :type api_response: httplib2.Response + :param api_response: response returned from an API call + """ + self._properties.clear() + cleaned = api_response.copy() + if 'name' in cleaned: + self.name = config_name_from_full_name(cleaned.pop('name')) + self._properties.update(cleaned) + + def exists(self, client=None): + """Determines whether or not this config exists. + + :type client: :class:`~google.cloud.runtimeconfig.client.Client` + :param client: + (Optional) The client to use. If not passed, falls back to the + ``client`` stored on the current config. + + :rtype: bool + :returns: True if the config exists in Cloud Runtime Configurator. + """ + client = self._require_client(client) + try: + # We only need the status code (200 or not) so we seek to + # minimize the returned payload. + query_params = {'fields': 'name'} + client.connection.api_request( + method='GET', path=self.path, query_params=query_params) + return True + except NotFound: + return False + + def reload(self, client=None): + """API call: reload the config via a ``GET`` request. + + This method will reload the newest data for the config. + + See: + https://cloud.google.com/deployment-manager/runtime-configurator/reference/rest/v1beta1/projects.configs/get + + :type client: :class:`google.cloud.runtimeconfig.client.Client` + :param client: + (Optional) The client to use. If not passed, falls back to the + client stored on the current config. + """ + client = self._require_client(client) + + # We assume the config exists. If it doesn't it will raise a NotFound + # exception. + resp = client.connection.api_request(method='GET', path=self.path) + self._set_properties(api_response=resp) + + def get_variable(self, variable_name, client=None): + """API call: get a variable via a ``GET`` request. + + This will return None if the variable doesn't exist:: + + >>> from google.cloud import runtimeconfig + >>> client = runtimeconfig.Client() + >>> config = client.get_config('my-config') + >>> print(config.get_varialbe('variable-name')) + + >>> print(config.get_variable('does-not-exist')) + None + + :type variable_name: str + :param variable_name: The name of the variable to retrieve. + + :type client: :class:`~google.cloud.runtimeconfig.client.Client` + :param client: + (Optional) The client to use. If not passed, falls back to the + ``client`` stored on the current config. + + :rtype: :class:`google.cloud.runtimeconfig.variable.Variable` or None + :returns: The variable object if it exists, otherwise None. + """ + client = self._require_client(client) + variable = Variable(config=self, name=variable_name) + try: + variable.reload(client=client) + return variable + except NotFound: + return None + + def list_variables(self, page_size=None, page_token=None, client=None): + """API call: list variables for this config. + + This only lists variable names, not the values. + + See: + https://cloud.google.com/deployment-manager/runtime-configurator/reference/rest/v1beta1/projects.configs.variables/list + + :type page_size: int + :param page_size: + (Optional) Maximum number of variables to return per page. + + :type page_token: str + :param page_token: opaque marker for the next "page" of variables. If + not passed, will return the first page of variables. + + :type client: :class:`~google.cloud.runtimeconfig.client.Client` + :param client: + (Optional) The client to use. If not passed, falls back to the + ``client`` stored on the current config. + + :rtype: :class:`~google.cloud.iterator.Iterator` + :returns: + Iterator of :class:`~google.cloud.runtimeconfig.variable.Variable` + belonging to this project. + """ + path = '%s/variables' % (self.path,) + iterator = Iterator( + client=self._require_client(client), path=path, + items_key='variables', item_to_value=_item_to_variable, + page_token=page_token, max_results=page_size) + iterator._MAX_RESULTS = 'pageSize' + iterator.config = self + return iterator + + +def _item_to_variable(iterator, resource): + """Convert a JSON variable to the native object. + + :type iterator: :class:`~google.cloud.iterator.Iterator` + :param iterator: The iterator that has retrieved the item. + + :type resource: dict + :param resource: An item to be converted to a variable. + + :rtype: :class:`.Variable` + :returns: The next variable in the page. + """ + return Variable.from_api_repr(resource, iterator.config) diff --git a/runtimeconfig/google/cloud/runtimeconfig/connection.py b/runtimeconfig/google/cloud/runtimeconfig/connection.py new file mode 100644 index 000000000000..958b6a4aa869 --- /dev/null +++ b/runtimeconfig/google/cloud/runtimeconfig/connection.py @@ -0,0 +1,47 @@ +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# 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 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Create / interact with Google Cloud RuntimeConfig connections.""" + + +from google.cloud import connection as base_connection + + +class Connection(base_connection.JSONConnection): + """A connection to Google Cloud RuntimeConfig via the JSON REST API. + + :type credentials: :class:`oauth2client.client.OAuth2Credentials` + :param credentials: (Optional) The OAuth2 Credentials to use for this + connection. + + :type http: :class:`httplib2.Http` or class that defines ``request()``. + :param http: (Optional) HTTP object to make requests. + + :type api_base_url: str + :param api_base_url: The base of the API call URL. Defaults to the value + :attr:`Connection.API_BASE_URL`. + """ + + API_BASE_URL = 'https://runtimeconfig.googleapis.com' + """The base of the API call URL.""" + + API_VERSION = 'v1beta1' + """The version of the API, used in building the API call's URL.""" + + API_URL_TEMPLATE = '{api_base_url}/{api_version}{path}' + """A template for the URL of a particular API call.""" + + SCOPE = ('https://www.googleapis.com/auth/cloudruntimeconfig',) + """The scopes required for authenticating as a RuntimeConfig consumer.""" diff --git a/runtimeconfig/google/cloud/runtimeconfig/variable.py b/runtimeconfig/google/cloud/runtimeconfig/variable.py new file mode 100644 index 000000000000..cf2c733c3a21 --- /dev/null +++ b/runtimeconfig/google/cloud/runtimeconfig/variable.py @@ -0,0 +1,233 @@ +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# 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 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Create / interact with Google Cloud RuntimeConfig variables. + +.. data:: STATE_UNSPECIFIED + + The default variable state. See: + https://cloud.google.com/deployment-manager/runtime-configurator/reference/rest/v1beta1/projects.configs.variables#VariableState + +.. data:: STATE_UPDATED + + Indicates the variable was updated, while `variables.watch` was executing. + See: + https://cloud.google.com/deployment-manager/runtime-configurator/reference/rest/v1beta1/projects.configs.variables#VariableState + +.. data:: STATE_DELETED + + Indicates the variable was deleted, while `variables.watch`_ was executing. + See: + https://cloud.google.com/deployment-manager/runtime-configurator/reference/rest/v1beta1/projects.configs.variables#VariableState + +.. _variables.watch: + https://cloud.google.com/deployment-manager/runtime-configurator/reference/rest/v1beta1/projects.configs.variables/watch +""" + +import base64 + +from google.cloud._helpers import _rfc3339_to_datetime +from google.cloud.exceptions import NotFound +from google.cloud.runtimeconfig._helpers import variable_name_from_full_name + + +STATE_UNSPECIFIED = 'VARIABLE_STATE_UNSPECIFIED' +STATE_UPDATED = 'UPDATED' +STATE_DELETED = 'DELETED' + + +class Variable(object): + """A variable in the Cloud RuntimeConfig service. + + See: + https://cloud.google.com/deployment-manager/runtime-configurator/reference/rest/v1beta1/projects.configs.variables + + :type name: str + :param name: The name of the variable. This corresponds to the + unique path of the variable in the config. + + :type config: :class:`google.cloud.runtimeconfig.config.Config` + :param config: The config to which this variable belongs. + """ + + def __init__(self, name, config): + self.name = name + self.config = config + self._properties = {} + + @classmethod + def from_api_repr(cls, resource, config): + """Factory: construct a Variable given its API representation + + :type resource: dict + :param resource: change set representation returned from the API. + + :type config: :class:`google.cloud.runtimeconfig.config.Config` + :param config: The config to which this variable belongs. + + :rtype: :class:`google.cloud.runtimeconfig.variable.Variable` + :returns: Variable parsed from ``resource``. + """ + name = variable_name_from_full_name(resource.get('name')) + variable = cls(name=name, config=config) + variable._set_properties(resource=resource) + return variable + + @property + def full_name(self): + """Fully-qualified name of this variable. + + Example: + ``projects/my-project/configs/my-config/variables/my-var`` + + :rtype: str + :returns: The full name based on config and variable names. + + :raises: :class:`ValueError` if the variable is missing a name. + """ + if not self.name: + raise ValueError('Missing variable name.') + return '%s/variables/%s' % (self.config.full_name, self.name) + + @property + def path(self): + """URL path for the variable's APIs. + + :rtype: str + :returns: The URL path based on config and variable names. + """ + return '/%s' % (self.full_name,) + + @property + def client(self): + """The client bound to this variable.""" + return self.config.client + + @property + def value(self): + """Value of the variable, as bytes. + + See: + https://cloud.google.com/deployment-manager/runtime-configurator/reference/rest/v1beta1/projects.configs.variables + + :rtype: bytes or ``NoneType`` + :returns: The value of the variable or ``None`` if the property + is not set locally. + """ + value = self._properties.get('value') + if value is not None: + value = base64.b64decode(value) + return value + + @property + def state(self): + """Retrieve the state of the variable. + + See: + https://cloud.google.com/deployment-manager/runtime-configurator/reference/rest/v1beta1/projects.configs.variables#VariableState + + :rtype: str + :returns: + If set, one of "UPDATED", "DELETED", or + "VARIABLE_STATE_UNSPECIFIED", else ``None``. + """ + return self._properties.get('state') + + @property + def update_time(self): + """Retrieve the timestamp at which the variable was updated. + + See: + https://cloud.google.com/deployment-manager/runtime-configurator/reference/rest/v1beta1/projects.configs.variables + + :rtype: :class:`datetime.datetime` or ``NoneType`` + :returns: Datetime object parsed from RFC3339 valid timestamp, or + ``None`` if the property is not set locally. + """ + value = self._properties.get('updateTime') + if value is not None: + value = _rfc3339_to_datetime(value) + return value + + def _require_client(self, client): + """Check client or verify over-ride. + + :type client: :class:`google.cloud.runtimconfig.client.Client` + :param client: + (Optional) The client to use. If not passed, falls back to the + ``client`` stored on the current zone. + + :rtype: :class:`google.cloud.runtimeconfig.client.Client` + :returns: The client passed in or the currently bound client. + """ + if client is None: + client = self.client + return client + + def _set_properties(self, resource): + """Update properties from resource in body of ``api_response`` + + :type resource: dict + :param resource: variable representation returned from the API. + """ + self._properties.clear() + cleaned = resource.copy() + if 'name' in cleaned: + self.name = variable_name_from_full_name(cleaned.pop('name')) + self._properties.update(cleaned) + + def exists(self, client=None): + """API call: test for the existence of the variable via a GET request + + See: + https://cloud.google.com/deployment-manager/runtime-configurator/reference/rest/v1beta1/projects.configs.variables/get + + :type client: :class:`~google.cloud.runtimeconfig.client.Client` + :param client: + (Optional) The client to use. If not passed, falls back to the + ``client`` stored on the variable's config. + + :rtype: bool + :returns: True if the variable exists in Cloud RuntimeConfig. + """ + client = self._require_client(client) + try: + # We only need the status code (200 or not) so we seek to + # minimize the returned payload. + query_params = {'fields': 'name'} + client.connection.api_request(method='GET', path=self.path, + query_params=query_params) + return True + except NotFound: + return False + + def reload(self, client=None): + """API call: reload the variable via a ``GET`` request. + + This method will reload the newest data for the variable. + + See: + https://cloud.google.com/deployment-manager/runtime-configurator/reference/rest/v1beta1/projects.configs/get + + :type client: :class:`google.cloud.runtimeconfig.client.Client` + :param client: + (Optional) The client to use. If not passed, falls back to the + client stored on the current config. + """ + client = self._require_client(client) + + # We assume the variable exists. If it doesn't it will raise a NotFound + # exception. + resp = client.connection.api_request(method='GET', path=self.path) + self._set_properties(resource=resp) diff --git a/runtimeconfig/setup.cfg b/runtimeconfig/setup.cfg new file mode 100644 index 000000000000..2a9acf13daa9 --- /dev/null +++ b/runtimeconfig/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 diff --git a/runtimeconfig/setup.py b/runtimeconfig/setup.py new file mode 100644 index 000000000000..295d478f2506 --- /dev/null +++ b/runtimeconfig/setup.py @@ -0,0 +1,68 @@ +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# 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 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os + +from setuptools import find_packages +from setuptools import setup + + +PACKAGE_ROOT = os.path.abspath(os.path.dirname(__file__)) + +with open(os.path.join(PACKAGE_ROOT, 'README.rst')) as file_obj: + README = file_obj.read() + +# NOTE: This is duplicated throughout and we should try to +# consolidate. +SETUP_BASE = { + 'author': 'Google Cloud Platform', + 'author_email': 'jjg+google-cloud-python@google.com', + 'scripts': [], + 'url': 'https://github.com/GoogleCloudPlatform/google-cloud-python', + 'license': 'Apache 2.0', + 'platforms': 'Posix; MacOS X; Windows', + 'include_package_data': True, + 'zip_safe': False, + 'classifiers': [ + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Apache Software License', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Topic :: Internet', + ], +} + + +REQUIREMENTS = [ + 'google-cloud-core >= 0.20.0', +] + +setup( + name='google-cloud-runtimeconfig', + version='0.20.0', + description='Python Client for Google Cloud RuntimeConfig', + long_description=README, + namespace_packages=[ + 'google', + 'google.cloud', + ], + packages=find_packages(), + install_requires=REQUIREMENTS, + **SETUP_BASE +) diff --git a/runtimeconfig/tox.ini b/runtimeconfig/tox.ini new file mode 100644 index 000000000000..0f35dccadd2b --- /dev/null +++ b/runtimeconfig/tox.ini @@ -0,0 +1,30 @@ +[tox] +envlist = + py27,py34,py35,cover + +[testing] +deps = + {toxinidir}/../core + pytest +covercmd = + py.test --quiet \ + --cov=google.cloud.runtimeconfig \ + --cov=unit_tests \ + --cov-config {toxinidir}/.coveragerc \ + unit_tests + +[testenv] +commands = + py.test --quiet {posargs} unit_tests +deps = + {[testing]deps} + +[testenv:cover] +basepython = + python2.7 +commands = + {[testing]covercmd} +deps = + {[testenv]deps} + coverage + pytest-cov diff --git a/runtimeconfig/unit_tests/__init__.py b/runtimeconfig/unit_tests/__init__.py new file mode 100644 index 000000000000..58e0d9153632 --- /dev/null +++ b/runtimeconfig/unit_tests/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# 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 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/runtimeconfig/unit_tests/test__helpers.py b/runtimeconfig/unit_tests/test__helpers.py new file mode 100644 index 000000000000..936326c90b87 --- /dev/null +++ b/runtimeconfig/unit_tests/test__helpers.py @@ -0,0 +1,73 @@ +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# 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 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + + +class Test_config_name_from_full_name(unittest.TestCase): + + def _callFUT(self, full_name): + from google.cloud.runtimeconfig._helpers import ( + config_name_from_full_name) + return config_name_from_full_name(full_name) + + def test_w_simple_name(self): + CONFIG_NAME = 'CONFIG_NAME' + PROJECT = 'my-project-1234' + PATH = 'projects/%s/configs/%s' % (PROJECT, CONFIG_NAME) + config_name = self._callFUT(PATH) + self.assertEqual(config_name, CONFIG_NAME) + + def test_w_name_w_all_extras(self): + CONFIG_NAME = 'CONFIG_NAME-part.one~part.two%part-three' + PROJECT = 'my-project-1234' + PATH = 'projects/%s/configs/%s' % (PROJECT, CONFIG_NAME) + config_name = self._callFUT(PATH) + self.assertEqual(config_name, CONFIG_NAME) + + def test_w_bad_format(self): + PATH = 'definitley/not/a/resource-name' + with self.assertRaises(ValueError): + self._callFUT(PATH) + + +class Test_variable_name_from_full_name(unittest.TestCase): + + def _callFUT(self, full_name): + from google.cloud.runtimeconfig._helpers import ( + variable_name_from_full_name) + return variable_name_from_full_name(full_name) + + def test_w_simple_name(self): + VARIABLE_NAME = 'VARIABLE_NAME' + CONFIG_NAME = 'CONFIG_NAME' + PROJECT = 'my-project-1234' + PATH = 'projects/%s/configs/%s/variables/%s' % ( + PROJECT, CONFIG_NAME, VARIABLE_NAME) + variable_name = self._callFUT(PATH) + self.assertEqual(variable_name, VARIABLE_NAME) + + def test_w_name_w_all_extras(self): + VARIABLE_NAME = 'VARIABLE_NAME-part.one/part.two/part-three' + CONFIG_NAME = 'CONFIG_NAME' + PROJECT = 'my-project-1234' + PATH = 'projects/%s/configs/%s/variables/%s' % ( + PROJECT, CONFIG_NAME, VARIABLE_NAME) + variable_name = self._callFUT(PATH) + self.assertEqual(variable_name, VARIABLE_NAME) + + def test_w_bad_format(self): + PATH = 'definitley/not/a/resource/name/for/a/variable' + with self.assertRaises(ValueError): + self._callFUT(PATH) diff --git a/runtimeconfig/unit_tests/test_client.py b/runtimeconfig/unit_tests/test_client.py new file mode 100644 index 000000000000..350a825f6cdf --- /dev/null +++ b/runtimeconfig/unit_tests/test_client.py @@ -0,0 +1,53 @@ +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# 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 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import unittest + + +class TestClient(unittest.TestCase): + + def _getTargetClass(self): + from google.cloud.runtimeconfig.client import Client + return Client + + def _makeOne(self, *args, **kw): + return self._getTargetClass()(*args, **kw) + + def test_config(self): + PROJECT = 'PROJECT' + CONFIG_NAME = 'config_name' + creds = _Credentials() + + client_obj = self._makeOne(project=PROJECT, credentials=creds) + new_config = client_obj.config(CONFIG_NAME) + self.assertEqual(new_config.name, CONFIG_NAME) + self.assertIs(new_config._client, client_obj) + self.assertEqual(new_config.project, PROJECT) + self.assertEqual(new_config.full_name, + 'projects/%s/configs/%s' % (PROJECT, CONFIG_NAME)) + self.assertFalse(new_config.description) + + +class _Credentials(object): + + _scopes = None + + @staticmethod + def create_scoped_required(): + return True + + def create_scoped(self, scope): + self._scopes = scope + return self diff --git a/runtimeconfig/unit_tests/test_config.py b/runtimeconfig/unit_tests/test_config.py new file mode 100644 index 000000000000..d063968d32d3 --- /dev/null +++ b/runtimeconfig/unit_tests/test_config.py @@ -0,0 +1,345 @@ +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# 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 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +from google.cloud._helpers import _rfc3339_to_datetime +from google.cloud.runtimeconfig._helpers import config_name_from_full_name + + +class TestConfig(unittest.TestCase): + PROJECT = 'PROJECT' + CONFIG_NAME = 'config_name' + CONFIG_PATH = 'projects/%s/configs/%s' % (PROJECT, CONFIG_NAME) + + def _getTargetClass(self): + from google.cloud.runtimeconfig.config import Config + return Config + + def _makeOne(self, *args, **kw): + return self._getTargetClass()(*args, **kw) + + def _verifyResourceProperties(self, config, resource): + if 'name' in resource: + self.assertEqual(config.full_name, resource['name']) + self.assertEqual( + config.name, + config_name_from_full_name(resource['name'])) + if 'description' in resource: + self.assertEqual(config.description, resource['description']) + + def test_ctor(self): + client = _Client(project=self.PROJECT) + config = self._makeOne(name=self.CONFIG_NAME, + client=client) + self.assertEqual(config.name, self.CONFIG_NAME) + self.assertEqual(config.project, self.PROJECT) + self.assertEqual(config.full_name, self.CONFIG_PATH) + + def test_ctor_w_no_name(self): + client = _Client(project=self.PROJECT) + config = self._makeOne(name=None, client=client) + with self.assertRaises(ValueError): + _ = config.full_name + + def test_exists_miss_w_bound_client(self): + conn = _Connection() + client = _Client(project=self.PROJECT, connection=conn) + config = self._makeOne(client=client, name=self.CONFIG_NAME) + + self.assertFalse(config.exists()) + + self.assertEqual(len(conn._requested), 1) + req = conn._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/%s' % (self.CONFIG_PATH,)) + self.assertEqual(req['query_params'], {'fields': 'name'}) + + def test_exists_hit_w_alternate_client(self): + conn1 = _Connection() + CLIENT1 = _Client(project=self.PROJECT, connection=conn1) + conn2 = _Connection({}) + CLIENT2 = _Client(project=self.PROJECT, connection=conn2) + config = self._makeOne(client=CLIENT1, name=self.CONFIG_NAME) + + self.assertTrue(config.exists(client=CLIENT2)) + + self.assertEqual(len(conn1._requested), 0) + self.assertEqual(len(conn2._requested), 1) + req = conn2._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/%s' % (self.CONFIG_PATH,)) + self.assertEqual(req['query_params'], {'fields': 'name'}) + + def test_reload_w_empty_resource(self): + RESOURCE = {} + conn = _Connection(RESOURCE) + client = _Client(project=self.PROJECT, connection=conn) + config = self._makeOne(name=self.CONFIG_NAME, client=client) + + config.reload() + + self.assertEqual(len(conn._requested), 1) + req = conn._requested[0] + # Name should not be overwritten if not in the response. + self.assertEqual(self.CONFIG_NAME, config.name) + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/%s' % (self.CONFIG_PATH,)) + self._verifyResourceProperties(config, RESOURCE) + + def test_reload_w_bound_client(self): + RESOURCE = {'name': self.CONFIG_PATH, 'description': 'hello'} + conn = _Connection(RESOURCE) + client = _Client(project=self.PROJECT, connection=conn) + config = self._makeOne(name=self.CONFIG_NAME, client=client) + + config.reload() + + self.assertEqual(len(conn._requested), 1) + req = conn._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/%s' % (self.CONFIG_PATH,)) + self._verifyResourceProperties(config, RESOURCE) + + def test_reload_w_alternate_client(self): + RESOURCE = {'name': self.CONFIG_PATH, 'description': 'hello'} + conn1 = _Connection() + CLIENT1 = _Client(project=self.PROJECT, connection=conn1) + conn2 = _Connection(RESOURCE) + CLIENT2 = _Client(project=self.PROJECT, connection=conn2) + config = self._makeOne(name=self.CONFIG_NAME, client=CLIENT1) + + config.reload(client=CLIENT2) + + self.assertEqual(len(conn1._requested), 0) + self.assertEqual(len(conn2._requested), 1) + req = conn2._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/%s' % (self.CONFIG_PATH,)) + self._verifyResourceProperties(config, RESOURCE) + + def test_variable(self): + VARIABLE_NAME = 'my-variable/abcd' + VARIABLE_PATH = '%s/variables/%s' % (self.CONFIG_PATH, VARIABLE_NAME) + conn = _Connection() + client = _Client(project=self.PROJECT, connection=conn) + config = self._makeOne(name=self.CONFIG_NAME, client=client) + + variable = config.variable(VARIABLE_NAME) + + self.assertEqual(variable.name, VARIABLE_NAME) + self.assertEqual(variable.full_name, VARIABLE_PATH) + self.assertEqual(len(conn._requested), 0) + + def test_get_variable_w_bound_client(self): + VARIABLE_NAME = 'my-variable/abcd' + VARIABLE_PATH = '%s/variables/%s' % (self.CONFIG_PATH, VARIABLE_NAME) + RESOURCE = { + 'name': VARIABLE_PATH, + 'value': 'bXktdmFyaWFibGUtdmFsdWU=', # base64 my-variable-value + 'updateTime': '2016-04-14T21:21:54.5000Z', + 'state': 'VARIABLE_STATE_UNSPECIFIED', + } + conn = _Connection(RESOURCE) + client = _Client(project=self.PROJECT, connection=conn) + config = self._makeOne(name=self.CONFIG_NAME, client=client) + + variable = config.get_variable(VARIABLE_NAME) + + self.assertEqual(variable.name, VARIABLE_NAME) + self.assertEqual(variable.full_name, VARIABLE_PATH) + self.assertEqual( + variable.update_time, + _rfc3339_to_datetime(RESOURCE['updateTime'])) + self.assertEqual(variable.state, RESOURCE['state']) + + self.assertEqual(len(conn._requested), 1) + req = conn._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/%s' % (VARIABLE_PATH,)) + + def test_get_variable_w_notfound(self): + VARIABLE_NAME = 'my-variable/abcd' + conn = _Connection() + client = _Client(project=self.PROJECT, connection=conn) + config = self._makeOne(name=self.CONFIG_NAME, client=client) + variable = config.get_variable(VARIABLE_NAME) + self.assertIsNone(variable) + + def test_get_variable_w_alternate_client(self): + VARIABLE_NAME = 'my-variable/abcd' + VARIABLE_PATH = '%s/variables/%s' % (self.CONFIG_PATH, VARIABLE_NAME) + RESOURCE = { + 'name': VARIABLE_PATH, + 'value': 'bXktdmFyaWFibGUtdmFsdWU=', # base64 my-variable-value + 'updateTime': '2016-04-14T21:21:54.5000Z', + 'state': 'VARIABLE_STATE_UNSPECIFIED', + } + conn1 = _Connection() + CLIENT1 = _Client(project=self.PROJECT, connection=conn1) + conn2 = _Connection(RESOURCE) + CLIENT2 = _Client(project=self.PROJECT, connection=conn2) + config = self._makeOne(client=CLIENT1, name=self.CONFIG_NAME) + + variable = config.get_variable(VARIABLE_NAME, client=CLIENT2) + + self.assertEqual(variable.name, VARIABLE_NAME) + self.assertEqual(variable.full_name, VARIABLE_PATH) + self.assertEqual( + variable.update_time, + _rfc3339_to_datetime(RESOURCE['updateTime'])) + self.assertEqual(variable.state, RESOURCE['state']) + + self.assertEqual(len(conn1._requested), 0) + self.assertEqual(len(conn2._requested), 1) + req = conn2._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/%s' % (VARIABLE_PATH,)) + + def test_list_variables_empty(self): + conn = _Connection({}) + client = _Client(project=self.PROJECT, connection=conn) + config = self._makeOne(name=self.CONFIG_NAME, client=client) + + iterator = config.list_variables() + iterator.update_page() + variables = list(iterator.page) + token = iterator.next_page_token + + self.assertEqual(variables, []) + self.assertIsNone(token) + self.assertEqual(len(conn._requested), 1) + req = conn._requested[0] + self.assertEqual(req['method'], 'GET') + PATH = 'projects/%s/configs/%s/variables' % ( + self.PROJECT, self.CONFIG_NAME) + self.assertEqual(req['path'], '/%s' % (PATH,)) + + def test_list_variables_defaults(self): + from google.cloud.runtimeconfig.variable import Variable + + VARIABLE_1 = 'variable-one' + VARIABLE_2 = 'variable/two' + PATH = 'projects/%s/configs/%s/variables' % ( + self.PROJECT, self.CONFIG_NAME) + TOKEN = 'TOKEN' + DATA = { + 'nextPageToken': TOKEN, + 'variables': [ + {'name': '%s/%s' % (PATH, VARIABLE_1), + 'updateTime': '2016-04-14T21:21:54.5000Z'}, + {'name': '%s/%s' % (PATH, VARIABLE_2), + 'updateTime': '2016-04-21T21:21:54.6000Z'}, + ] + } + + conn = _Connection(DATA) + client = _Client(project=self.PROJECT, connection=conn) + config = self._makeOne(name=self.CONFIG_NAME, client=client) + + iterator = config.list_variables() + iterator.update_page() + variables = list(iterator.page) + token = iterator.next_page_token + + self.assertEqual(len(variables), len(DATA['variables'])) + for found, expected in zip(variables, DATA['variables']): + self.assertIsInstance(found, Variable) + self.assertEqual(found.full_name, expected['name']) + self.assertEqual( + found.update_time, + _rfc3339_to_datetime(expected['updateTime'])) + self.assertEqual(token, TOKEN) + + self.assertEqual(len(conn._requested), 1) + req = conn._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/%s' % (PATH,)) + self.assertNotIn('filter', req['query_params']) + + def test_list_variables_explicit(self): + from google.cloud.runtimeconfig.variable import Variable + + VARIABLE_1 = 'variable-one' + VARIABLE_2 = 'variable/two' + PATH = 'projects/%s/configs/%s/variables' % ( + self.PROJECT, self.CONFIG_NAME) + TOKEN = 'TOKEN' + DATA = { + 'variables': [ + {'name': '%s/%s' % (PATH, VARIABLE_1), + 'updateTime': '2016-04-14T21:21:54.5000Z'}, + {'name': '%s/%s' % (PATH, VARIABLE_2), + 'updateTime': '2016-04-21T21:21:54.6000Z'}, + ] + } + + conn = _Connection(DATA) + client = _Client(project=self.PROJECT, connection=conn) + config = self._makeOne(name=self.CONFIG_NAME, client=client) + + iterator = config.list_variables( + page_size=3, + page_token=TOKEN, + client=client) + iterator.update_page() + variables = list(iterator.page) + token = iterator.next_page_token + + self.assertEqual(len(variables), len(DATA['variables'])) + for found, expected in zip(variables, DATA['variables']): + self.assertIsInstance(found, Variable) + self.assertEqual(found.full_name, expected['name']) + self.assertEqual( + found.update_time, + _rfc3339_to_datetime(expected['updateTime'])) + self.assertIsNone(token) + + self.assertEqual(len(conn._requested), 1) + req = conn._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/%s' % (PATH,)) + self.assertEqual( + req['query_params'], + { + 'pageSize': 3, + 'pageToken': TOKEN, + }) + + +class _Client(object): + + connection = None + + def __init__(self, project, connection=None): + self.project = project + self.connection = connection + + +class _Connection(object): + + def __init__(self, *responses): + self._responses = responses + self._requested = [] + + def api_request(self, **kw): + from google.cloud.exceptions import NotFound + self._requested.append(kw) + + try: + response, self._responses = self._responses[0], self._responses[1:] + except: + raise NotFound('miss') + else: + return response diff --git a/runtimeconfig/unit_tests/test_connection.py b/runtimeconfig/unit_tests/test_connection.py new file mode 100644 index 000000000000..efba72635615 --- /dev/null +++ b/runtimeconfig/unit_tests/test_connection.py @@ -0,0 +1,44 @@ +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# 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 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + + +class TestConnection(unittest.TestCase): + + def _getTargetClass(self): + from google.cloud.runtimeconfig.connection import Connection + return Connection + + def _makeOne(self, *args, **kw): + return self._getTargetClass()(*args, **kw) + + def test_default_url(self): + creds = _Credentials() + conn = self._makeOne(creds) + klass = self._getTargetClass() + self.assertEqual(conn.credentials._scopes, klass.SCOPE) + + +class _Credentials(object): + + _scopes = None + + @staticmethod + def create_scoped_required(): + return True + + def create_scoped(self, scope): + self._scopes = scope + return self diff --git a/runtimeconfig/unit_tests/test_variable.py b/runtimeconfig/unit_tests/test_variable.py new file mode 100644 index 000000000000..5f9d441884c8 --- /dev/null +++ b/runtimeconfig/unit_tests/test_variable.py @@ -0,0 +1,189 @@ +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# 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 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import base64 +import unittest + +from google.cloud.runtimeconfig.config import Config +from google.cloud._helpers import _rfc3339_to_datetime + + +class TestVariable(unittest.TestCase): + PROJECT = 'PROJECT' + CONFIG_NAME = 'config_name' + VARIABLE_NAME = 'variable_name' + PATH = 'projects/%s/configs/%s/variables/%s' % ( + PROJECT, CONFIG_NAME, VARIABLE_NAME) + + def _getTargetClass(self): + from google.cloud.runtimeconfig.variable import Variable + return Variable + + def _makeOne(self, *args, **kw): + return self._getTargetClass()(*args, **kw) + + def _verifyResourceProperties(self, variable, resource): + if 'name' in resource: + self.assertEqual(variable.full_name, resource['name']) + + if 'value' in resource: + self.assertEqual( + variable.value, base64.b64decode(resource['value'])) + else: + self.assertIsNone(variable.value) + + if 'state' in resource: + self.assertEqual(variable.state, resource['state']) + + if 'updateTime' in resource: + self.assertEqual( + variable.update_time, + _rfc3339_to_datetime(resource['updateTime'])) + else: + self.assertIsNone(variable.update_time) + + def test_ctor(self): + client = _Client(project=self.PROJECT) + config = Config(name=self.CONFIG_NAME, client=client) + variable = self._makeOne(name=self.VARIABLE_NAME, config=config) + self.assertEqual(variable.name, self.VARIABLE_NAME) + self.assertEqual(variable.full_name, self.PATH) + self.assertEqual(variable.path, '/%s' % (self.PATH,)) + self.assertIs(variable.client, client) + + def test_ctor_w_no_name(self): + client = _Client(project=self.PROJECT) + config = Config(name=self.CONFIG_NAME, client=client) + variable = self._makeOne(name=None, config=config) + with self.assertRaises(ValueError): + _ = variable.full_name + + def test_exists_miss_w_bound_client(self): + conn = _Connection() + client = _Client(project=self.PROJECT, connection=conn) + config = Config(name=self.CONFIG_NAME, client=client) + variable = self._makeOne(name=self.VARIABLE_NAME, config=config) + + self.assertFalse(variable.exists()) + + self.assertEqual(len(conn._requested), 1) + req = conn._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/%s' % (self.PATH,)) + self.assertEqual(req['query_params'], {'fields': 'name'}) + + def test_exists_hit_w_alternate_client(self): + conn1 = _Connection() + CLIENT1 = _Client(project=self.PROJECT, connection=conn1) + CONFIG1 = Config(name=self.CONFIG_NAME, client=CLIENT1) + conn2 = _Connection({}) + CLIENT2 = _Client(project=self.PROJECT, connection=conn2) + variable = self._makeOne(name=self.VARIABLE_NAME, config=CONFIG1) + + self.assertTrue(variable.exists(client=CLIENT2)) + + self.assertEqual(len(conn1._requested), 0) + self.assertEqual(len(conn2._requested), 1) + req = conn2._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/%s' % (self.PATH,)) + self.assertEqual(req['query_params'], {'fields': 'name'}) + + def test_reload_w_bound_client(self): + RESOURCE = { + 'name': self.PATH, + 'value': 'bXktdmFyaWFibGUtdmFsdWU=', # base64 my-variable-value + 'updateTime': '2016-04-14T21:21:54.5000Z', + 'state': 'VARIABLE_STATE_UNSPECIFIED', + } + conn = _Connection(RESOURCE) + client = _Client(project=self.PROJECT, connection=conn) + config = Config(name=self.CONFIG_NAME, client=client) + variable = self._makeOne(name=self.VARIABLE_NAME, config=config) + + variable.reload() + + self.assertEqual(len(conn._requested), 1) + req = conn._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/%s' % (self.PATH,)) + self._verifyResourceProperties(variable, RESOURCE) + + def test_reload_w_empty_resource(self): + RESOURCE = {} + conn = _Connection(RESOURCE) + client = _Client(project=self.PROJECT, connection=conn) + config = Config(name=self.CONFIG_NAME, client=client) + variable = self._makeOne(name=self.VARIABLE_NAME, config=config) + + variable.reload() + + # Name should not be overwritten. + self.assertEqual(self.VARIABLE_NAME, variable.name) + + self.assertEqual(len(conn._requested), 1) + req = conn._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/%s' % (self.PATH,)) + self._verifyResourceProperties(variable, RESOURCE) + + def test_reload_w_alternate_client(self): + RESOURCE = { + 'name': self.PATH, + 'value': 'bXktdmFyaWFibGUtdmFsdWU=', # base64 my-variable-value + 'updateTime': '2016-04-14T21:21:54.5000Z', + 'state': 'VARIABLE_STATE_UNSPECIFIED', + } + conn1 = _Connection() + CLIENT1 = _Client(project=self.PROJECT, connection=conn1) + CONFIG1 = Config(name=self.CONFIG_NAME, client=CLIENT1) + conn2 = _Connection(RESOURCE) + CLIENT2 = _Client(project=self.PROJECT, connection=conn2) + variable = self._makeOne(name=self.VARIABLE_NAME, config=CONFIG1) + + variable.reload(client=CLIENT2) + + self.assertEqual(len(conn1._requested), 0) + self.assertEqual(len(conn2._requested), 1) + req = conn2._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/%s' % (self.PATH,)) + self._verifyResourceProperties(variable, RESOURCE) + + +class _Client(object): + + connection = None + + def __init__(self, project, connection=None): + self.project = project + self.connection = connection + + +class _Connection(object): + + def __init__(self, *responses): + self._responses = responses + self._requested = [] + + def api_request(self, **kw): + from google.cloud.exceptions import NotFound + self._requested.append(kw) + + try: + response, self._responses = self._responses[0], self._responses[1:] + except: + raise NotFound('miss') + else: + return response