diff options
Diffstat (limited to 'openstackclient')
| -rw-r--r-- | openstackclient/api/auth.py | 48 | ||||
| -rw-r--r-- | openstackclient/api/auth_plugin.py | 2 | ||||
| -rw-r--r-- | openstackclient/common/clientmanager.py | 78 | ||||
| -rw-r--r-- | openstackclient/compute/v2/aggregate.py | 34 | ||||
| -rw-r--r-- | openstackclient/compute/v2/flavor.py | 49 | ||||
| -rw-r--r-- | openstackclient/image/v2/image.py | 3 | ||||
| -rw-r--r-- | openstackclient/network/v2/security_group_rule.py | 4 | ||||
| -rw-r--r-- | openstackclient/tests/compute/v2/fakes.py | 38 | ||||
| -rw-r--r-- | openstackclient/tests/compute/v2/test_aggregate.py | 65 | ||||
| -rw-r--r-- | openstackclient/tests/compute/v2/test_flavor.py | 86 |
10 files changed, 311 insertions, 96 deletions
diff --git a/openstackclient/api/auth.py b/openstackclient/api/auth.py index 0018e76e..d5412594 100644 --- a/openstackclient/api/auth.py +++ b/openstackclient/api/auth.py @@ -105,10 +105,12 @@ def select_auth_plugin(options): def build_auth_params(auth_plugin_name, cmd_options): - auth_params = dict(cmd_options.auth) if auth_plugin_name: LOG.debug('auth_type: %s', auth_plugin_name) auth_plugin_loader = base.get_plugin_loader(auth_plugin_name) + auth_params = {opt.dest: opt.default + for opt in base.get_plugin_options(auth_plugin_name)} + auth_params.update(dict(cmd_options.auth)) # grab tenant from project for v2.0 API compatibility if auth_plugin_name.startswith("v2"): if 'project_id' in auth_params: @@ -121,6 +123,7 @@ def build_auth_params(auth_plugin_name, cmd_options): LOG.debug('no auth_type') # delay the plugin choice, grab every option auth_plugin_loader = None + auth_params = dict(cmd_options.auth) plugin_options = set([o.replace('-', '_') for o in get_options_list()]) for option in plugin_options: LOG.debug('fetching option %s', option) @@ -147,29 +150,28 @@ def check_valid_authorization_options(options, auth_plugin_name): def check_valid_authentication_options(options, auth_plugin_name): """Validate authentication options, and provide helpful error messages.""" - msgs = [] - if auth_plugin_name.endswith('password'): - if not options.auth.get('username'): - msgs.append(_('Set a username with --os-username, OS_USERNAME,' - ' or auth.username')) - if not options.auth.get('auth_url'): - msgs.append(_('Set an authentication URL, with --os-auth-url,' - ' OS_AUTH_URL or auth.auth_url')) - elif auth_plugin_name.endswith('token'): - if not options.auth.get('token'): - msgs.append(_('Set a token with --os-token, OS_TOKEN or ' - 'auth.token')) - if not options.auth.get('auth_url'): - msgs.append(_('Set a service AUTH_URL, with --os-auth-url, ' - 'OS_AUTH_URL or auth.auth_url')) - elif auth_plugin_name == 'token_endpoint': - if not options.auth.get('token'): - msgs.append(_('Set a token with --os-token, OS_TOKEN or ' - 'auth.token')) - if not options.auth.get('url'): - msgs.append(_('Set a service URL, with --os-url, OS_URL or ' - 'auth.url')) + # Get all the options defined within the plugin. + plugin_opts = base.get_plugin_options(auth_plugin_name) + plugin_opts = {opt.dest: opt for opt in plugin_opts} + # NOTE(aloga): this is an horrible hack. We need a way to specify the + # required options in the plugins. Using the "required" argument for + # the oslo_config.cfg.Opt does not work, as it is not possible to load the + # plugin if the option is not defined, so the error will simply be: + # "NoMatchingPlugin: The plugin foobar could not be found" + msgs = [] + if 'password' in plugin_opts and not options.auth.get('username'): + msgs.append(_('Set a username with --os-username, OS_USERNAME,' + ' or auth.username')) + if 'auth_url' in plugin_opts and not options.auth.get('auth_url'): + msgs.append(_('Set a service AUTH_URL, with --os-auth-url, ' + 'OS_AUTH_URL or auth.auth_url')) + if 'url' in plugin_opts and not options.auth.get('url'): + msgs.append(_('Set a service URL, with --os-url, ' + 'OS_URL or auth.url')) + if 'token' in plugin_opts and not options.auth.get('token'): + msgs.append(_('Set a token with --os-token, ' + 'OS_TOKEN or auth.token')) if msgs: raise exc.CommandError( _('Missing parameter(s): \n%s') % '\n'.join(msgs)) diff --git a/openstackclient/api/auth_plugin.py b/openstackclient/api/auth_plugin.py index 36dc5160..4434bc8f 100644 --- a/openstackclient/api/auth_plugin.py +++ b/openstackclient/api/auth_plugin.py @@ -38,7 +38,7 @@ class TokenEndpoint(token_endpoint.AdminToken): is for bootstrapping the Keystone database. """ - def load_from_options(self, url, token): + def load_from_options(self, url, token, **kwargs): """A plugin for static authentication with an existing token :param string url: Service endpoint diff --git a/openstackclient/common/clientmanager.py b/openstackclient/common/clientmanager.py index 5dbfb417..3c35b529 100644 --- a/openstackclient/common/clientmanager.py +++ b/openstackclient/common/clientmanager.py @@ -140,8 +140,49 @@ class ClientManager(object): # prior to dereferrencing auth_ref. self._auth_setup_completed = False + def _set_default_scope_options(self): + # TODO(mordred): This is a usability improvement that's broadly useful + # We should port it back up into os-client-config. + default_domain = self._cli_options.default_domain + + # NOTE(hieulq): If USER_DOMAIN_NAME, USER_DOMAIN_ID, PROJECT_DOMAIN_ID + # or PROJECT_DOMAIN_NAME is present and API_VERSION is 2.0, then + # ignore all domain related configs. + if (self._api_version.get('identity') == '2.0' and + self.auth_plugin_name.endswith('password')): + domain_props = ['project_domain_name', 'project_domain_id', + 'user_domain_name', 'user_domain_id'] + for prop in domain_props: + if self._auth_params.pop(prop, None) is not None: + LOG.warning("Ignoring domain related configs " + + prop + " because identity API version is 2.0") + return + + # NOTE(aloga): The scope parameters below only apply to v3 and v3 + # related auth plugins, so we stop the parameter checking if v2 is + # being used. + if (self._api_version.get('identity') != '3' or + self.auth_plugin_name.startswith('v2')): + return + + # NOTE(stevemar): If PROJECT_DOMAIN_ID or PROJECT_DOMAIN_NAME is + # present, then do not change the behaviour. Otherwise, set the + # PROJECT_DOMAIN_ID to 'OS_DEFAULT_DOMAIN' for better usability. + if ('project_domain_id' in self._auth_params and + not self._auth_params.get('project_domain_id') and + not self._auth_params.get('project_domain_name')): + self._auth_params['project_domain_id'] = default_domain + + # NOTE(stevemar): If USER_DOMAIN_ID or USER_DOMAIN_NAME is present, + # then do not change the behaviour. Otherwise, set the + # USER_DOMAIN_ID to 'OS_DEFAULT_DOMAIN' for better usability. + if ('user_domain_id' in self._auth_params and + not self._auth_params.get('user_domain_id') and + not self._auth_params.get('user_domain_name')): + self._auth_params['user_domain_id'] = default_domain + def setup_auth(self): - """Set up authentication. + """Set up authentication This is deferred until authentication is actually attempted because it gets in the way of things that do not require auth. @@ -169,40 +210,7 @@ class ClientManager(object): self._cli_options, ) - # TODO(mordred): This is a usability improvement that's broadly useful - # We should port it back up into os-client-config. - default_domain = self._cli_options.default_domain - # NOTE(stevemar): If PROJECT_DOMAIN_ID or PROJECT_DOMAIN_NAME is - # present, then do not change the behaviour. Otherwise, set the - # PROJECT_DOMAIN_ID to 'OS_DEFAULT_DOMAIN' for better usability. - if (self._api_version.get('identity') == '3' and - self.auth_plugin_name.endswith('password') and - not self._auth_params.get('project_domain_id') and - not self.auth_plugin_name.startswith('v2') and - not self._auth_params.get('project_domain_name')): - self._auth_params['project_domain_id'] = default_domain - - # NOTE(stevemar): If USER_DOMAIN_ID or USER_DOMAIN_NAME is present, - # then do not change the behaviour. Otherwise, set the USER_DOMAIN_ID - # to 'OS_DEFAULT_DOMAIN' for better usability. - if (self._api_version.get('identity') == '3' and - self.auth_plugin_name.endswith('password') and - not self.auth_plugin_name.startswith('v2') and - not self._auth_params.get('user_domain_id') and - not self._auth_params.get('user_domain_name')): - self._auth_params['user_domain_id'] = default_domain - - # NOTE(hieulq): If USER_DOMAIN_NAME, USER_DOMAIN_ID, PROJECT_DOMAIN_ID - # or PROJECT_DOMAIN_NAME is present and API_VERSION is 2.0, then - # ignore all domain related configs. - if (self._api_version.get('identity') == '2.0' and - self.auth_plugin_name.endswith('password')): - domain_props = ['project_domain_name', 'project_domain_id', - 'user_domain_name', 'user_domain_id'] - for prop in domain_props: - if self._auth_params.pop(prop, None) is not None: - LOG.warning("Ignoring domain related configs " + - prop + " because identity API version is 2.0") + self._set_default_scope_options() # For compatibility until all clients can be updated if 'project_name' in self._auth_params: diff --git a/openstackclient/compute/v2/aggregate.py b/openstackclient/compute/v2/aggregate.py index 8000c93a..2e2838c5 100644 --- a/openstackclient/compute/v2/aggregate.py +++ b/openstackclient/compute/v2/aggregate.py @@ -16,14 +16,20 @@ """Compute v2 Aggregate action implementations""" +import logging + from osc_lib.cli import parseractions from osc_lib.command import command +from osc_lib import exceptions from osc_lib import utils import six from openstackclient.i18n import _ +LOG = logging.getLogger(__name__) + + class AddAggregateHost(command.ShowOne): """Add host to aggregate""" @@ -99,25 +105,37 @@ class CreateAggregate(command.ShowOne): class DeleteAggregate(command.Command): - """Delete an existing aggregate""" + """Delete existing aggregate(s)""" def get_parser(self, prog_name): parser = super(DeleteAggregate, self).get_parser(prog_name) parser.add_argument( 'aggregate', metavar='<aggregate>', - help=_("Aggregate to delete (name or ID)") + nargs='+', + help=_("Aggregate(s) to delete (name or ID)") ) return parser def take_action(self, parsed_args): - compute_client = self.app.client_manager.compute - data = utils.find_resource( - compute_client.aggregates, - parsed_args.aggregate, - ) - compute_client.aggregates.delete(data.id) + result = 0 + for a in parsed_args.aggregate: + try: + data = utils.find_resource( + compute_client.aggregates, a) + compute_client.aggregates.delete(data.id) + except Exception as e: + result += 1 + LOG.error(_("Failed to delete aggregate with name or " + "ID '%(aggregate)s': %(e)s") + % {'aggregate': a, 'e': e}) + + if result > 0: + total = len(parsed_args.aggregate) + msg = (_("%(result)s of %(total)s aggregates failed " + "to delete.") % {'result': result, 'total': total}) + raise exceptions.CommandError(msg) class ListAggregate(command.Lister): diff --git a/openstackclient/compute/v2/flavor.py b/openstackclient/compute/v2/flavor.py index bcf97c69..01d7da75 100644 --- a/openstackclient/compute/v2/flavor.py +++ b/openstackclient/compute/v2/flavor.py @@ -121,10 +121,22 @@ class CreateFlavor(command.ShowOne): action="store_false", help=_("Flavor is not available to other projects") ) + parser.add_argument( + '--project', + metavar='<project>', + help=_("Allow <project> to access private flavor (name or ID) " + "(Must be used with --private option)"), + ) + identity_common.add_project_domain_option_to_parser(parser) return parser def take_action(self, parsed_args): compute_client = self.app.client_manager.compute + identity_client = self.app.client_manager.identity + + if parsed_args.project and parsed_args.public: + msg = _("--project is only allowed with --private") + raise exceptions.CommandError(msg) args = ( parsed_args.name, @@ -141,25 +153,54 @@ class CreateFlavor(command.ShowOne): flavor = compute_client.flavors.create(*args)._info.copy() flavor.pop("links") + if parsed_args.project: + try: + project_id = identity_common.find_project( + identity_client, + parsed_args.project, + parsed_args.project_domain, + ).id + compute_client.flavor_access.add_tenant_access( + parsed_args.id, project_id) + except Exception as e: + msg = _("Failed to add project %(project)s access to " + "flavor: %(e)s") + LOG.error(msg % {'project': parsed_args.project, 'e': e}) + return zip(*sorted(six.iteritems(flavor))) class DeleteFlavor(command.Command): - """Delete flavor""" + """Delete flavor(s)""" def get_parser(self, prog_name): parser = super(DeleteFlavor, self).get_parser(prog_name) parser.add_argument( "flavor", metavar="<flavor>", - help=_("Flavor to delete (name or ID)") + nargs='+', + help=_("Flavor(s) to delete (name or ID)") ) return parser def take_action(self, parsed_args): compute_client = self.app.client_manager.compute - flavor = _find_flavor(compute_client, parsed_args.flavor) - compute_client.flavors.delete(flavor.id) + result = 0 + for f in parsed_args.flavor: + try: + flavor = _find_flavor(compute_client, f) + compute_client.flavors.delete(flavor.id) + except Exception as e: + result += 1 + LOG.error(_("Failed to delete flavor with name or " + "ID '%(flavor)s': %(e)s") + % {'flavor': f, 'e': e}) + + if result > 0: + total = len(parsed_args.flavor) + msg = (_("%(result)s of %(total)s flavors failed " + "to delete.") % {'result': result, 'total': total}) + raise exceptions.CommandError(msg) class ListFlavor(command.Lister): diff --git a/openstackclient/image/v2/image.py b/openstackclient/image/v2/image.py index 82566ff3..309b1b6b 100644 --- a/openstackclient/image/v2/image.py +++ b/openstackclient/image/v2/image.py @@ -836,7 +836,8 @@ class SetImage(command.Command): image = image_client.images.update(image.id, **kwargs) except Exception as e: if activation_status is not None: - print("Image %s was %s." % (image.id, activation_status)) + LOG.info(_("Image %(id)s was %(status)s."), + {'id': image.id, 'status': activation_status}) raise e diff --git a/openstackclient/network/v2/security_group_rule.py b/openstackclient/network/v2/security_group_rule.py index 18863223..e3be44ec 100644 --- a/openstackclient/network/v2/security_group_rule.py +++ b/openstackclient/network/v2/security_group_rule.py @@ -526,8 +526,8 @@ class ShowSecurityGroupRule(common.NetworkAndComputeShowOne): break if obj is None: - msg = _("Could not find security group rule with ID ") + \ - parsed_args.rule + msg = _("Could not find security group rule " + "with ID '%s'") % parsed_args.rule raise exceptions.CommandError(msg) # NOTE(rtheis): Format security group rule diff --git a/openstackclient/tests/compute/v2/fakes.py b/openstackclient/tests/compute/v2/fakes.py index 60abb8ef..8416b630 100644 --- a/openstackclient/tests/compute/v2/fakes.py +++ b/openstackclient/tests/compute/v2/fakes.py @@ -87,6 +87,42 @@ class FakeAggregate(object): loaded=True) return aggregate + @staticmethod + def create_aggregates(attrs=None, count=2): + """Create multiple fake aggregates. + + :param Dictionary attrs: + A dictionary with all attributes + :param int count: + The number of aggregates to fake + :return: + A list of FakeResource objects faking the aggregates + """ + aggregates = [] + for i in range(0, count): + aggregates.append(FakeAggregate.create_one_aggregate(attrs)) + + return aggregates + + @staticmethod + def get_aggregates(aggregates=None, count=2): + """Get an iterable MagicMock object with a list of faked aggregates. + + If aggregates list is provided, then initialize the Mock object + with the list. Otherwise create one. + + :param List aggregates: + A list of FakeResource objects faking aggregates + :param int count: + The number of aggregates to fake + :return: + An iterable Mock object with side_effect set to a list of faked + aggregates + """ + if aggregates is None: + aggregates = FakeAggregate.create_aggregates(count) + return mock.MagicMock(side_effect=aggregates) + class FakeComputev2Client(object): @@ -732,7 +768,7 @@ class FakeFlavor(object): flavors """ if flavors is None: - flavors = FakeServer.create_flavors(count) + flavors = FakeFlavor.create_flavors(count) return mock.MagicMock(side_effect=flavors) diff --git a/openstackclient/tests/compute/v2/test_aggregate.py b/openstackclient/tests/compute/v2/test_aggregate.py index 58dd7755..3ebca35f 100644 --- a/openstackclient/tests/compute/v2/test_aggregate.py +++ b/openstackclient/tests/compute/v2/test_aggregate.py @@ -13,6 +13,12 @@ # under the License. # +import mock +from mock import call + +from osc_lib import exceptions +from osc_lib import utils + from openstackclient.compute.v2 import aggregate from openstackclient.tests.compute.v2 import fakes as compute_fakes from openstackclient.tests import utils as tests_utils @@ -135,25 +141,74 @@ class TestAggregateCreate(TestAggregate): class TestAggregateDelete(TestAggregate): + fake_ags = compute_fakes.FakeAggregate.create_aggregates(count=2) + def setUp(self): super(TestAggregateDelete, self).setUp() - self.aggregate_mock.get.return_value = self.fake_ag + self.aggregate_mock.get = ( + compute_fakes.FakeAggregate.get_aggregates(self.fake_ags)) self.cmd = aggregate.DeleteAggregate(self.app, None) def test_aggregate_delete(self): arglist = [ - 'ag1', + self.fake_ags[0].id ] verifylist = [ - ('aggregate', 'ag1'), + ('aggregate', [self.fake_ags[0].id]), ] parsed_args = self.check_parser(self.cmd, arglist, verifylist) result = self.cmd.take_action(parsed_args) - self.aggregate_mock.get.assert_called_once_with(parsed_args.aggregate) - self.aggregate_mock.delete.assert_called_once_with(self.fake_ag.id) + self.aggregate_mock.get.assert_called_once_with(self.fake_ags[0].id) + self.aggregate_mock.delete.assert_called_once_with(self.fake_ags[0].id) self.assertIsNone(result) + def test_delete_multiple_aggregates(self): + arglist = [] + for a in self.fake_ags: + arglist.append(a.id) + verifylist = [ + ('aggregate', arglist), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + result = self.cmd.take_action(parsed_args) + + calls = [] + for a in self.fake_ags: + calls.append(call(a.id)) + self.aggregate_mock.delete.assert_has_calls(calls) + self.assertIsNone(result) + + def test_delete_multiple_agggregates_with_exception(self): + arglist = [ + self.fake_ags[0].id, + 'unexist_aggregate', + ] + verifylist = [ + ('aggregate', arglist), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + find_mock_result = [self.fake_ags[0], exceptions.CommandError] + with mock.patch.object(utils, 'find_resource', + side_effect=find_mock_result) as find_mock: + try: + self.cmd.take_action(parsed_args) + self.fail('CommandError should be raised.') + except exceptions.CommandError as e: + self.assertEqual('1 of 2 aggregates failed to delete.', + str(e)) + + find_mock.assert_any_call(self.aggregate_mock, self.fake_ags[0].id) + find_mock.assert_any_call(self.aggregate_mock, 'unexist_aggregate') + + self.assertEqual(2, find_mock.call_count) + self.aggregate_mock.delete.assert_called_once_with( + self.fake_ags[0].id + ) + class TestAggregateList(TestAggregate): diff --git a/openstackclient/tests/compute/v2/test_flavor.py b/openstackclient/tests/compute/v2/test_flavor.py index 864a4327..da76b6d7 100644 --- a/openstackclient/tests/compute/v2/test_flavor.py +++ b/openstackclient/tests/compute/v2/test_flavor.py @@ -14,6 +14,8 @@ # import copy +import mock +from mock import call from osc_lib import exceptions from osc_lib import utils @@ -75,6 +77,12 @@ class TestFlavorCreate(TestFlavor): def setUp(self): super(TestFlavorCreate, self).setUp() + # Return a project + self.projects_mock.get.return_value = fakes.FakeResource( + None, + copy.deepcopy(identity_fakes.PROJECT), + loaded=True, + ) self.flavors_mock.create.return_value = self.flavor self.cmd = flavor.CreateFlavor(self.app, None) @@ -161,6 +169,7 @@ class TestFlavorCreate(TestFlavor): '--vcpus', str(self.flavor.vcpus), '--rxtx-factor', str(self.flavor.rxtx_factor), '--private', + '--project', identity_fakes.project_id, ] verifylist = [ ('name', self.flavor.name), @@ -172,6 +181,7 @@ class TestFlavorCreate(TestFlavor): ('vcpus', self.flavor.vcpus), ('rxtx_factor', self.flavor.rxtx_factor), ('public', False), + ('project', identity_fakes.project_id), ] parsed_args = self.check_parser(self.cmd, arglist, verifylist) @@ -188,10 +198,28 @@ class TestFlavorCreate(TestFlavor): ) columns, data = self.cmd.take_action(parsed_args) self.flavors_mock.create.assert_called_once_with(*args) - + self.flavor_access_mock.add_tenant_access.assert_called_with( + self.flavor.id, + identity_fakes.project_id, + ) self.assertEqual(self.columns, columns) self.assertEqual(self.data, data) + def test_public_flavor_create_with_project(self): + arglist = [ + '--project', identity_fakes.project_id, + self.flavor.name, + ] + verifylist = [ + ('project', identity_fakes.project_id), + ('name', self.flavor.name), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + self.assertRaises(exceptions.CommandError, + self.cmd.take_action, + parsed_args) + def test_flavor_create_no_options(self): arglist = [] verifylist = None @@ -204,47 +232,73 @@ class TestFlavorCreate(TestFlavor): class TestFlavorDelete(TestFlavor): - flavor = compute_fakes.FakeFlavor.create_one_flavor() + flavors = compute_fakes.FakeFlavor.create_flavors(count=2) def setUp(self): super(TestFlavorDelete, self).setUp() - self.flavors_mock.get.return_value = self.flavor + self.flavors_mock.get = ( + compute_fakes.FakeFlavor.get_flavors(self.flavors)) self.flavors_mock.delete.return_value = None self.cmd = flavor.DeleteFlavor(self.app, None) def test_flavor_delete(self): arglist = [ - self.flavor.id + self.flavors[0].id ] verifylist = [ - ('flavor', self.flavor.id), + ('flavor', [self.flavors[0].id]), ] parsed_args = self.check_parser(self.cmd, arglist, verifylist) result = self.cmd.take_action(parsed_args) - self.flavors_mock.delete.assert_called_with(self.flavor.id) + self.flavors_mock.delete.assert_called_with(self.flavors[0].id) self.assertIsNone(result) - def test_flavor_delete_with_unexist_flavor(self): - self.flavors_mock.get.side_effect = exceptions.NotFound(None) - self.flavors_mock.find.side_effect = exceptions.NotFound(None) + def test_delete_multiple_flavors(self): + arglist = [] + for f in self.flavors: + arglist.append(f.id) + verifylist = [ + ('flavor', arglist), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + result = self.cmd.take_action(parsed_args) + + calls = [] + for f in self.flavors: + calls.append(call(f.id)) + self.flavors_mock.delete.assert_has_calls(calls) + self.assertIsNone(result) + + def test_multi_flavors_delete_with_exception(self): arglist = [ - 'unexist_flavor' + self.flavors[0].id, + 'unexist_flavor', ] verifylist = [ - ('flavor', 'unexist_flavor'), + ('flavor', [self.flavors[0].id, 'unexist_flavor']) ] - parsed_args = self.check_parser(self.cmd, arglist, verifylist) - self.assertRaises( - exceptions.CommandError, - self.cmd.take_action, - parsed_args) + find_mock_result = [self.flavors[0], exceptions.CommandError] + self.flavors_mock.get = ( + mock.MagicMock(side_effect=find_mock_result) + ) + self.flavors_mock.find.side_effect = exceptions.NotFound(None) + + try: + self.cmd.take_action(parsed_args) + self.fail('CommandError should be raised.') + except exceptions.CommandError as e: + self.assertEqual('1 of 2 flavors failed to delete.', str(e)) + + self.flavors_mock.get.assert_any_call(self.flavors[0].id) + self.flavors_mock.get.assert_any_call('unexist_flavor') + self.flavors_mock.delete.assert_called_once_with(self.flavors[0].id) class TestFlavorList(TestFlavor): |
