From 9ab382643a865554219aedadd3b2dbe75ac7c6a1 Mon Sep 17 00:00:00 2001 From: Guillaume Espanel Date: Thu, 5 Mar 2015 13:38:50 +0100 Subject: [PATCH] Global rewrite of the client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dynamic import of cloudkitty modules - Support the new cloudkitty-api - Support the new hashmap API Change-Id: I8e3067d3144ed9f78ffd6a89c97a2435939f0590 Co-Authored-By: Stéphane Albert --- CONTRIBUTING.rst | 16 + HACKING.rst | 4 + LICENSE | 1 + MANIFEST.in | 6 + README.rst | 11 +- babel.cfg | 2 + cloudkittyclient/__init__.py | 16 +- cloudkittyclient/client.py | 332 ++++++++++-- cloudkittyclient/common/auth.py | 79 --- cloudkittyclient/common/base.py | 173 +++++++ cloudkittyclient/common/client.py | 76 --- cloudkittyclient/common/exceptions.py | 84 --- cloudkittyclient/common/utils.py | 211 ++++++++ cloudkittyclient/exc.py | 121 +++++ cloudkittyclient/openstack/common/__init__.py | 17 - cloudkittyclient/openstack/common/_i18n.py | 45 ++ .../openstack/common/apiclient/auth.py | 13 + .../openstack/common/apiclient/base.py | 51 +- .../openstack/common/apiclient/client.py | 37 +- .../openstack/common/apiclient/exceptions.py | 45 +- .../openstack/common/apiclient/fake_client.py | 19 +- .../openstack/common/apiclient/utils.py | 100 ++++ cloudkittyclient/openstack/common/cliutils.py | 271 ++++++++++ .../openstack/common/gettextutils.py | 479 ------------------ .../openstack/common/importutils.py | 73 --- cloudkittyclient/openstack/common/strutils.py | 295 ----------- cloudkittyclient/shell.py | 322 ++++++++++++ cloudkittyclient/tests/base.py | 15 +- cloudkittyclient/tests/common/__init__.py | 0 cloudkittyclient/tests/common/test_auth.py | 94 ---- cloudkittyclient/tests/fakes.py | 64 +++ cloudkittyclient/tests/test_client.py | 167 ++++-- .../test_cloudkittyclient.py} | 28 +- cloudkittyclient/tests/utils.py | 24 + cloudkittyclient/tests/v1/billing/__init__.py | 0 .../tests/v1/billing/test_modules.py | 115 ----- .../tests/v1/billing/test_quote.py | 60 --- cloudkittyclient/tests/v1/test_core.py | 139 +++++ cloudkittyclient/tests/v1/test_hashmap.py | 425 ++++++++++++++++ cloudkittyclient/tests/v1/test_report.py | 48 -- cloudkittyclient/v1/__init__.py | 26 +- .../v1/billing/hashmap/__init__.py | 112 ++++ cloudkittyclient/v1/billing/hashmap/client.py | 31 ++ .../v1/billing/hashmap/extension.py | 31 ++ cloudkittyclient/v1/billing/hashmap/shell.py | 265 ++++++++++ cloudkittyclient/v1/billing/modules.py | 136 ----- cloudkittyclient/v1/billing/quote.py | 64 --- cloudkittyclient/v1/client.py | 82 +-- cloudkittyclient/v1/collector/__init__.py | 30 ++ cloudkittyclient/v1/core.py | 53 ++ cloudkittyclient/v1/report.py | 59 --- cloudkittyclient/v1/report/__init__.py | 40 ++ cloudkittyclient/v1/report/shell.py | 42 ++ cloudkittyclient/v1/shell.py | 66 +++ doc/makefile | 177 ------- doc/source/conf.py | 252 +-------- doc/source/contributing.rst | 4 + doc/source/index.rst | 20 +- doc/source/installation.rst | 12 + doc/source/readme.rst | 1 + doc/source/usage.rst | 7 + openstack-common.conf | 4 +- requirements.txt | 9 +- setup.cfg | 41 +- setup.py | 27 +- test-requirements.txt | 17 +- tox.ini | 34 +- 67 files changed, 3369 insertions(+), 2351 deletions(-) create mode 100644 CONTRIBUTING.rst create mode 100644 HACKING.rst create mode 100644 MANIFEST.in create mode 100644 babel.cfg delete mode 100644 cloudkittyclient/common/auth.py create mode 100644 cloudkittyclient/common/base.py delete mode 100644 cloudkittyclient/common/client.py delete mode 100644 cloudkittyclient/common/exceptions.py create mode 100644 cloudkittyclient/common/utils.py create mode 100644 cloudkittyclient/exc.py create mode 100644 cloudkittyclient/openstack/common/_i18n.py create mode 100644 cloudkittyclient/openstack/common/apiclient/utils.py create mode 100644 cloudkittyclient/openstack/common/cliutils.py delete mode 100644 cloudkittyclient/openstack/common/gettextutils.py delete mode 100644 cloudkittyclient/openstack/common/importutils.py delete mode 100644 cloudkittyclient/openstack/common/strutils.py create mode 100644 cloudkittyclient/shell.py delete mode 100644 cloudkittyclient/tests/common/__init__.py delete mode 100644 cloudkittyclient/tests/common/test_auth.py create mode 100644 cloudkittyclient/tests/fakes.py rename cloudkittyclient/{i18n.py => tests/test_cloudkittyclient.py} (51%) create mode 100644 cloudkittyclient/tests/utils.py delete mode 100644 cloudkittyclient/tests/v1/billing/__init__.py delete mode 100644 cloudkittyclient/tests/v1/billing/test_modules.py delete mode 100644 cloudkittyclient/tests/v1/billing/test_quote.py create mode 100644 cloudkittyclient/tests/v1/test_core.py create mode 100644 cloudkittyclient/tests/v1/test_hashmap.py delete mode 100644 cloudkittyclient/tests/v1/test_report.py create mode 100644 cloudkittyclient/v1/billing/hashmap/__init__.py create mode 100644 cloudkittyclient/v1/billing/hashmap/client.py create mode 100644 cloudkittyclient/v1/billing/hashmap/extension.py create mode 100644 cloudkittyclient/v1/billing/hashmap/shell.py delete mode 100644 cloudkittyclient/v1/billing/modules.py delete mode 100644 cloudkittyclient/v1/billing/quote.py create mode 100644 cloudkittyclient/v1/collector/__init__.py create mode 100644 cloudkittyclient/v1/core.py delete mode 100644 cloudkittyclient/v1/report.py create mode 100644 cloudkittyclient/v1/report/__init__.py create mode 100644 cloudkittyclient/v1/report/shell.py create mode 100644 cloudkittyclient/v1/shell.py delete mode 100644 doc/makefile mode change 100644 => 100755 doc/source/conf.py create mode 100644 doc/source/contributing.rst create mode 100644 doc/source/installation.rst create mode 100644 doc/source/readme.rst create mode 100644 doc/source/usage.rst mode change 100644 => 100755 setup.py diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 0000000..4d25748 --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,16 @@ +If you would like to contribute to the development of OpenStack, +you must follow the steps in this page: + + http://docs.openstack.org/infra/manual/developers.html + +Once those steps have been completed, changes to OpenStack +should be submitted for review via the Gerrit tool, following +the workflow documented at: + + http://docs.openstack.org/infra/manual/developers.html#development-workflow + +Pull requests submitted through GitHub will be ignored. + +Bugs should be filed on Launchpad, not GitHub: + + https://bugs.launchpad.net/cloudkitty diff --git a/HACKING.rst b/HACKING.rst new file mode 100644 index 0000000..b3bc072 --- /dev/null +++ b/HACKING.rst @@ -0,0 +1,4 @@ +python-cloudkittyclient Style Commandments +=============================================== + +Read the OpenStack Style Commandments http://docs.openstack.org/developer/hacking/ diff --git a/LICENSE b/LICENSE index 67db858..68c771a 100644 --- a/LICENSE +++ b/LICENSE @@ -173,3 +173,4 @@ defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..c978a52 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,6 @@ +include AUTHORS +include ChangeLog +exclude .gitignore +exclude .gitreview + +global-exclude *.pyc diff --git a/README.rst b/README.rst index e6e5295..d693e22 100644 --- a/README.rst +++ b/README.rst @@ -1,7 +1,7 @@ Python bindings to the CloudKitty API ===================================== -:version: 1.0 +:version: 0.2 :Wiki: `CloudKitty Wiki`_ :IRC: #cloudkitty @ freenode @@ -21,12 +21,3 @@ Status This project is **highly** work in progress. - -Roadmap -======= - -* Add some tests. -* Add some doc. -* Move from importutils to stevedore. -* Add a command-line tool. -* Global code improvement. diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 0000000..15cd6cb --- /dev/null +++ b/babel.cfg @@ -0,0 +1,2 @@ +[python: **.py] + diff --git a/cloudkittyclient/__init__.py b/cloudkittyclient/__init__.py index 5e79948..32be245 100644 --- a/cloudkittyclient/__init__.py +++ b/cloudkittyclient/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright 2014 Objectif Libre -# +# Copyright 2015 Objectif Libre + # 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 @@ -12,15 +12,9 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. -# -# @author: François Magimel (linkid) - -__all__ = ['__version__'] import pbr.version -version_info = pbr.version.VersionInfo('python-cloudkittyclient') -try: - __version__ = version_info.version_string() -except AttributeError: - __version__ = None + +__version__ = pbr.version.VersionInfo( + 'cloudkittyclient').version_string() diff --git a/cloudkittyclient/client.py b/cloudkittyclient/client.py index eabfa79..7f528a3 100644 --- a/cloudkittyclient/client.py +++ b/cloudkittyclient/client.py @@ -1,66 +1,316 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 Objectif Libre +# 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 # -# 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 # -# 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. -# -# @author: François Magimel (linkid) +# 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. -""" -OpenStack Client interface. Handles the REST calls and responses. -""" +from keystoneclient.auth.identity import v2 as v2_auth +from keystoneclient.auth.identity import v3 as v3_auth +from keystoneclient import discover +from keystoneclient import exceptions as ks_exc +from keystoneclient import session +from oslo.utils import strutils +import six.moves.urllib.parse as urlparse -from cloudkittyclient.common import auth as ks_auth -from cloudkittyclient.common import client -from cloudkittyclient.openstack.common import importutils +from cloudkittyclient.common import utils +from cloudkittyclient import exc +from cloudkittyclient.openstack.common.apiclient import auth +from cloudkittyclient.openstack.common.apiclient import exceptions -def get_client(api_version, **kwargs): - """Get an authenticated client. +def _discover_auth_versions(session, auth_url): + # discover the API versions the server is supporting based on the + # given URL + v2_auth_url = None + v3_auth_url = None + try: + ks_discover = discover.Discover(session=session, auth_url=auth_url) + v2_auth_url = ks_discover.url_for('2.0') + v3_auth_url = ks_discover.url_for('3.0') + except ks_exc.DiscoveryFailure: + raise + except exceptions.ClientException: + # Identity service may not support discovery. In that case, + # try to determine version from auth_url + url_parts = urlparse.urlparse(auth_url) + (scheme, netloc, path, params, query, fragment) = url_parts + path = path.lower() + if path.startswith('/v3'): + v3_auth_url = auth_url + elif path.startswith('/v2'): + v2_auth_url = auth_url + else: + raise exc.CommandError('Unable to determine the Keystone ' + 'version to authenticate with ' + 'using the given auth_url.') + return v2_auth_url, v3_auth_url - This is based on the credentials in the keyword args. - :param api_version: the API version to use +def _get_keystone_session(**kwargs): + # TODO(fabgia): the heavy lifting here should be really done by Keystone. + # Unfortunately Keystone does not support a richer method to perform + # discovery and return a single viable URL. A bug against Keystone has + # been filed: https://bugs.launchpad.net/python-keystoneclient/+bug/1330677 + + # first create a Keystone session + cacert = kwargs.pop('cacert', None) + cert = kwargs.pop('cert', None) + key = kwargs.pop('key', None) + insecure = kwargs.pop('insecure', False) + auth_url = kwargs.pop('auth_url', None) + project_id = kwargs.pop('project_id', None) + project_name = kwargs.pop('project_name', None) + + if insecure: + verify = False + else: + verify = cacert or True + + if cert and key: + # passing cert and key together is deprecated in favour of the + # requests lib form of having the cert and key as a tuple + cert = (cert, key) + + # create the keystone client session + ks_session = session.Session(verify=verify, cert=cert) + v2_auth_url, v3_auth_url = _discover_auth_versions(ks_session, auth_url) + + username = kwargs.pop('username', None) + user_id = kwargs.pop('user_id', None) + user_domain_name = kwargs.pop('user_domain_name', None) + user_domain_id = kwargs.pop('user_domain_id', None) + project_domain_name = kwargs.pop('project_domain_name', None) + project_domain_id = kwargs.pop('project_domain_id', None) + auth = None + + use_domain = (user_domain_id or user_domain_name or + project_domain_id or project_domain_name) + use_v3 = v3_auth_url and (use_domain or (not v2_auth_url)) + use_v2 = v2_auth_url and not use_domain + + if use_v3: + # the auth_url as v3 specified + # e.g. http://no.where:5000/v3 + # Keystone will return only v3 as viable option + auth = v3_auth.Password( + v3_auth_url, + username=username, + password=kwargs.pop('password', None), + user_id=user_id, + user_domain_name=user_domain_name, + user_domain_id=user_domain_id, + project_domain_name=project_domain_name, + project_domain_id=project_domain_id) + elif use_v2: + # the auth_url as v2 specified + # e.g. http://no.where:5000/v2.0 + # Keystone will return only v2 as viable option + auth = v2_auth.Password( + v2_auth_url, + username, + kwargs.pop('password', None), + tenant_id=project_id, + tenant_name=project_name) + else: + raise exc.CommandError('Unable to determine the Keystone version ' + 'to authenticate with using the given ' + 'auth_url.') + + ks_session.auth = auth + return ks_session + + +def _get_endpoint(ks_session, **kwargs): + """Get an endpoint using the provided keystone session.""" + + # set service specific endpoint types + endpoint_type = kwargs.get('endpoint_type') or 'publicURL' + service_type = kwargs.get('service_type') or 'rating' + + endpoint = ks_session.get_endpoint(service_type=service_type, + interface=endpoint_type, + region_name=kwargs.get('region_name')) + + return endpoint + + +class AuthPlugin(auth.BaseAuthPlugin): + opt_names = ['tenant_id', 'region_name', 'auth_token', + 'service_type', 'endpoint_type', 'cacert', + 'auth_url', 'insecure', 'cert_file', 'key_file', + 'cert', 'key', 'tenant_name', 'project_name', + 'project_id', 'user_domain_id', 'user_domain_name', + 'password', 'username', 'endpoint'] + + def __init__(self, auth_system=None, **kwargs): + self.opt_names.extend(self.common_opt_names) + super(AuthPlugin, self).__init__(auth_system, **kwargs) + + def _do_authenticate(self, http_client): + token = self.opts.get('token') or self.opts.get('auth_token') + endpoint = self.opts.get('endpoint') + if not (token and endpoint): + project_id = (self.opts.get('project_id') or + self.opts.get('tenant_id')) + project_name = (self.opts.get('project_name') or + self.opts.get('tenant_name')) + ks_kwargs = { + 'username': self.opts.get('username'), + 'password': self.opts.get('password'), + 'user_id': self.opts.get('user_id'), + 'user_domain_id': self.opts.get('user_domain_id'), + 'user_domain_name': self.opts.get('user_domain_name'), + 'project_id': project_id, + 'project_name': project_name, + 'project_domain_name': self.opts.get('project_domain_name'), + 'project_domain_id': self.opts.get('project_domain_id'), + 'auth_url': self.opts.get('auth_url'), + 'cacert': self.opts.get('cacert'), + 'cert': self.opts.get('cert'), + 'key': self.opts.get('key'), + 'insecure': strutils.bool_from_string( + self.opts.get('insecure')), + 'endpoint_type': self.opts.get('endpoint_type'), + } + + # retrieve session + ks_session = _get_keystone_session(**ks_kwargs) + token = lambda: ks_session.get_token() + endpoint = (self.opts.get('endpoint') or + _get_endpoint(ks_session, **ks_kwargs)) + self.opts['token'] = token + self.opts['endpoint'] = endpoint + + def token_and_endpoint(self, endpoint_type, service_type): + token = self.opts.get('token') + if callable(token): + token = token() + return token, self.opts.get('endpoint') + + def sufficient_options(self): + """Check if all required options are present. + + :raises: AuthPluginOptionsMissing + """ + has_token = self.opts.get('token') or self.opts.get('auth_token') + no_auth = has_token and self.opts.get('endpoint') + has_tenant = self.opts.get('tenant_id') or self.opts.get('tenant_name') + has_credential = (self.opts.get('username') and has_tenant + and self.opts.get('password') + and self.opts.get('auth_url')) + missing = not (no_auth or has_credential) + if missing: + missing_opts = [] + opts = ['token', 'endpoint', 'username', 'password', 'auth_url', + 'tenant_id', 'tenant_name'] + for opt in opts: + if not self.opts.get(opt): + missing_opts.append(opt) + raise exceptions.AuthPluginOptionsMissing(missing_opts) + + +def Client(version, *args, **kwargs): + module = utils.import_versioned_module(version, 'client') + client_class = getattr(module, 'Client') + kwargs['token'] = kwargs.get('token') or kwargs.get('auth_token') + return client_class(*args, **kwargs) + + +def _adjust_params(kwargs): + timeout = kwargs.get('timeout') + if timeout is not None: + timeout = int(timeout) + if timeout <= 0: + timeout = None + + insecure = strutils.bool_from_string(kwargs.get('insecure')) + verify = kwargs.get('verify') + if verify is None: + if insecure: + verify = False + else: + verify = kwargs.get('cacert') or True + + cert = kwargs.get('cert_file') + key = kwargs.get('key_file') + if cert and key: + cert = cert, key + return {'verify': verify, 'cert': cert, 'timeout': timeout} + + +def get_client(version, **kwargs): + """Get an authenticated client, based on the credentials in the kwargs. + + :param api_version: the API version to use ('1') :param kwargs: keyword args containing credentials, either: - * os_auth_token: pre-existing token to re-use - * endpoint: CloudKitty API endpoint + + * os_token: pre-existing token to re-use + * os_endpoint: Cloudkitty API endpoint or: * os_username: name of user * os_password: user's password + * os_user_id: user's id + * os_user_domain_id: the domain id of the user + * os_user_domain_name: the domain name of the user + * os_project_id: the user project id + * os_tenant_id: V2 alternative to os_project_id + * os_project_name: the user project name + * os_tenant_name: V2 alternative to os_project_name + * os_project_domain_name: domain name for the user project + * os_project_domain_id: domain id for the user project * os_auth_url: endpoint to authenticate against - * os_tenant_name: name of tenant + * os_cert|os_cacert: path of CA TLS certificate + * os_key: SSL private key + * insecure: allow insecure SSL (no cert verification) """ + endpoint = kwargs.get('os_endpoint') + cli_kwargs = { 'username': kwargs.get('os_username'), 'password': kwargs.get('os_password'), + 'tenant_id': kwargs.get('os_tenant_id'), 'tenant_name': kwargs.get('os_tenant_name'), - 'token': kwargs.get('os_auth_token'), 'auth_url': kwargs.get('os_auth_url'), - 'endpoint': kwargs.get('cloudkitty_url') + 'region_name': kwargs.get('os_region_name'), + 'service_type': kwargs.get('os_service_type'), + 'endpoint_type': kwargs.get('os_endpoint_type'), + 'cacert': kwargs.get('os_cacert'), + 'cert_file': kwargs.get('os_cert'), + 'key_file': kwargs.get('os_key'), + 'token': kwargs.get('os_token') or kwargs.get('os_auth_token'), + 'user_domain_name': kwargs.get('os_user_domain_name'), + 'user_domain_id': kwargs.get('os_user_domain_id'), + 'project_domain_name': kwargs.get('os_project_domain_name'), + 'project_domain_id': kwargs.get('os_project_domain_id'), } - return Client(api_version, **cli_kwargs) + + cli_kwargs.update(kwargs) + cli_kwargs.update(_adjust_params(cli_kwargs)) + + return Client(version, endpoint, **cli_kwargs) -def Client(version, **kwargs): - module = importutils.import_versioned_module(version, 'client') - client_class = getattr(module, 'Client') - - keystone_auth = ks_auth.KeystoneAuthPlugin( +def get_auth_plugin(endpoint, **kwargs): + auth_plugin = AuthPlugin( + auth_url=kwargs.get('auth_url'), + service_type=kwargs.get('service_type'), + token=kwargs.get('token'), + endpoint_type=kwargs.get('endpoint_type'), + cacert=kwargs.get('cacert'), + tenant_id=kwargs.get('project_id') or kwargs.get('tenant_id'), + endpoint=endpoint, username=kwargs.get('username'), password=kwargs.get('password'), tenant_name=kwargs.get('tenant_name'), - token=kwargs.get('token'), - auth_url=kwargs.get('auth_url'), - endpoint=kwargs.get('endpoint')) - http_client = client.HTTPClient(keystone_auth) - - return client_class(http_client) + user_domain_name=kwargs.get('user_domain_name'), + user_domain_id=kwargs.get('user_domain_id'), + project_domain_name=kwargs.get('project_domain_name'), + project_domain_id=kwargs.get('project_domain_id') + ) + return auth_plugin diff --git a/cloudkittyclient/common/auth.py b/cloudkittyclient/common/auth.py deleted file mode 100644 index 594aed2..0000000 --- a/cloudkittyclient/common/auth.py +++ /dev/null @@ -1,79 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 Objectif Libre -# -# 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. - -""" -Keystone auth plugin. -""" - -from keystoneclient.v2_0 import client as ksclient - -from cloudkittyclient.openstack.common.apiclient import auth -from cloudkittyclient.openstack.common.apiclient import exceptions - - -class KeystoneAuthPlugin(auth.BaseAuthPlugin): - - opt_names = [ - "username", - "password", - "tenant_name", - "token", - "auth_url", - "endpoint" - ] - - def _do_authenticate(self, http_client): - if self.opts.get('token') is None: - ks_kwargs = { - 'username': self.opts.get('username'), - 'password': self.opts.get('password'), - 'tenant_name': self.opts.get('tenant_name'), - 'auth_url': self.opts.get('auth_url'), - } - - self._ksclient = ksclient.Client(**ks_kwargs) - - def token_and_endpoint(self, endpoint_type, service_type): - token = endpoint = None - - if self.opts.get('token') and self.opts.get('endpoint'): - token = self.opts.get('token') - endpoint = self.opts.get('endpoint') - - elif hasattr(self, '_ksclient'): - token = self._ksclient.auth_token - endpoint = (self.opts.get('endpoint') or - self._ksclient.service_catalog.url_for( - service_type=service_type, - endpoint_type=endpoint_type)) - - return (token, endpoint) - - def sufficient_options(self): - """Check if all required options are present. - - :raises: AuthPluginOptionsMissing - """ - - if self.opts.get('token'): - lookup_table = ["token", "endpoint"] - else: - lookup_table = ["username", "password", "tenant_name", "auth_url"] - - missing = [opt - for opt in lookup_table - if not self.opts.get(opt)] - if missing: - raise exceptions.AuthPluginOptionsMissing(missing) diff --git a/cloudkittyclient/common/base.py b/cloudkittyclient/common/base.py new file mode 100644 index 0000000..90cf218 --- /dev/null +++ b/cloudkittyclient/common/base.py @@ -0,0 +1,173 @@ +# Copyright 2012 OpenStack Foundation +# Copyright 2015 Objectif Libre +# 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 copy + +from six.moves.urllib import parse + +from cloudkittyclient import exc +from cloudkittyclient.openstack.common.apiclient import base + + +def getid(obj): + """Extracts object 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): + """Managers interact with a particular type of API. + + It works with samples, meters, alarms, etc. and provide CRUD operations for + them. + """ + resource_class = None + + def __init__(self, api): + self.api = api + + @property + def client(self): + """Compatible with latest oslo-incubator.apiclient code.""" + return self.api + + def _create(self, url, body): + body = self.api.post(url, json=body).json() + if body: + return self.resource_class(self, body) + + def _list(self, url, response_key=None, obj_class=None, body=None, + expect_single=False): + resp = self.api.get(url) + if not resp.content: + raise exc.HTTPNotFound + body = resp.json() + + if obj_class is None: + obj_class = self.resource_class + + if response_key: + try: + data = body[response_key] + except KeyError: + return [] + else: + data = body + if expect_single: + data = [data] + return [obj_class(self, res, loaded=True) for res in data if res] + + def _update(self, url, item, response_key=None): + if not item.dirty_fields: + return item + item = self.api.put(url, json=item.dirty_fields).json() + # PUT requests may not return a item + if item: + return self.resource_class(self, item) + + def _delete(self, url): + self.api.delete(url) + + +class CrudManager(base.CrudManager): + """A CrudManager that automatically gets its base URL.""" + + base_url = None + + def build_url(self, base_url=None, **kwargs): + base_url = base_url or self.base_url + return super(CrudManager, self).build_url(base_url, **kwargs) + + def get(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + return self._get( + self.build_url(**kwargs)) + + def create(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + return self._post( + self.build_url(**kwargs), kwargs) + + def update(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + params = kwargs.copy() + + return self._put( + self.build_url(**kwargs), params) + + def findall(self, base_url=None, **kwargs): + """Find multiple items with attributes matching ``**kwargs``. + + :param base_url: if provided, the generated URL will be appended to it + """ + kwargs = self._filter_kwargs(kwargs) + + rl = self._list( + '%(base_url)s%(query)s' % { + 'base_url': self.build_url(base_url=base_url, **kwargs), + 'query': '?%s' % parse.urlencode(kwargs) if kwargs else '', + }, + self.collection_key) + num = len(rl) + + if num == 0: + msg = _("No %(name)s matching %(args)s.") % { + 'name': self.resource_class.__name__, + 'args': kwargs + } + raise exc.NotFound(404, msg) + return rl + + +class Resource(base.Resource): + """A resource represents a particular instance of an object. + + Resource might be tenant, user, etc. + This is pretty much just a bag for attributes. + + :param manager: Manager object + :param info: dictionary representing resource attributes + :param loaded: prevent lazy-loading if set to True + """ + + key = None + + def to_dict(self): + return copy.deepcopy(self._info) + + @property + def dirty_fields(self): + out = self.to_dict() + for k, v in self._info.items(): + if self.__dict__[k] != v: + out[k] = self.__dict__[k] + return out + + def update(self): + try: + return self.manager.update(**self.dirty_fields) + except AttributeError: + raise exc.NotUpdatableError(self) diff --git a/cloudkittyclient/common/client.py b/cloudkittyclient/common/client.py deleted file mode 100644 index ffa2973..0000000 --- a/cloudkittyclient/common/client.py +++ /dev/null @@ -1,76 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 Objectif Libre -# -# 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. -# -# @author: François Magimel (linkid) - -""" -OpenStack Client interface. Handles the REST calls and responses. -Override the oslo-incubator one. -""" - -import logging -import time - -from cloudkittyclient.common import exceptions -from cloudkittyclient.openstack.common.apiclient import client - - -_logger = logging.getLogger(__name__) - - -class HTTPClient(client.HTTPClient): - """This client handles sending HTTP requests to OpenStack servers. - [Overrider] - """ - def request(self, method, url, **kwargs): - """Send an http request with the specified characteristics. - - Wrapper around `requests.Session.request` to handle tasks such as - setting headers, JSON encoding/decoding, and error handling. - - :param method: method of HTTP request - :param url: URL of HTTP request - :param kwargs: any other parameter that can be passed to - requests.Session.request (such as `headers`) or `json` - that will be encoded as JSON and used as `data` argument - """ - kwargs.setdefault("headers", kwargs.get("headers", {})) - kwargs["headers"]["User-Agent"] = self.user_agent - if self.original_ip: - kwargs["headers"]["Forwarded"] = "for=%s;by=%s" % ( - self.original_ip, self.user_agent) - if self.timeout is not None: - kwargs.setdefault("timeout", self.timeout) - kwargs.setdefault("verify", self.verify) - if self.cert is not None: - kwargs.setdefault("cert", self.cert) - self.serialize(kwargs) - - self._http_log_req(method, url, kwargs) - if self.timings: - start_time = time.time() - resp = self.http.request(method, url, **kwargs) - if self.timings: - self.times.append(("%s %s" % (method, url), - start_time, time.time())) - self._http_log_resp(resp) - - if resp.status_code >= 400: - _logger.debug( - "Request returned failure status: %s", - resp.status_code) - raise exceptions.from_response(resp, method, url) - - return resp diff --git a/cloudkittyclient/common/exceptions.py b/cloudkittyclient/common/exceptions.py deleted file mode 100644 index 9100305..0000000 --- a/cloudkittyclient/common/exceptions.py +++ /dev/null @@ -1,84 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 Objectif Libre -# -# 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. -# -# @author: François Magimel (linkid) - -""" -Exception definitions. -See cloudkittyclient.openstack.common.apiclient.exceptions. -""" - -from cloudkittyclient.openstack.common.apiclient.exceptions import * - - -# _code_map contains all the classes that have http_status attribute. -_code_map = dict( - (getattr(obj, 'http_status', None), obj) - for name, obj in six.iteritems(vars(sys.modules[__name__])) - if inspect.isclass(obj) and getattr(obj, 'http_status', False) -) - - -def from_response(response, method, url): - """Returns an instance of :class:`HttpError` or subclass based on response. - - :param response: instance of `requests.Response` class - :param method: HTTP method used for request - :param url: URL used for request - """ - - req_id = response.headers.get("x-openstack-request-id") - # NOTE(hdd) true for older versions of nova and cinder - if not req_id: - req_id = response.headers.get("x-compute-request-id") - kwargs = { - "http_status": response.status_code, - "response": response, - "method": method, - "url": url, - "request_id": req_id, - } - if "retry-after" in response.headers: - kwargs["retry_after"] = response.headers["retry-after"] - - content_type = response.headers.get("Content-Type", "") - if content_type.startswith("application/json"): - try: - body = response.json() - except ValueError: - pass - else: - if isinstance(body, dict): - if isinstance(body.get("error"), dict): - error = body["error"] - kwargs["message"] = error.get("message") - kwargs["details"] = error.get("details") - elif "faultstring" in body and "faultcode" in body: - # WSME - kwargs["message"] = "%(faultcode)s: %(faultstring)s" % body - kwargs["details"] = body.get("debuginfo", "") - elif content_type.startswith("text/"): - kwargs["details"] = response.text - - try: - cls = _code_map[response.status_code] - except KeyError: - if 500 <= response.status_code < 600: - cls = HttpServerError - elif 400 <= response.status_code < 500: - cls = HTTPClientError - else: - cls = HttpError - return cls(**kwargs) diff --git a/cloudkittyclient/common/utils.py b/cloudkittyclient/common/utils.py new file mode 100644 index 0000000..15cd9b8 --- /dev/null +++ b/cloudkittyclient/common/utils.py @@ -0,0 +1,211 @@ +# Copyright 2012 OpenStack Foundation +# 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. + +from __future__ import print_function + +import datetime +import sys +import textwrap +import uuid + +from oslo.serialization import jsonutils +from oslo.utils import encodeutils +from oslo.utils import importutils +import prettytable +import six + +from cloudkittyclient import exc +from cloudkittyclient.openstack.common import cliutils + + +def import_versioned_module(version, submodule=None): + module = 'cloudkittyclient.v%s' % version + if submodule: + module = '.'.join((module, submodule)) + return importutils.import_module(module) + + +# Decorator for cli-args +def arg(*args, **kwargs): + def _decorator(func): + if 'help' in kwargs: + if 'default' in kwargs: + kwargs['help'] += " Defaults to %s." % kwargs['default'] + required = kwargs.get('required', False) + if required: + kwargs['help'] += " Required." + + # Because of the sematics of decorator composition if we just append + # to the options list positional options will appear to be backwards. + func.__dict__.setdefault('arguments', []).insert(0, (args, kwargs)) + return func + return _decorator + + +def pretty_choice_list(l): + return ', '.join("'%s'" % i for i in l) + + +def print_list(objs, fields, field_labels, formatters={}, sortby=0): + + def _make_default_formatter(field): + return lambda o: getattr(o, field, '') + + new_formatters = {} + for field, field_label in six.moves.zip(fields, field_labels): + if field in formatters: + new_formatters[field_label] = formatters[field] + else: + new_formatters[field_label] = _make_default_formatter(field) + + cliutils.print_list(objs, field_labels, + formatters=new_formatters, + sortby_index=sortby) + + +def nested_list_of_dict_formatter(field, column_names): + # (TMaddox) Because the formatting scheme actually drops the whole object + # into the formatter, rather than just the specified field, we have to + # extract it and then pass the value. + return lambda o: format_nested_list_of_dict(getattr(o, field), + column_names) + + +def format_nested_list_of_dict(l, column_names): + pt = prettytable.PrettyTable(caching=False, print_empty=False, + header=True, hrules=prettytable.FRAME, + field_names=column_names) + for d in l: + pt.add_row(list(map(lambda k: d[k], column_names))) + return pt.get_string() + + +def print_dict(d, dict_property="Property", wrap=0): + pt = prettytable.PrettyTable([dict_property, 'Value'], print_empty=False) + pt.align = 'l' + for k, v in sorted(six.iteritems(d)): + # convert dict to str to check length + if isinstance(v, dict): + v = jsonutils.dumps(v) + # if value has a newline, add in multiple rows + # e.g. fault with stacktrace + if v and isinstance(v, six.string_types) and r'\n' in v: + lines = v.strip().split(r'\n') + col1 = k + for line in lines: + if wrap > 0: + line = textwrap.fill(str(line), wrap) + pt.add_row([col1, line]) + col1 = '' + else: + if wrap > 0: + v = textwrap.fill(str(v), wrap) + pt.add_row([k, v]) + encoded = encodeutils.safe_encode(pt.get_string()) + # FIXME(gordc): https://bugs.launchpad.net/oslo-incubator/+bug/1370710 + if six.PY3: + encoded = encoded.decode() + print(encoded) + + +def find_resource(manager, name_or_id): + """Helper for the _find_* methods.""" + # first try to get entity as integer id + try: + if isinstance(name_or_id, int) or name_or_id.isdigit(): + return manager.get(int(name_or_id)) + except exc.HTTPNotFound: + pass + + # now try to get entity as uuid + try: + uuid.UUID(str(name_or_id)) + return manager.get(name_or_id) + except (ValueError, exc.HTTPNotFound): + pass + + # finally try to find entity by name + try: + return manager.find(name=name_or_id) + except exc.HTTPNotFound: + msg = ("No %s with a name or ID of '%s' exists." % + (manager.resource_class.__name__.lower(), name_or_id)) + raise exc.CommandError(msg) + + +def args_array_to_dict(kwargs, key_to_convert): + values_to_convert = kwargs.get(key_to_convert) + if values_to_convert: + try: + kwargs[key_to_convert] = dict(v.split("=", 1) + for v in values_to_convert) + except ValueError: + raise exc.CommandError( + '%s must be a list of key=value not "%s"' % ( + key_to_convert, values_to_convert)) + return kwargs + + +def args_array_to_list_of_dicts(kwargs, key_to_convert): + """Converts ['a=1;b=2','c=3;d=4'] to [{a:1,b:2},{c:3,d:4}].""" + values_to_convert = kwargs.get(key_to_convert) + if values_to_convert: + try: + kwargs[key_to_convert] = [] + for lst in values_to_convert: + pairs = lst.split(";") + dct = dict() + for pair in pairs: + kv = pair.split("=", 1) + dct[kv[0]] = kv[1].strip(" \"'") # strip spaces and quotes + kwargs[key_to_convert].append(dct) + except Exception: + raise exc.CommandError( + '%s must be a list of key1=value1;key2=value2;... not "%s"' % ( + key_to_convert, values_to_convert)) + return kwargs + + +def key_with_slash_to_nested_dict(kwargs): + nested_kwargs = {} + for k in list(kwargs): + keys = k.split('/', 1) + if len(keys) == 2: + nested_kwargs.setdefault(keys[0], {})[keys[1]] = kwargs[k] + del kwargs[k] + kwargs.update(nested_kwargs) + return kwargs + + +def merge_nested_dict(dest, source, depth=0): + for (key, value) in six.iteritems(source): + if isinstance(value, dict) and depth: + merge_nested_dict(dest[key], value, + depth=(depth - 1)) + else: + dest[key] = value + + +def ts2dt(timestamp): + """timestamp to datetime format.""" + if not isinstance(timestamp, float): + timestamp = float(timestamp) + return datetime.datetime.utcfromtimestamp(timestamp) + + +def exit(msg=''): + if msg: + print(msg, file=sys.stderr) + sys.exit(1) diff --git a/cloudkittyclient/exc.py b/cloudkittyclient/exc.py new file mode 100644 index 0000000..a4c1a52 --- /dev/null +++ b/cloudkittyclient/exc.py @@ -0,0 +1,121 @@ +# 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 json +import sys + + +class BaseException(Exception): + """An error occurred.""" + def __init__(self, message=None): + self.message = message + + def __str__(self): + return self.message or self.__class__.__doc__ + + +class CommandError(BaseException): + """Invalid usage of CLI.""" + + +class InvalidEndpoint(BaseException): + """The provided endpoint is invalid.""" + + +class CommunicationError(BaseException): + """Unable to communicate with server.""" + + +class NotUpdatableError(BaseException): + """This Resource is not updatable.""" + + def __init__(self, resource): + message = "%s is not updatable" % resource + super(BaseException, self).__init__(message) + + +class HTTPException(BaseException): + """Base exception for all HTTP-derived exceptions.""" + code = 'N/A' + + def __init__(self, details=None): + self.details = details + + def __str__(self): + try: + data = json.loads(self.details) + message = data.get("error_message", {}).get("faultstring") + if message: + return "%s (HTTP %s) ERROR %s" % ( + self.__class__.__name__, self.code, message) + except (ValueError, TypeError, AttributeError): + pass + return "%s (HTTP %s)" % (self.__class__.__name__, self.code) + + +class HTTPBadRequest(HTTPException): + code = 400 + + +class HTTPUnauthorized(HTTPException): + code = 401 + + +class HTTPForbidden(HTTPException): + code = 403 + + +class HTTPNotFound(HTTPException): + code = 404 + + +class HTTPMethodNotAllowed(HTTPException): + code = 405 + + +class HTTPConflict(HTTPException): + code = 409 + + +class HTTPOverLimit(HTTPException): + code = 413 + + +class HTTPInternalServerError(HTTPException): + code = 500 + + +class HTTPNotImplemented(HTTPException): + code = 501 + + +class HTTPBadGateway(HTTPException): + code = 502 + + +class HTTPServiceUnavailable(HTTPException): + code = 503 + + +# NOTE(bcwaldon): Build a mapping of HTTP codes to corresponding exception +# classes +_code_map = {} +for obj_name in dir(sys.modules[__name__]): + if obj_name.startswith('HTTP'): + obj = getattr(sys.modules[__name__], obj_name) + _code_map[obj.code] = obj + + +def from_response(response, details=None): + """Return an instance of an HTTPException based on httplib response.""" + cls = _code_map.get(response.status, HTTPException) + return cls(details) diff --git a/cloudkittyclient/openstack/common/__init__.py b/cloudkittyclient/openstack/common/__init__.py index d1223ea..e69de29 100644 --- a/cloudkittyclient/openstack/common/__init__.py +++ b/cloudkittyclient/openstack/common/__init__.py @@ -1,17 +0,0 @@ -# -# 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 - - -six.add_move(six.MovedModule('mox', 'mox', 'mox3.mox')) diff --git a/cloudkittyclient/openstack/common/_i18n.py b/cloudkittyclient/openstack/common/_i18n.py new file mode 100644 index 0000000..0dc9bb3 --- /dev/null +++ b/cloudkittyclient/openstack/common/_i18n.py @@ -0,0 +1,45 @@ +# 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. + +"""oslo.i18n integration module. + +See http://docs.openstack.org/developer/oslo.i18n/usage.html + +""" + +try: + import oslo.i18n + + # NOTE(dhellmann): This reference to o-s-l-o will be replaced by the + # application name when this module is synced into the separate + # repository. It is OK to have more than one translation function + # using the same domain, since there will still only be one message + # catalog. + _translators = oslo.i18n.TranslatorFactory(domain='cloudkittyclient') + + # The primary translation function using the well-known name "_" + _ = _translators.primary + + # Translators for log levels. + # + # The abbreviated names are meant to reflect the usual use of a short + # name like '_'. The "L" is for "log" and the other letter comes from + # the level. + _LI = _translators.log_info + _LW = _translators.log_warning + _LE = _translators.log_error + _LC = _translators.log_critical +except ImportError: + # NOTE(dims): Support for cases where a project wants to use + # code from oslo-incubator, but is not ready to be internationalized + # (like tempest) + _ = _LI = _LW = _LE = _LC = lambda x: x diff --git a/cloudkittyclient/openstack/common/apiclient/auth.py b/cloudkittyclient/openstack/common/apiclient/auth.py index ce5f797..44efe1f 100644 --- a/cloudkittyclient/openstack/common/apiclient/auth.py +++ b/cloudkittyclient/openstack/common/apiclient/auth.py @@ -17,6 +17,19 @@ # E0202: An attribute inherited from %s hide this method # pylint: disable=E0202 +######################################################################## +# +# THIS MODULE IS DEPRECATED +# +# Please refer to +# https://etherpad.openstack.org/p/kilo-cloudkittyclient-library-proposals for +# the discussion leading to this deprecation. +# +# We recommend checking out the python-openstacksdk project +# (https://launchpad.net/python-openstacksdk) instead. +# +######################################################################## + import abc import argparse import os diff --git a/cloudkittyclient/openstack/common/apiclient/base.py b/cloudkittyclient/openstack/common/apiclient/base.py index f9527f5..ab87a0b 100644 --- a/cloudkittyclient/openstack/common/apiclient/base.py +++ b/cloudkittyclient/openstack/common/apiclient/base.py @@ -20,18 +20,32 @@ Base utilities to build API operation managers and objects on top of. """ +######################################################################## +# +# THIS MODULE IS DEPRECATED +# +# Please refer to +# https://etherpad.openstack.org/p/kilo-cloudkittyclient-library-proposals for +# the discussion leading to this deprecation. +# +# We recommend checking out the python-openstacksdk project +# (https://launchpad.net/python-openstacksdk) instead. +# +######################################################################## + + # E1102: %s is not callable # pylint: disable=E1102 import abc import copy +from oslo.utils import strutils import six from six.moves.urllib import parse +from cloudkittyclient.openstack.common._i18n import _ from cloudkittyclient.openstack.common.apiclient import exceptions -from cloudkittyclient.openstack.common.gettextutils import _ -from cloudkittyclient.openstack.common import strutils def getid(obj): @@ -99,12 +113,13 @@ class BaseManager(HookableMixin): super(BaseManager, self).__init__() self.client = client - def _list(self, url, response_key, obj_class=None, json=None): + def _list(self, url, response_key=None, obj_class=None, json=None): """List the collection. :param url: a partial URL, e.g., '/servers' :param response_key: the key to be looked up in response dictionary, - e.g., 'servers' + e.g., 'servers'. If response_key is None - all response body + will be used. :param obj_class: class for constructing the returned objects (self.resource_class will be used by default) :param json: data that will be encoded as JSON and passed in POST @@ -118,7 +133,7 @@ class BaseManager(HookableMixin): if obj_class is None: obj_class = self.resource_class - data = body[response_key] + data = body[response_key] if response_key is not None else body # NOTE(ja): keystone returns values as list as {'values': [ ... ]} # unlike other services which just return the list... try: @@ -128,15 +143,17 @@ class BaseManager(HookableMixin): return [obj_class(self, res, loaded=True) for res in data if res] - def _get(self, url, response_key): + def _get(self, url, response_key=None): """Get an object from collection. :param url: a partial URL, e.g., '/servers' :param response_key: the key to be looked up in response dictionary, - e.g., 'server' + e.g., 'server'. If response_key is None - all response body + will be used. """ body = self.client.get(url).json() - return self.resource_class(self, body[response_key], loaded=True) + data = body[response_key] if response_key is not None else body + return self.resource_class(self, data, loaded=True) def _head(self, url): """Retrieve request headers for an object. @@ -146,21 +163,23 @@ class BaseManager(HookableMixin): resp = self.client.head(url) return resp.status_code == 204 - def _post(self, url, json, response_key, return_raw=False): + def _post(self, url, json, response_key=None, return_raw=False): """Create an object. :param url: a partial URL, e.g., '/servers' :param json: data that will be encoded as JSON and passed in POST request (GET will be sent by default) :param response_key: the key to be looked up in response dictionary, - e.g., 'servers' + e.g., 'server'. If response_key is None - all response body + will be used. :param return_raw: flag to force returning raw JSON instead of Python object of self.resource_class """ body = self.client.post(url, json=json).json() + data = body[response_key] if response_key is not None else body if return_raw: - return body[response_key] - return self.resource_class(self, body[response_key]) + return data + return self.resource_class(self, data) def _put(self, url, json=None, response_key=None): """Update an object with PUT method. @@ -169,7 +188,8 @@ class BaseManager(HookableMixin): :param json: data that will be encoded as JSON and passed in POST request (GET will be sent by default) :param response_key: the key to be looked up in response dictionary, - e.g., 'servers' + e.g., 'servers'. If response_key is None - all response body + will be used. """ resp = self.client.put(url, json=json) # PUT requests may not return a body @@ -187,7 +207,8 @@ class BaseManager(HookableMixin): :param json: data that will be encoded as JSON and passed in POST request (GET will be sent by default) :param response_key: the key to be looked up in response dictionary, - e.g., 'servers' + e.g., 'servers'. If response_key is None - all response body + will be used. """ body = self.client.patch(url, json=json).json() if response_key is not None: @@ -488,6 +509,8 @@ class Resource(object): new = self.manager.get(self.id) if new: self._add_details(new._info) + self._add_details( + {'x_request_id': self.manager.client.last_request_id}) def __eq__(self, other): if not isinstance(other, Resource): diff --git a/cloudkittyclient/openstack/common/apiclient/client.py b/cloudkittyclient/openstack/common/apiclient/client.py index 99495e9..ba5e8cf 100644 --- a/cloudkittyclient/openstack/common/apiclient/client.py +++ b/cloudkittyclient/openstack/common/apiclient/client.py @@ -25,6 +25,7 @@ OpenStack Client interface. Handles the REST calls and responses. # E0202: An attribute inherited from %s hide this method # pylint: disable=E0202 +import hashlib import logging import time @@ -33,14 +34,15 @@ try: except ImportError: import json +from oslo.utils import encodeutils +from oslo.utils import importutils import requests +from cloudkittyclient.openstack.common._i18n import _ from cloudkittyclient.openstack.common.apiclient import exceptions -from cloudkittyclient.openstack.common.gettextutils import _ -from cloudkittyclient.openstack.common import importutils - _logger = logging.getLogger(__name__) +SENSITIVE_HEADERS = ('X-Auth-Token', 'X-Subject-Token',) class HTTPClient(object): @@ -98,19 +100,32 @@ class HTTPClient(object): self.http = http or requests.Session() self.cached_token = None + self.last_request_id = None + + def _safe_header(self, name, value): + if name in SENSITIVE_HEADERS: + # because in python3 byte string handling is ... ug + v = value.encode('utf-8') + h = hashlib.sha1(v) + d = h.hexdigest() + return encodeutils.safe_decode(name), "{SHA1}%s" % d + else: + return (encodeutils.safe_decode(name), + encodeutils.safe_decode(value)) def _http_log_req(self, method, url, kwargs): if not self.debug: return string_parts = [ - "curl -i", + "curl -g -i", "-X '%s'" % method, "'%s'" % url, ] for element in kwargs['headers']: - header = "-H '%s: %s'" % (element, kwargs['headers'][element]) + header = ("-H '%s: %s'" % + self._safe_header(element, kwargs['headers'][element])) string_parts.append(header) _logger.debug("REQ: %s" % " ".join(string_parts)) @@ -156,7 +171,7 @@ class HTTPClient(object): requests.Session.request (such as `headers`) or `json` that will be encoded as JSON and used as `data` argument """ - kwargs.setdefault("headers", kwargs.get("headers", {})) + kwargs.setdefault("headers", {}) kwargs["headers"]["User-Agent"] = self.user_agent if self.original_ip: kwargs["headers"]["Forwarded"] = "for=%s;by=%s" % ( @@ -177,6 +192,8 @@ class HTTPClient(object): start_time, time.time())) self._http_log_resp(resp) + self.last_request_id = resp.headers.get('x-openstack-request-id') + if resp.status_code >= 400: _logger.debug( "Request returned failure status: %s", @@ -247,6 +264,10 @@ class HTTPClient(object): raise self.cached_token = None client.cached_endpoint = None + if self.auth_plugin.opts.get('token'): + self.auth_plugin.opts['token'] = None + if self.auth_plugin.opts.get('endpoint'): + self.auth_plugin.opts['endpoint'] = None self.authenticate() try: token, endpoint = self.auth_plugin.token_and_endpoint( @@ -323,6 +344,10 @@ class BaseClient(object): return self.http_client.client_request( self, method, url, **kwargs) + @property + def last_request_id(self): + return self.http_client.last_request_id + def head(self, url, **kwargs): return self.client_request("HEAD", url, **kwargs) diff --git a/cloudkittyclient/openstack/common/apiclient/exceptions.py b/cloudkittyclient/openstack/common/apiclient/exceptions.py index b5036ac..7b6a5f6 100644 --- a/cloudkittyclient/openstack/common/apiclient/exceptions.py +++ b/cloudkittyclient/openstack/common/apiclient/exceptions.py @@ -20,12 +20,25 @@ Exception definitions. """ +######################################################################## +# +# THIS MODULE IS DEPRECATED +# +# Please refer to +# https://etherpad.openstack.org/p/kilo-cloudkittyclient-library-proposals for +# the discussion leading to this deprecation. +# +# We recommend checking out the python-openstacksdk project +# (https://launchpad.net/python-openstacksdk) instead. +# +######################################################################## + import inspect import sys import six -from cloudkittyclient.openstack.common.gettextutils import _ +from cloudkittyclient.openstack.common._i18n import _ class ClientException(Exception): @@ -34,14 +47,6 @@ class ClientException(Exception): pass -class MissingArgs(ClientException): - """Supplied arguments are not sufficient for calling a function.""" - def __init__(self, missing): - self.missing = missing - msg = _("Missing arguments: %s") % ", ".join(missing) - super(MissingArgs, self).__init__(msg) - - class ValidationError(ClientException): """Error in validation on API client side.""" pass @@ -62,11 +67,16 @@ class AuthorizationFailure(ClientException): pass -class ConnectionRefused(ClientException): +class ConnectionError(ClientException): """Cannot connect to API service.""" pass +class ConnectionRefused(ConnectionError): + """Connection refused while trying to connect to API service.""" + pass + + class AuthPluginOptionsMissing(AuthorizationFailure): """Auth plugin misses some options.""" def __init__(self, opt_names): @@ -80,7 +90,7 @@ class AuthSystemNotFound(AuthorizationFailure): """User has specified an AuthSystem that is not installed.""" def __init__(self, auth_system): super(AuthSystemNotFound, self).__init__( - _("AuthSystemNotFound: %s") % repr(auth_system)) + _("AuthSystemNotFound: %r") % auth_system) self.auth_system = auth_system @@ -103,7 +113,7 @@ class AmbiguousEndpoints(EndpointException): """Found more than one matching endpoint in Service Catalog.""" def __init__(self, endpoints=None): super(AmbiguousEndpoints, self).__init__( - _("AmbiguousEndpoints: %s") % repr(endpoints)) + _("AmbiguousEndpoints: %r") % endpoints) self.endpoints = endpoints @@ -447,10 +457,13 @@ def from_response(response, method, url): except ValueError: pass else: - if isinstance(body, dict) and isinstance(body.get("error"), dict): - error = body["error"] - kwargs["message"] = error.get("message") - kwargs["details"] = error.get("details") + if isinstance(body, dict): + error = body.get(list(body)[0]) + if isinstance(error, dict): + kwargs["message"] = (error.get("message") or + error.get("faultstring")) + kwargs["details"] = (error.get("details") or + six.text_type(body)) elif content_type.startswith("text/"): kwargs["details"] = response.text diff --git a/cloudkittyclient/openstack/common/apiclient/fake_client.py b/cloudkittyclient/openstack/common/apiclient/fake_client.py index 117b0fd..bac13ab 100644 --- a/cloudkittyclient/openstack/common/apiclient/fake_client.py +++ b/cloudkittyclient/openstack/common/apiclient/fake_client.py @@ -21,6 +21,19 @@ wrong the tests might raise AssertionError. I've indicated in comments the places where actual behavior differs from the spec. """ +######################################################################## +# +# THIS MODULE IS DEPRECATED +# +# Please refer to +# https://etherpad.openstack.org/p/kilo-cloudkittyclient-library-proposals for +# the discussion leading to this deprecation. +# +# We recommend checking out the python-openstacksdk project +# (https://launchpad.net/python-openstacksdk) instead. +# +######################################################################## + # W0102: Dangerous default value %s as argument # pylint: disable=W0102 @@ -33,7 +46,9 @@ from six.moves.urllib import parse from cloudkittyclient.openstack.common.apiclient import client -def assert_has_keys(dct, required=[], optional=[]): +def assert_has_keys(dct, required=None, optional=None): + required = required or [] + optional = optional or [] for k in required: try: assert k in dct @@ -166,6 +181,8 @@ class FakeHTTPClient(client.HTTPClient): else: status, body = resp headers = {} + self.last_request_id = headers.get('x-openstack-request-id', + 'req-test') return TestResponse({ "status_code": status, "text": body, diff --git a/cloudkittyclient/openstack/common/apiclient/utils.py b/cloudkittyclient/openstack/common/apiclient/utils.py new file mode 100644 index 0000000..b7515b3 --- /dev/null +++ b/cloudkittyclient/openstack/common/apiclient/utils.py @@ -0,0 +1,100 @@ +# +# 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. + +######################################################################## +# +# THIS MODULE IS DEPRECATED +# +# Please refer to +# https://etherpad.openstack.org/p/kilo-cloudkittyclient-library-proposals for +# the discussion leading to this deprecation. +# +# We recommend checking out the python-openstacksdk project +# (https://launchpad.net/python-openstacksdk) instead. +# +######################################################################## + +from oslo.utils import encodeutils +from oslo.utils import uuidutils +import six + +from cloudkittyclient.openstack.common._i18n import _ +from cloudkittyclient.openstack.common.apiclient import exceptions + + +def find_resource(manager, name_or_id, **find_args): + """Look for resource in a given manager. + + Used as a helper for the _find_* methods. + Example: + + .. code-block:: python + + def _find_hypervisor(cs, hypervisor): + #Get a hypervisor by name or ID. + return cliutils.find_resource(cs.hypervisors, hypervisor) + """ + # first try to get entity as integer id + try: + return manager.get(int(name_or_id)) + except (TypeError, ValueError, exceptions.NotFound): + pass + + # now try to get entity as uuid + try: + if six.PY2: + tmp_id = encodeutils.safe_encode(name_or_id) + else: + tmp_id = encodeutils.safe_decode(name_or_id) + + if uuidutils.is_uuid_like(tmp_id): + return manager.get(tmp_id) + except (TypeError, ValueError, exceptions.NotFound): + pass + + # for str id which is not uuid + if getattr(manager, 'is_alphanum_id_allowed', False): + try: + return manager.get(name_or_id) + except exceptions.NotFound: + pass + + try: + try: + return manager.find(human_id=name_or_id, **find_args) + except exceptions.NotFound: + pass + + # finally try to find entity by name + try: + resource = getattr(manager, 'resource_class', None) + name_attr = resource.NAME_ATTR if resource else 'name' + kwargs = {name_attr: name_or_id} + kwargs.update(find_args) + return manager.find(**kwargs) + except exceptions.NotFound: + msg = _("No %(name)s with a name or " + "ID of '%(name_or_id)s' exists.") % \ + { + "name": manager.resource_class.__name__.lower(), + "name_or_id": name_or_id + } + raise exceptions.CommandError(msg) + except exceptions.NoUniqueMatch: + msg = _("Multiple %(name)s matches found for " + "'%(name_or_id)s', use an ID to be more specific.") % \ + { + "name": manager.resource_class.__name__.lower(), + "name_or_id": name_or_id + } + raise exceptions.CommandError(msg) diff --git a/cloudkittyclient/openstack/common/cliutils.py b/cloudkittyclient/openstack/common/cliutils.py new file mode 100644 index 0000000..368025e --- /dev/null +++ b/cloudkittyclient/openstack/common/cliutils.py @@ -0,0 +1,271 @@ +# Copyright 2012 Red Hat, 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. + +# W0603: Using the global statement +# W0621: Redefining name %s from outer scope +# pylint: disable=W0603,W0621 + +from __future__ import print_function + +import getpass +import inspect +import os +import sys +import textwrap + +from oslo.utils import encodeutils +from oslo.utils import strutils +import prettytable +import six +from six import moves + +from cloudkittyclient.openstack.common._i18n import _ + + +class MissingArgs(Exception): + """Supplied arguments are not sufficient for calling a function.""" + def __init__(self, missing): + self.missing = missing + msg = _("Missing arguments: %s") % ", ".join(missing) + super(MissingArgs, self).__init__(msg) + + +def validate_args(fn, *args, **kwargs): + """Check that the supplied args are sufficient for calling a function. + + >>> validate_args(lambda a: None) + Traceback (most recent call last): + ... + MissingArgs: Missing argument(s): a + >>> validate_args(lambda a, b, c, d: None, 0, c=1) + Traceback (most recent call last): + ... + MissingArgs: Missing argument(s): b, d + + :param fn: the function to check + :param arg: the positional arguments supplied + :param kwargs: the keyword arguments supplied + """ + argspec = inspect.getargspec(fn) + + num_defaults = len(argspec.defaults or []) + required_args = argspec.args[:len(argspec.args) - num_defaults] + + def isbound(method): + return getattr(method, '__self__', None) is not None + + if isbound(fn): + required_args.pop(0) + + missing = [arg for arg in required_args if arg not in kwargs] + missing = missing[len(args):] + if missing: + raise MissingArgs(missing) + + +def arg(*args, **kwargs): + """Decorator for CLI args. + + Example: + + >>> @arg("name", help="Name of the new entity") + ... def entity_create(args): + ... pass + """ + def _decorator(func): + add_arg(func, *args, **kwargs) + return func + return _decorator + + +def env(*args, **kwargs): + """Returns the first environment variable set. + + If all are empty, defaults to '' or keyword arg `default`. + """ + for arg in args: + value = os.environ.get(arg) + if value: + return value + return kwargs.get('default', '') + + +def add_arg(func, *args, **kwargs): + """Bind CLI arguments to a shell.py `do_foo` function.""" + + if not hasattr(func, 'arguments'): + func.arguments = [] + + # NOTE(sirp): avoid dups that can occur when the module is shared across + # tests. + if (args, kwargs) not in func.arguments: + # Because of the semantics of decorator composition if we just append + # to the options list positional options will appear to be backwards. + func.arguments.insert(0, (args, kwargs)) + + +def unauthenticated(func): + """Adds 'unauthenticated' attribute to decorated function. + + Usage: + + >>> @unauthenticated + ... def mymethod(f): + ... pass + """ + func.unauthenticated = True + return func + + +def isunauthenticated(func): + """Checks if the function does not require authentication. + + Mark such functions with the `@unauthenticated` decorator. + + :returns: bool + """ + return getattr(func, 'unauthenticated', False) + + +def print_list(objs, fields, formatters=None, sortby_index=0, + mixed_case_fields=None, field_labels=None): + """Print a list or objects as a table, one row per object. + + :param objs: iterable of :class:`Resource` + :param fields: attributes that correspond to columns, in order + :param formatters: `dict` of callables for field formatting + :param sortby_index: index of the field for sorting table rows + :param mixed_case_fields: fields corresponding to object attributes that + have mixed case names (e.g., 'serverId') + :param field_labels: Labels to use in the heading of the table, default to + fields. + """ + formatters = formatters or {} + mixed_case_fields = mixed_case_fields or [] + field_labels = field_labels or fields + if len(field_labels) != len(fields): + raise ValueError(_("Field labels list %(labels)s has different number " + "of elements than fields list %(fields)s"), + {'labels': field_labels, 'fields': fields}) + + if sortby_index is None: + kwargs = {} + else: + kwargs = {'sortby': field_labels[sortby_index]} + pt = prettytable.PrettyTable(field_labels) + pt.align = 'l' + + for o in objs: + row = [] + for field in fields: + if field in formatters: + row.append(formatters[field](o)) + else: + if field in mixed_case_fields: + field_name = field.replace(' ', '_') + else: + field_name = field.lower().replace(' ', '_') + data = getattr(o, field_name, '') + row.append(data) + pt.add_row(row) + + if six.PY3: + print(encodeutils.safe_encode(pt.get_string(**kwargs)).decode()) + else: + print(encodeutils.safe_encode(pt.get_string(**kwargs))) + + +def print_dict(dct, dict_property="Property", wrap=0): + """Print a `dict` as a table of two columns. + + :param dct: `dict` to print + :param dict_property: name of the first column + :param wrap: wrapping for the second column + """ + pt = prettytable.PrettyTable([dict_property, 'Value']) + pt.align = 'l' + for k, v in six.iteritems(dct): + # convert dict to str to check length + if isinstance(v, dict): + v = six.text_type(v) + if wrap > 0: + v = textwrap.fill(six.text_type(v), wrap) + # if value has a newline, add in multiple rows + # e.g. fault with stacktrace + if v and isinstance(v, six.string_types) and r'\n' in v: + lines = v.strip().split(r'\n') + col1 = k + for line in lines: + pt.add_row([col1, line]) + col1 = '' + else: + pt.add_row([k, v]) + + if six.PY3: + print(encodeutils.safe_encode(pt.get_string()).decode()) + else: + print(encodeutils.safe_encode(pt.get_string())) + + +def get_password(max_password_prompts=3): + """Read password from TTY.""" + verify = strutils.bool_from_string(env("OS_VERIFY_PASSWORD")) + pw = None + if hasattr(sys.stdin, "isatty") and sys.stdin.isatty(): + # Check for Ctrl-D + try: + for __ in moves.range(max_password_prompts): + pw1 = getpass.getpass("OS Password: ") + if verify: + pw2 = getpass.getpass("Please verify: ") + else: + pw2 = pw1 + if pw1 == pw2 and pw1: + pw = pw1 + break + except EOFError: + pass + return pw + + +def service_type(stype): + """Adds 'service_type' attribute to decorated function. + + Usage: + + .. code-block:: python + + @service_type('volume') + def mymethod(f): + ... + """ + def inner(f): + f.service_type = stype + return f + return inner + + +def get_service_type(f): + """Retrieves service type from function.""" + return getattr(f, 'service_type', None) + + +def pretty_choice_list(l): + return ', '.join("'%s'" % i for i in l) + + +def exit(msg=''): + if msg: + print (msg, file=sys.stderr) + sys.exit(1) diff --git a/cloudkittyclient/openstack/common/gettextutils.py b/cloudkittyclient/openstack/common/gettextutils.py deleted file mode 100644 index 260ef04..0000000 --- a/cloudkittyclient/openstack/common/gettextutils.py +++ /dev/null @@ -1,479 +0,0 @@ -# Copyright 2012 Red Hat, Inc. -# Copyright 2013 IBM Corp. -# 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. - -""" -gettext for openstack-common modules. - -Usual usage in an openstack.common module: - - from cloudkittyclient.openstack.common.gettextutils import _ -""" - -import copy -import gettext -import locale -from logging import handlers -import os - -from babel import localedata -import six - -_AVAILABLE_LANGUAGES = {} - -# FIXME(dhellmann): Remove this when moving to oslo.i18n. -USE_LAZY = False - - -class TranslatorFactory(object): - """Create translator functions - """ - - def __init__(self, domain, localedir=None): - """Establish a set of translation functions for the domain. - - :param domain: Name of translation domain, - specifying a message catalog. - :type domain: str - :param lazy: Delays translation until a message is emitted. - Defaults to False. - :type lazy: Boolean - :param localedir: Directory with translation catalogs. - :type localedir: str - """ - self.domain = domain - if localedir is None: - localedir = os.environ.get(domain.upper() + '_LOCALEDIR') - self.localedir = localedir - - def _make_translation_func(self, domain=None): - """Return a new translation function ready for use. - - Takes into account whether or not lazy translation is being - done. - - The domain can be specified to override the default from the - factory, but the localedir from the factory is always used - because we assume the log-level translation catalogs are - installed in the same directory as the main application - catalog. - - """ - if domain is None: - domain = self.domain - t = gettext.translation(domain, - localedir=self.localedir, - fallback=True) - # Use the appropriate method of the translation object based - # on the python version. - m = t.gettext if six.PY3 else t.ugettext - - def f(msg): - """oslo.i18n.gettextutils translation function.""" - if USE_LAZY: - return Message(msg, domain=domain) - return m(msg) - return f - - @property - def primary(self): - "The default translation function." - return self._make_translation_func() - - def _make_log_translation_func(self, level): - return self._make_translation_func(self.domain + '-log-' + level) - - @property - def log_info(self): - "Translate info-level log messages." - return self._make_log_translation_func('info') - - @property - def log_warning(self): - "Translate warning-level log messages." - return self._make_log_translation_func('warning') - - @property - def log_error(self): - "Translate error-level log messages." - return self._make_log_translation_func('error') - - @property - def log_critical(self): - "Translate critical-level log messages." - return self._make_log_translation_func('critical') - - -# NOTE(dhellmann): When this module moves out of the incubator into -# oslo.i18n, these global variables can be moved to an integration -# module within each application. - -# Create the global translation functions. -_translators = TranslatorFactory('cloudkittyclient') - -# The primary translation function using the well-known name "_" -_ = _translators.primary - -# Translators for log levels. -# -# The abbreviated names are meant to reflect the usual use of a short -# name like '_'. The "L" is for "log" and the other letter comes from -# the level. -_LI = _translators.log_info -_LW = _translators.log_warning -_LE = _translators.log_error -_LC = _translators.log_critical - -# NOTE(dhellmann): End of globals that will move to the application's -# integration module. - - -def enable_lazy(): - """Convenience function for configuring _() to use lazy gettext - - Call this at the start of execution to enable the gettextutils._ - function to use lazy gettext functionality. This is useful if - your project is importing _ directly instead of using the - gettextutils.install() way of importing the _ function. - """ - global USE_LAZY - USE_LAZY = True - - -def install(domain): - """Install a _() function using the given translation domain. - - Given a translation domain, install a _() function using gettext's - install() function. - - The main difference from gettext.install() is that we allow - overriding the default localedir (e.g. /usr/share/locale) using - a translation-domain-specific environment variable (e.g. - NOVA_LOCALEDIR). - - Note that to enable lazy translation, enable_lazy must be - called. - - :param domain: the translation domain - """ - from six import moves - tf = TranslatorFactory(domain) - moves.builtins.__dict__['_'] = tf.primary - - -class Message(six.text_type): - """A Message object is a unicode object that can be translated. - - Translation of Message is done explicitly using the translate() method. - For all non-translation intents and purposes, a Message is simply unicode, - and can be treated as such. - """ - - def __new__(cls, msgid, msgtext=None, params=None, - domain='cloudkittyclient', *args): - """Create a new Message object. - - In order for translation to work gettext requires a message ID, this - msgid will be used as the base unicode text. It is also possible - for the msgid and the base unicode text to be different by passing - the msgtext parameter. - """ - # If the base msgtext is not given, we use the default translation - # of the msgid (which is in English) just in case the system locale is - # not English, so that the base text will be in that locale by default. - if not msgtext: - msgtext = Message._translate_msgid(msgid, domain) - # We want to initialize the parent unicode with the actual object that - # would have been plain unicode if 'Message' was not enabled. - msg = super(Message, cls).__new__(cls, msgtext) - msg.msgid = msgid - msg.domain = domain - msg.params = params - return msg - - def translate(self, desired_locale=None): - """Translate this message to the desired locale. - - :param desired_locale: The desired locale to translate the message to, - if no locale is provided the message will be - translated to the system's default locale. - - :returns: the translated message in unicode - """ - - translated_message = Message._translate_msgid(self.msgid, - self.domain, - desired_locale) - if self.params is None: - # No need for more translation - return translated_message - - # This Message object may have been formatted with one or more - # Message objects as substitution arguments, given either as a single - # argument, part of a tuple, or as one or more values in a dictionary. - # When translating this Message we need to translate those Messages too - translated_params = _translate_args(self.params, desired_locale) - - translated_message = translated_message % translated_params - - return translated_message - - @staticmethod - def _translate_msgid(msgid, domain, desired_locale=None): - if not desired_locale: - system_locale = locale.getdefaultlocale() - # If the system locale is not available to the runtime use English - if not system_locale[0]: - desired_locale = 'en_US' - else: - desired_locale = system_locale[0] - - locale_dir = os.environ.get(domain.upper() + '_LOCALEDIR') - lang = gettext.translation(domain, - localedir=locale_dir, - languages=[desired_locale], - fallback=True) - if six.PY3: - translator = lang.gettext - else: - translator = lang.ugettext - - translated_message = translator(msgid) - return translated_message - - def __mod__(self, other): - # When we mod a Message we want the actual operation to be performed - # by the parent class (i.e. unicode()), the only thing we do here is - # save the original msgid and the parameters in case of a translation - params = self._sanitize_mod_params(other) - unicode_mod = super(Message, self).__mod__(params) - modded = Message(self.msgid, - msgtext=unicode_mod, - params=params, - domain=self.domain) - return modded - - def _sanitize_mod_params(self, other): - """Sanitize the object being modded with this Message. - - - Add support for modding 'None' so translation supports it - - Trim the modded object, which can be a large dictionary, to only - those keys that would actually be used in a translation - - Snapshot the object being modded, in case the message is - translated, it will be used as it was when the Message was created - """ - if other is None: - params = (other,) - elif isinstance(other, dict): - # Merge the dictionaries - # Copy each item in case one does not support deep copy. - params = {} - if isinstance(self.params, dict): - for key, val in self.params.items(): - params[key] = self._copy_param(val) - for key, val in other.items(): - params[key] = self._copy_param(val) - else: - params = self._copy_param(other) - return params - - def _copy_param(self, param): - try: - return copy.deepcopy(param) - except Exception: - # Fallback to casting to unicode this will handle the - # python code-like objects that can't be deep-copied - return six.text_type(param) - - def __add__(self, other): - msg = _('Message objects do not support addition.') - raise TypeError(msg) - - def __radd__(self, other): - return self.__add__(other) - - if six.PY2: - def __str__(self): - # NOTE(luisg): Logging in python 2.6 tries to str() log records, - # and it expects specifically a UnicodeError in order to proceed. - msg = _('Message objects do not support str() because they may ' - 'contain non-ascii characters. ' - 'Please use unicode() or translate() instead.') - raise UnicodeError(msg) - - -def get_available_languages(domain): - """Lists the available languages for the given translation domain. - - :param domain: the domain to get languages for - """ - if domain in _AVAILABLE_LANGUAGES: - return copy.copy(_AVAILABLE_LANGUAGES[domain]) - - localedir = '%s_LOCALEDIR' % domain.upper() - find = lambda x: gettext.find(domain, - localedir=os.environ.get(localedir), - languages=[x]) - - # NOTE(mrodden): en_US should always be available (and first in case - # order matters) since our in-line message strings are en_US - language_list = ['en_US'] - # NOTE(luisg): Babel <1.0 used a function called list(), which was - # renamed to locale_identifiers() in >=1.0, the requirements master list - # requires >=0.9.6, uncapped, so defensively work with both. We can remove - # this check when the master list updates to >=1.0, and update all projects - list_identifiers = (getattr(localedata, 'list', None) or - getattr(localedata, 'locale_identifiers')) - locale_identifiers = list_identifiers() - - for i in locale_identifiers: - if find(i) is not None: - language_list.append(i) - - # NOTE(luisg): Babel>=1.0,<1.3 has a bug where some OpenStack supported - # locales (e.g. 'zh_CN', and 'zh_TW') aren't supported even though they - # are perfectly legitimate locales: - # https://github.com/mitsuhiko/babel/issues/37 - # In Babel 1.3 they fixed the bug and they support these locales, but - # they are still not explicitly "listed" by locale_identifiers(). - # That is why we add the locales here explicitly if necessary so that - # they are listed as supported. - aliases = {'zh': 'zh_CN', - 'zh_Hant_HK': 'zh_HK', - 'zh_Hant': 'zh_TW', - 'fil': 'tl_PH'} - for (locale_, alias) in six.iteritems(aliases): - if locale_ in language_list and alias not in language_list: - language_list.append(alias) - - _AVAILABLE_LANGUAGES[domain] = language_list - return copy.copy(language_list) - - -def translate(obj, desired_locale=None): - """Gets the translated unicode representation of the given object. - - If the object is not translatable it is returned as-is. - If the locale is None the object is translated to the system locale. - - :param obj: the object to translate - :param desired_locale: the locale to translate the message to, if None the - default system locale will be used - :returns: the translated object in unicode, or the original object if - it could not be translated - """ - message = obj - if not isinstance(message, Message): - # If the object to translate is not already translatable, - # let's first get its unicode representation - message = six.text_type(obj) - if isinstance(message, Message): - # Even after unicoding() we still need to check if we are - # running with translatable unicode before translating - return message.translate(desired_locale) - return obj - - -def _translate_args(args, desired_locale=None): - """Translates all the translatable elements of the given arguments object. - - This method is used for translating the translatable values in method - arguments which include values of tuples or dictionaries. - If the object is not a tuple or a dictionary the object itself is - translated if it is translatable. - - If the locale is None the object is translated to the system locale. - - :param args: the args to translate - :param desired_locale: the locale to translate the args to, if None the - default system locale will be used - :returns: a new args object with the translated contents of the original - """ - if isinstance(args, tuple): - return tuple(translate(v, desired_locale) for v in args) - if isinstance(args, dict): - translated_dict = {} - for (k, v) in six.iteritems(args): - translated_v = translate(v, desired_locale) - translated_dict[k] = translated_v - return translated_dict - return translate(args, desired_locale) - - -class TranslationHandler(handlers.MemoryHandler): - """Handler that translates records before logging them. - - The TranslationHandler takes a locale and a target logging.Handler object - to forward LogRecord objects to after translating them. This handler - depends on Message objects being logged, instead of regular strings. - - The handler can be configured declaratively in the logging.conf as follows: - - [handlers] - keys = translatedlog, translator - - [handler_translatedlog] - class = handlers.WatchedFileHandler - args = ('/var/log/api-localized.log',) - formatter = context - - [handler_translator] - class = openstack.common.log.TranslationHandler - target = translatedlog - args = ('zh_CN',) - - If the specified locale is not available in the system, the handler will - log in the default locale. - """ - - def __init__(self, locale=None, target=None): - """Initialize a TranslationHandler - - :param locale: locale to use for translating messages - :param target: logging.Handler object to forward - LogRecord objects to after translation - """ - # NOTE(luisg): In order to allow this handler to be a wrapper for - # other handlers, such as a FileHandler, and still be able to - # configure it using logging.conf, this handler has to extend - # MemoryHandler because only the MemoryHandlers' logging.conf - # parsing is implemented such that it accepts a target handler. - handlers.MemoryHandler.__init__(self, capacity=0, target=target) - self.locale = locale - - def setFormatter(self, fmt): - self.target.setFormatter(fmt) - - def emit(self, record): - # We save the message from the original record to restore it - # after translation, so other handlers are not affected by this - original_msg = record.msg - original_args = record.args - - try: - self._translate_and_log_record(record) - finally: - record.msg = original_msg - record.args = original_args - - def _translate_and_log_record(self, record): - record.msg = translate(record.msg, self.locale) - - # In addition to translating the message, we also need to translate - # arguments that were passed to the log method that were not part - # of the main message e.g., log.info(_('Some message %s'), this_one)) - record.args = _translate_args(record.args, self.locale) - - self.target.emit(record) diff --git a/cloudkittyclient/openstack/common/importutils.py b/cloudkittyclient/openstack/common/importutils.py deleted file mode 100644 index 7a14188..0000000 --- a/cloudkittyclient/openstack/common/importutils.py +++ /dev/null @@ -1,73 +0,0 @@ -# Copyright 2011 OpenStack Foundation. -# 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 related utilities and helper functions. -""" - -import sys -import traceback - - -def import_class(import_str): - """Returns a class from a string including module and class.""" - mod_str, _sep, class_str = import_str.rpartition('.') - __import__(mod_str) - try: - return getattr(sys.modules[mod_str], class_str) - except AttributeError: - raise ImportError('Class %s cannot be found (%s)' % - (class_str, - traceback.format_exception(*sys.exc_info()))) - - -def import_object(import_str, *args, **kwargs): - """Import a class and return an instance of it.""" - return import_class(import_str)(*args, **kwargs) - - -def import_object_ns(name_space, import_str, *args, **kwargs): - """Tries to import object from default namespace. - - Imports a class and return an instance of it, first by trying - to find the class in a default namespace, then failing back to - a full path if not found in the default namespace. - """ - import_value = "%s.%s" % (name_space, import_str) - try: - return import_class(import_value)(*args, **kwargs) - except ImportError: - return import_class(import_str)(*args, **kwargs) - - -def import_module(import_str): - """Import a module.""" - __import__(import_str) - return sys.modules[import_str] - - -def import_versioned_module(version, submodule=None): - module = 'cloudkittyclient.v%s' % version - if submodule: - module = '.'.join((module, submodule)) - return import_module(module) - - -def try_import(import_str, default=None): - """Try to import a module and if it fails return default.""" - try: - return import_module(import_str) - except ImportError: - return default diff --git a/cloudkittyclient/openstack/common/strutils.py b/cloudkittyclient/openstack/common/strutils.py deleted file mode 100644 index 37ff771..0000000 --- a/cloudkittyclient/openstack/common/strutils.py +++ /dev/null @@ -1,295 +0,0 @@ -# Copyright 2011 OpenStack Foundation. -# 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. - -""" -System-level utilities and helper functions. -""" - -import math -import re -import sys -import unicodedata - -import six - -from cloudkittyclient.openstack.common.gettextutils import _ - - -UNIT_PREFIX_EXPONENT = { - 'k': 1, - 'K': 1, - 'Ki': 1, - 'M': 2, - 'Mi': 2, - 'G': 3, - 'Gi': 3, - 'T': 4, - 'Ti': 4, -} -UNIT_SYSTEM_INFO = { - 'IEC': (1024, re.compile(r'(^[-+]?\d*\.?\d+)([KMGT]i?)?(b|bit|B)$')), - 'SI': (1000, re.compile(r'(^[-+]?\d*\.?\d+)([kMGT])?(b|bit|B)$')), -} - -TRUE_STRINGS = ('1', 't', 'true', 'on', 'y', 'yes') -FALSE_STRINGS = ('0', 'f', 'false', 'off', 'n', 'no') - -SLUGIFY_STRIP_RE = re.compile(r"[^\w\s-]") -SLUGIFY_HYPHENATE_RE = re.compile(r"[-\s]+") - - -# NOTE(flaper87): The following 3 globals are used by `mask_password` -_SANITIZE_KEYS = ['adminPass', 'admin_pass', 'password', 'admin_password'] - -# NOTE(ldbragst): Let's build a list of regex objects using the list of -# _SANITIZE_KEYS we already have. This way, we only have to add the new key -# to the list of _SANITIZE_KEYS and we can generate regular expressions -# for XML and JSON automatically. -_SANITIZE_PATTERNS = [] -_FORMAT_PATTERNS = [r'(%(key)s\s*[=]\s*[\"\']).*?([\"\'])', - r'(<%(key)s>).*?()', - r'([\"\']%(key)s[\"\']\s*:\s*[\"\']).*?([\"\'])', - r'([\'"].*?%(key)s[\'"]\s*:\s*u?[\'"]).*?([\'"])', - r'([\'"].*?%(key)s[\'"]\s*,\s*\'--?[A-z]+\'\s*,\s*u?[\'"])' - '.*?([\'"])', - r'(%(key)s\s*--?[A-z]+\s*)\S+(\s*)'] - -for key in _SANITIZE_KEYS: - for pattern in _FORMAT_PATTERNS: - reg_ex = re.compile(pattern % {'key': key}, re.DOTALL) - _SANITIZE_PATTERNS.append(reg_ex) - - -def int_from_bool_as_string(subject): - """Interpret a string as a boolean and return either 1 or 0. - - Any string value in: - - ('True', 'true', 'On', 'on', '1') - - is interpreted as a boolean True. - - Useful for JSON-decoded stuff and config file parsing - """ - return bool_from_string(subject) and 1 or 0 - - -def bool_from_string(subject, strict=False, default=False): - """Interpret a string as a boolean. - - A case-insensitive match is performed such that strings matching 't', - 'true', 'on', 'y', 'yes', or '1' are considered True and, when - `strict=False`, anything else returns the value specified by 'default'. - - Useful for JSON-decoded stuff and config file parsing. - - If `strict=True`, unrecognized values, including None, will raise a - ValueError which is useful when parsing values passed in from an API call. - Strings yielding False are 'f', 'false', 'off', 'n', 'no', or '0'. - """ - if not isinstance(subject, six.string_types): - subject = six.text_type(subject) - - lowered = subject.strip().lower() - - if lowered in TRUE_STRINGS: - return True - elif lowered in FALSE_STRINGS: - return False - elif strict: - acceptable = ', '.join( - "'%s'" % s for s in sorted(TRUE_STRINGS + FALSE_STRINGS)) - msg = _("Unrecognized value '%(val)s', acceptable values are:" - " %(acceptable)s") % {'val': subject, - 'acceptable': acceptable} - raise ValueError(msg) - else: - return default - - -def safe_decode(text, incoming=None, errors='strict'): - """Decodes incoming text/bytes string using `incoming` if they're not - already unicode. - - :param incoming: Text's current encoding - :param errors: Errors handling policy. See here for valid - values http://docs.python.org/2/library/codecs.html - :returns: text or a unicode `incoming` encoded - representation of it. - :raises TypeError: If text is not an instance of str - """ - if not isinstance(text, (six.string_types, six.binary_type)): - raise TypeError("%s can't be decoded" % type(text)) - - if isinstance(text, six.text_type): - return text - - if not incoming: - incoming = (sys.stdin.encoding or - sys.getdefaultencoding()) - - try: - return text.decode(incoming, errors) - except UnicodeDecodeError: - # Note(flaper87) If we get here, it means that - # sys.stdin.encoding / sys.getdefaultencoding - # didn't return a suitable encoding to decode - # text. This happens mostly when global LANG - # var is not set correctly and there's no - # default encoding. In this case, most likely - # python will use ASCII or ANSI encoders as - # default encodings but they won't be capable - # of decoding non-ASCII characters. - # - # Also, UTF-8 is being used since it's an ASCII - # extension. - return text.decode('utf-8', errors) - - -def safe_encode(text, incoming=None, - encoding='utf-8', errors='strict'): - """Encodes incoming text/bytes string using `encoding`. - - If incoming is not specified, text is expected to be encoded with - current python's default encoding. (`sys.getdefaultencoding`) - - :param incoming: Text's current encoding - :param encoding: Expected encoding for text (Default UTF-8) - :param errors: Errors handling policy. See here for valid - values http://docs.python.org/2/library/codecs.html - :returns: text or a bytestring `encoding` encoded - representation of it. - :raises TypeError: If text is not an instance of str - """ - if not isinstance(text, (six.string_types, six.binary_type)): - raise TypeError("%s can't be encoded" % type(text)) - - if not incoming: - incoming = (sys.stdin.encoding or - sys.getdefaultencoding()) - - if isinstance(text, six.text_type): - return text.encode(encoding, errors) - elif text and encoding != incoming: - # Decode text before encoding it with `encoding` - text = safe_decode(text, incoming, errors) - return text.encode(encoding, errors) - else: - return text - - -def string_to_bytes(text, unit_system='IEC', return_int=False): - """Converts a string into an float representation of bytes. - - The units supported for IEC :: - - Kb(it), Kib(it), Mb(it), Mib(it), Gb(it), Gib(it), Tb(it), Tib(it) - KB, KiB, MB, MiB, GB, GiB, TB, TiB - - The units supported for SI :: - - kb(it), Mb(it), Gb(it), Tb(it) - kB, MB, GB, TB - - Note that the SI unit system does not support capital letter 'K' - - :param text: String input for bytes size conversion. - :param unit_system: Unit system for byte size conversion. - :param return_int: If True, returns integer representation of text - in bytes. (default: decimal) - :returns: Numerical representation of text in bytes. - :raises ValueError: If text has an invalid value. - - """ - try: - base, reg_ex = UNIT_SYSTEM_INFO[unit_system] - except KeyError: - msg = _('Invalid unit system: "%s"') % unit_system - raise ValueError(msg) - match = reg_ex.match(text) - if match: - magnitude = float(match.group(1)) - unit_prefix = match.group(2) - if match.group(3) in ['b', 'bit']: - magnitude /= 8 - else: - msg = _('Invalid string format: %s') % text - raise ValueError(msg) - if not unit_prefix: - res = magnitude - else: - res = magnitude * pow(base, UNIT_PREFIX_EXPONENT[unit_prefix]) - if return_int: - return int(math.ceil(res)) - return res - - -def to_slug(value, incoming=None, errors="strict"): - """Normalize string. - - Convert to lowercase, remove non-word characters, and convert spaces - to hyphens. - - Inspired by Django's `slugify` filter. - - :param value: Text to slugify - :param incoming: Text's current encoding - :param errors: Errors handling policy. See here for valid - values http://docs.python.org/2/library/codecs.html - :returns: slugified unicode representation of `value` - :raises TypeError: If text is not an instance of str - """ - value = safe_decode(value, incoming, errors) - # NOTE(aababilov): no need to use safe_(encode|decode) here: - # encodings are always "ascii", error handling is always "ignore" - # and types are always known (first: unicode; second: str) - value = unicodedata.normalize("NFKD", value).encode( - "ascii", "ignore").decode("ascii") - value = SLUGIFY_STRIP_RE.sub("", value).strip().lower() - return SLUGIFY_HYPHENATE_RE.sub("-", value) - - -def mask_password(message, secret="***"): - """Replace password with 'secret' in message. - - :param message: The string which includes security information. - :param secret: value with which to replace passwords. - :returns: The unicode value of message with the password fields masked. - - For example: - - >>> mask_password("'adminPass' : 'aaaaa'") - "'adminPass' : '***'" - >>> mask_password("'admin_pass' : 'aaaaa'") - "'admin_pass' : '***'" - >>> mask_password('"password" : "aaaaa"') - '"password" : "***"' - >>> mask_password("'original_password' : 'aaaaa'") - "'original_password' : '***'" - >>> mask_password("u'original_password' : u'aaaaa'") - "u'original_password' : u'***'" - """ - message = six.text_type(message) - - # NOTE(ldbragst): Check to see if anything in message contains any key - # specified in _SANITIZE_KEYS, if not then just return the message since - # we don't have to mask any passwords. - if not any(key in message for key in _SANITIZE_KEYS): - return message - - secret = r'\g<1>' + secret + r'\g<2>' - for pattern in _SANITIZE_PATTERNS: - message = re.sub(pattern, secret, message) - return message diff --git a/cloudkittyclient/shell.py b/cloudkittyclient/shell.py new file mode 100644 index 0000000..dccf281 --- /dev/null +++ b/cloudkittyclient/shell.py @@ -0,0 +1,322 @@ +# Copyright 2015 Objectif Libre + +# 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. + +""" +Command-line interface to the OpenStack Cloudkitty API. +""" + +from __future__ import print_function + +import argparse +import logging +import sys + +from oslo.utils import encodeutils +import six +from stevedore import extension + +import cloudkittyclient +from cloudkittyclient import client as ckclient +from cloudkittyclient.common import utils +from cloudkittyclient import exc +from cloudkittyclient.openstack.common import cliutils +from cloudkittyclient.v1.report import shell as report_shell + +SUBMODULES_NAMESPACE = 'cloudkitty.client.modules' + + +def _positive_non_zero_int(argument_value): + if argument_value is None: + return None + try: + value = int(argument_value) + except ValueError: + msg = "%s must be an integer" % argument_value + raise argparse.ArgumentTypeError(msg) + if value <= 0: + msg = "%s must be greater than 0" % argument_value + raise argparse.ArgumentTypeError(msg) + return value + + +class CloudkittyShell(object): + + def get_base_parser(self): + parser = argparse.ArgumentParser( + prog='cloudkitty', + description=__doc__.strip(), + epilog='See "cloudkitty help COMMAND" ' + 'for help on a specific command.', + add_help=False, + formatter_class=HelpFormatter, + ) + + # Global arguments + parser.add_argument('-h', '--help', + action='store_true', + help=argparse.SUPPRESS, + ) + + parser.add_argument('--version', + action='version', + version=cloudkittyclient.__version__) + + parser.add_argument('-d', '--debug', + default=bool(cliutils.env('CLOUDKITTYCLIENT_DEBUG') + ), + action='store_true', + help='Defaults to env[CLOUDKITTYCLIENT_DEBUG].') + + parser.add_argument('-v', '--verbose', + default=False, action="store_true", + help="Print more verbose output.") + + parser.add_argument('--timeout', + default=600, + type=_positive_non_zero_int, + help='Number of seconds to wait for a response.') + + parser.add_argument('--cloudkitty-url', metavar='', + dest='os_endpoint', + default=cliutils.env('CLOUDKITTY_URL'), + help=("DEPRECATED, use --os-endpoint instead. " + "Defaults to env[CLOUDKITTY_URL].")) + + parser.add_argument('--cloudkitty_url', + dest='os_endpoint', + help=argparse.SUPPRESS) + + parser.add_argument('--cloudkitty-api-version', + default=cliutils.env( + 'CLOUDKITTY_API_VERSION', default='1'), + help='Defaults to env[CLOUDKITTY_API_VERSION] ' + 'or 1.') + + parser.add_argument('--cloudkitty_api_version', + help=argparse.SUPPRESS) + + self.auth_plugin.add_opts(parser) + self.auth_plugin.add_common_opts(parser) + + return parser + + def get_subcommand_parser(self, version): + parser = self.get_base_parser() + + self.subcommands = {} + subparsers = parser.add_subparsers(metavar='') + submodule = utils.import_versioned_module(version, 'shell') + self._find_actions(subparsers, submodule) + self._find_actions(subparsers, report_shell) + extensions = extension.ExtensionManager( + SUBMODULES_NAMESPACE, + ) + for ext in extensions: + shell = ext.plugin.get_shell() + self._find_actions(subparsers, shell) + self._find_actions(subparsers, self) + self._add_bash_completion_subparser(subparsers) + return parser + + def _add_bash_completion_subparser(self, subparsers): + subparser = subparsers.add_parser( + 'bash_completion', + add_help=False, + formatter_class=HelpFormatter + ) + self.subcommands['bash_completion'] = subparser + subparser.set_defaults(func=self.do_bash_completion) + + def _find_actions(self, subparsers, actions_module): + for attr in (a for a in dir(actions_module) if a.startswith('do_')): + # I prefer to be hypen-separated instead of underscores. + command = attr[3:].replace('_', '-') + callback = getattr(actions_module, attr) + desc = callback.__doc__ or '' + help = desc.strip().split('\n')[0] + arguments = getattr(callback, 'arguments', []) + + subparser = subparsers.add_parser(command, help=help, + description=desc, + add_help=False, + formatter_class=HelpFormatter) + subparser.add_argument('-h', '--help', action='help', + help=argparse.SUPPRESS) + self.subcommands[command] = subparser + for (args, kwargs) in arguments: + subparser.add_argument(*args, **kwargs) + subparser.set_defaults(func=callback) + + @staticmethod + def _setup_logging(debug): + format = '%(levelname)s (%(module)s) %(message)s' + if debug: + logging.basicConfig(format=format, level=logging.DEBUG) + else: + logging.basicConfig(format=format, level=logging.WARN) + logging.getLogger('iso8601').setLevel(logging.WARNING) + logging.getLogger('urllib3.connectionpool').setLevel(logging.WARNING) + + def parse_args(self, argv): + # Parse args once to find version + self.auth_plugin = ckclient.AuthPlugin() + parser = self.get_base_parser() + (options, args) = parser.parse_known_args(argv) + self.auth_plugin.parse_opts(options) + self._setup_logging(options.debug) + + # build available subcommands based on version + api_version = options.cloudkitty_api_version + subcommand_parser = self.get_subcommand_parser(api_version) + self.parser = subcommand_parser + + # Handle top-level --help/-h before attempting to parse + # a command off the command line + if options.help or not argv: + self.do_help(options) + return 0 + + # Return parsed args + return api_version, subcommand_parser.parse_args(argv) + + @staticmethod + def no_project_and_domain_set(args): + if not (args.os_project_id or (args.os_project_name and + (args.os_user_domain_name or args.os_user_domain_id)) or + (args.os_tenant_id or args.os_tenant_name)): + return True + else: + return False + + def main(self, argv): + parsed = self.parse_args(argv) + if parsed == 0: + return 0 + api_version, args = parsed + + # Short-circuit and deal with help command right away. + if args.func == self.do_help: + self.do_help(args) + return 0 + elif args.func == self.do_bash_completion: + self.do_bash_completion(args) + return 0 + + if not ((self.auth_plugin.opts.get('token') + or self.auth_plugin.opts.get('auth_token')) + and self.auth_plugin.opts['endpoint']): + if not self.auth_plugin.opts['username']: + raise exc.CommandError("You must provide a username via " + "either --os-username or via " + "env[OS_USERNAME]") + + if not self.auth_plugin.opts['password']: + raise exc.CommandError("You must provide a password via " + "either --os-password or via " + "env[OS_PASSWORD]") + + if self.no_project_and_domain_set(args): + # steer users towards Keystone V3 API + raise exc.CommandError("You must provide a project_id via " + "either --os-project-id or via " + "env[OS_PROJECT_ID] and " + "a domain_name via either " + "--os-user-domain-name or via " + "env[OS_USER_DOMAIN_NAME] or " + "a domain_id via either " + "--os-user-domain-id or via " + "env[OS_USER_DOMAIN_ID]") + + if not (self.auth_plugin.opts['tenant_id'] + or self.auth_plugin.opts['tenant_name']): + raise exc.CommandError("You must provide a tenant_id via " + "either --os-tenant-id or via " + "env[OS_TENANT_ID]") + + if not self.auth_plugin.opts['auth_url']: + raise exc.CommandError("You must provide an auth url via " + "either --os-auth-url or via " + "env[OS_AUTH_URL]") + + client_kwargs = vars(args) + client_kwargs.update(self.auth_plugin.opts) + client_kwargs['auth_plugin'] = self.auth_plugin + client = ckclient.get_client(api_version, **client_kwargs) + # call whatever callback was selected + try: + args.func(client, args) + except exc.HTTPUnauthorized: + raise exc.CommandError("Invalid OpenStack Identity credentials.") + + def do_bash_completion(self, args): + """Prints all of the commands and options to stdout. + + The cloudkitty.bash_completion script doesn't have to hard code them. + """ + commands = set() + options = set() + for sc_str, sc in self.subcommands.items(): + commands.add(sc_str) + for option in list(sc._optionals._option_string_actions): + options.add(option) + + commands.remove('bash-completion') + commands.remove('bash_completion') + print(' '.join(commands | options)) + + @utils.arg('command', metavar='', nargs='?', + help='Display help for ') + def do_help(self, args): + """Display help about this program or one of its subcommands.""" + if getattr(args, 'command', None): + if args.command in self.subcommands: + self.subcommands[args.command].print_help() + else: + raise exc.CommandError("'%s' is not a valid subcommand" % + args.command) + else: + self.parser.print_help() + + +class HelpFormatter(argparse.HelpFormatter): + def __init__(self, prog, indent_increment=2, max_help_position=32, + width=None): + super(HelpFormatter, self).__init__(prog, indent_increment, + max_help_position, width) + + def start_section(self, heading): + # Title-case the headings + heading = '%s%s' % (heading[0].upper(), heading[1:]) + super(HelpFormatter, self).start_section(heading) + + +def main(args=None): + try: + if args is None: + args = sys.argv[1:] + + CloudkittyShell().main(args) + + except Exception as e: + if '--debug' in args or '-d' in args: + raise + else: + print(encodeutils.safe_encode(six.text_type(e)), file=sys.stderr) + sys.exit(1) + except KeyboardInterrupt: + print("Stopping Cloudkitty Client", file=sys.stderr) + sys.exit(130) + +if __name__ == "__main__": + main() diff --git a/cloudkittyclient/tests/base.py b/cloudkittyclient/tests/base.py index 795cee3..1c30cdb 100644 --- a/cloudkittyclient/tests/base.py +++ b/cloudkittyclient/tests/base.py @@ -1,4 +1,7 @@ +# -*- coding: utf-8 -*- + # Copyright 2010-2011 OpenStack Foundation +# Copyright (c) 2013 Hewlett-Packard Development Company, L.P. # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain @@ -12,15 +15,9 @@ # License for the specific language governing permissions and limitations # under the License. -import fixtures -from oslotest import base as test +from oslotest import base -class TestCase(test.BaseTestCase): +class TestCase(base.BaseTestCase): + """Test case base class for all unit tests.""" - - def setUp(self): - """Run before each test method to initialize test environment.""" - - super(TestCase, self).setUp() - self.log_fixture = self.useFixture(fixtures.FakeLogger()) diff --git a/cloudkittyclient/tests/common/__init__.py b/cloudkittyclient/tests/common/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/cloudkittyclient/tests/common/test_auth.py b/cloudkittyclient/tests/common/test_auth.py deleted file mode 100644 index 37aaae9..0000000 --- a/cloudkittyclient/tests/common/test_auth.py +++ /dev/null @@ -1,94 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 Objectif Libre -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -""" -Tests for the Keystone auth plugin -`cloudkittyclient.common.KeystoneAuthPluginTest`. -""" - -from keystoneclient.v2_0 import client as ksclient -import mock - -from cloudkittyclient.common import auth -from cloudkittyclient.openstack.common.apiclient import client -from cloudkittyclient.openstack.common.apiclient import exceptions -from cloudkittyclient.tests import base - - -@mock.patch.object(ksclient, 'Client') -class KeystoneAuthPluginTest(base.TestCase): - def setUp(self): - super(KeystoneAuthPluginTest, self).setUp() - plugin = auth.KeystoneAuthPlugin( - username="fake-username", - password="fake-password", - tenant_name="fake-tenant-name", - auth_url="http://auth:5000", - endpoint="http://cloudkitty:8888") - self.cs = client.HTTPClient(auth_plugin=plugin) - - def test_authenticate(self, mock_ksclient): - self.cs.authenticate() - mock_ksclient.assert_called_with( - username="fake-username", - password="fake-password", - tenant_name="fake-tenant-name", - auth_url="http://auth:5000") - - def test_token_and_endpoint(self, mock_ksclient): - self.cs.authenticate() - (token, endpoint) = self.cs.auth_plugin.token_and_endpoint( - "fake-endpoint-type", "fake-service-type") - self.assertIsInstance(token, mock.MagicMock) - self.assertEqual("http://cloudkitty:8888", endpoint) - - def test_token_and_endpoint_before_auth(self, mock_ksclient): - (token, endpoint) = self.cs.auth_plugin.token_and_endpoint( - "fake-endpoint-type", "fake-service-type") - self.assertIsNone(token, None) - self.assertIsNone(endpoint, None) - - def test_sufficient_options_missing_tenant_name(self, mock_ksclient): - plugin = auth.KeystoneAuthPlugin( - username="fake-username", - password="fake-password", - auth_url="http://auth:5000", - endpoint="http://cloudkitty:8888") - cs = client.HTTPClient(auth_plugin=plugin) - self.assertRaises(exceptions.AuthPluginOptionsMissing, - cs.authenticate) - - -@mock.patch.object(ksclient, 'Client') -class KeystoneAuthPluginTokenTest(base.TestCase): - def test_token_and_endpoint(self, mock_ksclient): - plugin = auth.KeystoneAuthPlugin( - token="fake-token", - endpoint="http://cloudkitty:8888") - cs = client.HTTPClient(auth_plugin=plugin) - - cs.authenticate() - (token, endpoint) = cs.auth_plugin.token_and_endpoint( - "fake-endpoint-type", "fake-service-type") - self.assertEqual('fake-token', token) - self.assertEqual('http://cloudkitty:8888', endpoint) - - def test_sufficient_options_missing_endpoint(self, mock_ksclient): - plugin = auth.KeystoneAuthPlugin( - token="fake-token") - cs = client.HTTPClient(auth_plugin=plugin) - - self.assertRaises(exceptions.AuthPluginOptionsMissing, - cs.authenticate) diff --git a/cloudkittyclient/tests/fakes.py b/cloudkittyclient/tests/fakes.py new file mode 100644 index 0000000..705efd6 --- /dev/null +++ b/cloudkittyclient/tests/fakes.py @@ -0,0 +1,64 @@ +# 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 keystoneclient.v2_0 import client as ksclient + + +def script_keystone_client(): + ksclient.Client(auth_url='http://no.where', + insecure=False, + password='password', + tenant_id='', + tenant_name='tenant_name', + username='username').AndReturn(FakeKeystone('abcd1234')) + + +def fake_headers(): + return {'X-Auth-Token': 'abcd1234', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'python-cloudkittyclient'} + + +class FakeServiceCatalog(object): + @staticmethod + def url_for(endpoint_type, service_type): + return 'http://192.168.1.5:8004/v1/f14b41234' + + +class FakeKeystone(object): + service_catalog = FakeServiceCatalog() + + def __init__(self, auth_token): + self.auth_token = auth_token + + +class FakeHTTPResponse(object): + + version = 1.1 + + def __init__(self, status, reason, headers, body): + self.headers = headers + self.body = body + self.status = status + self.reason = reason + + def getheader(self, name, default=None): + return self.headers.get(name, default) + + def getheaders(self): + return self.headers.items() + + def read(self, amt=None): + b = self.body + self.body = None + return b diff --git a/cloudkittyclient/tests/test_client.py b/cloudkittyclient/tests/test_client.py index 9d74c44..a579db0 100644 --- a/cloudkittyclient/tests/test_client.py +++ b/cloudkittyclient/tests/test_client.py @@ -1,45 +1,148 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 Objectif Libre +# Copyright 2015 Objectif Libre +# 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 # -# 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 # -# 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. -# -# @author: François Magimel (linkid) +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. -""" -Tests for the main Client interface `cloudkittyclient.client`. -""" +import types import mock -import six from cloudkittyclient import client -from cloudkittyclient.common import auth -from cloudkittyclient.tests import base +from cloudkittyclient.tests import fakes +from cloudkittyclient.tests import utils from cloudkittyclient.v1 import client as v1client - -VERSIONS = { - '1': v1client, +FAKE_ENV = { + 'username': 'username', + 'password': 'password', + 'tenant_name': 'tenant_name', + 'auth_url': 'http://no.where', + 'os_endpoint': 'http://no.where', + 'auth_plugin': 'fake_auth', + 'token': '1234', + 'user_domain_name': 'default', + 'project_domain_name': 'default', } -class ClientTest(base.TestCase): - def test_client_unsupported_version(self): - self.assertRaises(ImportError, client.Client, - '111.11', **{}) +class ClientTest(utils.BaseTestCase): - def test_client(self): - for (version, instance) in six.iteritems(VERSIONS): - with mock.patch.object(auth, 'KeystoneAuthPlugin'): - c = client.Client(version, **{}) - self.assertIsInstance(c, instance.Client) + @staticmethod + def create_client(env, api_version=1, endpoint=None, exclude=[]): + env = dict((k, v) for k, v in env.items() + if k not in exclude) + + return client.get_client(api_version, **env) + + def setUp(self): + super(ClientTest, self).setUp() + + def test_client_version(self): + c1 = self.create_client(env=FAKE_ENV, api_version=1) + self.assertIsInstance(c1, v1client.Client) + + def test_client_auth_lambda(self): + env = FAKE_ENV.copy() + env['token'] = lambda: env['token'] + self.assertIsInstance(env['token'], + types.FunctionType) + c1 = self.create_client(env) + self.assertIsInstance(c1, v1client.Client) + + def test_client_auth_non_lambda(self): + env = FAKE_ENV.copy() + env['token'] = "1234" + self.assertIsInstance(env['token'], str) + c1 = self.create_client(env) + self.assertIsInstance(c1, v1client.Client) + + @mock.patch('keystoneclient.v2_0.client', fakes.FakeKeystone) + def test_client_without_auth_plugin(self): + env = FAKE_ENV.copy() + del env['auth_plugin'] + c = self.create_client(env, api_version=1, endpoint='fake_endpoint') + self.assertIsInstance(c.auth_plugin, client.AuthPlugin) + + def test_client_without_auth_plugin_keystone_v3(self): + env = FAKE_ENV.copy() + del env['auth_plugin'] + expected = { + 'username': 'username', + 'endpoint': 'http://no.where', + 'tenant_name': 'tenant_name', + 'service_type': None, + 'token': '1234', + 'endpoint_type': None, + 'auth_url': 'http://no.where', + 'tenant_id': None, + 'cacert': None, + 'password': 'password', + 'user_domain_name': 'default', + 'user_domain_id': None, + 'project_domain_name': 'default', + 'project_domain_id': None, + } + with mock.patch('cloudkittyclient.client.AuthPlugin') as auth_plugin: + self.create_client(env, api_version=1) + auth_plugin.assert_called_with(**expected) + + def test_client_with_auth_plugin(self): + c = self.create_client(FAKE_ENV, api_version=1) + self.assertIsInstance(c.auth_plugin, str) + + def test_v1_client_timeout_invalid_value(self): + env = FAKE_ENV.copy() + env['timeout'] = 'abc' + self.assertRaises(ValueError, self.create_client, env) + env['timeout'] = '1.5' + self.assertRaises(ValueError, self.create_client, env) + + def _test_v1_client_timeout_integer(self, timeout, expected_value): + env = FAKE_ENV.copy() + env['timeout'] = timeout + expected = { + 'auth_plugin': 'fake_auth', + 'timeout': expected_value, + 'original_ip': None, + 'http': None, + 'region_name': None, + 'verify': True, + 'timings': None, + 'keyring_saver': None, + 'cert': None, + 'endpoint_type': None, + 'user_agent': None, + 'debug': None, + } + cls = 'cloudkittyclient.openstack.common.apiclient.client.HTTPClient' + with mock.patch(cls) as mocked: + self.create_client(env) + mocked.assert_called_with(**expected) + + def test_v1_client_timeout_zero(self): + self._test_v1_client_timeout_integer(0, None) + + def test_v1_client_timeout_valid_value(self): + self._test_v1_client_timeout_integer(30, 30) + + def test_v1_client_cacert_in_verify(self): + env = FAKE_ENV.copy() + env['cacert'] = '/path/to/cacert' + client = self.create_client(env) + self.assertEqual('/path/to/cacert', client.client.verify) + + def test_v1_client_certfile_and_keyfile(self): + env = FAKE_ENV.copy() + env['cert_file'] = '/path/to/cert' + env['key_file'] = '/path/to/keycert' + client = self.create_client(env) + self.assertEqual(('/path/to/cert', '/path/to/keycert'), + client.client.cert) diff --git a/cloudkittyclient/i18n.py b/cloudkittyclient/tests/test_cloudkittyclient.py similarity index 51% rename from cloudkittyclient/i18n.py rename to cloudkittyclient/tests/test_cloudkittyclient.py index a2d136e..9ca97b0 100644 --- a/cloudkittyclient/i18n.py +++ b/cloudkittyclient/tests/test_cloudkittyclient.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright 2014 Objectif Libre -# + # 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 @@ -13,20 +12,17 @@ # License for the specific language governing permissions and limitations # under the License. -from oslo import i18n +""" +test_cloudkittyclient +---------------------------------- -_translators = i18n.TranslatorFactory(domain='cloudkittyclient') -i18n.enable_lazy() +Tests for `cloudkittyclient` module. +""" -# The primary translation function using the well-known name "_" -_ = _translators.primary +from cloudkittyclient.tests import base -# Translators for log levels. -# -# The abbreviated names are meant to reflect the usual use of a short -# name like '_'. The "L" is for "log" and the other letter comes from -# the level. -_LI = _translators.log_info -_LW = _translators.log_warning -_LE = _translators.log_error -_LC = _translators.log_critical + +class TestCloudkittyclient(base.TestCase): + + def test_something(self): + pass diff --git a/cloudkittyclient/tests/utils.py b/cloudkittyclient/tests/utils.py new file mode 100644 index 0000000..57bc276 --- /dev/null +++ b/cloudkittyclient/tests/utils.py @@ -0,0 +1,24 @@ +# Copyright 2012 OpenStack Foundation +# 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 fixtures +import testtools + + +class BaseTestCase(testtools.TestCase): + + def setUp(self): + super(BaseTestCase, self).setUp() + self.useFixture(fixtures.FakeLogger()) diff --git a/cloudkittyclient/tests/v1/billing/__init__.py b/cloudkittyclient/tests/v1/billing/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/cloudkittyclient/tests/v1/billing/test_modules.py b/cloudkittyclient/tests/v1/billing/test_modules.py deleted file mode 100644 index 7de44fd..0000000 --- a/cloudkittyclient/tests/v1/billing/test_modules.py +++ /dev/null @@ -1,115 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 Objectif Libre -# -# 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. -# -# @author: François Magimel (linkid) - -""" -Tests for the manager billing.modules `cloudkittyclient.v1.billing.modules`. -""" - -import json - -from cloudkittyclient.openstack.common.apiclient import fake_client -from cloudkittyclient.tests import base -from cloudkittyclient.v1.billing import modules -from cloudkittyclient.v1 import client - - -modules_test = ["noop", "test"] -info_module = { - "enabled": True, - "name": "test", - "hot-config": True, - "description": "Test description" -} - -fixtures_list_modules = { - '/v1/billing/modules': { - 'GET': ( - {}, - json.dumps(modules_test) - ), - } -} -fixtures_get_one_module = { - '/v1/billing/modules/test': { - 'GET': ( - {}, - json.dumps(info_module) - ), - } -} -fixtures_get_status_module = { - '/v1/billing/modules/test/enabled': { - 'GET': ( - {}, - json.dumps(str(info_module['enabled'])) - ), - } -} -fixtures_put_status_module = { - '/v1/billing/modules/test/enabled': { - 'PUT': ( - {}, - json.dumps(str(False)) - ), - } -} - - -class ModulesManagerTest(base.TestCase): - def connect_client(self, fixtures): - """Returns the manager.""" - fake_http_client = fake_client.FakeHTTPClient(fixtures=fixtures) - api_client = client.Client(fake_http_client) - return modules.ModulesManager(api_client) - - def test_list_modules(self): - mgr = self.connect_client(fixtures_list_modules) - modules_expected = [ - modules.Module(modules.ModulesManager, module) - for module in modules_test - ] - - self.assertEqual(modules_expected, mgr.list()) - - def test_get_one_module(self): - mgr = self.connect_client(fixtures_get_one_module) - module_expected = modules.ExtensionSummary( - modules.ModulesManager, info_module) - module_get = mgr.get(module_id='test') - - self.assertIn('ExtensionSummary', repr(module_get)) - self.assertEqual(module_expected, module_get) - self.assertEqual(module_expected.enabled, module_get.enabled) - self.assertEqual('test', module_get.name) - self.assertEqual(getattr(module_expected, 'hot-config'), - getattr(module_get, 'hot-config')) - self.assertEqual(module_expected.description, module_get.description) - - def test_get_status_module(self): - mgr = self.connect_client(fixtures_get_status_module) - module_status_expected = info_module['enabled'] - module_status_get = mgr.get_status(module_id='test') - - self.assertIn('Module', repr(module_status_get)) - self.assertIn('test', repr(module_status_get)) - self.assertEqual(str(module_status_expected), module_status_get.id) - - def test_update_status_module(self): - mgr = self.connect_client(fixtures_put_status_module) - module_status_put = mgr.update(module_id='test', enabled=False) - - self.assertEqual('False', module_status_put.id) diff --git a/cloudkittyclient/tests/v1/billing/test_quote.py b/cloudkittyclient/tests/v1/billing/test_quote.py deleted file mode 100644 index b19b319..0000000 --- a/cloudkittyclient/tests/v1/billing/test_quote.py +++ /dev/null @@ -1,60 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 Objectif Libre -# -# 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. -# -# @author: François Magimel (linkid) - -""" -Tests for the manager billing.quote `cloudkittyclient.v1.billing.quote`. -""" - -from cloudkittyclient.openstack.common.apiclient import fake_client -from cloudkittyclient.tests import base -from cloudkittyclient.v1.billing import quote -from cloudkittyclient.v1 import client - - -compute = { - 'desc': { - 'image_id': "a41fba37-2429-4f15-aa00-b5bc4bf557bf", - }, - 'service': "compute", - 'volume': 1 -} - -fixtures = { - '/v1/billing/quote': { - 'POST': ( - {}, - '4.2' - ), - } -} - - -class QuoteManagerTest(base.TestCase): - def setUp(self): - super(QuoteManagerTest, self).setUp() - fake_http_client = fake_client.FakeHTTPClient(fixtures=fixtures) - api_client = client.Client(fake_http_client) - self.mgr = quote.QuoteManager(api_client) - - def test_post(self): - _quote = self.mgr.post(json=compute) - self.assertIn('Quote', repr(_quote)) - self.assertEqual(4.2, _quote.price) - - def test_post_raw(self): - _quote = self.mgr.post(json=compute, return_raw=True) - self.assertEqual(4.2, _quote) diff --git a/cloudkittyclient/tests/v1/test_core.py b/cloudkittyclient/tests/v1/test_core.py new file mode 100644 index 0000000..a76ac88 --- /dev/null +++ b/cloudkittyclient/tests/v1/test_core.py @@ -0,0 +1,139 @@ +# Copyright 2015 Objectif Libre +# 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. +from cloudkittyclient.openstack.common.apiclient import client +from cloudkittyclient.openstack.common.apiclient import fake_client +from cloudkittyclient.tests import utils +import cloudkittyclient.v1.core + + +fixtures = { + '/v1/billing/modules': { + 'GET': ( + {}, + {'modules': [ + { + 'module_id': 'hashmap', + 'enabled': True, + }, + { + 'module_id': 'noop', + 'enabled': False, + }, + ]}, + ), + }, + '/v1/billing/modules/hashmap': { + 'GET': ( + {}, + { + 'module_id': 'hashmap', + 'enabled': True, + } + ), + 'PUT': ( + {}, + { + 'module_id': 'hashmap', + 'enabled': False, + } + ), + }, + '/v1/billing/modules/noop': { + 'GET': ( + {}, + { + 'module_id': 'noop', + 'enabled': False, + } + ), + 'PUT': ( + {}, + { + 'module_id': 'noop', + 'enabled': True, + } + ), + }, + '/v1/collectors': { + 'GET': ( + {}, + {'collectors': [ + { + 'module_id': 'ceilo', + 'enabled': True, + }, + ]}, + ), + }, +} + + +class CloudkittyModuleManagerTest(utils.BaseTestCase): + + def setUp(self): + super(CloudkittyModuleManagerTest, self).setUp() + self.http_client = fake_client.FakeHTTPClient(fixtures=fixtures) + self.api = client.BaseClient(self.http_client) + self.mgr = cloudkittyclient.v1.core.CloudkittyModuleManager(self.api) + + def test_list_all(self): + resources = list(self.mgr.list()) + expect = [ + 'GET', '/v1/billing/modules' + ] + self.http_client.assert_called(*expect) + self.assertEqual(len(resources), 2) + self.assertEqual(resources[0].module_id, 'hashmap') + self.assertEqual(resources[1].module_id, 'noop') + + def test_get_module_status(self): + resource = self.mgr.get(module_id='hashmap') + expect = [ + 'GET', '/v1/billing/modules/hashmap' + ] + self.http_client.assert_called(*expect) + self.assertEqual(resource.module_id, 'hashmap') + self.assertEqual(resource.enabled, True) + + +class CloudkittyModuleTest(utils.BaseTestCase): + + def setUp(self): + super(CloudkittyModuleTest, self).setUp() + self.http_client = fake_client.FakeHTTPClient(fixtures=fixtures) + self.api = client.BaseClient(self.http_client) + self.mgr = cloudkittyclient.v1.core.CloudkittyModuleManager(self.api) + + def test_enable(self): + self.ck_module = self.mgr.get(module_id='noop') + self.ck_module.enable() + # PUT /v1/billing/modules/noop + # body : {'enabled': True} + expect = [ + 'PUT', '/v1/billing/modules/noop', {'module_id': 'noop', + 'enabled': True}, + ] + self.http_client.assert_called(*expect) + + def test_disable(self): + self.ck_module = self.mgr.get(module_id='hashmap') + self.ck_module.disable() + # PUT /v1/billing/modules/hashmap + # body : {'enabled': False} + expect = [ + 'PUT', '/v1/billing/modules/hashmap', {'module_id': 'hashmap', + 'enabled': False}, + ] + self.http_client.assert_called(*expect) diff --git a/cloudkittyclient/tests/v1/test_hashmap.py b/cloudkittyclient/tests/v1/test_hashmap.py new file mode 100644 index 0000000..fd146bf --- /dev/null +++ b/cloudkittyclient/tests/v1/test_hashmap.py @@ -0,0 +1,425 @@ +# Copyright 2015 Objectif Libre +# 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. +from cloudkittyclient.openstack.common.apiclient import client +from cloudkittyclient.openstack.common.apiclient import fake_client +from cloudkittyclient.tests import utils +from cloudkittyclient.v1.billing import hashmap + + +fixtures = { + # services + '/v1/billing/module_config/hashmap/services': { + 'GET': ( + {}, + {'services': + [ + { + 'service_id': '2451c2e0-2c6b-4e75-987f-93661eef0fd5', + 'name': 'compute' + }, + { + 'service_id': '2451c2e0-2c6b-4e75-987f-93661eef0fd6', + 'name': 'volume' + }, + { + 'service_id': '2451c2e0-2c6b-4e75-987f-93661eef0fd7', + 'name': 'network' + }, + ], + } + ), + }, + # a service + ('/v1/billing/module_config/hashmap/services/' + '2451c2e0-2c6b-4e75-987f-93661eef0fd5'): { + 'GET': ( + {}, + { + 'service_id': '2451c2e0-2c6b-4e75-987f-93661eef0fd5', + 'name': 'compute', + } + ), + 'DELETE': ( + {}, + {}, + ), + }, + # a field + ('/v1/billing/module_config/hashmap/fields/' + 'a53db546-bac0-472c-be4b-5bf9f6117581'): { + 'GET': ( + {}, + { + 'field_id': 'a53db546-bac0-472c-be4b-5bf9f6117581', + 'service_id': '2451c2e0-2c6b-4e75-987f-93661eef0fd5', + 'name': 'flavor', + }, + ), + 'PUT': ( + {}, + {}, + ), + }, + ('/v1/billing/module_config/hashmap/fields' + '?service_id=2451c2e0-2c6b-4e75-987f-93661eef0fd5'): { + 'GET': ( + {}, + {'fields': [ + { + 'field_id': 'a53db546-bac0-472c-be4b-5bf9f6117581', + 'service_id': '2451c2e0-2c6b-4e75-987f-93661eef0fd5', + 'name': 'flavor', + }, + { + 'field_id': 'a53db546-bac0-472c-be4b-5bf9f6117582', + 'service_id': '2451c2e0-2c6b-4e75-987f-93661eef0fd5', + 'name': 'LOLOL', + }, + ] + }, + ), + 'PUT': ( + {}, + {}, + ), + }, + # a mapping + ('/v1/billing/module_config/hashmap/mappings/' + 'bff0d209-a8e4-46f8-8c1a-f231db375dcb'): { + 'GET': ( + {}, + { + 'mapping_id': 'bff0d209-a8e4-46f8-8c1a-f231db375dcb', + 'service_id': '2451c2e0-2c6b-4e75-987f-93661eef0fd5', + 'field_id': 'a53db546-bac0-472c-be4b-5bf9f6117581', + 'group_id': None, + 'value': 'm1.small', + 'cost': 0.50, + 'type': 'flat', + }, + ), + 'PUT': ( + {}, + { + 'mapping_id': 'bff0d209-a8e4-46f8-8c1a-f231db375dcb', + 'service_id': '2451c2e0-2c6b-4e75-987f-93661eef0fd5', + 'field_id': 'a53db546-bac0-472c-be4b-5bf9f6117581', + 'group_id': None, + 'value': 'm1.small', + 'cost': 0.20, + 'type': 'flat', + }, + ), + }, + # some mappings + ('/v1/billing/module_config/hashmap/mappings' + '?service_id=2451c2e0-2c6b-4e75-987f-93661eef0fd5'): { + 'GET': ( + {}, + {'mappings': + [ + { + 'mapping_id': 'bff0d209-a8e4-46f8-8c1a-f231db375dcb', + 'service_id': '2451c2e0-2c6b-4e75-987f-93661eef0fd5', + 'field_id': None, + 'group_id': None, + 'value': 'm1.small', + 'cost': 0.50, + 'type': 'flat', + }, + { + 'mapping_id': 'bff0d209-a8e4-46f8-8c1a-f231db375dcc', + 'service_id': '2451c2e0-2c6b-4e75-987f-93661eef0fd5', + 'field_id': None, + 'group_id': None, + 'value': 'm1.tiny', + 'cost': 1.10, + 'type': 'flat', + }, + { + 'mapping_id': 'bff0d209-a8e4-46f8-8c1a-f231db375dcd', + 'service_id': '2451c2e0-2c6b-4e75-987f-93661eef0fd5', + 'field_id': None, + 'group_id': None, + 'value': 'm1.big', + 'cost': 1.50, + 'type': 'flat', + }, + ], + } + ), + 'PUT': ( + {}, + {}, + ), + }, + '/v1/billing/module_config/hashmap/groups': { + 'GET': ( + {}, + {'groups': + [ + { + 'group_id': 'aaa1c2e0-2c6b-4e75-987f-93661eef0fd5', + 'name': 'object_consumption' + }, + { + 'group_id': 'aaa1c2e0-2c6b-4e75-987f-93661eef0fd6', + 'name': 'compute_instance' + }, + { + 'group_id': 'aaa1c2e0-2c6b-4e75-987f-93661eef0fd7', + 'name': 'netowrking' + }, + ], + } + ), + }, + ('/v1/billing/module_config/hashmap/groups/' + 'aaa1c2e0-2c6b-4e75-987f-93661eef0fd5'): { + 'GET': ( + {}, + { + 'group_id': 'aaa1c2e0-2c6b-4e75-987f-93661eef0fd5', + 'name': 'object_consumption' + }, + ), + 'DELETE': ( + {}, + {}, + ), + }, + ('/v1/billing/module_config/hashmap/groups/' + 'aaa1c2e0-2c6b-4e75-987f-93661eef0fd5?recursive=True'): { + 'DELETE': ( + {}, + {}, + ), + }, +} + + +class ServiceManagerTest(utils.BaseTestCase): + + def setUp(self): + super(ServiceManagerTest, self).setUp() + self.http_client = fake_client.FakeHTTPClient(fixtures=fixtures) + self.api = client.BaseClient(self.http_client) + self.mgr = hashmap.ServiceManager(self.api) + + def test_list_services(self): + resources = list(self.mgr.list()) + expect = [ + 'GET', '/v1/billing/module_config/hashmap/services' + ] + self.http_client.assert_called(*expect) + self.assertEqual(len(resources), 3) + self.assertEqual( + resources[0].service_id, + '2451c2e0-2c6b-4e75-987f-93661eef0fd5' + ) + self.assertEqual(resources[0].name, 'compute') + self.assertEqual(resources[1].name, 'volume') + self.assertEqual(resources[2].name, 'network') + + def test_get_a_service(self): + resource = self.mgr.get( + service_id='2451c2e0-2c6b-4e75-987f-93661eef0fd5' + ) + expect = [ + 'GET', ('/v1/billing/module_config/hashmap/services/' + '2451c2e0-2c6b-4e75-987f-93661eef0fd5') + ] + self.http_client.assert_called(*expect) + self.assertEqual(resource.service_id, + '2451c2e0-2c6b-4e75-987f-93661eef0fd5') + self.assertEqual(resource.name, 'compute') + + +class ServiceTest(utils.BaseTestCase): + + def setUp(self): + super(ServiceTest, self).setUp() + self.http_client = fake_client.FakeHTTPClient(fixtures=fixtures) + self.api = client.BaseClient(self.http_client) + self.mgr = hashmap.ServiceManager(self.api) + self.resource = self.mgr.get( + service_id='2451c2e0-2c6b-4e75-987f-93661eef0fd5' + ) + + def test_get_fields(self): + fields = self.resource.fields[:] + expect = [ + 'GET', ('/v1/billing/module_config/hashmap/fields' + '?service_id=2451c2e0-2c6b-4e75-987f-93661eef0fd5'), + ] + self.http_client.assert_called(*expect) + self.assertEqual(len(fields), 2) + + def test_get_mappings(self): + mappings = self.resource.mappings[:] + expect = [ + 'GET', ('/v1/billing/module_config/hashmap/mappings' + '?service_id=2451c2e0-2c6b-4e75-987f-93661eef0fd5'), + ] + self.http_client.assert_called(*expect) + self.assertEqual(len(mappings), 3) + + +class FieldManagerTest(utils.BaseTestCase): + + def setUp(self): + super(FieldManagerTest, self).setUp() + self.http_client = fake_client.FakeHTTPClient(fixtures=fixtures) + self.api = client.BaseClient(self.http_client) + self.mgr = hashmap.FieldManager(self.api) + + def test_get_a_field(self): + resource = self.mgr.get( + field_id='a53db546-bac0-472c-be4b-5bf9f6117581' + ) + expect = [ + 'GET', ('/v1/billing/module_config/hashmap/fields/' + 'a53db546-bac0-472c-be4b-5bf9f6117581') + ] + self.http_client.assert_called(*expect) + self.assertEqual(resource.field_id, + 'a53db546-bac0-472c-be4b-5bf9f6117581') + self.assertEqual( + resource.service_id, + '2451c2e0-2c6b-4e75-987f-93661eef0fd5' + ) + self.assertEqual(resource.name, 'flavor') + + +class MappingManagerTest(utils.BaseTestCase): + + def setUp(self): + super(MappingManagerTest, self).setUp() + self.http_client = fake_client.FakeHTTPClient(fixtures=fixtures) + self.api = client.BaseClient(self.http_client) + self.mgr = hashmap.MappingManager(self.api) + + def test_get_a_mapping(self): + resource = self.mgr.get( + mapping_id='bff0d209-a8e4-46f8-8c1a-f231db375dcb' + ) + expect = [ + 'GET', ('/v1/billing/module_config/hashmap/mappings/' + 'bff0d209-a8e4-46f8-8c1a-f231db375dcb') + ] + self.http_client.assert_called(*expect) + self.assertEqual(resource.mapping_id, + 'bff0d209-a8e4-46f8-8c1a-f231db375dcb') + self.assertEqual( + resource.service_id, + '2451c2e0-2c6b-4e75-987f-93661eef0fd5' + ) + self.assertEqual( + resource.field_id, + 'a53db546-bac0-472c-be4b-5bf9f6117581' + ) + self.assertEqual(resource.value, 'm1.small') + self.assertEqual(resource.cost, 0.5) + + def test_update_a_mapping(self): + resource = self.mgr.get( + mapping_id='bff0d209-a8e4-46f8-8c1a-f231db375dcb' + ) + resource.cost = 0.2 + self.mgr.update(**resource.dirty_fields) + expect = [ + 'PUT', ('/v1/billing/module_config/hashmap/mappings/' + 'bff0d209-a8e4-46f8-8c1a-f231db375dcb'), + {u'mapping_id': u'bff0d209-a8e4-46f8-8c1a-f231db375dcb', + u'cost': 0.2, u'type': u'flat', + u'service_id': u'2451c2e0-2c6b-4e75-987f-93661eef0fd5', + u'field_id': u'a53db546-bac0-472c-be4b-5bf9f6117581', + u'value': u'm1.small'} + ] + self.http_client.assert_called(*expect) + + +class GroupManagerTest(utils.BaseTestCase): + + def setUp(self): + super(GroupManagerTest, self).setUp() + self.http_client = fake_client.FakeHTTPClient(fixtures=fixtures) + self.api = client.BaseClient(self.http_client) + self.mgr = hashmap.GroupManager(self.api) + + def test_get_a_group(self): + resource = self.mgr.get( + group_id='aaa1c2e0-2c6b-4e75-987f-93661eef0fd5' + ) + expect = [ + 'GET', ('/v1/billing/module_config/hashmap/groups/' + 'aaa1c2e0-2c6b-4e75-987f-93661eef0fd5') + ] + self.http_client.assert_called(*expect) + self.assertEqual(resource.group_id, + 'aaa1c2e0-2c6b-4e75-987f-93661eef0fd5') + self.assertEqual(resource.name, 'object_consumption') + + def test_delete_a_group(self): + self.mgr.delete(group_id='aaa1c2e0-2c6b-4e75-987f-93661eef0fd5') + expect = [ + 'DELETE', ('/v1/billing/module_config/hashmap/groups/' + 'aaa1c2e0-2c6b-4e75-987f-93661eef0fd5') + ] + self.http_client.assert_called(*expect) + + def test_delete_a_group_recursively(self): + self.mgr.delete(group_id='aaa1c2e0-2c6b-4e75-987f-93661eef0fd5', + recursive=True) + expect = [ + 'DELETE', ('/v1/billing/module_config/hashmap/groups/' + 'aaa1c2e0-2c6b-4e75-987f-93661eef0fd5?recursive=True') + ] + self.http_client.assert_called(*expect) + + +class GroupTest(utils.BaseTestCase): + + def setUp(self): + super(GroupTest, self).setUp() + self.http_client = fake_client.FakeHTTPClient(fixtures=fixtures) + self.api = client.BaseClient(self.http_client) + self.mgr = hashmap.GroupManager(self.api) + + def test_delete(self): + self.group = self.mgr.get( + group_id='aaa1c2e0-2c6b-4e75-987f-93661eef0fd5' + ) + self.group.delete() + # DELETE /v1/billing/groups/aaa1c2e0-2c6b-4e75-987f-93661eef0fd5 + expect = [ + 'DELETE', ('/v1/billing/module_config/hashmap/groups/' + 'aaa1c2e0-2c6b-4e75-987f-93661eef0fd5') + ] + self.http_client.assert_called(*expect) + + def test_delete_recursive(self): + self.group = self.mgr.get( + group_id='aaa1c2e0-2c6b-4e75-987f-93661eef0fd5' + ) + self.group.delete(recursive=True) + # DELETE + # /v1/billing/groups/aaa1c2e0-2c6b-4e75-987f-93661eef0fd5?recusrive=True + expect = [ + 'DELETE', ('/v1/billing/module_config/hashmap/groups/' + 'aaa1c2e0-2c6b-4e75-987f-93661eef0fd5' + '?recursive=True') + ] + self.http_client.assert_called(*expect) diff --git a/cloudkittyclient/tests/v1/test_report.py b/cloudkittyclient/tests/v1/test_report.py deleted file mode 100644 index 4eb5773..0000000 --- a/cloudkittyclient/tests/v1/test_report.py +++ /dev/null @@ -1,48 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 Objectif Libre -# -# 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. -# -# @author: François Magimel (linkid) - -""" -Tests for the manager report `cloudkittyclient.v1.report`. -""" - -from cloudkittyclient.openstack.common.apiclient import fake_client -from cloudkittyclient.tests import base -from cloudkittyclient.v1 import client -from cloudkittyclient.v1 import report - - -fixtures = { - '/v1/report/total': { - 'GET': ( - {}, - '10.0' - ), - } -} - - -class ReportManagerTest(base.TestCase): - def setUp(self): - super(ReportManagerTest, self).setUp() - fake_http_client = fake_client.FakeHTTPClient(fixtures=fixtures) - api_client = client.Client(fake_http_client) - self.mgr = report.ReportManager(api_client) - - def test_get(self): - _report = self.mgr.get() - self.assertIn('Report', repr(_report)) - self.assertEqual(10.0, _report.total) diff --git a/cloudkittyclient/v1/__init__.py b/cloudkittyclient/v1/__init__.py index 8c395fc..e86e6fe 100644 --- a/cloudkittyclient/v1/__init__.py +++ b/cloudkittyclient/v1/__init__.py @@ -1,18 +1,16 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 Objectif Libre +# Copyright 2015 Objectif Libre +# 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 +# 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 +# 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. -# -# @author: François Magimel (linkid) +# 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 cloudkittyclient.v1.client import Client # noqa +from cloudkittyclient.v1.client import Client # noqa diff --git a/cloudkittyclient/v1/billing/hashmap/__init__.py b/cloudkittyclient/v1/billing/hashmap/__init__.py new file mode 100644 index 0000000..2aa0abc --- /dev/null +++ b/cloudkittyclient/v1/billing/hashmap/__init__.py @@ -0,0 +1,112 @@ +# Copyright 2015 Objectif Libre +# +# 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 cloudkittyclient.common import base + + +class Service(base.Resource): + key = 'service' + + def __repr__(self): + return "" % self._info + + @property + def fields(self): + return FieldManager(client=self.manager.client).findall( + service_id=self.service_id + ) + + @property + def mappings(self): + return MappingManager(client=self.manager.client).findall( + service_id=self.service_id + ) + + +class ServiceManager(base.CrudManager): + resource_class = Service + base_url = '/v1/billing/module_config/hashmap' + key = 'service' + collection_key = 'services' + + +class Field(base.Resource): + key = 'field' + + def __repr__(self): + return "" % self._info + + @property + def service(self): + return ServiceManager(client=self.manager.client).get( + service_id=self.service_id + ) + + +class FieldManager(base.CrudManager): + resource_class = Field + base_url = '/v1/billing/module_config/hashmap' + key = 'field' + collection_key = 'fields' + + +class Mapping(base.Resource): + key = 'mapping' + + def __repr__(self): + return "" % self._info + + @property + def service(self): + return ServiceManager(client=self.manager.client).get( + service_id=self.service_id + ) + + @property + def field(self): + if self.field_id is None: + return None + return FieldManager(client=self.manager.client).get( + service_id=self.service_id + ) + + +class MappingManager(base.CrudManager): + resource_class = Mapping + base_url = '/v1/billing/module_config/hashmap' + key = 'mapping' + collection_key = 'mappings' + + +class Group(base.Resource): + key = 'group' + + def __repr__(self): + return "" % self._info + + def delete(self, recursive=False): + return self.manager.delete(group_id=self.group_id, recursive=recursive) + + +class GroupManager(base.CrudManager): + resource_class = Group + base_url = '/v1/billing/module_config/hashmap' + key = 'group' + collection_key = 'groups' + + def delete(self, group_id, recursive=False): + url = self.build_url(group_id=group_id) + if recursive: + url += "?recursive=True" + return self._delete(url) diff --git a/cloudkittyclient/v1/billing/hashmap/client.py b/cloudkittyclient/v1/billing/hashmap/client.py new file mode 100644 index 0000000..4660e9c --- /dev/null +++ b/cloudkittyclient/v1/billing/hashmap/client.py @@ -0,0 +1,31 @@ +# Copyright 2015 Objectif Libre +# 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. + +from cloudkittyclient.v1.billing import hashmap + + +class Client(object): + """Client for the Hashmap v1 API. + + :param http_client: A http client. + """ + + def __init__(self, http_client): + """Initialize a new client for the Hashmap v1 API.""" + self.http_client = http_client + self.services = hashmap.ServiceManager(self.http_client) + self.fields = hashmap.FieldManager(self.http_client) + self.mappings = hashmap.MappingManager(self.http_client) + self.groups = hashmap.GroupManager(self.http_client) diff --git a/cloudkittyclient/v1/billing/hashmap/extension.py b/cloudkittyclient/v1/billing/hashmap/extension.py new file mode 100644 index 0000000..d2e64e8 --- /dev/null +++ b/cloudkittyclient/v1/billing/hashmap/extension.py @@ -0,0 +1,31 @@ +# Copyright 2015 Objectif Libre +# 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. + +from cloudkittyclient.v1.billing.hashmap import client +from cloudkittyclient.v1.billing.hashmap import shell + + +class Extension(object): + """Hashmap extension. + + """ + + @staticmethod + def get_client(http_client): + return client.Client(http_client) + + @staticmethod + def get_shell(): + return shell diff --git a/cloudkittyclient/v1/billing/hashmap/shell.py b/cloudkittyclient/v1/billing/hashmap/shell.py new file mode 100644 index 0000000..e01aacf --- /dev/null +++ b/cloudkittyclient/v1/billing/hashmap/shell.py @@ -0,0 +1,265 @@ +# Copyright 2015 Objectif Libre +# 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. + +from cloudkittyclient.common import utils +from cloudkittyclient import exc + + +@utils.arg('-n', '--name', + help='Service name', + required=True) +def do_hashmap_service_create(cc, args={}): + """Create a service.""" + arg_to_field_mapping = { + 'name': 'name' + } + fields = {} + for k, v in vars(args).items(): + if k in arg_to_field_mapping: + if v is not None: + fields[arg_to_field_mapping.get(k, k)] = v + out = cc.hashmap_services.create(**fields) + utils.print_dict(out.to_dict()) + + +def do_hashmap_service_list(cc, args={}): + """List services.""" + try: + services = cc.hashmap_services.list() + except exc.HTTPNotFound: + raise exc.CommandError('Services not found: %s' % args.counter_name) + else: + field_labels = ['Name', 'Service id'] + fields = ['name', 'service_id'] + utils.print_list(services, fields, field_labels, + sortby=0) + + +@utils.arg('-s', '--service-id', + help='Service uuid', + required=True) +def do_hashmap_service_delete(cc, args={}): + """Delete a service.""" + try: + cc.hashmap_services.delete(service_id=args.service_id) + except exc.HTTPNotFound: + raise exc.CommandError('Service not found: %s' % args.counter_name) + + +@utils.arg('-n', '--name', + help='Field name', + required=True) +@utils.arg('-s', '--service-id', + help='Service id', + required=True) +def do_hashmap_field_create(cc, args={}): + """Create a field.""" + arg_to_field_mapping = { + 'name': 'name', + 'service_id': 'service_id' + } + fields = {} + for k, v in vars(args).items(): + if k in arg_to_field_mapping: + if v is not None: + fields[arg_to_field_mapping.get(k, k)] = v + out = cc.hashmap_fields.create(**fields) + utils.print_dict(out.to_dict()) + + +@utils.arg('-s', '--service-id', + help='Service id', + required=True) +def do_hashmap_field_list(cc, args={}): + """Create a field.""" + try: + created_field = cc.hashmap_fields.list(service_id=args.service_id) + except exc.HTTPNotFound: + raise exc.CommandError('Fields not found: %s' % args.counter_name) + else: + field_labels = ['Name', 'Field id'] + fields = ['name', 'field_id'] + utils.print_list(created_field, fields, field_labels, + sortby=0) + + +@utils.arg('-f', '--field-id', + help='Field uuid', + required=True) +def do_hashmap_field_delete(cc, args={}): + """Delete a field.""" + try: + cc.hashmap_fields.delete(field_id=args.field_id) + except exc.HTTPNotFound: + raise exc.CommandError('Field not found: %s' % args.counter_name) + + +@utils.arg('-c', '--cost', + help='Mapping cost', + required=True) +@utils.arg('-v', '--value', + help='Mapping value', + required=False) +@utils.arg('-t', '--type', + help='Mapping type (flat, rate)', + required=False) +@utils.arg('-s', '--service-id', + help='Service id', + required=False) +@utils.arg('-f', '--field-id', + help='Field id', + required=False) +@utils.arg('-g', '--group-id', + help='Group id', + required=False) +def do_hashmap_mapping_create(cc, args={}): + """Create a ampping.""" + arg_to_field_mapping = { + 'cost': 'cost', + 'value': 'value', + 'type': 'type', + 'service_id': 'service_id', + 'field_id': 'field_id', + 'group_id': 'group_id', + } + fields = {} + for k, v in vars(args).items(): + if k in arg_to_field_mapping: + if v is not None: + fields[arg_to_field_mapping.get(k, k)] = v + out = cc.hashmap_mappings.create(**fields) + utils.print_dict(out) + + +@utils.arg('-m', '--mapping-id', + help='Mapping id', + required=True) +@utils.arg('-c', '--cost', + help='Mapping cost', + required=False) +@utils.arg('-v', '--value', + help='Mapping value', + required=False) +@utils.arg('-t', '--type', + help='Mapping type (flat, rate)', + required=False) +@utils.arg('-g', '--group-id', + help='Group id', + required=False) +def do_hashmap_mapping_update(cc, args={}): + """Update a mapping.""" + arg_to_field_mapping = { + 'mapping_id': 'mapping_id', + 'cost': 'cost', + 'value': 'value', + 'type': 'type', + 'group_id': 'group_id', + } + try: + mapping = cc.hashmap_mappings.get(mapping_id=args.mapping_id) + except exc.HTTPNotFound: + raise exc.CommandError('Modules not found: %s' % args.counter_name) + for k, v in vars(args).items(): + if k in arg_to_field_mapping: + if v is not None: + setattr(mapping, k, v) + cc.hashmap_mappings.update(**mapping.dirty_fields) + + +@utils.arg('-s', '--service-id', + help='Service id', + required=False) +@utils.arg('-f', '--field-id', + help='Field id', + required=False) +@utils.arg('-g', '--group-id', + help='Group id', + required=False) +def do_hashmap_mapping_list(cc, args={}): + """List mappings.""" + if args.service_id is None and args.field_id is None: + raise exc.CommandError("Provide either service-id or field-id") + try: + mappings = cc.hashmap_mappings.list(service_id=args.service_id, + field_id=args.field_id, + group_id=args.group_id) + except exc.HTTPNotFound: + raise exc.CommandError('Mapping not found: %s' % args.counter_name) + else: + field_labels = ['Mapping id', 'Value', 'Cost', + 'Type', 'Field id', + 'Service id', 'Group id'] + fields = ['mapping_id', 'value', 'cost', + 'type', 'field_id', + 'service_id', 'group_id'] + utils.print_list(mappings, fields, field_labels, + sortby=0) + + +@utils.arg('-m', '--mapping-id', + help='Mapping uuid', + required=True) +def do_hashmap_mapping_delete(cc, args={}): + """Delete a mapping.""" + try: + cc.hashmap_mappings.delete(mapping_id=args.mapping_id) + except exc.HTTPNotFound: + raise exc.CommandError('Mapping not found: %s' % args.mapping_id) + + +@utils.arg('-n', '--name', + help='Group name', + required=True) +def do_hashmap_group_create(cc, args={}): + """Create a group.""" + arg_to_field_mapping = { + 'name': 'name', + } + fields = {} + for k, v in vars(args).items(): + if k in arg_to_field_mapping: + if v is not None: + fields[arg_to_field_mapping.get(k, k)] = v + cc.hashmap_groups.create(**fields) + + +def do_hashmap_group_list(cc, args={}): + """List groups.""" + try: + groups = cc.hashmap_groups.list() + except exc.HTTPNotFound: + raise exc.CommandError('Mapping not found: %s' % args.counter_name) + else: + field_labels = ['Name', + 'Group id'] + fields = ['name', 'group_id'] + utils.print_list(groups, fields, field_labels, + sortby=0) + + +@utils.arg('-g', '--group-id', + help='Group uuid', + required=True) +@utils.arg('-r', '--recursive', + help="""Delete the group's mappings""", + required=False, + default=False) +def do_hashmap_group_delete(cc, args={}): + """Delete a group.""" + try: + cc.hashmap_groups.delete(group_id=args.group_id, + recursive=args.recursive) + except exc.HTTPNotFound: + raise exc.CommandError('Group not found: %s' % args.group_id) diff --git a/cloudkittyclient/v1/billing/modules.py b/cloudkittyclient/v1/billing/modules.py deleted file mode 100644 index c724434..0000000 --- a/cloudkittyclient/v1/billing/modules.py +++ /dev/null @@ -1,136 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 Objectif Libre -# -# 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. -# -# @author: François Magimel (linkid) - -""" -Modules resource and manager. -""" - -from cloudkittyclient.openstack.common.apiclient import base - - -class ExtensionSummary(base.Resource): - """A billing extension summary.""" - - def __repr__(self): - return "" % self.name - - @property - def id(self): - return self.name - - -class Module(base.Resource): - def __repr__(self): - name = self._info - if hasattr(self.manager, 'module_id'): - name = self.manager.module_id - return "" % name - - def _add_details(self, info): - pass - - @property - def id(self): - name = self._info - return name - - -class ModulesManager(base.CrudManager): - resource_class = Module - collection_key = 'billing/modules' - key = 'module' - - def _list(self, url, response_key, obj_class=None, json=None): - if json: - body = self.client.post(url, json=json).json() - else: - body = self.client.get(url).json() - - if obj_class is None: - obj_class = self.resource_class - - # hack - if type(body) == dict: - data = body[response_key] - else: - data = body - - # NOTE(ja): keystone returns values as list as {'values': [ ... ]} - # unlike other services which just return the list... - try: - data = data['values'] - except (KeyError, TypeError): - pass - - return [obj_class(self, res, loaded=True) for res in data if res] - - def list(self, base_url=None, **kwargs): - """Get module list in the billing pipeline. - /v1/billing/modules - """ - return super(ModulesManager, self).list(base_url='/v1', **kwargs) - - def _get(self, url, response_key=None, obj_class=None): - body = self.client.get(url).json() - - if obj_class is None: - obj_class = self.resource_class - - # hack - if response_key is None: - return obj_class(self, body, loaded=True) - else: - return obj_class(self, body[response_key], loaded=True) - - def get(self, **kwargs): - """Get a module. - /v1/billing/module/ - """ - kwargs = self._filter_kwargs(kwargs) - self.module_id = kwargs.get('module_id') - - return self._get( - url=self.build_url(base_url='/v1', **kwargs), - response_key=None, - obj_class=ExtensionSummary) - - def get_status(self, **kwargs): - """Get the status of a module. - /v1/billing/module//enabled - """ - kwargs = self._filter_kwargs(kwargs) - self.module_id = kwargs.get('module_id') - - return self._get( - url='%(base_url)s/enabled' % { - 'base_url': self.build_url(base_url='/v1', **kwargs), - }, - response_key=None) - - def update(self, **kwargs): - """Update the status of a module. - /v1/billing/modules//enabled - """ - kwargs = self._filter_kwargs(kwargs) - self.module_id = kwargs.get('module_id') - - return self._put( - url='%(base_url)s/enabled' % { # hack - 'base_url': self.build_url(base_url='/v1', **kwargs), - }, - json=kwargs.get('enabled'), - response_key=None) diff --git a/cloudkittyclient/v1/billing/quote.py b/cloudkittyclient/v1/billing/quote.py deleted file mode 100644 index 641f0a8..0000000 --- a/cloudkittyclient/v1/billing/quote.py +++ /dev/null @@ -1,64 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 Objectif Libre -# -# 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. -# -# @author: François Magimel (linkid) - -""" -Quote resource and manager. -""" - -from cloudkittyclient.openstack.common.apiclient import base - - -class Quote(base.Resource): - """A resource represents a particular instance of an object (tenant, user, - etc). This is pretty much just a bag for attributes. - - :param manager: Manager object - :param info: dictionary representing resource attributes - :param loaded: prevent lazy-loading if set to True - """ - - def _add_details(self, info): - try: - setattr(self, 'price', info) - except AttributeError: - # In this case we already defined the attribute on the class - pass - - -class QuoteManager(base.CrudManager): - """Managers interact with a particular type of API and provide CRUD - operations for them. - """ - - resource_class = Quote - collection_key = 'billing/quote' - key = 'quote' - - def _post(self, url, json, response_key=None, return_raw=False): - """Create an object.""" - body = self.client.post(url, json=json).json() - if return_raw: - return body - return self.resource_class(self, body) - - def post(self, **kwargs): - """Get the price corresponding to resources attributes.""" - kwargs = self._filter_kwargs(kwargs) - return self._post( - url=self.build_url(base_url='/v1', **kwargs), - json=kwargs.get('json'), - return_raw=kwargs.get('return_raw')) diff --git a/cloudkittyclient/v1/client.py b/cloudkittyclient/v1/client.py index 118ba6a..ff7a130 100644 --- a/cloudkittyclient/v1/client.py +++ b/cloudkittyclient/v1/client.py @@ -1,42 +1,66 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 Objectif Libre +# Copyright 2015 Objectif Libre +# 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 +# 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 +# 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. -# -# @author: François Magimel (linkid) +# 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. -""" -OpenStack Client interface. Handles the REST calls and responses. -""" +from stevedore import extension +from cloudkittyclient import client as ckclient from cloudkittyclient.openstack.common.apiclient import client -from cloudkittyclient.v1.billing import modules -from cloudkittyclient.v1.billing import quote +from cloudkittyclient.v1 import core from cloudkittyclient.v1 import report +SUBMODULES_NAMESPACE = 'cloudkitty.client.modules' -class Client(client.BaseClient): - """Client for the Cloudkitty v1 API.""" - def __init__(self, http_client, extensions=None): +class Client(object): + """Client for the Cloudkitty v1 API. + + :param string endpoint: A user-supplied endpoint URL for the cloudkitty + service. + :param function token: Provides token for authentication. + :param integer timeout: Allows customization of the timeout for client + http requests. (optional) + """ + + def __init__(self, *args, **kwargs): """Initialize a new client for the Cloudkitty v1 API.""" - super(Client, self).__init__(http_client, extensions) + self.auth_plugin = (kwargs.get('auth_plugin') + or ckclient.get_auth_plugin(*args, **kwargs)) + self.client = client.HTTPClient( + auth_plugin=self.auth_plugin, + region_name=kwargs.get('region_name'), + endpoint_type=kwargs.get('endpoint_type'), + original_ip=kwargs.get('original_ip'), + verify=kwargs.get('verify'), + cert=kwargs.get('cert'), + timeout=kwargs.get('timeout'), + timings=kwargs.get('timings'), + keyring_saver=kwargs.get('keyring_saver'), + debug=kwargs.get('debug'), + user_agent=kwargs.get('user_agent'), + http=kwargs.get('http') + ) - self.billing = Billing(self) - self.report = report.ReportManager(self) + self.http_client = client.BaseClient(self.client) + self.modules = core.CloudkittyModuleManager(self.http_client) + self.reports = report.ReportManager(self.http_client) + self._expose_submodules() - -class Billing(object): - def __init__(self, http_client): - self.modules = modules.ModulesManager(http_client) - self.quote = quote.QuoteManager(http_client) + def _expose_submodules(self): + extensions = extension.ExtensionManager( + SUBMODULES_NAMESPACE, + ) + for ext in extensions: + client = ext.plugin.get_client(self.http_client) + setattr(self, ext.name, client) diff --git a/cloudkittyclient/v1/collector/__init__.py b/cloudkittyclient/v1/collector/__init__.py new file mode 100644 index 0000000..eca9704 --- /dev/null +++ b/cloudkittyclient/v1/collector/__init__.py @@ -0,0 +1,30 @@ +# Copyright 2015 Objectif Libre +# +# 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 cloudkittyclient.common import base + + +class Collector(base.Resource): + + key = 'collector' + + def __repr__(self): + return "" % self._info + + +class CollectorManager(base.Manager): + resource_class = Collector + base_url = "/v1/billing" + key = "collector" + collection_key = "collectors" diff --git a/cloudkittyclient/v1/core.py b/cloudkittyclient/v1/core.py new file mode 100644 index 0000000..8aa196d --- /dev/null +++ b/cloudkittyclient/v1/core.py @@ -0,0 +1,53 @@ +# Copyright 2015 Objectif Libre +# +# 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 cloudkittyclient.common import base + + +class CloudkittyModule(base.Resource): + + key = 'module' + + def __repr__(self): + return "" % self._info + + def enable(self): + self.enabled = True + self.update() + + def disable(self): + self.enabled = False + self.update() + + +class CloudkittyModuleManager(base.CrudManager): + resource_class = CloudkittyModule + base_url = "/v1/billing" + key = 'module' + collection_key = "modules" + + +class Collector(base.Resource): + + key = 'collector' + + def __repr__(self): + return "" % self._info + + +class CollectorManager(base.Manager): + resource_class = Collector + base_url = "/v1/billing" + key = "collector" + collection_key = "collectors" diff --git a/cloudkittyclient/v1/report.py b/cloudkittyclient/v1/report.py deleted file mode 100644 index 014f171..0000000 --- a/cloudkittyclient/v1/report.py +++ /dev/null @@ -1,59 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 Objectif Libre -# -# 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. -# -# @author: François Magimel (linkid) - -""" -Report resource and manager. -""" - -from cloudkittyclient.openstack.common.apiclient import base - - -class Report(base.Resource): - """A resource represents a particular instance of an object (tenant, user, - etc). This is pretty much just a bag for attributes. - - :param manager: Manager object - :param info: dictionary representing resource attributes - :param loaded: prevent lazy-loading if set to True - """ - - def _add_details(self, info): - try: - setattr(self, 'total', info) - except AttributeError: - # In this case we already defined the attribute on the class - pass - - -class ReportManager(base.CrudManager): - """Managers interact with a particular type of API and provide CRUD - operations for them. - """ - - resource_class = Report - collection_key = 'report/total' - key = 'report/total' - - def _get(self, url, response_key=None): - body = self.client.get(url).json() - return self.resource_class(self, body, loaded=True) - - def get(self, **kwargs): - """Get the amount to pay for the current month. - /v1/report/total - """ - return super(ReportManager, self).get(base_url='/v1', **kwargs) diff --git a/cloudkittyclient/v1/report/__init__.py b/cloudkittyclient/v1/report/__init__.py new file mode 100644 index 0000000..a305a4f --- /dev/null +++ b/cloudkittyclient/v1/report/__init__.py @@ -0,0 +1,40 @@ +# Copyright 2015 Objectif Libre +# +# 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 cloudkittyclient.common import base + + +class ReportResult(base.Resource): + + key = 'report' + + def __repr__(self): + return "" % self._info + + +class ReportManager(base.Manager): + + base_url = "/v1/report" + + def list_tenants(self): + return self.client.get(self.base_url + "/tenants").json() + + def get_total(self, tenant_id, begin=None, end=None): + url = self.base_url + "/total?tenant_id=%s" % tenant_id + filter = [url] + if begin: + filter.append("begin=%s" % begin.isoformat()) + if end: + filter.append("end=%s" % end.isoformat()) + return self.client.get("&".join(filter)).json() diff --git a/cloudkittyclient/v1/report/shell.py b/cloudkittyclient/v1/report/shell.py new file mode 100644 index 0000000..bdcd2ea --- /dev/null +++ b/cloudkittyclient/v1/report/shell.py @@ -0,0 +1,42 @@ +# Copyright 2015 Objectif Libre +# +# 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. + +from cloudkittyclient.common import utils + + +def do_report_tenant_list(cc, args): + tenants = cc.reports.list_tenants() + out_table = utils.prettytable.PrettyTable() + out_table.add_column("Tenant UUID", tenants) + print(out_table) + + +@utils.arg('-t', '--tenant-id', + help='Tenant id', + required=False, dest='total_tenant_id') +@utils.arg('-b', '--begin', + help='Begin timestamp', + required=False) +@utils.arg('-e', '--end', + help='End timestamp', + required=False) +def do_total_get(cc, args): + begin = utils.ts2dt(args.begin) if args.begin else None + end = utils.ts2dt(args.end) if args.end else None + total = cc.reports.get_total(args.total_tenant_id, + begin=begin, + end=end) + utils.print_dict({'Total': total or 0.0}) diff --git a/cloudkittyclient/v1/shell.py b/cloudkittyclient/v1/shell.py new file mode 100644 index 0000000..4f45872 --- /dev/null +++ b/cloudkittyclient/v1/shell.py @@ -0,0 +1,66 @@ +# Copyright 2015 Objectif Libre +# 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. + +from cloudkittyclient.common import utils +from cloudkittyclient import exc + + +def do_module_list(cc, args): + '''List the samples for this meters.''' + try: + modules = cc.modules.list() + except exc.HTTPNotFound: + raise exc.CommandError('Modules not found: %s' % args.counter_name) + else: + field_labels = ['Module', 'Enabled'] + fields = ['module_id', 'enabled'] + utils.print_list(modules, fields, field_labels, + sortby=0) + + +@utils.arg('-n', '--name', + help='Module name', + required=True) +def do_module_enable(cc, args): + '''Enable a module.''' + try: + module = cc.modules.get(module_id=args.name) + module.enable() + except exc.HTTPNotFound: + raise exc.CommandError('Modules not found: %s' % args.counter_name) + else: + field_labels = ['Module', 'Enabled'] + fields = ['module_id', 'enabled'] + modules = [cc.modules.get(module_id=args.name)] + utils.print_list(modules, fields, field_labels, + sortby=0) + + +@utils.arg('-n', '--name', + help='Module name', + required=True) +def do_module_disable(cc, args): + '''Disable a module.''' + try: + module = cc.modules.get(module_id=args.name) + module.disable() + except exc.HTTPNotFound: + raise exc.CommandError('Modules not found: %s' % args.counter_name) + else: + field_labels = ['Module', 'Enabled'] + fields = ['module_id', 'enabled'] + modules = [cc.modules.get(module_id=args.name)] + utils.print_list(modules, fields, field_labels, + sortby=0) diff --git a/doc/makefile b/doc/makefile deleted file mode 100644 index 27d9102..0000000 --- a/doc/makefile +++ /dev/null @@ -1,177 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = build - -# User-friendly check for sphinx-build -ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) -$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) -endif - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext - -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " xml to make Docutils-native XML files" - @echo " pseudoxml to make pseudoxml-XML files for display purposes" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/python-cloudkittyclient.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/python-cloudkittyclient.qhc" - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/python-cloudkittyclient" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/python-cloudkittyclient" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -latexpdfja: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through platex and dvipdfmx..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." - -xml: - $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml - @echo - @echo "Build finished. The XML files are in $(BUILDDIR)/xml." - -pseudoxml: - $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml - @echo - @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/doc/source/conf.py b/doc/source/conf.py old mode 100644 new mode 100755 index 51ff5f4..932ad6a --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -3,7 +3,7 @@ # 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 +# 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, @@ -15,67 +15,30 @@ import os import sys - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +sys.path.insert(0, os.path.abspath('../..')) +# -- General configuration ---------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ 'sphinx.ext.autodoc', - 'oslosphinx', + #'sphinx.ext.intersphinx', + 'oslosphinx' ] -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +# autodoc generation is a bit aggressive and a nuisance when doing heavy +# text edit cycles. +# execute "export SPHINX_DEBUG=1" in your terminal to disable # The suffix of source filenames. source_suffix = '.rst' -# The encoding of source files. -#source_encoding = 'utf-8-sig' - # The master toctree document. master_doc = 'index' # General information about the project. project = u'python-cloudkittyclient' -copyright = u'2014, Objectif Libre' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = '0.1' -# The full version, including alpha/beta/rc tags. -release = '0.1' - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -#language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -exclude_patterns = [] - -# The reST default role (used for this markup: `text`) to use for all -# documents. -#default_role = None +copyright = u'2013, OpenStack Foundation' # If true, '()' will be appended to :func: etc. cross-reference text. add_function_parentheses = True @@ -84,196 +47,29 @@ add_function_parentheses = True # unit titles (such as .. function::). add_module_names = True -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False - # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# -- Options for HTML output -------------------------------------------------- -# If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False - - -# -- Options for HTML output ---------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -#html_theme = 'default' -html_theme = 'nature' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -#html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -#html_logo = None - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -#html_extra_path = [] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_domain_indices = True - -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# The theme to use for HTML and HTML Help pages. Major themes that come with +# Sphinx are currently 'default' and 'sphinxdoc'. +# html_theme_path = ["."] +# html_theme = '_theme' +# html_static_path = ['static'] # Output file base name for HTML help builder. htmlhelp_basename = '%sdoc' % project - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', -} - # Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). +# (source start file, target name, title, author, documentclass +# [howto/manual]). latex_documents = [ - ( - 'index', - '%s.tex' % project, - u'%s Documentation' % project, - u'Objectif Libre', - 'manual' - ), + ('index', + '%s.tex' % project, + u'%s Documentation' % project, + u'OpenStack Foundation', 'manual'), ] -# The name of an image file (relative to this directory) to place at the top of -# the title page. -#latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -#latex_use_parts = False - -# If true, show page references after internal links. -#latex_show_pagerefs = False - -# If true, show URL addresses after external links. -#latex_show_urls = False - -# Documents to append as an appendix to all manuals. -#latex_appendices = [] - -# If false, no module index is generated. -#latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - ( - 'index', - project, - u'%s Documentation' % project, - [u'Objectif Libre'], - 1 - ), -] - -# If true, show URL addresses after external links. -#man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ( - 'index', - project, - u'%s Documentation' % project, - u'Objectif Libre', - project, - 'Python client library for CloudKitty API', - 'Miscellaneous' - ), -] - -# Documents to append as an appendix to all manuals. -#texinfo_appendices = [] - -# If false, no module index is generated. -#texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +# Example configuration for intersphinx: refer to the Python standard library. +#intersphinx_mapping = {'http://docs.python.org/': None} diff --git a/doc/source/contributing.rst b/doc/source/contributing.rst new file mode 100644 index 0000000..1728a61 --- /dev/null +++ b/doc/source/contributing.rst @@ -0,0 +1,4 @@ +============ +Contributing +============ +.. include:: ../../CONTRIBUTING.rst diff --git a/doc/source/index.rst b/doc/source/index.rst index 762e3dd..0018bb3 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -1,19 +1,20 @@ .. python-cloudkittyclient documentation master file, created by - sphinx-quickstart on Thu Jul 3 17:15:04 2014. + sphinx-quickstart on Tue Jul 9 22:26:36 2013. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Welcome to CloudKitty Client's documentation! -============================================= +Welcome to python-cloudkittyclient's documentation! +======================================================== -Introduction -============ +Contents: -CloudKitty is a PricingAsAService project aimed at translating Ceilometer -metrics to prices. - -python-cloudkitty is the Python client library for CloudKitty API. +.. toctree:: + :maxdepth: 2 + readme + installation + usage + contributing Indices and tables ================== @@ -21,3 +22,4 @@ Indices and tables * :ref:`genindex` * :ref:`modindex` * :ref:`search` + diff --git a/doc/source/installation.rst b/doc/source/installation.rst new file mode 100644 index 0000000..6e2ad99 --- /dev/null +++ b/doc/source/installation.rst @@ -0,0 +1,12 @@ +============ +Installation +============ + +At the command line:: + + $ pip install python-cloudkittyclient + +Or, if you have virtualenvwrapper installed:: + + $ mkvirtualenv python-cloudkittyclient + $ pip install python-cloudkittyclient diff --git a/doc/source/readme.rst b/doc/source/readme.rst new file mode 100644 index 0000000..a6210d3 --- /dev/null +++ b/doc/source/readme.rst @@ -0,0 +1 @@ +.. include:: ../../README.rst diff --git a/doc/source/usage.rst b/doc/source/usage.rst new file mode 100644 index 0000000..a8f2ca9 --- /dev/null +++ b/doc/source/usage.rst @@ -0,0 +1,7 @@ +======== +Usage +======== + +To use python-cloudkittyclient in a project:: + + import cloudkittyclient diff --git a/openstack-common.conf b/openstack-common.conf index 56e26fd..487f3cb 100644 --- a/openstack-common.conf +++ b/openstack-common.conf @@ -1,8 +1,8 @@ [DEFAULT] -# The list of modules to copy from openstack-common +# The list of modules to copy from oslo-incubator.git module=apiclient -module=importutils +module=cliutils # The base module to hold the copy of openstack.common base=cloudkittyclient diff --git a/requirements.txt b/requirements.txt index 844b360..356efc5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,8 @@ -argparse -oslo.i18n +# The order of packages is significant, because pip processes them in the order +# of appearance. Changing the order has an impact on the overall integration +# process, which may cause wedges in the gate later. + pbr>=0.6,!=0.7,<1.0 +Babel>=1.3 python-keystoneclient -six>=1.7.0 +stevedore diff --git a/setup.cfg b/setup.cfg index 4be5572..63b1e29 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,13 +1,12 @@ [metadata] name = python-cloudkittyclient -summary = Python client library for CloudKitty API +summary = Cloudkittyclient is the api client for the cloudkitty rating project. description-file = README.rst -author = Objectif Libre -author-email = francois.magimel@objectif-libre.com -home-page = http://objectif-libre.com +author = OpenStack +author-email = openstack-dev@lists.openstack.org +home-page = http://www.openstack.org/ classifier = - Environment :: Console Environment :: OpenStack Intended Audience :: Information Technology Intended Audience :: System Administrators @@ -15,20 +14,40 @@ classifier = Operating System :: POSIX :: Linux Programming Language :: Python Programming Language :: Python :: 2 - Programming Language :: Python :: 2.6 Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 Programming Language :: Python :: 3.3 + Programming Language :: Python :: 3.4 [files] packages = cloudkittyclient -[global] -setup-hooks = - pbr.hooks.setup_hook +[entry_points] +console_scripts = + cloudkitty = cloudkittyclient.shell:main + +cloudkitty.client.modules = + hashmap = cloudkittyclient.v1.billing.hashmap.extension:Extension [build_sphinx] -all_files = 1 -build-dir = doc/build source-dir = doc/source +build-dir = doc/build +all_files = 1 + +[upload_sphinx] +upload-dir = doc/build/html + +[compile_catalog] +directory = cloudkittyclient/locale +domain = python-cloudkittyclient + +[update_catalog] +domain = python-cloudkittyclient +output_dir = cloudkittyclient/locale +input_file = cloudkittyclient/locale/python-cloudkittyclient.pot + +[extract_messages] +keywords = _ gettext ngettext l_ lazy_gettext +mapping_file = babel.cfg +output_file = cloudkittyclient/locale/python-cloudkittyclient.pot diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index 5ac948d..70c2b3f --- a/setup.py +++ b/setup.py @@ -1,29 +1,22 @@ #!/usr/bin/env python -# Copyright 2014 Objectif Libre +# Copyright (c) 2013 Hewlett-Packard Development Company, L.P. # -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at +# 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 +# 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. +# 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. # THIS FILE IS MANAGED BY THE GLOBAL REQUIREMENTS REPO - DO NOT EDIT import setuptools -# In python < 2.7.4, a lazy loading of package `pbr` will break -# setuptools if some other modules registered functions in `atexit`. -# solution from: http://bugs.python.org/issue15881#msg170215 -try: - import multiprocessing # noqa -except ImportError: - pass - setuptools.setup( setup_requires=['pbr'], pbr=True) diff --git a/test-requirements.txt b/test-requirements.txt index 761b394..d494076 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,12 +1,15 @@ -# Hacking already pins down pep8, pyflakes and flake8 -hacking>=0.9.1,<0.10 +# The order of packages is significant, because pip processes them in the order +# of appearance. Changing the order has an impact on the overall integration +# process, which may cause wedges in the gate later. + +hacking>=0.9.2,<0.10 coverage>=3.6 discover -doc8 -fixtures>=0.3.14 -mock>=1.0 +python-subunit +sphinx>=1.1.2 oslosphinx -oslotest -sphinx>=1.1.2,!=1.2.0,<1.3 +oslotest>=1.1.0.0a1 testrepository>=0.0.18 +testscenarios>=0.4 +testtools>=0.9.34 diff --git a/tox.ini b/tox.ini index 82afe19..6dfd84e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,38 +1,34 @@ [tox] -envlist = py26,py27,py33,py34,pep8 minversion = 1.6 +envlist = py33,py34,py27,pypy,pep8 skipsdist = True [testenv] usedevelop = True install_command = pip install -U {opts} {packages} -setenv = VIRTUAL_ENV={envdir} +setenv = + VIRTUAL_ENV={envdir} deps = -r{toxinidir}/requirements.txt -r{toxinidir}/test-requirements.txt -commands = - python setup.py testr --testr-args='{posargs}' - -[tox:jenkins] -downloadcache = ~/cache/pip +commands = python setup.py testr --slowest --testr-args='{posargs}' [testenv:pep8] -commands = flake8 {posargs} +commands = flake8 + +[testenv:venv] +commands = {posargs} [testenv:cover] commands = python setup.py testr --coverage --testr-args='{posargs}' [testenv:docs] -commands = - doc8 -e .rst README.rst doc/source - python setup.py build_sphinx - -[testenv:venv] -commands = {posargs} +commands = python setup.py build_sphinx [flake8] -# H405 multi line docstring summary not separated with an empty line -# H904 Wrap long lines in parentheses instead of a backslash -# H102 Apache 2.0 license header not found -ignore = H405,H904,H102 +# H803 skipped on purpose per list discussion. +# E123, E125 skipped as they are invalid PEP-8. + show-source = True -exclude = .venv,.git,.tox,dist,doc,*openstack/common*,*lib/python*,*egg,build,tools,./cloudkittyclient/common/exceptions.py +ignore = E123,E125,H803 +builtins = _ +exclude=.venv,.git,.tox,dist,doc,*openstack/common*,*lib/python*,*egg,build