From edc0ccd973e85cb53c41159568673f15f5ab26fb Mon Sep 17 00:00:00 2001 From: Rui Chen Date: Sat, 24 Sep 2016 17:42:49 +0800 Subject: [PATCH] Add client resource base class Change-Id: I0e1179fbf1804087c753e529ca67a6e503b6d1d4 --- nimbleclient/common/base.py | 356 ++++++++++++++++++++ nimbleclient/tests/unit/common/test_base.py | 277 +++++++++++++++ nimbleclient/tests/unit/fakes.py | 82 +++++ test-requirements.txt | 1 + tox.ini | 2 +- 5 files changed, 717 insertions(+), 1 deletion(-) create mode 100644 nimbleclient/common/base.py create mode 100644 nimbleclient/tests/unit/common/test_base.py diff --git a/nimbleclient/common/base.py b/nimbleclient/common/base.py new file mode 100644 index 0000000..bc5dd13 --- /dev/null +++ b/nimbleclient/common/base.py @@ -0,0 +1,356 @@ +# Copyright 2016 Huawei, Inc. All rights reserved. +# +# 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. +# + +""" +Base utilities to build API operation managers and objects on top of. +""" + +import abc +import copy + +from requests import Response +import six + +from nimbleclient.common import exceptions + + +# Python 2.4 compat +try: + all +except NameError: + def all(iterable): + return True not in (not x for x in iterable) + + +def getid(obj): + """Get obj's id or object itself if no id + + Abstracts the common pattern of allowing both an object or + an object's ID (UUID) as a parameter when dealing with relationships. + """ + try: + return obj.id + except AttributeError: + return obj + + +class Manager(object): + """Interacts with type of API + + Managers interact with a particular type of API (instances, types, etc.) + and provide CRUD operations for them. + """ + resource_class = None + + def __init__(self, api): + self.api = api + + def _list(self, url, response_key=None, obj_class=None, + data=None, headers=None): + + if headers is None: + headers = {} + resp, body = self.api.get(url, headers=headers) + + if obj_class is None: + obj_class = self.resource_class + + if response_key: + if response_key not in body: + body[response_key] = [] + data = body[response_key] + else: + data = body + items = [obj_class(self, res, loaded=True) for res in data if res] + + return ListWithMeta(items, resp) + + def _delete(self, url, headers=None): + if headers is None: + headers = {} + resp, body = self.api.delete(url, headers=headers) + + return self.convert_into_with_meta(body, resp) + + def _update(self, url, data, response_key=None, headers=None): + if headers is None: + headers = {} + resp, body = self.api.patch(url, data=data, headers=headers) + # PUT requests may not return a body + if body: + if response_key: + return self.resource_class(self, body[response_key], resp=resp) + return self.resource_class(self, body, resp=resp) + else: + return StrWithMeta(body, resp) + + def _create(self, url, data=None, response_key=None, return_raw=False, + headers=None): + if headers is None: + headers = {} + if data: + resp, body = self.api.post(url, data=data, headers=headers) + else: + resp, body = self.api.post(url, headers=headers) + if return_raw: + if response_key: + body = body[response_key] + return self.convert_into_with_meta(body, resp) + + if response_key: + return self.resource_class(self, body[response_key], resp=resp) + return self.resource_class(self, body, resp=resp) + + def _get(self, url, response_key=None, return_raw=False, headers=None): + if headers is None: + headers = {} + resp, body = self.api.get(url, headers=headers) + if return_raw: + if response_key: + body = body[response_key] + return self.convert_into_with_meta(body, resp) + + if response_key: + return self.resource_class(self, body[response_key], loaded=True, + resp=resp) + return self.resource_class(self, body, loaded=True, resp=resp) + + def convert_into_with_meta(self, item, resp): + if isinstance(item, six.string_types): + if six.PY2 and isinstance(item, six.text_type): + return UnicodeWithMeta(item, resp) + else: + return StrWithMeta(item, resp) + elif isinstance(item, six.binary_type): + return BytesWithMeta(item, resp) + elif isinstance(item, list): + return ListWithMeta(item, resp) + elif isinstance(item, tuple): + return TupleWithMeta(item, resp) + elif item is None: + return TupleWithMeta((), resp) + else: + return DictWithMeta(item, resp) + + +@six.add_metaclass(abc.ABCMeta) +class ManagerWithFind(Manager): + """Manager with additional `find()`/`findall()` methods.""" + + @abc.abstractmethod + def list(self): + pass + + def find(self, **kwargs): + """Find a single item with attributes matching ``**kwargs``. + + This isn't very efficient: it loads the entire list then filters on + the Python side. + """ + matches = self.findall(**kwargs) + num = len(matches) + + if num == 0: + msg = "No %s matching %s." % (self.resource_class.__name__, kwargs) + raise exceptions.NotFound(msg) + elif num > 1: + raise exceptions.NoUniqueMatch + else: + return self.get(matches[0].id) + + def findall(self, **kwargs): + """Find all items with attributes matching ``**kwargs``. + + This isn't very efficient: it loads the entire list then filters on + the Python side. + """ + found = [] + searches = kwargs.items() + + for obj in self.list(): + try: + if all(getattr(obj, attr) == value + for (attr, value) in searches): + found.append(obj) + except AttributeError: + continue + + return found + + +class RequestIdMixin(object): + """Wrapper class to expose x-openstack-request-id to the caller.""" + def request_ids_setup(self): + self.x_openstack_request_ids = [] + + @property + def request_ids(self): + return self.x_openstack_request_ids + + def append_request_ids(self, resp): + """Add request_ids as an attribute to the object + + :param resp: Response object or list of Response objects + """ + if isinstance(resp, list): + # Add list of request_ids if response is of type list. + for resp_obj in resp: + self._append_request_id(resp_obj) + elif resp is not None: + # Add request_ids if response contains single object. + self._append_request_id(resp) + + def _append_request_id(self, resp): + if isinstance(resp, Response): + # Extract 'x-openstack-request-id' from headers if + # response is a Response object. + request_id = (resp.headers.get('x-openstack-request-id') or + resp.headers.get('x-compute-request-id')) + else: + # If resp is of type string or None. + request_id = resp + if request_id not in self.x_openstack_request_ids: + self.x_openstack_request_ids.append(request_id) + + +class Resource(RequestIdMixin): + """Represents an instance of an object + + A resource represents a particular instance of an object (instance, type, + etc). This is pretty much just a bag for attributes. + + :param manager: BaseManager object + :param info: dictionary representing resource attributes + :param loaded: prevent lazy-loading if set to True + :param resp: Response or list of Response objects + """ + + def __init__(self, manager, info, loaded=False, resp=None): + self.manager = manager + self._info = info + self._add_details(info) + self._loaded = loaded + self.request_ids_setup() + self.append_request_ids(resp) + + def _add_details(self, info): + for (k, v) in six.iteritems(info): + try: + setattr(self, k, v) + self._info[k] = v + except AttributeError: + # In this case we already defined the attribute on the class + pass + + def __setstate__(self, d): + for k, v in d.items(): + setattr(self, k, v) + + def __getattr__(self, k): + if k not in self.__dict__: + # NOTE(RuiChen): disallow lazy-loading if already loaded once + if not self.is_loaded(): + self.get() + return self.__getattr__(k) + raise AttributeError(k) + else: + return self.__dict__[k] + + def __repr__(self): + reprkeys = sorted(k for k in self.__dict__.keys() if k[0] != '_' and + k not in ('manager', 'x_openstack_request_ids')) + info = ", ".join("%s=%s" % (k, getattr(self, k)) for k in reprkeys) + return "<%s %s>" % (self.__class__.__name__, info) + + def get(self): + # set_loaded() first ... so if we have to bail, we know we tried. + self.set_loaded(True) + if not hasattr(self.manager, 'get'): + return + + new = self.manager.get(self.id) + if new: + self._add_details(new._info) + # The 'request_ids' attribute has been added, + # so store the request id to it instead of _info + self.append_request_ids(new.request_ids) + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return False + return self._info == other._info + + def __ne__(self, other): + return not self.__eq__(other) + + def is_loaded(self): + return self._loaded + + def set_loaded(self, val): + self._loaded = val + + def to_dict(self): + return copy.deepcopy(self._info) + + +class ListWithMeta(list, RequestIdMixin): + def __init__(self, values, resp): + super(ListWithMeta, self).__init__(values) + self.request_ids_setup() + self.append_request_ids(resp) + + +class DictWithMeta(dict, RequestIdMixin): + def __init__(self, values, resp): + super(DictWithMeta, self).__init__(values) + self.request_ids_setup() + self.append_request_ids(resp) + + +class TupleWithMeta(tuple, RequestIdMixin): + def __new__(cls, values, resp): + return super(TupleWithMeta, cls).__new__(cls, values) + + def __init__(self, values, resp): + self.request_ids_setup() + self.append_request_ids(resp) + + +class StrWithMeta(str, RequestIdMixin): + def __new__(cls, value, resp): + return super(StrWithMeta, cls).__new__(cls, value) + + def __init__(self, values, resp): + self.request_ids_setup() + self.append_request_ids(resp) + + +class BytesWithMeta(six.binary_type, RequestIdMixin): + def __new__(cls, value, resp): + return super(BytesWithMeta, cls).__new__(cls, value) + + def __init__(self, values, resp): + self.request_ids_setup() + self.append_request_ids(resp) + + +if six.PY2: + class UnicodeWithMeta(six.text_type, RequestIdMixin): + def __new__(cls, value, resp): + return super(UnicodeWithMeta, cls).__new__(cls, value) + + def __init__(self, values, resp): + self.request_ids_setup() + self.append_request_ids(resp) diff --git a/nimbleclient/tests/unit/common/test_base.py b/nimbleclient/tests/unit/common/test_base.py new file mode 100644 index 0000000..7c3aa6e --- /dev/null +++ b/nimbleclient/tests/unit/common/test_base.py @@ -0,0 +1,277 @@ +# Copyright 2016 Huawei, Inc. All rights reserved. +# +# 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 copy + +import mock +import six + +from nimbleclient.common import base +from nimbleclient.common import exceptions +from nimbleclient.tests.unit import base as test_base +from nimbleclient.tests.unit import fakes + + +class TestResource(test_base.TestBase): + + def test_resource_repr(self): + r = base.Resource(None, dict(foo='bar', baz='spam')) + self.assertEqual('', repr(r)) + + def test_getid(self): + self.assertEqual(4, base.getid(4)) + + class TmpObject(object): + id = 4 + self.assertEqual(4, base.getid(TmpObject)) + + def test_init_with_attribute_info(self): + r = base.Resource(None, dict(foo='bar', baz='spam')) + self.assertTrue(hasattr(r, 'foo')) + self.assertEqual('bar', r.foo) + self.assertTrue(hasattr(r, 'baz')) + self.assertEqual('spam', r.baz) + + def test_resource_lazy_getattr(self): + fake_manager = mock.Mock() + return_resource = base.Resource(None, dict(id=mock.sentinel.fake_id, + foo='bar', + name='fake_name')) + fake_manager.get.return_value = return_resource + + r = base.Resource(fake_manager, + dict(id=mock.sentinel.fake_id, foo='bar')) + self.assertTrue(hasattr(r, 'foo')) + self.assertEqual('bar', r.foo) + self.assertFalse(r.is_loaded()) + + # Trigger load + self.assertEqual('fake_name', r.name) + fake_manager.get.assert_called_once_with(mock.sentinel.fake_id) + self.assertTrue(r.is_loaded()) + + # Missing stuff still fails after a second get + self.assertRaises(AttributeError, getattr, r, 'blahblah') + + def test_eq(self): + # Two resources of the same type with the same id: not equal + r1 = base.Resource(None, {'id': 1, 'name': 'hi'}) + r2 = base.Resource(None, {'id': 1, 'name': 'hello'}) + self.assertNotEqual(r1, r2) + + # Two resources of different types: never equal + r1 = base.Resource(None, {'id': 1}) + r2 = fakes.FaksResource(None, {'id': 1}) + self.assertNotEqual(r1, r2) + + # Two resources with no ID: equal if their info is equal + r1 = base.Resource(None, {'name': 'joe', 'age': 12}) + r2 = base.Resource(None, {'name': 'joe', 'age': 12}) + self.assertEqual(r1, r2) + + def test_resource_object_with_request_ids(self): + resp_obj = fakes.create_response_obj_with_header() + r = base.Resource(None, {'name': '1'}, resp=resp_obj) + self.assertEqual(fakes.FAKE_REQUEST_ID_LIST, r.request_ids) + + def test_resource_object_with_compute_request_ids(self): + resp_obj = fakes.create_response_obj_with_compute_header() + r = base.Resource(None, {'name': '1'}, resp=resp_obj) + self.assertEqual(fakes.FAKE_REQUEST_ID_LIST, r.request_ids) + + +class TestManager(test_base.TestBase): + fake_manager = fakes.create_resource_manager() + + @mock.patch.object(fakes.FakeHTTPClient, 'get') + def test_manager_get(self, mock_get): + mock_get.return_value = (fakes.create_response_obj_with_header(), + mock.MagicMock()) + fake_resource = fakes.FaksResource( + None, dict(id=fakes.FAKE_RESOURCE_ID, + name=fakes.FAKE_RESOURCE_NAME)) + result = self.fake_manager.get(fake_resource) + self.assertIsInstance(result, base.Resource) + self.assertIsInstance(result._info, mock.MagicMock) + self.assertTrue(result.is_loaded()) + expect_url = (fakes.FAKE_RESOURCE_ITEM_URL % fakes.FAKE_RESOURCE_ID) + mock_get.assert_called_once_with(expect_url, headers={}) + + @mock.patch.object(fakes.FakeHTTPClient, 'get') + def test_manager_list(self, mock_get): + mock_get.return_value = (fakes.create_response_obj_with_header(), + mock.MagicMock()) + result = self.fake_manager.list() + self.assertIsInstance(result, base.ListWithMeta) + self.assertEqual([], result) + expect_url = fakes.FAKE_RESOURCE_COLLECTION_URL + mock_get.assert_called_once_with(expect_url, headers={}) + + @mock.patch.object(fakes.FakeHTTPClient, 'patch') + def test_manager_update(self, mock_patch): + mock_patch.return_value = (fakes.create_response_obj_with_header(), + mock.MagicMock()) + fake_resource = fakes.FaksResource( + None, dict(id=fakes.FAKE_RESOURCE_ID, + name=fakes.FAKE_RESOURCE_NAME)) + result = self.fake_manager.update(fake_resource) + self.assertIsInstance(result, base.Resource) + self.assertIsInstance(result._info, mock.MagicMock) + self.assertFalse(result.is_loaded()) + expect_url = (fakes.FAKE_RESOURCE_ITEM_URL % fakes.FAKE_RESOURCE_ID) + mock_patch.assert_called_once_with(expect_url, data=fake_resource, + headers={}) + + @mock.patch.object(fakes.FakeHTTPClient, 'delete') + def test_manager_delete(self, mock_delete): + mock_delete.return_value = (fakes.create_response_obj_with_header(), + None) + fake_resource = fakes.FaksResource( + None, dict(id=fakes.FAKE_RESOURCE_ID, + name=fakes.FAKE_RESOURCE_NAME)) + result = self.fake_manager.delete(fake_resource) + self.assertIsInstance(result, base.TupleWithMeta) + self.assertEqual(tuple(), result) + expect_url = (fakes.FAKE_RESOURCE_ITEM_URL % fakes.FAKE_RESOURCE_ID) + mock_delete.assert_called_once_with(expect_url, headers={}) + + @mock.patch.object(fakes.FakeHTTPClient, 'post') + def test_manager_create(self, mock_post): + mock_post.return_value = (fakes.create_response_obj_with_header(), + mock.MagicMock()) + fake_resource = fakes.FaksResource( + None, dict(id=fakes.FAKE_RESOURCE_ID, + name=fakes.FAKE_RESOURCE_NAME)) + result = self.fake_manager.create(fake_resource) + self.assertIsInstance(result, base.Resource) + self.assertIsInstance(result._info, mock.MagicMock) + self.assertFalse(result.is_loaded()) + expect_url = fakes.FAKE_RESOURCE_COLLECTION_URL + mock_post.assert_called_once_with(expect_url, data=fake_resource, + headers={}) + + @mock.patch.object(fakes.FakeHTTPClient, 'get') + def test_manager_find(self, mock_get): + fake_json_body_1 = dict(id=fakes.FAKE_RESOURCE_ID, + name=fakes.FAKE_RESOURCE_NAME) + fake_json_body_2 = dict(id='no_existed_id', + name='no_existed_name') + mock_get.side_effect = [ + (fakes.create_response_obj_with_header(), + {'resources': [fake_json_body_1, + fake_json_body_2]}), + (fakes.create_response_obj_with_header(), + fake_json_body_1) + ] + result = self.fake_manager.find(id=fakes.FAKE_RESOURCE_ID, + name=fakes.FAKE_RESOURCE_NAME) + self.assertIsInstance(result, base.Resource) + self.assertEqual(fakes.FAKE_RESOURCE_ID, result.id) + self.assertEqual(fakes.FAKE_RESOURCE_NAME, result.name) + self.assertTrue(result.is_loaded()) + expect_collection_url = fakes.FAKE_RESOURCE_COLLECTION_URL + expect_item_url = (fakes.FAKE_RESOURCE_ITEM_URL % + fakes.FAKE_RESOURCE_ID) + mock_get.assert_has_calls( + [mock.call(expect_collection_url, headers={}), + mock.call(expect_item_url, headers={})]) + + @mock.patch.object(fakes.FakeHTTPClient, 'get') + def test_manager_find_no_result(self, mock_get): + mock_get.return_value = (fakes.create_response_obj_with_header(), + {'resources': []}) + self.assertRaises(exceptions.NotFound, + self.fake_manager.find, + id=fakes.FAKE_RESOURCE_ID, + name=fakes.FAKE_RESOURCE_NAME) + expect_collection_url = fakes.FAKE_RESOURCE_COLLECTION_URL + mock_get.assert_called_once_with(expect_collection_url, headers={}) + + @mock.patch.object(fakes.FakeHTTPClient, 'get') + def test_manager_find_more_than_one_result(self, mock_get): + fake_json_body_1 = dict(id=fakes.FAKE_RESOURCE_ID, + name=fakes.FAKE_RESOURCE_NAME) + fake_json_body_2 = copy.deepcopy(fake_json_body_1) + mock_get.return_value = (fakes.create_response_obj_with_header(), + {'resources': [fake_json_body_1, + fake_json_body_2]}) + self.assertRaises(exceptions.NoUniqueMatch, + self.fake_manager.find, + id=fakes.FAKE_RESOURCE_ID, + name=fakes.FAKE_RESOURCE_NAME) + expect_collection_url = fakes.FAKE_RESOURCE_COLLECTION_URL + mock_get.assert_called_once_with(expect_collection_url, headers={}) + + +class ListWithMetaTest(test_base.TestBase): + def test_list_with_meta(self): + resp = fakes.create_response_obj_with_header() + obj = base.ListWithMeta([], resp) + self.assertEqual([], obj) + # Check request_ids attribute is added to obj + self.assertTrue(hasattr(obj, 'request_ids')) + self.assertEqual(fakes.FAKE_REQUEST_ID_LIST, obj.request_ids) + + +class DictWithMetaTest(test_base.TestBase): + def test_dict_with_meta(self): + resp = fakes.create_response_obj_with_header() + obj = base.DictWithMeta({}, resp) + self.assertEqual({}, obj) + # Check request_ids attribute is added to obj + self.assertTrue(hasattr(obj, 'request_ids')) + self.assertEqual(fakes.FAKE_REQUEST_ID_LIST, obj.request_ids) + + +class TupleWithMetaTest(test_base.TestBase): + def test_tuple_with_meta(self): + resp = fakes.create_response_obj_with_header() + expected_tuple = (1, 2) + obj = base.TupleWithMeta(expected_tuple, resp) + self.assertEqual(expected_tuple, obj) + # Check request_ids attribute is added to obj + self.assertTrue(hasattr(obj, 'request_ids')) + self.assertEqual(fakes.FAKE_REQUEST_ID_LIST, obj.request_ids) + + +class StrWithMetaTest(test_base.TestBase): + def test_str_with_meta(self): + resp = fakes.create_response_obj_with_header() + obj = base.StrWithMeta('test-str', resp) + self.assertEqual('test-str', obj) + # Check request_ids attribute is added to obj + self.assertTrue(hasattr(obj, 'request_ids')) + self.assertEqual(fakes.FAKE_REQUEST_ID_LIST, obj.request_ids) + + +class BytesWithMetaTest(test_base.TestBase): + def test_bytes_with_meta(self): + resp = fakes.create_response_obj_with_header() + obj = base.BytesWithMeta(b'test-bytes', resp) + self.assertEqual(b'test-bytes', obj) + # Check request_ids attribute is added to obj + self.assertTrue(hasattr(obj, 'request_ids')) + self.assertEqual(fakes.FAKE_REQUEST_ID_LIST, obj.request_ids) + + +if six.PY2: + class UnicodeWithMetaTest(test_base.TestBase): + def test_unicode_with_meta(self): + resp = fakes.create_response_obj_with_header() + obj = base.UnicodeWithMeta(u'test-unicode', resp) + self.assertEqual(u'test-unicode', obj) + # Check request_ids attribute is added to obj + self.assertTrue(hasattr(obj, 'request_ids')) + self.assertEqual(fakes.FAKE_REQUEST_ID_LIST, obj.request_ids) diff --git a/nimbleclient/tests/unit/fakes.py b/nimbleclient/tests/unit/fakes.py index 9878ca3..6a24174 100644 --- a/nimbleclient/tests/unit/fakes.py +++ b/nimbleclient/tests/unit/fakes.py @@ -14,6 +14,88 @@ # from oslo_serialization import jsonutils +from requests import Response + +from nimbleclient.common import base + +# fake request id +FAKE_REQUEST_ID = 'req-0594c66b-6973-405c-ae2c-43fcfc00f2e3' +FAKE_REQUEST_ID_LIST = [FAKE_REQUEST_ID] + +# fake resource id +FAKE_RESOURCE_ID = '0594c66b-6973-405c-ae2c-43fcfc00f2e3' +FAKE_RESOURCE_NAME = 'name-0594c66b-6973-405c-ae2c-43fcfc00f2e3' + +# fake resource response key +FAKE_RESOURCE_ITEM_URL = '/resources/%s' +FAKE_RESOURCE_COLLECTION_URL = '/resources' + + +def create_response_obj_with_header(): + resp = Response() + resp.headers['x-openstack-request-id'] = FAKE_REQUEST_ID + return resp + + +def create_response_obj_with_compute_header(): + resp = Response() + resp.headers['x-compute-request-id'] = FAKE_REQUEST_ID + return resp + + +def create_resource_manager(): + return FakeManager() + + +class FakeHTTPClient(object): + + def get(self): + pass + + def head(self): + pass + + def post(self): + pass + + def put(self): + pass + + def delete(self): + pass + + def patch(self): + pass + + +class FaksResource(base.Resource): + id = 'N/A' + + +class FakeManager(base.ManagerWithFind): + resource_class = FaksResource + + def __init__(self, api=None): + if not api: + api = FakeHTTPClient() + super(FakeManager, self).__init__(api) + + def get(self, resource): + return self._get(FAKE_RESOURCE_ITEM_URL % base.getid(resource)) + + def list(self): + return self._list(FAKE_RESOURCE_COLLECTION_URL, + response_key='resources') + + def update(self, resource): + return self._update(FAKE_RESOURCE_ITEM_URL % base.getid(resource), + resource) + + def create(self, resource): + return self._create(FAKE_RESOURCE_COLLECTION_URL, resource) + + def delete(self, resource): + return self._delete(FAKE_RESOURCE_ITEM_URL % base.getid(resource)) class FakeRaw(object): diff --git a/test-requirements.txt b/test-requirements.txt index fb25ec5..20697ac 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -8,6 +8,7 @@ mock>=2.0 # BSD python-openstackclient>=2.1.0 # Apache-2.0 python-subunit>=0.0.18 # Apache-2.0/BSD oslosphinx!=3.4.0,>=2.5.0 # Apache-2.0 +oslotest>=1.10.0 # Apache-2.0 reno>=1.8.0 # Apache2 requests-mock>=1.0 # Apache-2.0 sphinx!=1.3b1,<1.3,>=1.2.1 # BSD diff --git a/tox.ini b/tox.ini index d9e6688..516e5b8 100644 --- a/tox.ini +++ b/tox.ini @@ -28,7 +28,7 @@ commands = sphinx-build -a -E -W -d releasenotes/build/doctrees -b html releasenotes/source releasenotes/build/html [testenv:debug] -commands = oslo_debug_helper {posargs} +commands = oslo_debug_helper -t nimbleclient/tests {posargs} [flake8] # E123, E125 skipped as they are invalid PEP-8.