From 05b4f117bbf9544d34a5ea7836d9da811d07eb8f Mon Sep 17 00:00:00 2001 From: Thomas Maddox <thomas.maddox@rackspace.com> Date: Fri, 3 Mar 2017 17:19:29 +0000 Subject: [PATCH] CLI and client support for get/set/delete of resource vars Functionality is supported both for the Python client: host = craton.hosts.get(item_id=8) host.variables.update(x='foo', y={'a': 47, 'b': True}, z='baz') host.variables.delete("foo", "bar", "baz") As well as for the CLI: craton host-vars-get 1 craton host-vars-set 3 x=true y=47 z=foo/bar w=3.14159 cat <<EOF | craton host-vars-set 13 { "glance_default_store": "not-so-swift", "neutron_l2_population": false, "make_stuff_up": true, "some/namespaced/variable": {"a": 1, "b": 2} } EOF craton --format json host-vars-get 13 | jq -C craton host-vars-delete 13 make_stuff_up craton host-vars-set 13 x= y=42 # deletes x This patch implements the basis for supporting this in other resources as well, however we only address hosts here as an initial implementation. We will fast-follow with support in other resources. Partial-Bug: 1659110 Change-Id: Id30188937518d7103d6f943cf1d038b039dc30cc --- cratonclient/common/cliutils.py | 98 +++++++++++++ cratonclient/crud.py | 28 +++- cratonclient/formatters/base.py | 4 +- cratonclient/shell/v1/hosts_shell.py | 60 ++++++++ .../integration/shell/v1/test_hosts_shell.py | 99 +++++++++++++ cratonclient/tests/integration/test_crud.py | 1 + cratonclient/tests/unit/common/__init__.py | 1 + .../tests/unit/common/test_cliutils.py | 134 ++++++++++++++++++ .../unit/formatters/test_base_formatter.py | 2 +- cratonclient/tests/unit/test_crud.py | 2 + cratonclient/tests/unit/v1/test_clouds.py | 2 +- cratonclient/tests/unit/v1/test_regions.py | 2 +- cratonclient/tests/unit/v1/test_variables.py | 67 +++++++++ cratonclient/v1/hosts.py | 5 +- cratonclient/v1/variables.py | 116 +++++++++++++++ 15 files changed, 611 insertions(+), 10 deletions(-) create mode 100644 cratonclient/tests/unit/common/__init__.py create mode 100644 cratonclient/tests/unit/common/test_cliutils.py create mode 100644 cratonclient/tests/unit/v1/test_variables.py create mode 100644 cratonclient/v1/variables.py diff --git a/cratonclient/common/cliutils.py b/cratonclient/common/cliutils.py index 3c17e98..5c17101 100644 --- a/cratonclient/common/cliutils.py +++ b/cratonclient/common/cliutils.py @@ -12,7 +12,15 @@ # License for the specific language governing permissions and limitations # under the License. """Craton CLI helper classes and functions.""" +import functools +import json import os +import sys + +from oslo_utils import encodeutils +from oslo_utils import strutils + +from cratonclient import exceptions as exc def arg(*args, **kwargs): @@ -66,6 +74,43 @@ def field_labels_from(attributes): return [field.replace('_', ' ').title() for field in attributes] +def handle_shell_exception(function): + """Generic error handler for shell methods.""" + @functools.wraps(function) + def wrapper(cc, args): + prop_map = { + "vars": "variables" + } + try: + function(cc, args) + except exc.ClientException as client_exc: + # NOTE(thomasem): All shell methods follow a similar pattern, + # so we can parse this name to get intended parts for + # messaging what went wrong to the end-user. + # The pattern is "do_<resource>_(<prop>_)<verb>", like + # do_project_show or do_project_vars_get, where <prop> is + # not guaranteed to be there, but will afford support for + # actions on some property of the resource. + parsed = function.__name__.split('_') + resource = parsed[1] + verb = parsed[-1] + prop = parsed[2] if len(parsed) > 3 else None + msg = 'Failed to {}'.format(verb) + if prop: + # NOTE(thomasem): Prop would be something like "vars" in + # "do_project_vars_get". + msg = '{} {}'.format(msg, prop_map.get(prop)) + # NOTE(thomasem): Append resource and ClientException details + # to error message. + msg = '{} for {} {} due to "{}: {}"'.format( + msg, resource, args.id, client_exc.__class__, + encodeutils.exception_to_unicode(client_exc) + ) + raise exc.CommandError(msg) + + return wrapper + + def env(*args, **kwargs): """Return the first environment variable set. @@ -76,3 +121,56 @@ def env(*args, **kwargs): if value: return value return kwargs.get('default', '') + + +def convert_arg_value(v): + """Convert different user inputs to normalized type.""" + # NOTE(thomasem): Handle case where one wants to escape this value + # conversion using the format key='"value"' + if v.startswith('"'): + return v.strip('"') + + lower_v = v.lower() + if strutils.is_int_like(v): + return int(v) + if strutils.is_valid_boolstr(lower_v): + return strutils.bool_from_string(lower_v) + if lower_v == 'null' or lower_v == 'none': + return None + try: + return float(v) + except ValueError: + pass + return v + + +def variable_updates(variables): + """Derive list of expected variables for a resource and set them.""" + update_variables = {} + delete_variables = set() + for variable in variables: + k, v = variable.split('=', 1) + if v: + update_variables[k] = convert_arg_value(v) + else: + delete_variables.add(k) + if not sys.stdin.isatty(): + if update_variables or delete_variables: + raise exc.CommandError("Cannot use variable settings from both " + "stdin and command line arguments. Please " + "choose one or the other.") + update_variables = json.load(sys.stdin) + return (update_variables, list(delete_variables)) + + +def variable_deletes(variables): + """Delete a list of variables (by key) from a resource.""" + if not sys.stdin.isatty(): + if variables: + raise exc.CommandError("Cannot use variable settings from both " + "stdin and command line arguments. Please " + "choose one or the other.") + delete_variables = json.load(sys.stdin) + else: + delete_variables = variables + return delete_variables diff --git a/cratonclient/crud.py b/cratonclient/crud.py index 73aafc7..4bcf1bd 100644 --- a/cratonclient/crud.py +++ b/cratonclient/crud.py @@ -20,7 +20,7 @@ from oslo_utils import strutils class CRUDClient(object): """Class that handles the basic create, read, upload, delete workflow.""" - key = None + key = "" base_path = None resource_class = None @@ -45,7 +45,7 @@ class CRUDClient(object): base_path = '/regions' - And it's ``key``, e.g., + And its ``key``, e.g., .. code-block:: python @@ -128,16 +128,26 @@ class CRUDClient(object): response = self.session.put(url, json=kwargs) return self.resource_class(self, response.json(), loaded=True) - def delete(self, item_id=None, skip_merge=True, **kwargs): + def delete(self, item_id=None, skip_merge=True, json=None, **kwargs): """Delete the item based on the keyword arguments provided.""" self.merge_request_arguments(kwargs, skip_merge) kwargs.setdefault(self.key + '_id', item_id) url = self.build_url(path_arguments=kwargs) - response = self.session.delete(url, params=kwargs) + response = self.session.delete(url, params=kwargs, json=json) if 200 <= response.status_code < 300: return True return False + def __repr__(self): + """Return string representation of a Variable.""" + return '%(class)s(%(session)s, %(url)s, %(extra_request_kwargs)s)' % \ + { + "class": self.__class__.__name__, + "session": self.session, + "url": self.url, + "extra_request_kwargs": self.extra_request_kwargs, + } + # NOTE(sigmavirus24): Credit for this Resource object goes to the # keystoneclient developers and contributors. @@ -149,6 +159,7 @@ class Resource(object): HUMAN_ID = False NAME_ATTR = 'name' + subresource_managers = {} def __init__(self, manager, info, loaded=False): """Populate and bind to a manager. @@ -162,6 +173,15 @@ class Resource(object): self._add_details(info) self._loaded = loaded + session = self.manager.session + subresource_base_url = self.manager.build_url( + {"{0}_id".format(self.manager.key): self.id} + ) + for attribute, cls in self.subresource_managers.items(): + setattr(self, attribute, + cls(session, subresource_base_url, + **self.manager.extra_request_kwargs)) + def __repr__(self): """Return string representation of resource attributes.""" reprkeys = sorted(k diff --git a/cratonclient/formatters/base.py b/cratonclient/formatters/base.py index 62dec88..acc702d 100644 --- a/cratonclient/formatters/base.py +++ b/cratonclient/formatters/base.py @@ -10,7 +10,6 @@ # License for the specific language governing permissions and limitations # under the License. """Base class implementation for formatting plugins.""" -from cratonclient import crud class Formatter(object): @@ -66,7 +65,8 @@ class Formatter(object): we will not know how to handle it. In that case, we will raise a ValueError. """ - if isinstance(item_to_format, crud.Resource): + to_dict = getattr(item_to_format, 'to_dict', None) + if to_dict is not None: self.handle_instance(item_to_format) return diff --git a/cratonclient/shell/v1/hosts_shell.py b/cratonclient/shell/v1/hosts_shell.py index b804bd4..cdbb185 100644 --- a/cratonclient/shell/v1/hosts_shell.py +++ b/cratonclient/shell/v1/hosts_shell.py @@ -14,6 +14,9 @@ """Hosts resource and resource shell wrapper.""" from __future__ import print_function +import argparse +import sys + from cratonclient.common import cliutils from cratonclient import exceptions as exc @@ -279,3 +282,60 @@ def do_host_delete(cc, args): else: print("Host {0} was {1} deleted.". format(args.id, 'successfully' if response else 'not')) + + +@cliutils.arg('id', + metavar='<host>', + type=int, + help='ID or name of the host.') +@cliutils.handle_shell_exception +def do_host_vars_get(cc, args): + """Get variables for a host.""" + variables = cc.hosts.get(args.id).variables.get() + formatter = args.formatter.configure(dict_property="Variable", wrap=72) + formatter.handle(variables) + + +@cliutils.arg('id', + metavar='<host>', + type=int, + help='ID of the host.') +@cliutils.arg('variables', nargs=argparse.REMAINDER) +@cliutils.handle_shell_exception +def do_host_vars_set(cc, args): + """Set variables for a host.""" + host_id = args.id + if not args.variables and sys.stdin.isatty(): + raise exc.CommandError( + 'Nothing to update... Please specify variables to set in the ' + 'following format: "key=value". You may also specify variables to ' + 'delete by key using the format: "key="' + ) + adds, deletes = cliutils.variable_updates(args.variables) + variables = cc.hosts.get(host_id).variables + if deletes: + variables.delete(*deletes) + variables.update(**adds) + formatter = args.formatter.configure(wrap=72, dict_property="Variable") + formatter.handle(variables.get()) + + +@cliutils.arg('id', + metavar='<host>', + type=int, + help='ID of the host.') +@cliutils.arg('variables', nargs=argparse.REMAINDER) +@cliutils.handle_shell_exception +def do_host_vars_delete(cc, args): + """Delete variables for a host by key.""" + host_id = args.id + if not args.variables and sys.stdin.isatty(): + raise exc.CommandError( + 'Nothing to delete... Please specify variables to delete by ' + 'listing the keys you wish to delete separated by spaces.' + ) + deletes = cliutils.variable_deletes(args.variables) + variables = cc.hosts.get(host_id).variables + response = variables.delete(*deletes) + print("Variables {0} deleted.". + format('successfully' if response else 'not')) diff --git a/cratonclient/tests/integration/shell/v1/test_hosts_shell.py b/cratonclient/tests/integration/shell/v1/test_hosts_shell.py index 9b932d5..c97e5ef 100644 --- a/cratonclient/tests/integration/shell/v1/test_hosts_shell.py +++ b/cratonclient/tests/integration/shell/v1/test_hosts_shell.py @@ -22,6 +22,7 @@ from cratonclient import exceptions as exc from cratonclient.shell.v1 import hosts_shell from cratonclient.tests.integration.shell import base from cratonclient.v1 import hosts +from cratonclient.v1 import variables class TestHostsShell(base.ShellTestCase): @@ -389,3 +390,101 @@ class TestHostsShell(base.ShellTestCase): test_args = Namespace(id=1, region=1) hosts_shell.do_host_delete(client, test_args) mock_delete.assert_called_once_with(vars(test_args)['id']) + + +class TestHostsVarsShell(base.ShellTestCase): + """Test Host Variable shell calls.""" + + def setUp(self): + """Basic set up for all tests in this suite.""" + super(TestHostsVarsShell, self).setUp() + self.host_url = 'http://127.0.0.1/v1/hosts/1' + self.variables_url = '{}/variables'.format(self.host_url) + self.test_args = Namespace(id=1, formatter=mock.Mock()) + + # NOTE(thomasem): Make all calls seem like they come from CLI args + self.stdin_patcher = \ + mock.patch('cratonclient.common.cliutils.sys.stdin') + self.patched_stdin = self.stdin_patcher.start() + self.patched_stdin.isatty.return_value = True + + # NOTE(thomasem): Mock out a session object to assert resulting API + # calls + self.mock_session = mock.Mock() + self.mock_get_response = self.mock_session.get.return_value + self.mock_put_response = self.mock_session.put.return_value + self.mock_delete_response = self.mock_session.delete.return_value + self.mock_delete_response.status_code = 204 + + # NOTE(thomasem): Mock out a client to assert craton Python API calls + self.client = mock.Mock() + self.mock_host_resource = self.client.hosts.get.return_value + self.mock_host_resource.variables = variables.VariableManager( + self.mock_session, self.host_url + ) + + def tearDown(self): + """Clean up between tests.""" + super(TestHostsVarsShell, self).tearDown() + self.stdin_patcher.stop() + + def test_do_host_vars_get_gets_correct_host(self): + """Assert the proper host is retrieved when calling get.""" + self.mock_get_response.json.return_value = \ + {"variables": {"foo": "bar"}} + hosts_shell.do_host_vars_get(self.client, self.test_args) + self.client.hosts.get.assert_called_once_with( + vars(self.test_args)['id']) + + def test_do_host_vars_delete_gets_correct_host(self): + """Assert the proper host is retrieved when calling delete.""" + self.test_args.variables = ['foo', 'bar'] + hosts_shell.do_host_vars_delete(self.client, self.test_args) + self.client.hosts.get.assert_called_once_with( + vars(self.test_args)['id']) + + def test_do_host_vars_update_gets_correct_host(self): + """Assert the proper host is retrieved when calling update.""" + self.test_args.variables = ['foo=', 'bar='] + mock_resp_json = {"variables": {"foo": "bar"}} + self.mock_get_response.json.return_value = mock_resp_json + self.mock_put_response.json.return_value = mock_resp_json + + hosts_shell.do_host_vars_set(self.client, self.test_args) + self.client.hosts.get.assert_called_once_with( + vars(self.test_args)['id']) + + def test_do_host_vars_get_calls_session_get(self): + """Assert the proper host is retrieved when calling get.""" + self.mock_get_response.json.return_value = \ + {"variables": {"foo": "bar"}} + hosts_shell.do_host_vars_get(self.client, self.test_args) + self.mock_session.get.assert_called_once_with(self.variables_url) + + def test_do_host_vars_delete_calls_session_delete(self): + """Verify that do host-vars-delete calls expected session.delete.""" + self.test_args.variables = ['foo', 'bar'] + hosts_shell.do_host_vars_delete(self.client, self.test_args) + self.mock_session.delete.assert_called_once_with( + self.variables_url, + json=('foo', 'bar'), + params={}, + ) + + def test_do_host_vars_update_calls_session_put(self): + """Verify that do host-vars-delete calls expected session.delete.""" + self.test_args.variables = ['foo=baz', 'bar=boo', 'test='] + mock_resp_json = {"variables": {"foo": "bar"}} + self.mock_get_response.json.return_value = mock_resp_json + self.mock_put_response.json.return_value = mock_resp_json + + hosts_shell.do_host_vars_set(self.client, self.test_args) + self.mock_session.delete.assert_called_once_with( + self.variables_url, + json=('test',), + params={}, + ) + self.mock_session.put.assert_called_once_with( + self.variables_url, + json={'foo': 'baz', 'bar': 'boo'} + ) diff --git a/cratonclient/tests/integration/test_crud.py b/cratonclient/tests/integration/test_crud.py index 491f5f2..6c583ad 100644 --- a/cratonclient/tests/integration/test_crud.py +++ b/cratonclient/tests/integration/test_crud.py @@ -108,6 +108,7 @@ class TestCrudIntegration(base.TestCase): self.session.request.assert_called_once_with( method='DELETE', url='http://example.com/v1/test/1', + json=None, params={}, endpoint_filter={'service_type': 'fleet_management'}, ) diff --git a/cratonclient/tests/unit/common/__init__.py b/cratonclient/tests/unit/common/__init__.py new file mode 100644 index 0000000..4488217 --- /dev/null +++ b/cratonclient/tests/unit/common/__init__.py @@ -0,0 +1 @@ +"""Unit tests for cratonclient.common submodules.""" diff --git a/cratonclient/tests/unit/common/test_cliutils.py b/cratonclient/tests/unit/common/test_cliutils.py new file mode 100644 index 0000000..92dcad1 --- /dev/null +++ b/cratonclient/tests/unit/common/test_cliutils.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- + +# Copyright 2010-2011 OpenStack Foundation +# Copyright (c) 2013 Hewlett-Packard Development Company, L.P. +# +# 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. +"""Unit tests for the cratonclient.crud module members.""" + +import mock + +from cratonclient.common import cliutils +from cratonclient.tests import base + + +class TestCLIUtils(base.TestCase): + """Test for the CRUDClient class.""" + + def test_convert_arg_value_bool(self): + """Assert bool conversion.""" + trues = ['true', 'TRUE', 'True', 'trUE'] + falses = ['false', 'FALSE', 'False', 'falSe'] + + for true in trues: + self.assertTrue(cliutils.convert_arg_value(true)) + + for false in falses: + self.assertFalse(cliutils.convert_arg_value(false)) + + def test_convert_arg_value_none(self): + """Assert None conversion.""" + nones = ['none', 'null', 'NULL', 'None', 'NONE'] + + for none in nones: + self.assertIsNone(cliutils.convert_arg_value(none)) + + def test_convert_arg_value_integer(self): + """Assert integer conversion.""" + ints = ['1', '10', '145'] + + for integer in ints: + value = cliutils.convert_arg_value(integer) + self.assertTrue(isinstance(value, int)) + + def test_convert_arg_value_float(self): + """Assert float conversion.""" + floats = ['5.234', '1.000', '1.0001', '224.1234'] + + for num in floats: + value = cliutils.convert_arg_value(num) + self.assertTrue(isinstance(value, float)) + + def test_convert_arg_value_string(self): + """Assert string conversion.""" + strings = ["hello", "path/to/thing", "sp#cial!", "heyy:this:works"] + + for string in strings: + value = cliutils.convert_arg_value(string) + self.assertTrue(isinstance(value, str)) + + def test_convert_arg_value_escapes(self): + """Assert escaped conversion works to afford literal values.""" + escapes = ['"007"', '"1"', '"1.0"', '"False"', '"True"', '"None"'] + + for escaped in escapes: + value = cliutils.convert_arg_value(escaped) + self.assertTrue(isinstance(value, str)) + + @mock.patch('cratonclient.common.cliutils.sys.stdin') + def test_variable_updates_from_args(self, mock_stdin): + """Assert cliutils.variable_updates(...) when using arguments.""" + test_data = ["foo=bar", "test=", "baz=1", "bumbleywump=cucumberpatch"] + mock_stdin.isatty.return_value = True + expected_updates = { + "foo": "bar", + "baz": 1, + "bumbleywump": "cucumberpatch" + } + expected_deletes = ["test"] + + updates, deletes = cliutils.variable_updates(test_data) + + self.assertEqual(expected_updates, updates) + self.assertEqual(expected_deletes, deletes) + + @mock.patch('cratonclient.common.cliutils.sys.stdin') + def test_variable_updates_from_stdin(self, mock_stdin): + """Assert cliutils.variable_updates(...) when using stdin.""" + mock_stdin.isatty.return_value = False + mock_stdin.read.return_value = \ + '{"foo": {"bar": "baz"}, "bumbleywump": "cucumberpatch"}' + expected_updates = { + "foo": { + "bar": "baz" + }, + "bumbleywump": "cucumberpatch" + } + + updates, deletes = cliutils.variable_updates([]) + + self.assertEqual(expected_updates, updates) + self.assertEqual([], deletes) + + @mock.patch('cratonclient.common.cliutils.sys.stdin') + def test_variable_deletes_from_args(self, mock_stdin): + """Assert cliutils.variable_deletes(...) when using arguments.""" + test_data = ["foo", "test", "baz"] + mock_stdin.isatty.return_value = True + expected_deletes = test_data + + deletes = cliutils.variable_deletes(test_data) + + self.assertEqual(expected_deletes, deletes) + + @mock.patch('cratonclient.common.cliutils.sys.stdin') + def test_variable_deletes_from_stdin(self, mock_stdin): + """Assert cliutils.variable_deletes(...) when using stdin.""" + mock_stdin.isatty.return_value = False + mock_stdin.read.return_value = \ + '["foo", "test", "baz"]' + expected_deletes = ["foo", "test", "baz"] + + deletes = cliutils.variable_deletes([]) + + self.assertEqual(expected_deletes, deletes) diff --git a/cratonclient/tests/unit/formatters/test_base_formatter.py b/cratonclient/tests/unit/formatters/test_base_formatter.py index 2dc56d9..1cd58df 100644 --- a/cratonclient/tests/unit/formatters/test_base_formatter.py +++ b/cratonclient/tests/unit/formatters/test_base_formatter.py @@ -46,7 +46,7 @@ class TestBaseFormatter(testbase.TestCase): def test_handle_detects_resources(self): """Verify we handle instances explicitly.""" - resource = crud.Resource(mock.Mock(), {}) + resource = crud.Resource(mock.Mock(), {"id": 1234}) method = 'handle_instance' with mock.patch.object(self.formatter, method) as handle_instance: self.formatter.handle(resource) diff --git a/cratonclient/tests/unit/test_crud.py b/cratonclient/tests/unit/test_crud.py index e3ef84b..a125204 100644 --- a/cratonclient/tests/unit/test_crud.py +++ b/cratonclient/tests/unit/test_crud.py @@ -121,6 +121,7 @@ class TestCRUDClient(base.TestCase): self.session.delete.assert_called_once_with( 'http://example.com/v1/test/1', + json=None, params={} ) self.assertFalse(self.resource_spec.called) @@ -134,6 +135,7 @@ class TestCRUDClient(base.TestCase): self.session.delete.assert_called_once_with( 'http://example.com/v1/test/1', + json=None, params={} ) self.assertFalse(self.resource_spec.called) diff --git a/cratonclient/tests/unit/v1/test_clouds.py b/cratonclient/tests/unit/v1/test_clouds.py index 6e97fa2..3a6b1e1 100644 --- a/cratonclient/tests/unit/v1/test_clouds.py +++ b/cratonclient/tests/unit/v1/test_clouds.py @@ -24,7 +24,7 @@ class TestCloud(base.TestCase): def test_is_a_resource_instance(self): """Verify that a Cloud instance is an instance of a Resource.""" manager = mock.Mock() - self.assertIsInstance(clouds.Cloud(manager, {}), + self.assertIsInstance(clouds.Cloud(manager, {"id": 1234}), crud.Resource) diff --git a/cratonclient/tests/unit/v1/test_regions.py b/cratonclient/tests/unit/v1/test_regions.py index 969f596..184b823 100644 --- a/cratonclient/tests/unit/v1/test_regions.py +++ b/cratonclient/tests/unit/v1/test_regions.py @@ -24,7 +24,7 @@ class TestRegion(base.TestCase): def test_is_a_resource_instance(self): """Verify that a Region instance is an instance of a Resource.""" manager = mock.Mock() - self.assertIsInstance(regions.Region(manager, {}), + self.assertIsInstance(regions.Region(manager, {"id": 1234}), crud.Resource) diff --git a/cratonclient/tests/unit/v1/test_variables.py b/cratonclient/tests/unit/v1/test_variables.py new file mode 100644 index 0000000..7985189 --- /dev/null +++ b/cratonclient/tests/unit/v1/test_variables.py @@ -0,0 +1,67 @@ +# 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. +"""Tests for `cratonclient.v1.clouds` module.""" + +from cratonclient import crud +from cratonclient.tests import base +from cratonclient.v1 import variables + +import mock + + +class TestVariables(base.TestCase): + """Tests for the Cloud Resource.""" + + def setUp(self): + """Basic test setup.""" + super(TestVariables, self).setUp() + session = mock.Mock() + self.variable_mgr = variables.VariableManager(session, '') + + def test_is_a_crudclient(self): + """Verify our CloudManager is a CRUDClient.""" + self.assertIsInstance(self.variable_mgr, crud.CRUDClient) + + def test_variables_dict(self): + """Assert Variables instantiation produces sane variables dict.""" + test_data = { + "foo": "bar", + "zoo": { + "baz": "boo" + } + } + resource_obj = self.variable_mgr.resource_class( + mock.Mock(), + {"variables": test_data} + ) + + expected_variables_dict = { + "foo": variables.Variable("foo", "bar"), + "zoo": variables.Variable("zoo", {"baz": "boo"}) + } + self.assertDictEqual(expected_variables_dict, + resource_obj._variables_dict) + + def test_to_dict(self): + """Assert Variables.to_dict() produces original variables dict.""" + test_data = { + "foo": "bar", + "zoo": { + "baz": "boo" + } + } + resource_obj = self.variable_mgr.resource_class( + mock.Mock(), + {"variables": test_data} + ) + + self.assertDictEqual(test_data, resource_obj.to_dict()) diff --git a/cratonclient/v1/hosts.py b/cratonclient/v1/hosts.py index bd135c3..136f223 100644 --- a/cratonclient/v1/hosts.py +++ b/cratonclient/v1/hosts.py @@ -13,12 +13,15 @@ # under the License. """Hosts resource and resource manager.""" from cratonclient import crud +from cratonclient.v1 import variables class Host(crud.Resource): """Representation of a Host.""" - pass + subresource_managers = { + 'variables': variables.VariableManager, + } class HostManager(crud.CRUDClient): diff --git a/cratonclient/v1/variables.py b/cratonclient/v1/variables.py new file mode 100644 index 0000000..f08eb1f --- /dev/null +++ b/cratonclient/v1/variables.py @@ -0,0 +1,116 @@ +# 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. +"""Variables manager code.""" +from collections import MutableMapping +from cratonclient import crud + + +class Variable(object): + """Represents a Craton variable key/value pair.""" + + def __init__(self, name, value): + """Instantiate key/value pair.""" + self.name = name + self.value = value + + def __eq__(self, other): + """Assess equality of Variable objects.""" + if isinstance(other, type(self)): + return self.name == other.name and self.value == other.value + return False + + def __repr__(self): + """Return string representation of a Variable.""" + return '%(class)s(name=%(name)r, value=%(value)r)' % \ + { + "class": self.__class__.__name__, + "name": self.name, + "value": self.value + } + + +class Variables(MutableMapping): + """Represents a dictionary of Variables.""" + + _var_key = 'variables' + + def __init__(self, manager, info, loaded=False): + """Instantiate a Variables dict. + + Converts each value to a Variable representation of the full key/value + pair and assigns this mapping to self, as this extends the "dict" + type. + + This class is intended to look like crud.Resource interface-wise, but + it's unfortunately a hack to get around some limitations of + crud.Resource, specifically how crud.Resource overlaying an API + response onto a Python class can have variable keys conflicting with + legitimate attributes of the class itself. Because this is supposed + to be a dictionary-like thing, though, we don't wish to use the + manager to make API calls when users are treating this like a dict. + """ + self._manager = manager + self._loaded = loaded + self._variables_dict = { + k: Variable(k, v) + for (k, v) in info.get(self._var_key, dict()).items() + } + + def __getitem__(self, key): + """Get item from self._variables_dict.""" + return self._variables_dict[key] + + def __setitem__(self, key, value): + """Set item in self._variables_dict.""" + self._variables_dict[key] = value + + def __delitem__(self, key): + """Delete item from self._variables_dict.""" + del self._variables_dict[key] + + def __len__(self): + """Get length of self._variables_dict.""" + return len(self._variables_dict) + + def __iter__(self): + """Return iterator of self._variables_dict.""" + return iter(self._variables_dict) + + def __repr__(self): + """Return string representation of Variables.""" + info = ", ".join("%s=%s" % (k, self._variables_dict[k]) for k, v in + self._variables_dict.items()) + return "<%s %s>" % (self.__class__.__name__, info) + + def to_dict(self): + """Return this the original variables as a dict.""" + return {k: v.value for (k, v) in self._variables_dict.items()} + + +class VariableManager(crud.CRUDClient): + """A CRUD manager for variables.""" + + base_path = '/variables' + resource_class = Variables + + def delete(self, *args, **kwargs): + """Wrap crud.CRUDClient's delete to simplify for the variables. + + One can pass in a series of keys to delete, and this will pass the + correct arguments to the crud.CRUDClient.delete function. + + .. code-block:: python + + >>> craton.hosts.get(1234).variables.delete('var-a', 'var-b') + <Response [204]> + """ + return super(VariableManager, self).delete(json=args, **kwargs)