From 84a50ad6cd0765bd86a4ed7c338aec2612e5e91c Mon Sep 17 00:00:00 2001 From: Ludovico Magnocavallo Date: Fri, 5 Jun 2020 18:44:44 +0200 Subject: [PATCH] feat: support variable create / update methods and text attribute (#17) This PR adds support for variable create and update methods. The create method has been implemented in the Variable class instead of Config (which would have given symmetry with get_variable) for a couple of reasons: it allows a more natural workflow of calling config.variable(), setting the desired attribute, then saving it updates the created variable in place via _set_properties like other variable methods This also adds support for the text attribute, which can now be used in alternative to the previously supported value. The create and update methods enforce mutual exclusivity of the two attributes. Fixes #1 --- google/cloud/runtimeconfig/exceptions.py | 19 +++ google/cloud/runtimeconfig/variable.py | 111 +++++++++++++++- tests/unit/test_variable.py | 155 +++++++++++++++++++++++ 3 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 google/cloud/runtimeconfig/exceptions.py diff --git a/google/cloud/runtimeconfig/exceptions.py b/google/cloud/runtimeconfig/exceptions.py new file mode 100644 index 0000000..665b71e --- /dev/null +++ b/google/cloud/runtimeconfig/exceptions.py @@ -0,0 +1,19 @@ +# Copyright 2020 Google LLC +# +# 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. + +"""Exceptions used in the Google RuntimeConfig client.""" + + +class Error(Exception): + """Exception for all non-warning RuntimeConfig errors.""" diff --git a/google/cloud/runtimeconfig/variable.py b/google/cloud/runtimeconfig/variable.py index e7974c7..d68378b 100644 --- a/google/cloud/runtimeconfig/variable.py +++ b/google/cloud/runtimeconfig/variable.py @@ -41,8 +41,9 @@ import pytz from google.api_core import datetime_helpers -from google.cloud.exceptions import NotFound +from google.cloud.exceptions import Conflict, NotFound from google.cloud.runtimeconfig._helpers import variable_name_from_full_name +from google.cloud.runtimeconfig.exceptions import Error STATE_UNSPECIFIED = "VARIABLE_STATE_UNSPECIFIED" @@ -117,6 +118,34 @@ def client(self): """The client bound to this variable.""" return self.config.client + @property + def text(self): + """Text of the variable, as string. + + See + https://cloud.google.com/deployment-manager/runtime-configurator/reference/rest/v1beta1/projects.configs.variables + + :rtype: str or ``NoneType`` + :returns: The text of the variable or ``None`` if the property + is not set locally. + """ + return self._properties.get("text") + + @text.setter + def text(self, value): + """Set text property. + + If the variable is already using value, this will raise + exceptions.Error since text and value are mutually exclusive. + To persist the change, call create() or update(). + + :type value: str + :param value: The new value for the text property. + """ + if "value" in self._properties: + raise Error("Value and text are mutually exclusive.") + self._properties["text"] = value + @property def value(self): """Value of the variable, as bytes. @@ -133,6 +162,21 @@ def value(self): value = base64.b64decode(value) return value + @value.setter + def value(self, value): + """Set value property. + + If the variable is already using text, this will raise exceptions.Error + since text and value are mutually exclusive. + To persist the change, call create() or update(). + + :type value: bytes + :param value: The new value for the value property. + """ + if "text" in self._properties: + raise Error("Value and text are mutually exclusive.") + self._properties["value"] = value + @property def state(self): """Retrieve the state of the variable. @@ -204,6 +248,71 @@ def _set_properties(self, resource): self.name = variable_name_from_full_name(cleaned.pop("name")) self._properties.update(cleaned) + def _get_payload(self): + """Return the payload for create and update operations + + :rtype: dict + :returns: payload for API call with name and text or value attributes + """ + data = {"name": self.full_name} + if "text" in self._properties: + data["text"] = self._properties["text"] + elif "value" in self._properties: + value = self._properties["value"] + data["value"] = base64.b64encode(value).decode("utf-8") + else: + raise Error("No text or value set.") + return data + + def create(self, client=None): + """API call: create the variable via a POST request + + See + https://cloud.google.com/deployment-manager/runtime-configurator/reference/rest/v1beta1/projects.configs.variables/create + + :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 has been created, False on error. + """ + client = self._require_client(client) + path = "%s/variables" % self.config.path + data = self._get_payload() + try: + resp = client._connection.api_request(method="POST", path=path, data=data) + except Conflict: + return False + self._set_properties(resp) + return True + + def update(self, client=None): + """API call: update the variable via a PUT request + + See + https://cloud.google.com/deployment-manager/runtime-configurator/reference/rest/v1beta1/projects.configs.variables/update + + :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 has been created, False on error. + """ + client = self._require_client(client) + data = self._get_payload() + try: + resp = client._connection.api_request( + method="PUT", path=self.path, data=data + ) + except NotFound: + return False + self._set_properties(resp) + return True + def exists(self, client=None): """API call: test for the existence of the variable via a GET request diff --git a/tests/unit/test_variable.py b/tests/unit/test_variable.py index 3044a23..c4c3cd0 100644 --- a/tests/unit/test_variable.py +++ b/tests/unit/test_variable.py @@ -42,6 +42,11 @@ def _verifyResourceProperties(self, variable, resource): else: self.assertIsNone(variable.value) + if "text" in resource: + self.assertEqual(variable.text, resource["text"]) + else: + self.assertIsNone(variable.text) + if "state" in resource: self.assertEqual(variable.state, resource["state"]) @@ -112,6 +117,154 @@ def test_exists_hit_w_alternate_client(self): self.assertEqual(req["path"], "/%s" % (self.PATH,)) self.assertEqual(req["query_params"], {"fields": "name"}) + def test_create_no_data(self): + from google.cloud.runtimeconfig.config import Config + from google.cloud.runtimeconfig.exceptions import Error + + conn = _Connection() + client = _Client(project=self.PROJECT, connection=conn) + config = Config(name=self.CONFIG_NAME, client=client) + variable = config.variable(self.VARIABLE_NAME) + with self.assertRaises(Error) as ctx: + variable.create() + self.assertEqual("No text or value set.", str(ctx.exception)) + + def test_create_conflict(self): + from google.cloud.exceptions import Conflict + from google.cloud.runtimeconfig.config import Config + + conn = _Connection(Conflict("test")) + client = _Client(project=self.PROJECT, connection=conn) + config = Config(name=self.CONFIG_NAME, client=client) + variable = config.variable(self.VARIABLE_NAME) + variable.text = "foo" + self.assertFalse(variable.create()) + + def test_create_text(self): + from google.cloud.runtimeconfig.config import Config + + RESOURCE = { + "name": self.PATH, + "text": "foo", + "updateTime": "2016-04-14T21:21:54.5000Z", + "state": "UPDATED", + } + conn = _Connection(RESOURCE) + client = _Client(project=self.PROJECT, connection=conn) + config = Config(name=self.CONFIG_NAME, client=client) + variable = config.variable(self.VARIABLE_NAME) + variable.text = "foo" + result = variable.create() + self.assertTrue(result) + self.assertEqual(len(conn._requested), 1) + req = conn._requested[0] + self.assertEqual(req["method"], "POST") + self.assertEqual( + req["path"], + "/projects/%s/configs/%s/variables" % (self.PROJECT, self.CONFIG_NAME), + ) + self._verifyResourceProperties(variable, RESOURCE) + + def test_create_value(self): + from google.cloud.runtimeconfig.config import Config + + RESOURCE = { + "name": self.PATH, + "value": "bXktdmFyaWFibGUtdmFsdWU=", # base64 my-variable-value + "updateTime": "2016-04-14T21:21:54.5000Z", + "state": "UPDATED", + } + conn = _Connection(RESOURCE) + client = _Client(project=self.PROJECT, connection=conn) + config = Config(name=self.CONFIG_NAME, client=client) + variable = config.variable(self.VARIABLE_NAME) + variable.value = b"my-variable-value" + result = variable.create() + self.assertTrue(result) + self.assertEqual(len(conn._requested), 1) + req = conn._requested[0] + self.assertEqual(req["method"], "POST") + self.assertEqual( + req["path"], + "/projects/%s/configs/%s/variables" % (self.PROJECT, self.CONFIG_NAME), + ) + self._verifyResourceProperties(variable, RESOURCE) + + def test_update_text_conflict(self): + from google.cloud.runtimeconfig.config import Config + from google.cloud.runtimeconfig.exceptions import Error + + RESOURCE = { + "name": self.PATH, + "value": "bXktdmFyaWFibGUtdmFsdWU=", # base64 my-variable-value + "updateTime": "2016-04-14T21:21:54.5000Z", + "state": "UPDATED", + } + conn = _Connection(RESOURCE) + client = _Client(project=self.PROJECT, connection=conn) + config = Config(name=self.CONFIG_NAME, client=client) + variable = config.get_variable(self.VARIABLE_NAME) + with self.assertRaises(Error) as ctx: + variable.text = "bar" + self.assertEqual("Value and text are mutually exclusive.", str(ctx.exception)) + + def test_update_value_conflict(self): + from google.cloud.runtimeconfig.config import Config + from google.cloud.runtimeconfig.exceptions import Error + + RESOURCE = { + "name": self.PATH, + "text": "foo", + "updateTime": "2016-04-14T21:21:54.5000Z", + "state": "UPDATED", + } + conn = _Connection(RESOURCE) + client = _Client(project=self.PROJECT, connection=conn) + config = Config(name=self.CONFIG_NAME, client=client) + variable = config.get_variable(self.VARIABLE_NAME) + with self.assertRaises(Error) as ctx: + variable.value = b"bar" + self.assertEqual("Value and text are mutually exclusive.", str(ctx.exception)) + + def test_update_not_found(self): + from google.cloud.runtimeconfig.config import Config + + RESOURCE = { + "name": self.PATH, + "text": "foo", + "updateTime": "2016-04-14T21:21:54.5000Z", + "state": "UPDATED", + } + conn = _Connection(RESOURCE) + client = _Client(project=self.PROJECT, connection=conn) + config = Config(name=self.CONFIG_NAME, client=client) + variable = config.get_variable(self.VARIABLE_NAME) + self.assertFalse(variable.update()) + + def test_update_text(self): + from google.cloud.runtimeconfig.config import Config + + RESOURCE = { + "name": self.PATH, + "text": "foo", + "updateTime": "2016-04-14T21:21:54.5000Z", + "state": "UPDATED", + } + RESOURCE_UPD = RESOURCE.copy() + RESOURCE_UPD["text"] = "bar" + conn = _Connection(RESOURCE, RESOURCE_UPD) + client = _Client(project=self.PROJECT, connection=conn) + config = Config(name=self.CONFIG_NAME, client=client) + variable = config.get_variable(self.VARIABLE_NAME) + variable.text = "bar" + result = variable.update() + self.assertTrue(result) + self.assertEqual(len(conn._requested), 2) + req = conn._requested[1] + self.assertEqual(req["method"], "PUT") + self.assertEqual(req["path"], "/%s" % self.PATH) + self._verifyResourceProperties(variable, RESOURCE_UPD) + def test_reload_w_bound_client(self): from google.cloud.runtimeconfig.config import Config @@ -226,4 +379,6 @@ def api_request(self, **kw): except IndexError: raise NotFound("miss") else: + if issubclass(type(response), Exception): + raise response return response