diff --git a/.testr.conf b/.testr.conf index 6d83b3c..686b3e6 100644 --- a/.testr.conf +++ b/.testr.conf @@ -2,6 +2,6 @@ test_command=OS_STDOUT_CAPTURE=${OS_STDOUT_CAPTURE:-1} \ OS_STDERR_CAPTURE=${OS_STDERR_CAPTURE:-1} \ OS_TEST_TIMEOUT=${OS_TEST_TIMEOUT:-60} \ - ${PYTHON:-python} -m subunit.run discover -t ./ . $LISTOPT $IDOPTION + ${PYTHON:-python} -m subunit.run discover -t ./ ./storyboardclient/tests $LISTOPT $IDOPTION test_id_option=--load-list $IDFILE test_list_option=--list diff --git a/requirements.txt b/requirements.txt index 8bb39cc..53d13b8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,5 +9,6 @@ oslo.config>=1.4.0 oslo.i18n>=1.0.0 oslo.serialization>=1.0.0 oslo.utils>=1.0.0 +requests>=2.2.0,!=2.4.0 six>=1.7.0 -stevedore>=1.1.0 \ No newline at end of file +stevedore>=1.1.0 diff --git a/storyboardclient/auth/__init__.py b/storyboardclient/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/storyboardclient/auth/oauth.py b/storyboardclient/auth/oauth.py new file mode 100644 index 0000000..532347e --- /dev/null +++ b/storyboardclient/auth/oauth.py @@ -0,0 +1,33 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# 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 storyboardclient.openstack.common.apiclient import auth + + +class OAuthPlugin(auth.BaseAuthPlugin): + + def _do_authenticate(self, http_client): + # Skipping for now as there will be a separate spec and implementation + # for authenticating a python client with OAuth. + pass + + def __init__(self, api_url=None, access_token=None): + super(OAuthPlugin, self).__init__() + + self.api_url = api_url + self.access_token = access_token + + def token_and_endpoint(self, endpoint_type=None, service_type=None): + return self.access_token, self.api_url diff --git a/storyboardclient/base.py b/storyboardclient/base.py new file mode 100644 index 0000000..0a20193 --- /dev/null +++ b/storyboardclient/base.py @@ -0,0 +1,181 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# 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 inspect + +import six + +from storyboardclient.auth import oauth +from storyboardclient.openstack.common.apiclient import base +from storyboardclient.openstack.common.apiclient import client + +DEFAULT_API_URL = "https://storyboard.openstack.org/api/v1" + + +class BaseClient(client.BaseClient): + + def __init__(self, api_url=None, access_token=None): + if not api_url: + api_url = DEFAULT_API_URL + + self.auth_plugin = oauth.OAuthPlugin(api_url, access_token) + self.http_client = BaseHTTPClient(auth_plugin=self.auth_plugin) + + +class BaseHTTPClient(client.HTTPClient): + """Base class for setting up endpoint and token. + + This HTTP client is overriding a client_request method to add + Authorization header if OAuth token is provided. + + """ + + def client_request(self, client, method, url, **kwargs): + """Send an http request using `client`'s endpoint and specified `url`. + + If request was rejected as unauthorized (possibly because the token is + expired), issue one authorization attempt and send the request once + again. + + :param client: instance of BaseClient descendant + :param method: method of HTTP request + :param url: URL of HTTP request + :param kwargs: any other parameter that can be passed to + `HTTPClient.request` + """ + + token, endpoint = (self.cached_token, client.cached_endpoint) + if not (token and endpoint): + token, endpoint = self.auth_plugin.token_and_endpoint() + self.cached_token = token + client.cached_endpoint = endpoint + + if token: + kwargs.setdefault("headers", {})["Authorization"] = \ + "Bearer %s" % token + + return self.request(method, self.concat_url(endpoint, url), **kwargs) + + +class BaseManager(base.CrudManager): + + def build_url(self, base_url=None, **kwargs): + # Overriding to use "url_key" instead of the "collection_key". + # "key_id" is replaced with just "id" when querying a specific object. + url = base_url if base_url is not None else '' + + url += '/%s' % self.url_key + + entity_id = kwargs.get('id') + if entity_id is not None: + url += '/%s' % entity_id + + return url + + def get(self, id): + """Get a resource by id. + + Get method is accepting id as a positional argument for simplicity. + + :param id: The id of resource. + :return: The resource object. + """ + + query_kwargs = {"id": id} + return self._get(self.build_url(**query_kwargs), self.key) + + def create(self, **kwargs): + """Create a resource. + + The default implementation is overridden so that the dictionary is + passed 'as is' without any wrapping. + """ + + kwargs = self._filter_kwargs(kwargs) + return self._post( + self.build_url(**kwargs), + kwargs) + + +class BaseNestedManager(BaseManager): + + def __init__(self, client, parent_id): + super(BaseNestedManager, self).__init__(client) + + self.parent_id = parent_id + + def build_url(self, base_url=None, **kwargs): + # Overriding to use "url_key" instead of the "collection_key". + # "key_id" is replaced with just "id" when querying a specific object. + url = base_url if base_url is not None else '' + + url += '/%s/%s/%s' % (self.parent_url_key, self.parent_id, + self.url_key) + + entity_id = kwargs.get('id') + if entity_id is not None: + url += '/%s' % entity_id + + return url + + +class BaseObject(base.Resource): + + id = None + created_at = None + updated_at = None + + def __init__(self, manager, info, loaded=False, parent_id=None): + super(BaseObject, self).__init__(manager, info, loaded) + + self._parent_id = parent_id + self._init_nested_managers() + + def _add_details(self, info): + for field, value in six.iteritems(info): + + # Skip the fields which are not declared in the object + if not hasattr(self, field): + continue + + setattr(self, field, value) + + def _init_nested_managers(self): + # If an object has nested resource managers, they will be initialized + # here. + + manager_instances = {} + + for manager_name, manager_class in self._managers(): + manager_instance = manager_class(client=self.manager.client, + parent_id=self.id) + # Saving a manager to a dict as self.__dict__ should not be + # changed while iterating + manager_instances[manager_name] = manager_instance + + for name, manager_instance in six.iteritems(manager_instances): + # replacing managers declarations with real managers + setattr(self, name, manager_instance) + + def _managers(self): + # Iterator over nested managers + + for attr in dir(self): + # Skip private fields + if attr.startswith("_"): + continue + val = getattr(self, attr) + if inspect.isclass(val) and issubclass(val, BaseNestedManager): + yield attr, val \ No newline at end of file diff --git a/storyboardclient/tests/test_base/__init__.py b/storyboardclient/tests/test_base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/storyboardclient/tests/test_base/test_base_HTTPClient.py b/storyboardclient/tests/test_base/test_base_HTTPClient.py new file mode 100644 index 0000000..b4cf4f4 --- /dev/null +++ b/storyboardclient/tests/test_base/test_base_HTTPClient.py @@ -0,0 +1,52 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# 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 + +from storyboardclient.auth import oauth +from storyboardclient import base +from storyboardclient.tests import base as test_base + + +class BaseHTTPClientTestCase(test_base.TestCase): + + @mock.patch("storyboardclient.base.BaseHTTPClient.request") + def test_unauthorized_client_request(self, mock_request): + + auth_plugin = oauth.OAuthPlugin(api_url="http://some_endpoint") + client = base.BaseHTTPClient(auth_plugin=auth_plugin) + + client.client_request(client=mock.MagicMock(), + method="GET", url="/some_url") + + mock_request.assert_called_once_with("GET", + "http://some_endpoint/some_url") + + @mock.patch("storyboardclient.base.BaseHTTPClient.request") + def test_authorized_client_request(self, mock_request): + + auth_plugin = oauth.OAuthPlugin(api_url="http://some_endpoint", + access_token="some_token") + client = base.BaseHTTPClient(auth_plugin=auth_plugin) + + client.client_request(client=mock.MagicMock(), + method="GET", url="/some_url") + + mock_request.assert_called_once_with( + "GET", + "http://some_endpoint/some_url", + headers={ + "Authorization": "Bearer some_token" + }) diff --git a/storyboardclient/tests/test_base/test_base_manager.py b/storyboardclient/tests/test_base/test_base_manager.py new file mode 100644 index 0000000..9e1ce9f --- /dev/null +++ b/storyboardclient/tests/test_base/test_base_manager.py @@ -0,0 +1,39 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# 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 + +from storyboardclient import base +from storyboardclient.tests import base as test_base + + +class BaseManagerTestCase(test_base.TestCase): + + @mock.patch("storyboardclient.base.BaseManager._get") + def test_get(self, mock_private_get): + manager = base.BaseManager(mock.MagicMock()) + manager.url_key = "key" + manager.get("id1") + + mock_private_get.assert_called_once_with("/key/id1", None) + + @mock.patch("storyboardclient.base.BaseManager._post") + def test_create(self, mock_private_post): + manager = base.BaseManager(mock.MagicMock()) + manager.url_key = "key" + manager.create(title="test_story") + + mock_private_post.assert_called_once_with("/key", + {"title": "test_story"}) diff --git a/storyboardclient/tests/test_base/test_base_nested_manager.py b/storyboardclient/tests/test_base/test_base_nested_manager.py new file mode 100644 index 0000000..15e9a32 --- /dev/null +++ b/storyboardclient/tests/test_base/test_base_nested_manager.py @@ -0,0 +1,45 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# 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 + +from storyboardclient import base +from storyboardclient.tests import base as test_base + + +class BaseNestedManagerTestCase(test_base.TestCase): + + @mock.patch("storyboardclient.base.BaseManager._get") + def test_get(self, mock_private_get): + manager = base.BaseNestedManager(mock.MagicMock(), + parent_id="parent_id") + manager.parent_url_key = "parent_key" + manager.url_key = "key" + manager.get("id1") + + mock_private_get.assert_called_once_with( + "/parent_key/parent_id/key/id1", None) + + @mock.patch("storyboardclient.base.BaseManager._post") + def test_create(self, mock_private_post): + manager = base.BaseNestedManager(mock.MagicMock(), + parent_id="parent_id") + manager.parent_url_key = "parent_key" + manager.url_key = "key" + manager.create(title="test_task") + + mock_private_post.assert_called_once_with( + "/parent_key/parent_id/key", + {"title": "test_task"}) diff --git a/storyboardclient/tests/test_base/test_base_object.py b/storyboardclient/tests/test_base/test_base_object.py new file mode 100644 index 0000000..36afb41 --- /dev/null +++ b/storyboardclient/tests/test_base/test_base_object.py @@ -0,0 +1,43 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# 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 + +from storyboardclient import base +from storyboardclient.tests import base as test_base + + +class BaseObjectTestCase(test_base.TestCase): + + def test_init_no_nested(self): + manager_mock = mock.MagicMock() + obj = base.BaseObject(manager=manager_mock, info={"id": "test_id"}) + + self.assertEqual("test_id", obj.id) + self.assertEqual(manager_mock, obj.manager) + + def test_init_with_nested(self): + + manager_mock = mock.MagicMock() + + class TestInheritedObject(base.BaseObject): + manager_field = base.BaseNestedManager + + obj = TestInheritedObject(manager=manager_mock, info={"id": "test_id"}) + + self.assertEqual(base.BaseNestedManager, type(obj.manager_field)) + + self.assertEqual("test_id", obj.id) + self.assertEqual("test_id", obj.manager_field.parent_id) diff --git a/storyboardclient/tests/test_storyboard.py b/storyboardclient/tests/test_storyboard.py deleted file mode 100644 index 52fcf08..0000000 --- a/storyboardclient/tests/test_storyboard.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- - -# 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. - -""" -test_storyboard ----------------------------------- - -Tests for `storyboard` module. -""" - -from storyboardclient.tests import base - - -class TestStoryboard(base.TestCase): - - def test_something(self): - pass diff --git a/storyboardclient/tests/v1/__init__.py b/storyboardclient/tests/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/storyboardclient/tests/v1/test_user_preferences.py b/storyboardclient/tests/v1/test_user_preferences.py new file mode 100644 index 0000000..b6e6460 --- /dev/null +++ b/storyboardclient/tests/v1/test_user_preferences.py @@ -0,0 +1,47 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# 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 + +from storyboardclient.tests import base as test_base +from storyboardclient.v1 import user_preferences +from storyboardclient.v1 import users + + +class UserPreferencesTestCase(test_base.TestCase): + + @mock.patch("storyboardclient.v1.user_preferences.UserPreferencesManager" + "._get") + def test_user_preferences_get(self, mock_private_get): + mock_private_get.return_value = user_preferences.UserPreferences( + mock.MagicMock(), + info={"k1": "v1"}) + + user = users.User(manager=mock.MagicMock(), info={"id": "test_id"}) + + preferences = user.user_preferences.get_all() + self.assertEqual("v1", preferences.k1) + + p_k1 = user.user_preferences.get("k1") + self.assertEqual("v1", p_k1) + + @mock.patch("storyboardclient.v1.user_preferences.UserPreferencesManager" + "._post") + def test_user_preferences_set(self, mock_private_post): + user = users.User(manager=mock.MagicMock(), info={"id": "test_id"}) + + user.user_preferences.set({"k1": "v1"}) + mock_private_post.assert_called_once_with("/users/test_id/preferences", + {"k1": "v1"}) diff --git a/storyboardclient/v1/__init__.py b/storyboardclient/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/storyboardclient/v1/client.py b/storyboardclient/v1/client.py new file mode 100644 index 0000000..10906cf --- /dev/null +++ b/storyboardclient/v1/client.py @@ -0,0 +1,44 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# 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 storyboardclient import base +from storyboardclient.v1 import users + + +class Client(base.BaseClient): + """A client class for StoryBoard. + + Usage example: + @code: + from storyboard.v1 import client + + storyboard = client.Client("https://storyboard.openstack.org/api/v1", + "mytoken") + """ + + def __init__(self, api_url=None, access_token=None): + """Sets up a client with endpoint managers. + + :param api_url: (Optional) Full API url. Defaults to + https://storyboard.openstack.org/api/v1 + :param access_token: (Optional) OAuth2 access token. If skipped only + public read-only endpoint will be available. All other requests will + fail with Unauthorized error. + :return: a client instance. + """ + super(Client, self).__init__(api_url=api_url, + access_token=access_token) + + self.users = users.UsersManager(self) diff --git a/storyboardclient/v1/user_preferences.py b/storyboardclient/v1/user_preferences.py new file mode 100644 index 0000000..980c63f --- /dev/null +++ b/storyboardclient/v1/user_preferences.py @@ -0,0 +1,66 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# 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 six + +from storyboardclient import base +from storyboardclient.openstack.common import log + +LOG = log.getLogger(__name__) + + +class UserPreferences(base.BaseObject): + + def _add_details(self, info): + # User preferences can not be declared before the data is received. + # Adding all properties to an object directly. + for key, value in six.iteritems(info): + setattr(self, key, value) + + +class UserPreferencesManager(base.BaseNestedManager): + parent_url_key = "users" + url_key = "preferences" + resource_class = UserPreferences + + def get_all(self): + """Get a dictionary of User Preferences + + User preferences are returned always as a dict, so it's better to use + a get base method instead of a list here. + + :return: UserPreferences object + """ + + return super(UserPreferencesManager, self).get(None) + + def get(self, key): + all_prefs = super(UserPreferencesManager, self).get(None) + + return getattr(all_prefs, key) + + def set(self, data): + """Set a dictionary of user preferences. + + """ + + return self.create(**data) + + def set_one(self, key, value): + """Set a user preference by key. + + """ + + return self.set({key: value}) diff --git a/storyboardclient/v1/users.py b/storyboardclient/v1/users.py new file mode 100644 index 0000000..a355e0e --- /dev/null +++ b/storyboardclient/v1/users.py @@ -0,0 +1,33 @@ +# Copyright (c) 2014 Mirantis Inc. +# +# 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 storyboardclient import base +from storyboardclient.v1 import user_preferences + + +class User(base.BaseObject): + username = None + full_name = None + openid = None + is_superuser = None + last_login = None + enable_login = None + + user_preferences = user_preferences.UserPreferencesManager + + +class UsersManager(base.BaseManager): + url_key = "users" + resource_class = User diff --git a/tox.ini b/tox.ini index 6dfd84e..6e7f78f 100644 --- a/tox.ini +++ b/tox.ini @@ -29,6 +29,6 @@ commands = python setup.py build_sphinx # E123, E125 skipped as they are invalid PEP-8. show-source = True -ignore = E123,E125,H803 +ignore = E123,E125,H803,H904 builtins = _ exclude=.venv,.git,.tox,dist,doc,*openstack/common*,*lib/python*,*egg,build