From d20970df8ef52b4bf355ba451459a838f3b35218 Mon Sep 17 00:00:00 2001 From: Brian Curtin Date: Wed, 23 Mar 2016 14:25:47 -0400 Subject: [PATCH] BaseProxy refactoring for new Resource To work with the changes in https://review.openstack.org/#/c/289998/ there are some follow-on changes necessary in the BaseProxy. This is primarily the removal of `path_args` arguments from BaseProxy signatures in favor of using keyword arguments, and passing those keyword arguments down into Resource methods as well. As with the Resource refactoring, as this is a major undertaking when it comes to the entirety of the services affected, this is going to temporarily live in a secondary "proxy2" module so that we can apply the changes in waves. There is one temporary change in openstack/connection.py that allows us to load proxy classes that are subclassed from openstack.proxy2. The rest of it will eventually live on as the only proxy class once we have completed all services being transitioned onto the new one. Change-Id: I918a31078f157354b5ccad313c51ace50241ede4 --- openstack/connection.py | 4 +- openstack/proxy2.py | 308 ++++++++++++++++++++ openstack/tests/unit/test_proxy2.py | 418 ++++++++++++++++++++++++++++ 3 files changed, 729 insertions(+), 1 deletion(-) create mode 100644 openstack/proxy2.py create mode 100644 openstack/tests/unit/test_proxy2.py diff --git a/openstack/connection.py b/openstack/connection.py index 3f6515480..5ee73fd55 100644 --- a/openstack/connection.py +++ b/openstack/connection.py @@ -65,6 +65,7 @@ import os_client_config from openstack import profile as _profile from openstack import proxy +from openstack import proxy2 from openstack import session as _session from openstack import utils @@ -226,7 +227,8 @@ class Connection(object): try: __import__(module) proxy_class = getattr(sys.modules[module], "Proxy") - if not issubclass(proxy_class, proxy.BaseProxy): + if not (issubclass(proxy_class, proxy.BaseProxy) or + issubclass(proxy_class, proxy2.BaseProxy)): raise TypeError("%s.Proxy must inherit from BaseProxy" % proxy_class.__module__) setattr(self, attr_name, proxy_class(self.session)) diff --git a/openstack/proxy2.py b/openstack/proxy2.py new file mode 100644 index 000000000..47464a336 --- /dev/null +++ b/openstack/proxy2.py @@ -0,0 +1,308 @@ +# 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. + +from openstack import exceptions +from openstack import resource2 + + +# The _check_resource decorator is used on BaseProxy methods to ensure that +# the `actual` argument is in fact the type of the `expected` argument. +# It does so under two cases: +# 1. When strict=False, if and only if `actual` is a Resource instance, +# it is checked to see that it's an instance of the `expected` class. +# This allows `actual` to be other types, such as strings, when it makes +# sense to accept a raw id value. +# 2. When strict=True, `actual` must be an instance of the `expected` class. +def _check_resource(strict=False): + def wrap(method): + def check(self, expected, actual=None, *args, **kwargs): + if (strict and actual is not None and not + isinstance(actual, resource2.Resource)): + raise ValueError("A %s must be passed" % expected.__name__) + elif (isinstance(actual, resource2.Resource) and not + isinstance(actual, expected)): + raise ValueError("Expected %s but received %s" % ( + expected.__name__, actual.__class__.__name__)) + + return method(self, expected, actual, *args, **kwargs) + return check + return wrap + + +class BaseProxy(object): + + def __init__(self, session): + self.session = session + + def _get_resource(self, resource_type, value, **attrs): + """Get a resource object to work on + + :param resource_type: The type of resource to operate on. This should + be a subclass of + :class:`~openstack.resource2.Resource` with a + ``from_id`` method. + :param value: The ID of a resource or an object of ``resource_type`` + class if using an existing instance, or None to create a + new instance. + :param path_args: A dict containing arguments for forming the request + URL, if needed. + """ + if value is None: + # Create a bare resource + res = resource_type.new(**attrs) + elif not isinstance(value, resource_type): + # Create from an ID + res = resource_type.new(id=value, **attrs) + else: + # An existing resource instance + res = value + res._update(**attrs) + + return res + + def _get_uri_attribute(self, child, parent, name): + """Get a value to be associated with a URI attribute + + `child` will not be None here as it's a required argument + on the proxy method. `parent` is allowed to be None if `child` + is an actual resource, but when an ID is given for the child + one must also be provided for the parent. An example of this + is that a parent is a Server and a child is a ServerInterface. + """ + if parent is None: + value = getattr(child, name) + else: + value = resource2.Resource._get_id(parent) + return value + + def _find(self, resource_type, name_or_id, ignore_missing=True, + **attrs): + """Find a resource + + :param name_or_id: The name or ID of a resource to find. + :param bool ignore_missing: When set to ``False`` + :class:`~openstack.exceptions.ResourceNotFound` will be + raised when the resource does not exist. + When set to ``True``, None will be returned when + attempting to find a nonexistent resource2. + :param dict attrs: Attributes to be passed onto the + :meth:`~openstack.resource2.Resource.find` + method, such as query parameters. + + :returns: An instance of ``resource_type`` or None + """ + return resource_type.find(self.session, name_or_id, + ignore_missing=ignore_missing, + **attrs) + + @_check_resource(strict=False) + def _delete(self, resource_type, value, ignore_missing=True, **attrs): + """Delete a resource + + :param resource_type: The type of resource to delete. This should + be a :class:`~openstack.resource2.Resource` + subclass with a ``from_id`` method. + :param value: The value to delete. Can be either the ID of a + resource or a :class:`~openstack.resource2.Resource` + subclass. + :param bool ignore_missing: When set to ``False`` + :class:`~openstack.exceptions.ResourceNotFound` will be + raised when the resource does not exist. + When set to ``True``, no exception will be set when + attempting to delete a nonexistent resource2. + :param dict attrs: Attributes to be passed onto the + :meth:`~openstack.resource2.Resource.delete` + method, such as the ID of a parent resource. + + :returns: The result of the ``delete`` + :raises: ``ValueError`` if ``value`` is a + :class:`~openstack.resource2.Resource` that doesn't match + the ``resource_type``. + :class:`~openstack.exceptions.ResourceNotFound` when + ignore_missing if ``False`` and a nonexistent resource + is attempted to be deleted. + + """ + res = self._get_resource(resource_type, value, **attrs) + + try: + rv = res.delete(self.session) + except exceptions.NotFoundException as e: + if ignore_missing: + return None + else: + # Reraise with a more specific type and message + raise exceptions.ResourceNotFound( + message="No %s found for %s" % + (resource_type.__name__, value), + details=e.details, response=e.response, + request_id=e.request_id, url=e.url, method=e.method, + http_status=e.http_status, cause=e.cause) + + return rv + + @_check_resource(strict=False) + def _update(self, resource_type, value, **attrs): + """Update a resource + + :param resource_type: The type of resource to update. + :type resource_type: :class:`~openstack.resource2.Resource` + :param value: The resource to update. This must either be a + :class:`~openstack.resource2.Resource` or an id + that corresponds to a resource2. + :param dict attrs: Attributes to be passed onto the + :meth:`~openstack.resource2.Resource.update` + method to be updated. These should correspond + to either :class:`~openstack.resource2.Body` + or :class:`~openstack.resource2.Header` + values on this resource. + + :returns: The result of the ``update`` + :rtype: :class:`~openstack.resource2.Resource` + """ + res = self._get_resource(resource_type, value, **attrs) + return res.update(self.session) + + def _create(self, resource_type, **attrs): + """Create a resource from attributes + + :param resource_type: The type of resource to create. + :type resource_type: :class:`~openstack.resource2.Resource` + :param path_args: A dict containing arguments for forming the request + URL, if needed. + :param dict attrs: Attributes to be passed onto the + :meth:`~openstack.resource2.Resource.create` + method to be created. These should correspond + to either :class:`~openstack.resource2.Body` + or :class:`~openstack.resource2.Header` + values on this resource. + + :returns: The result of the ``create`` + :rtype: :class:`~openstack.resource2.Resource` + """ + res = resource_type.new(**attrs) + return res.create(self.session) + + @_check_resource(strict=False) + def _get(self, resource_type, value=None, **attrs): + """Get a resource + + :param resource_type: The type of resource to get. + :type resource_type: :class:`~openstack.resource2.Resource` + :param value: The value to get. Can be either the ID of a + resource or a :class:`~openstack.resource2.Resource` + subclass. + :param dict attrs: Attributes to be passed onto the + :meth:`~openstack.resource2.Resource.get` + method. These should correspond + to either :class:`~openstack.resource2.Body` + or :class:`~openstack.resource2.Header` + values on this resource. + + :returns: The result of the ``get`` + :rtype: :class:`~openstack.resource2.Resource` + """ + res = self._get_resource(resource_type, value, **attrs) + + try: + return res.get(self.session) + except exceptions.NotFoundException as e: + raise exceptions.ResourceNotFound( + message="No %s found for %s" % + (resource_type.__name__, value), + details=e.details, response=e.response, + request_id=e.request_id, url=e.url, method=e.method, + http_status=e.http_status, cause=e.cause) + + def _list(self, resource_type, value=None, paginated=False, **attrs): + """List a resource + + :param resource_type: The type of resource to delete. This should + be a :class:`~openstack.resource2.Resource` + subclass with a ``from_id`` method. + :param value: The resource to list. It can be the ID of a resource, or + a :class:`~openstack.resource2.Resource` object. When set + to None, a new bare resource is created. + :param bool paginated: When set to ``False``, expect all of the data + to be returned in one response. When set to + ``True``, the resource supports data being + returned across multiple pages. + :param dict attrs: Attributes to be passed onto the + :meth:`~openstack.resource2.Resource.list` method. These should + correspond to either :class:`~openstack.resource2.URI` values + or appear in :data:`~openstack.resource2.Resource._query_mapping`. + + :returns: A generator of Resource objects. + :raises: ``ValueError`` if ``value`` is a + :class:`~openstack.resource2.Resource` that doesn't match + the ``resource_type``. + """ + res = self._get_resource(resource_type, value, **attrs) + return res.list(self.session, paginated=paginated, **attrs) + + def _head(self, resource_type, value=None, **attrs): + """Retrieve a resource's header + + :param resource_type: The type of resource to retrieve. + :type resource_type: :class:`~openstack.resource2.Resource` + :param value: The value of a specific resource to retreive headers + for. Can be either the ID of a resource, + a :class:`~openstack.resource2.Resource` subclass, + or ``None``. + :param dict attrs: Attributes to be passed onto the + :meth:`~openstack.resource2.Resource.head` method. + These should correspond to + :class:`~openstack.resource2.URI` values. + + :returns: The result of the ``head`` call + :rtype: :class:`~openstack.resource2.Resource` + """ + res = self._get_resource(resource_type, value, **attrs) + return res.head(self.session) + + def wait_for_status(self, value, status, failures=[], interval=2, + wait=120): + """Wait for a resource to be in a particular status. + + :param value: The resource to wait on to reach the status. The + resource must have a status attribute. + :type value: :class:`~openstack.resource2.Resource` + :param status: Desired status of the resource2. + :param list failures: Statuses that would indicate the transition + failed such as 'ERROR'. + :param interval: Number of seconds to wait between checks. + :param wait: Maximum number of seconds to wait for the change. + + :return: Method returns resource on success. + :raises: :class:`~openstack.exceptions.ResourceTimeout` transition + to status failed to occur in wait seconds. + :raises: :class:`~openstack.exceptions.ResourceFailure` resource + transitioned to one of the failure states. + :raises: :class:`~AttributeError` if the resource does not have a + status attribute + """ + return resource2.wait_for_status(self.session, value, status, + failures, interval, wait) + + def wait_for_delete(self, value, interval=2, wait=120): + """Wait for the resource to be deleted. + + :param value: The resource to wait on to be deleted. + :type value: :class:`~openstack.resource2.Resource` + :param interval: Number of seconds to wait between checks. + :param wait: Maximum number of seconds to wait for the delete. + + :return: Method returns resource on success. + :raises: :class:`~openstack.exceptions.ResourceTimeout` transition + to delete failed to occur in wait seconds. + """ + return resource2.wait_for_delete(self.session, value, interval, wait) diff --git a/openstack/tests/unit/test_proxy2.py b/openstack/tests/unit/test_proxy2.py new file mode 100644 index 000000000..45d2725f3 --- /dev/null +++ b/openstack/tests/unit/test_proxy2.py @@ -0,0 +1,418 @@ +# 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 mock +import testtools + +from openstack import exceptions +from openstack import proxy2 +from openstack import resource2 + + +class DeleteableResource(resource2.Resource): + allow_delete = True + + +class UpdateableResource(resource2.Resource): + allow_update = True + + +class CreateableResource(resource2.Resource): + allow_create = True + + +class RetrieveableResource(resource2.Resource): + allow_retrieve = True + + +class ListableResource(resource2.Resource): + allow_list = True + + +class HeadableResource(resource2.Resource): + allow_head = True + + +class TestProxyPrivate(testtools.TestCase): + + def setUp(self): + super(TestProxyPrivate, self).setUp() + + def method(self, expected_type, value): + return value + + self.sot = mock.Mock() + self.sot.method = method + + self.fake_proxy = proxy2.BaseProxy("session") + + def _test_correct(self, value): + decorated = proxy2._check_resource(strict=False)(self.sot.method) + rv = decorated(self.sot, resource2.Resource, value) + + self.assertEqual(value, rv) + + def test__check_resource_correct_resource(self): + res = resource2.Resource() + self._test_correct(res) + + def test__check_resource_notstrict_id(self): + self._test_correct("abc123-id") + + def test__check_resource_strict_id(self): + decorated = proxy2._check_resource(strict=True)(self.sot.method) + self.assertRaisesRegexp(ValueError, "A Resource must be passed", + decorated, self.sot, resource2.Resource, + "this-is-not-a-resource") + + def test__check_resource_incorrect_resource(self): + class OneType(resource2.Resource): + pass + + class AnotherType(resource2.Resource): + pass + + value = AnotherType() + decorated = proxy2._check_resource(strict=False)(self.sot.method) + self.assertRaisesRegexp(ValueError, + "Expected OneType but received AnotherType", + decorated, self.sot, OneType, value) + + def test__get_uri_attribute_no_parent(self): + class Child(resource2.Resource): + something = resource2.Body("something") + + attr = "something" + value = "nothing" + child = Child(something=value) + + result = self.fake_proxy._get_uri_attribute(child, None, attr) + + self.assertEqual(value, result) + + def test__get_uri_attribute_with_parent(self): + class Parent(resource2.Resource): + pass + + value = "nothing" + parent = Parent(id=value) + + result = self.fake_proxy._get_uri_attribute("child", parent, "attr") + + self.assertEqual(value, result) + + def test__get_resource_new(self): + value = "hello" + fake_type = mock.Mock(spec=resource2.Resource) + fake_type.new = mock.Mock(return_value=value) + attrs = {"first": "Brian", "last": "Curtin"} + + result = self.fake_proxy._get_resource(fake_type, None, **attrs) + + fake_type.new.assert_called_with(**attrs) + self.assertEqual(value, result) + + def test__get_resource_from_id(self): + id = "eye dee" + value = "hello" + attrs = {"first": "Brian", "last": "Curtin"} + + # The isinstance check needs to take a type, not an instance, + # so the mock.assert_called_with method isn't helpful here since + # we can't pass in a mocked object. This class is a crude version + # of that same behavior to let us check that `new` gets + # called with the expected arguments. + + class Fake(object): + call = {} + + @classmethod + def new(cls, **kwargs): + cls.call = kwargs + return value + + result = self.fake_proxy._get_resource(Fake, id, **attrs) + + self.assertDictEqual(dict(id=id, **attrs), Fake.call) + self.assertEqual(value, result) + + def test__get_resource_from_resource(self): + res = mock.Mock(spec=resource2.Resource) + res._update = mock.Mock() + + attrs = {"first": "Brian", "last": "Curtin"} + + result = self.fake_proxy._get_resource(resource2.Resource, + res, **attrs) + + res._update.assert_called_once_with(**attrs) + self.assertEqual(result, res) + + +class TestProxyDelete(testtools.TestCase): + + def setUp(self): + super(TestProxyDelete, self).setUp() + + self.session = mock.Mock() + + self.fake_id = 1 + self.res = mock.Mock(spec=DeleteableResource) + self.res.id = self.fake_id + self.res.delete = mock.Mock() + + self.sot = proxy2.BaseProxy(self.session) + DeleteableResource.new = mock.Mock(return_value=self.res) + + def test_delete(self): + self.sot._delete(DeleteableResource, self.res) + self.res.delete.assert_called_with(self.session) + + self.sot._delete(DeleteableResource, self.fake_id) + DeleteableResource.new.assert_called_with(id=self.fake_id) + self.res.delete.assert_called_with(self.session) + + # Delete generally doesn't return anything, so we will normally + # swallow any return from within a service's proxy, but make sure + # we can still return for any cases where values are returned. + self.res.delete.return_value = self.fake_id + rv = self.sot._delete(DeleteableResource, self.fake_id) + self.assertEqual(rv, self.fake_id) + + def test_delete_ignore_missing(self): + self.res.delete.side_effect = exceptions.NotFoundException( + message="test", http_status=404) + + rv = self.sot._delete(DeleteableResource, self.fake_id) + self.assertIsNone(rv) + + def test_delete_ResourceNotFound(self): + self.res.delete.side_effect = exceptions.NotFoundException( + message="test", http_status=404) + + self.assertRaisesRegexp( + exceptions.ResourceNotFound, + "No %s found for %s" % (DeleteableResource.__name__, self.res), + self.sot._delete, DeleteableResource, self.res, + ignore_missing=False) + + def test_delete_HttpException(self): + self.res.delete.side_effect = exceptions.HttpException( + message="test", http_status=500) + + self.assertRaises(exceptions.HttpException, self.sot._delete, + DeleteableResource, self.res, ignore_missing=False) + + +class TestProxyUpdate(testtools.TestCase): + + def setUp(self): + super(TestProxyUpdate, self).setUp() + + self.session = mock.Mock() + + self.fake_id = 1 + self.fake_result = "fake_result" + + self.res = mock.Mock(spec=UpdateableResource) + self.res.update = mock.Mock(return_value=self.fake_result) + + self.sot = proxy2.BaseProxy(self.session) + + self.attrs = {"x": 1, "y": 2, "z": 3} + + UpdateableResource.new = mock.Mock(return_value=self.res) + + def test_update_resource(self): + rv = self.sot._update(UpdateableResource, self.res, **self.attrs) + + self.assertEqual(rv, self.fake_result) + self.res._update.assert_called_once_with(**self.attrs) + self.res.update.assert_called_once_with(self.session) + + def test_update_id(self): + rv = self.sot._update(UpdateableResource, self.fake_id, **self.attrs) + + self.assertEqual(rv, self.fake_result) + self.res.update.assert_called_once_with(self.session) + + +class TestProxyCreate(testtools.TestCase): + + def setUp(self): + super(TestProxyCreate, self).setUp() + + self.session = mock.Mock() + + self.fake_result = "fake_result" + self.res = mock.Mock(spec=CreateableResource) + self.res.create = mock.Mock(return_value=self.fake_result) + + self.sot = proxy2.BaseProxy(self.session) + + def test_create_attributes(self): + CreateableResource.new = mock.Mock(return_value=self.res) + + attrs = {"x": 1, "y": 2, "z": 3} + rv = self.sot._create(CreateableResource, **attrs) + + self.assertEqual(rv, self.fake_result) + CreateableResource.new.assert_called_once_with(**attrs) + self.res.create.assert_called_once_with(self.session) + + +class TestProxyGet(testtools.TestCase): + + def setUp(self): + super(TestProxyGet, self).setUp() + + self.session = mock.Mock() + + self.fake_id = 1 + self.fake_name = "fake_name" + self.fake_result = "fake_result" + self.res = mock.Mock(spec=RetrieveableResource) + self.res.id = self.fake_id + self.res.get = mock.Mock(return_value=self.fake_result) + + self.sot = proxy2.BaseProxy(self.session) + RetrieveableResource.new = mock.Mock(return_value=self.res) + + def test_get_resource(self): + rv = self.sot._get(RetrieveableResource, self.res) + + self.res.get.assert_called_with(self.session) + self.assertEqual(rv, self.fake_result) + + def test_get_resource_with_args(self): + args = {"key": "value"} + rv = self.sot._get(RetrieveableResource, self.res, **args) + + self.res._update.assert_called_once_with(**args) + self.res.get.assert_called_with(self.session) + self.assertEqual(rv, self.fake_result) + + def test_get_id(self): + rv = self.sot._get(RetrieveableResource, self.fake_id) + + RetrieveableResource.new.assert_called_with(id=self.fake_id) + self.res.get.assert_called_with(self.session) + self.assertEqual(rv, self.fake_result) + + def test_get_not_found(self): + self.res.get.side_effect = exceptions.NotFoundException( + message="test", http_status=404) + + self.assertRaisesRegexp( + exceptions.ResourceNotFound, + "No %s found for %s" % (RetrieveableResource.__name__, self.res), + self.sot._get, RetrieveableResource, self.res) + + +class TestProxyList(testtools.TestCase): + + def setUp(self): + super(TestProxyList, self).setUp() + + self.session = mock.Mock() + + self.args = {"a": "A", "b": "B", "c": "C"} + self.fake_response = [resource2.Resource()] + + self.sot = proxy2.BaseProxy(self.session) + ListableResource.list = mock.Mock() + ListableResource.list.return_value = self.fake_response + + def _test_list(self, paginated): + rv = self.sot._list(ListableResource, paginated=paginated, **self.args) + + self.assertEqual(self.fake_response, rv) + ListableResource.list.assert_called_once_with( + self.session, paginated=paginated, **self.args) + + def test_list_paginated(self): + self._test_list(True) + + def test_list_non_paginated(self): + self._test_list(False) + + +class TestProxyHead(testtools.TestCase): + + def setUp(self): + super(TestProxyHead, self).setUp() + + self.session = mock.Mock() + + self.fake_id = 1 + self.fake_name = "fake_name" + self.fake_result = "fake_result" + self.res = mock.Mock(spec=HeadableResource) + self.res.id = self.fake_id + self.res.head = mock.Mock(return_value=self.fake_result) + + self.sot = proxy2.BaseProxy(self.session) + HeadableResource.new = mock.Mock(return_value=self.res) + + def test_head_resource(self): + rv = self.sot._head(HeadableResource, self.res) + + self.res.head.assert_called_with(self.session) + self.assertEqual(rv, self.fake_result) + + def test_head_id(self): + rv = self.sot._head(HeadableResource, self.fake_id) + + HeadableResource.new.assert_called_with(id=self.fake_id) + self.res.head.assert_called_with(self.session) + self.assertEqual(rv, self.fake_result) + + +class TestProxyWaits(testtools.TestCase): + + def setUp(self): + super(TestProxyWaits, self).setUp() + + self.session = mock.Mock() + self.sot = proxy2.BaseProxy(self.session) + + @mock.patch("openstack.resource2.wait_for_status") + def test_wait_for(self, mock_wait): + mock_resource = mock.Mock() + mock_wait.return_value = mock_resource + self.sot.wait_for_status(mock_resource, 'ACTIVE') + mock_wait.assert_called_once_with( + self.session, mock_resource, 'ACTIVE', [], 2, 120) + + @mock.patch("openstack.resource2.wait_for_status") + def test_wait_for_params(self, mock_wait): + mock_resource = mock.Mock() + mock_wait.return_value = mock_resource + self.sot.wait_for_status(mock_resource, 'ACTIVE', ['ERROR'], 1, 2) + mock_wait.assert_called_once_with( + self.session, mock_resource, 'ACTIVE', ['ERROR'], 1, 2) + + @mock.patch("openstack.resource2.wait_for_delete") + def test_wait_for_delete(self, mock_wait): + mock_resource = mock.Mock() + mock_wait.return_value = mock_resource + self.sot.wait_for_delete(mock_resource) + mock_wait.assert_called_once_with( + self.session, mock_resource, 2, 120) + + @mock.patch("openstack.resource2.wait_for_delete") + def test_wait_for_delete_params(self, mock_wait): + mock_resource = mock.Mock() + mock_wait.return_value = mock_resource + self.sot.wait_for_delete(mock_resource, 1, 2) + mock_wait.assert_called_once_with( + self.session, mock_resource, 1, 2)