From aaf7c93ae6bc70a7d721764fc10cf2d53723f721 Mon Sep 17 00:00:00 2001 From: Dina Belova Date: Sun, 22 Sep 2013 22:22:35 +0400 Subject: [PATCH] Initial Climate client implementation Partially implements: blueprint python-client Change-Id: I7ac8aedf5b7504c63d12119e1ba0842e3ce01e63 --- MANIFEST.in | 2 + README.rst | 5 + climateclient/__init__.py | 0 climateclient/base.py | 130 +++++ climateclient/client.py | 36 ++ climateclient/command.py | 263 ++++++++++ climateclient/exception.py | 72 +++ climateclient/openstack/__init__.py | 0 climateclient/openstack/common/__init__.py | 0 .../openstack/common/gettextutils.py | 412 ++++++++++++++++ climateclient/openstack/common/importutils.py | 66 +++ climateclient/openstack/common/strutils.py | 222 +++++++++ climateclient/shell.py | 463 ++++++++++++++++++ climateclient/utils.py | 137 ++++++ climateclient/v1/__init__.py | 0 climateclient/v1/client.py | 38 ++ climateclient/v1/leases.py | 77 +++ climateclient/v1/shell_commands/__init__.py | 0 climateclient/v1/shell_commands/leases.py | 81 +++ climateclient/version.py | 19 + openstack-common.conf | 9 + requirements.txt | 6 + setup.cfg | 21 + setup.py | 22 + test-requirements.txt | 4 + tox.ini | 22 + 26 files changed, 2107 insertions(+) create mode 100644 MANIFEST.in create mode 100644 README.rst create mode 100644 climateclient/__init__.py create mode 100644 climateclient/base.py create mode 100644 climateclient/client.py create mode 100644 climateclient/command.py create mode 100644 climateclient/exception.py create mode 100644 climateclient/openstack/__init__.py create mode 100644 climateclient/openstack/common/__init__.py create mode 100644 climateclient/openstack/common/gettextutils.py create mode 100644 climateclient/openstack/common/importutils.py create mode 100644 climateclient/openstack/common/strutils.py create mode 100644 climateclient/shell.py create mode 100644 climateclient/utils.py create mode 100644 climateclient/v1/__init__.py create mode 100644 climateclient/v1/client.py create mode 100644 climateclient/v1/leases.py create mode 100644 climateclient/v1/shell_commands/__init__.py create mode 100644 climateclient/v1/shell_commands/leases.py create mode 100644 climateclient/version.py create mode 100644 openstack-common.conf create mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 test-requirements.txt create mode 100644 tox.ini diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..fe971ef --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include tox.ini +include README.rst diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..6adf513 --- /dev/null +++ b/README.rst @@ -0,0 +1,5 @@ +============== +Climate client +============== + +**Climate client** provides possibility to use *Climate* API(s). diff --git a/climateclient/__init__.py b/climateclient/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/climateclient/base.py b/climateclient/base.py new file mode 100644 index 0000000..0bb0c14 --- /dev/null +++ b/climateclient/base.py @@ -0,0 +1,130 @@ +# Copyright (c) 2013 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import json + +import requests + +from climateclient import exception + + +class BaseClientManager(object): + """Base manager to interact with a particular type of API. + + There are environments, nodes and jobs types of API requests. + Manager provides CRUD operations for them. + """ + def __init__(self, climate_url, auth_token): + self.climate_url = climate_url + self.auth_token = auth_token + + USER_AGENT = 'python-climateclient' + + def _get(self, url, response_key): + """Sends get request to Climate. + + :param url: URL to the wanted Climate resource. + :type url: str + + :param response_key: Type of resource (environment, node, job). + :type response_key: str + + :returns: Resource entity (entities) that was (were) asked. + :rtype: dict | list + """ + resp, body = self.request(url, 'GET') + return body[response_key] + + def _create(self, url, body, response_key): + """Sends create request to Climate. + + :param url: URL to the wanted Climate resource. + :type url: str + + :param body: Values resource to be created from. + :type body: dict + + :param response_key: Type of resource (environment, node, job). + :type response_key: str + + :returns: Resource entity that was created. + :rtype: dict + """ + resp, body = self.request(url, 'POST', body=body) + return body[response_key] + + def _delete(self, url): + """Sends delete request to Climate. + + :param url: URL to the wanted Climate resource. + :type url: str + """ + resp, body = self.request(url, 'DELETE') + + def _update(self, url, body, response_key=None): + """Sends update request to Climate. + + :param url: URL to the wanted Climate resource. + :type url: str + + :param body: Values resource to be updated from. + :type body: dict + + :param response_key: Type of resource (environment, node, job). + :type response_key: str + + :returns: Resource entity that was updated. + :rtype: dict + """ + resp, body = self.request(url, 'PUT', body=body) + return body[response_key] + + def request(self, url, method, **kwargs): + """Base request method. + + Adds specific headers and URL prefix to the request. + + :param url: Resource URL. + :type url: str + + :param method: Method to be called (GET, POST, PUT, DELETE). + :type method: str + + :returns: Response and body. + :rtype: tuple + """ + kwargs.setdefault('headers', kwargs.get('headers', {})) + kwargs['headers']['User-Agent'] = self.USER_AGENT + kwargs['headers']['Accept'] = 'application/json' + kwargs['headers']['x-auth-token'] = self.auth_token + + if 'body' in kwargs: + kwargs['headers']['Content-Type'] = 'application/json' + kwargs['data'] = json.dumps(kwargs['body']) + del kwargs['body'] + + resp = requests.request(method, self.climate_url + url, **kwargs) + + try: + body = json.loads(resp.text) + except ValueError: + body = None + + if resp.status_code >= 400: + raise exception.ClimateClientException(resp.body, + code=resp.status_code) + + return resp, body diff --git a/climateclient/client.py b/climateclient/client.py new file mode 100644 index 0000000..42f51f0 --- /dev/null +++ b/climateclient/client.py @@ -0,0 +1,36 @@ +# Copyright (c) 2013 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from climateclient import exception +from climateclient.openstack.common.gettextutils import _ # noqa +from climateclient.openstack.common import importutils + + +def Client(version=1, *args, **kwargs): + version_map = { + '1': 'climateclient.v1.client.Client', + '1a0': 'climateclient.v1.client.Client', + } + try: + client_path = version_map[str(version)] + except (KeyError, ValueError): + msg = _("Invalid client version '%(version)s'. " + "Must be one of: %(available_version)s") % ({ + 'version': version, + 'available_version': ', '.join(version_map.keys()) + }) + raise exception.UnsupportedVersion(msg) + + return importutils.import_object(client_path, *args, **kwargs) diff --git a/climateclient/command.py b/climateclient/command.py new file mode 100644 index 0000000..b430495 --- /dev/null +++ b/climateclient/command.py @@ -0,0 +1,263 @@ +# Copyright (c) 2013 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import print_function +import logging +import six + +from cliff import command +from cliff.formatters import table +from cliff import lister +from cliff import show + +from climateclient import utils + + +class OpenStackCommand(command.Command): + """Base class for OpenStack commands.""" + + api = None + + def run(self, parsed_args): + if not self.api: + return + else: + return super(OpenStackCommand, self).run(parsed_args) + + def get_data(self, parsed_args): + pass + + def take_action(self, parsed_args): + return self.get_data(parsed_args) + + +class TableFormatter(table.TableFormatter): + """This class is used to keep consistency with prettytable 0.6.""" + + def emit_list(self, column_names, data, stdout, parsed_args): + if column_names: + super(TableFormatter, self).emit_list(column_names, data, stdout, + parsed_args) + else: + stdout.write('\n') + + +class ClimateCommand(OpenStackCommand): + + """Base Climate CLI command.""" + api = 'reservation' + log = logging.getLogger(__name__ + '.ClimateCommand') + values_specs = [] + json_indent = None + resource = None + + def __init__(self, app, app_args): + super(ClimateCommand, self).__init__(app, app_args) + + # NOTE(dbelova): This is no longer supported in cliff version 1.5.2 + # the same moment occurred in Neutron: + # see https://bugs.launchpad.net/python-neutronclient/+bug/1265926 + + # if hasattr(self, 'formatters'): + # self.formatters['table'] = TableFormatter() + + def get_client(self): + return self.app.client + + def get_parser(self, prog_name): + parser = super(ClimateCommand, self).get_parser(prog_name) + return parser + + def format_output_data(self, data): + for k, v in six.iteritems(data): + if isinstance(v, list): + value = '\n'.join(utils.dumps( + i, indent=self.json_indent) if isinstance(i, dict) + else str(i) for i in v) + data[k] = value + elif isinstance(v, dict): + value = utils.dumps(v, indent=self.json_indent) + data[k] = value + elif v is None: + data[k] = '' + + def add_known_arguments(self, parser): + pass + + def args2body(self, parsed_args): + return {} + + +class CreateCommand(ClimateCommand, show.ShowOne): + """Create resource with passed args.""" + + api = 'reservation' + resource = None + log = None + + def get_data(self, parsed_args): + self.log.debug('get_data(%s)' % parsed_args) + climate_client = self.get_client() + body = self.args2body(parsed_args) + resource_manager = getattr(climate_client, self.resource) + data = resource_manager.create(**body) + self.format_output_data(data) + + if data: + print(self.app.stdout, 'Created a new %s:' % self.resource) + else: + data = {'': ''} + return zip(*sorted(six.iteritems(data))) + + +class UpdateCommand(ClimateCommand): + """Update resource's information.""" + + api = 'reservation' + resource = None + log = None + + def get_parser(self, prog_name): + parser = super(UpdateCommand, self).get_parser(prog_name) + parser.add_argument( + 'id', metavar=self.resource.upper(), + help='ID or name of %s to update' % self.resource + ) + self.add_known_arguments(parser) + return parser + + def run(self, parsed_args): + self.log.debug('run(%s)' % parsed_args) + climate_client = self.get_client() + body = self.args2body(parsed_args) + res_id = utils.find_resource_id_by_name_or_id(climate_client, + self.resource, + parsed_args.id) + resource_manager = getattr(climate_client, self.resource) + resource_manager.update(res_id, **body) + print(self.app.stdout, 'Updated %s: %s' % (self.resource, + parsed_args.id)) + return + + +class DeleteCommand(ClimateCommand): + """Delete a given resource.""" + + api = 'reservation' + resource = None + log = None + allow_names = True + + def get_parser(self, prog_name): + parser = super(DeleteCommand, self).get_parser(prog_name) + if self.allow_names: + help_str = 'ID or name of %s to delete' + else: + help_str = 'ID of %s to delete' + parser.add_argument( + 'id', metavar=self.resource.upper(), + help=help_str % self.resource) + return parser + + def run(self, parsed_args): + self.log.debug('run(%s)' % parsed_args) + climate_client = self.get_client() + resource_manager = getattr(climate_client, self.resource) + if self.allow_names: + res_id = utils.find_resource_id_by_name_or_id(climate_client, + self.resource, + parsed_args.id) + else: + res_id = parsed_args.id + resource_manager.delete(res_id) + print(self.app.stdout, 'Deleted %s: %s' % (self.resource, + parsed_args.id)) + return + + +class ListCommand(ClimateCommand, lister.Lister): + """List resources that belong to a given tenant.""" + + api = 'reservation' + resource = None + log = None + _formatters = {} + list_columns = [] + unknown_parts_flag = True + + def get_parser(self, prog_name): + parser = super(ListCommand, self).get_parser(prog_name) + return parser + + def retrieve_list(self, parsed_args): + """Retrieve a list of resources from Climate server""" + climate_client = self.get_client() + resource_manager = getattr(climate_client, self.resource) + data = resource_manager.list() + return data + + def setup_columns(self, info, parsed_args): + columns = len(info) > 0 and sorted(info[0].keys()) or [] + if not columns: + parsed_args.columns = [] + elif parsed_args.columns: + columns = [col for col in parsed_args.columns if col in columns] + elif self.list_columns: + columns = [col for col in self.list_columns if col in columns] + return ( + columns, + (utils.get_item_properties(s, columns, formatters=self._formatters) + for s in info) + ) + + def get_data(self, parsed_args): + self.log.debug('get_data(%s)' % parsed_args) + data = self.retrieve_list(parsed_args) + return self.setup_columns(data, parsed_args) + + +class ShowCommand(ClimateCommand, show.ShowOne): + """Show information of a given resource.""" + + api = 'reservation' + resource = None + log = None + allow_names = True + + def get_parser(self, prog_name): + parser = super(ShowCommand, self).get_parser(prog_name) + if self.allow_names: + help_str = 'ID or name of %s to look up' + else: + help_str = 'ID of %s to look up' + parser.add_argument('id', metavar=self.resource.upper(), + help=help_str % self.resource) + return parser + + def get_data(self, parsed_args): + self.log.debug('get_data(%s)' % parsed_args) + climate_client = self.get_client() + + if self.allow_names: + res_id = utils.find_resource_id_by_name_or_id(climate_client, + self.resource, + parsed_args.id) + else: + res_id = parsed_args.id + + resource_manager = getattr(climate_client, self.resource) + data = resource_manager.get(res_id) + self.format_output_data(data) + return zip(*sorted(six.iteritems(data))) diff --git a/climateclient/exception.py b/climateclient/exception.py new file mode 100644 index 0000000..2b9489f --- /dev/null +++ b/climateclient/exception.py @@ -0,0 +1,72 @@ +# Copyright (c) 2013 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from climateclient.openstack.common.gettextutils import _ # noqa + + +class ClimateClientException(Exception): + """Base exception class.""" + message = _("An unknown exception occurred %s.") + code = 500 + + def __init__(self, message=None, **kwargs): + self.kwargs = kwargs + + if 'code' not in self.kwargs: + try: + self.kwargs['code'] = self.code + except AttributeError: + pass + + if not message: + message = self.message % kwargs + + super(ClimateClientException, self).__init__(message) + + +class CommandError(ClimateClientException): + """Occurs if not all authentication vital options are set.""" + message = _("You have to provide all options like user name or tenant " + "id to make authentication possible.") + code = 401 + + +class NotAuthorized(ClimateClientException): + """HTTP 401 - Not authorized. + + User have no enough rights to perform action. + """ + code = 401 + message = _("Not authorized request.") + + +class NoClimateEndpoint(ClimateClientException): + """Occurs if no endpoint for Climate set in the Keystone.""" + message = _("No publicURL endpoint for Climate found. Set endpoint " + "for Climate in the Keystone.") + code = 404 + + +class NoUniqueMatch(ClimateClientException): + """Occurs if there are more than one appropriate resources.""" + message = _("There is no unique requested resource.") + code = 409 + + +class UnsupportedVersion(ClimateClientException): + """Occurs if unsupported client version was requested.""" + message = _("Unsupported client version requested.") + code = 406 diff --git a/climateclient/openstack/__init__.py b/climateclient/openstack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/climateclient/openstack/common/__init__.py b/climateclient/openstack/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/climateclient/openstack/common/gettextutils.py b/climateclient/openstack/common/gettextutils.py new file mode 100644 index 0000000..624a2c6 --- /dev/null +++ b/climateclient/openstack/common/gettextutils.py @@ -0,0 +1,412 @@ +# 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 climateclient.openstack.common.gettextutils import _ +""" + +import copy +import gettext +import locale +from logging import handlers +import os +import re + +from babel import localedata +import six + +_localedir = os.environ.get('climateclient'.upper() + '_LOCALEDIR') +_t = gettext.translation('climateclient', localedir=_localedir, fallback=True) + +_AVAILABLE_LANGUAGES = {} +USE_LAZY = False + + +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 _(msg): + if USE_LAZY: + return Message(msg, domain='climateclient') + else: + if six.PY3: + return _t.gettext(msg) + return _t.ugettext(msg) + + +def install(domain, lazy=False): + """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). + + :param domain: the translation domain + :param lazy: indicates whether or not to install the lazy _() function. + The lazy _() introduces a way to do deferred translation + of messages by installing a _ that builds Message objects, + instead of strings, which can then be lazily translated into + any available locale. + """ + if lazy: + # NOTE(mrodden): Lazy gettext functionality. + # + # The following introduces a deferred way to do translations on + # messages in OpenStack. We override the standard _() function + # and % (format string) operation to build Message objects that can + # later be translated when we have more information. + def _lazy_gettext(msg): + """Create and return a Message object. + + Lazy gettext function for a given domain, it is a factory method + for a project/module to get a lazy gettext function for its own + translation domain (i.e. nova, glance, cinder, etc.) + + Message encapsulates a string so that we can translate + it later when needed. + """ + return Message(msg, domain=domain) + + from six import moves + moves.builtins.__dict__['_'] = _lazy_gettext + else: + localedir = '%s_LOCALEDIR' % domain.upper() + if six.PY3: + gettext.install(domain, + localedir=os.environ.get(localedir)) + else: + gettext.install(domain, + localedir=os.environ.get(localedir), + unicode=True) + + +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='climateclient', + *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 + unicode_mod = super(Message, self).__mod__(other) + modded = Message(self.msgid, + msgtext=unicode_mod, + params=self._sanitize_mod_params(other), + 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): + params = self._trim_dictionary_parameters(other) + else: + params = self._copy_param(other) + return params + + def _trim_dictionary_parameters(self, dict_param): + """Return a dict that only has matching entries in the msgid.""" + # NOTE(luisg): Here we trim down the dictionary passed as parameters + # to avoid carrying a lot of unnecessary weight around in the message + # object, for example if someone passes in Message() % locals() but + # only some params are used, and additionally we prevent errors for + # non-deepcopyable objects by unicoding() them. + + # Look for %(param) keys in msgid; + # Skip %% and deal with the case where % is first character on the line + keys = re.findall('(?:[^%]|^)?%\((\w*)\)[a-z]', self.msgid) + + # If we don't find any %(param) keys but have a %s + if not keys and re.findall('(?:[^%]|^)%[a-z]', self.msgid): + # Apparently the full dictionary is the parameter + params = self._copy_param(dict_param) + else: + params = {} + for key in keys: + params[key] = self._copy_param(dict_param[key]) + + return params + + def _copy_param(self, param): + try: + return copy.deepcopy(param) + except TypeError: + # 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) + + 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) + _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/climateclient/openstack/common/importutils.py b/climateclient/openstack/common/importutils.py new file mode 100644 index 0000000..4fd9ae2 --- /dev/null +++ b/climateclient/openstack/common/importutils.py @@ -0,0 +1,66 @@ +# 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('.') + try: + __import__(mod_str) + return getattr(sys.modules[mod_str], class_str) + except (ValueError, 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 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/climateclient/openstack/common/strutils.py b/climateclient/openstack/common/strutils.py new file mode 100644 index 0000000..a3b9250 --- /dev/null +++ b/climateclient/openstack/common/strutils.py @@ -0,0 +1,222 @@ +# 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 re +import sys +import unicodedata + +import six + +from climateclient.openstack.common.gettextutils import _ + + +# Used for looking up extensions of text +# to their 'multiplied' byte amount +BYTE_MULTIPLIERS = { + '': 1, + 't': 1024 ** 4, + 'g': 1024 ** 3, + 'm': 1024 ** 2, + 'k': 1024, +} +BYTE_REGEX = re.compile(r'(^-?\d+)(\D*)') + +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]+") + + +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): + """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 is considered False. + + 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 = str(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 False + + +def safe_decode(text, incoming=None, errors='strict'): + """Decodes incoming str 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): + 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 str/unicode 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): + 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): + if six.PY3: + return text.encode(encoding, errors).decode(incoming) + else: + return text.encode(encoding, errors) + elif text and encoding != incoming: + # Decode text before encoding it with `encoding` + text = safe_decode(text, incoming, errors) + if six.PY3: + return text.encode(encoding, errors).decode(incoming) + else: + return text.encode(encoding, errors) + + return text + + +def to_bytes(text, default=0): + """Converts a string into an integer of bytes. + + Looks at the last characters of the text to determine + what conversion is needed to turn the input text into a byte number. + Supports "B, K(B), M(B), G(B), and T(B)". (case insensitive) + + :param text: String input for bytes size conversion. + :param default: Default return value when text is blank. + + """ + match = BYTE_REGEX.search(text) + if match: + magnitude = int(match.group(1)) + mult_key_org = match.group(2) + if not mult_key_org: + return magnitude + elif text: + msg = _('Invalid string format: %s') % text + raise TypeError(msg) + else: + return default + mult_key = mult_key_org.lower().replace('b', '', 1) + multiplier = BYTE_MULTIPLIERS.get(mult_key) + if multiplier is None: + msg = _('Unknown byte multiplier: %s') % mult_key_org + raise TypeError(msg) + return magnitude * multiplier + + +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) diff --git a/climateclient/shell.py b/climateclient/shell.py new file mode 100644 index 0000000..60abcf5 --- /dev/null +++ b/climateclient/shell.py @@ -0,0 +1,463 @@ +# Copyright (c) 2013 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Command-line interface to the Climate APIs +""" + +from __future__ import print_function +import argparse +import logging +import os +import sys + +from cliff import app +from cliff import commandmanager +from keystoneclient import exceptions as keystone_exceptions +import keystoneclient.v2_0 as keystone_client + +from climateclient import client as climate_client +from climateclient import exception +from climateclient.openstack.common import strutils +from climateclient import utils +from climateclient.v1.shell_commands import leases +from climateclient import version as base_version + +COMMANDS_V1 = { + 'lease-list': leases.ListLeases, + 'lease-show': leases.ShowLease, + 'lease-create': leases.CreateLease, + 'lease-update': leases.UpdateLease, + 'lease-delete': leases.DeleteLease +} + +VERSION = 1 +DEFAULT_API_VERSION = 1 +COMMANDS = {'v1': COMMANDS_V1} + + +def run_command(cmd, cmd_parser, sub_argv): + _argv = sub_argv + index = -1 + values_specs = [] + if '--' in sub_argv: + index = sub_argv.index('--') + _argv = sub_argv[:index] + values_specs = sub_argv[index:] + known_args, _values_specs = cmd_parser.parse_known_args(_argv) + cmd.values_specs = (index == -1 and _values_specs or values_specs) + return cmd.run(known_args) + + +def env(*_vars, **kwargs): + """Search for the first defined of possibly many env vars. + + Returns the first environment variable defined in vars, or + returns the default defined in kwargs. + + """ + for v in _vars: + value = os.environ.get(v, None) + if value: + return value + return kwargs.get('default', '') + + +class HelpAction(argparse.Action): + """Provide a custom action so the -h and --help options + to the main app will print a list of the commands. + + The commands are determined by checking the CommandManager + instance, passed in as the "default" value for the action. + """ + def __call__(self, parser, namespace, values, option_string=None): + outputs = [] + max_len = 0 + app = self.default + parser.print_help(app.stdout) + app.stdout.write('\nCommands for API v%s:\n' % app.api_version) + command_manager = app.command_manager + for name, ep in sorted(command_manager): + factory = ep.load() + cmd = factory(self, None) + one_liner = cmd.get_description().split('\n')[0] + outputs.append((name, one_liner)) + max_len = max(len(name), max_len) + for (name, one_liner) in outputs: + app.stdout.write(' %s %s\n' % (name.ljust(max_len), one_liner)) + sys.exit(0) + + +class ClimateShell(app.App): + """Manager class for the Climate CLI.""" + CONSOLE_MESSAGE_FORMAT = '%(message)s' + DEBUG_MESSAGE_FORMAT = '%(levelname)s: %(name)s %(message)s' + log = logging.getLogger(__name__) + + def __init__(self): + super(ClimateShell, self).__init__( + description=__doc__.strip(), + version=VERSION, + command_manager=commandmanager.CommandManager('climate.cli'), ) + self.commands = COMMANDS + + def build_option_parser(self, description, version, argparse_kwargs=None): + """Return an argparse option parser for this application. + + Subclasses may override this method to extend + the parser with more global options. + """ + parser = argparse.ArgumentParser( + description=description, + add_help=False) + parser.add_argument( + '--version', + action='version', + version=base_version.__version__) + parser.add_argument( + '-v', '--verbose', + action='count', + dest='verbose_level', + default=self.DEFAULT_VERBOSE_LEVEL, + help='Increase verbosity of output. Can be repeated.') + parser.add_argument( + '-q', '--quiet', + action='store_const', + dest='verbose_level', + const=0, + help='suppress output except warnings and errors') + parser.add_argument( + '-h', '--help', + action=HelpAction, + nargs=0, + default=self, + help="show this help message and exit") + parser.add_argument( + '--debug', + default=False, + action='store_true', + help='show tracebacks on errors') + + # Global arguments + parser.add_argument( + '--os-reservation-api-version', + default=env('OS_RESERVATION_API_VERSION', + default=DEFAULT_API_VERSION), + help='Accepts 1 now, defaults to 1.') + parser.add_argument( + '--os_reservation_api_version', + help=argparse.SUPPRESS) + parser.add_argument( + '--os-auth-strategy', metavar='', + default=env('OS_AUTH_STRATEGY', default='keystone'), + help='Authentication strategy (Env: OS_AUTH_STRATEGY' + ', default keystone). For now, any other value will' + ' disable the authentication') + parser.add_argument( + '--os_auth_strategy', + help=argparse.SUPPRESS) + parser.add_argument( + '--os-auth-url', metavar='', + default=env('OS_AUTH_URL'), + help='Authentication URL (Env: OS_AUTH_URL)') + parser.add_argument( + '--os_auth_url', + help=argparse.SUPPRESS) + parser.add_argument( + '--os-tenant-name', metavar='', + default=env('OS_TENANT_NAME'), + help='Authentication tenant name (Env: OS_TENANT_NAME)') + parser.add_argument( + '--os_tenant_name', + help=argparse.SUPPRESS) + parser.add_argument( + '--os-tenant-id', metavar='', + default=env('OS_TENANT_ID'), + help='Authentication tenant name (Env: OS_TENANT_ID)') + parser.add_argument( + '--os-username', metavar='', + default=utils.env('OS_USERNAME'), + help='Authentication username (Env: OS_USERNAME)') + parser.add_argument( + '--os_username', + help=argparse.SUPPRESS) + parser.add_argument( + '--os-password', metavar='', + default=utils.env('OS_PASSWORD'), + help='Authentication password (Env: OS_PASSWORD)') + parser.add_argument( + '--os_password', + help=argparse.SUPPRESS) + parser.add_argument( + '--os-region-name', metavar='', + default=env('OS_REGION_NAME'), + help='Authentication region name (Env: OS_REGION_NAME)') + parser.add_argument( + '--os_region_name', + help=argparse.SUPPRESS) + parser.add_argument( + '--os-token', metavar='', + default=env('OS_TOKEN'), + help='Defaults to env[OS_TOKEN]') + parser.add_argument( + '--os_token', + help=argparse.SUPPRESS) + parser.add_argument( + '--endpoint-type', metavar='', + default=env('OS_ENDPOINT_TYPE', default='publicURL'), + help='Defaults to env[OS_ENDPOINT_TYPE] or publicURL.') + parser.add_argument( + '--os-cacert', + metavar='', + default=env('OS_CACERT', default=None), + help="Specify a CA bundle file to use in " + "verifying a TLS (https) server certificate. " + "Defaults to env[OS_CACERT]") + parser.add_argument( + '--insecure', + action='store_true', + default=env('CLIMATECLIENT_INSECURE', default=False), + help="Explicitly allow climateclient to perform \"insecure\" " + "SSL (https) requests. The server's certificate will " + "not be verified against any certificate authorities. " + "This option should be used with caution.") + + return parser + + def _bash_completion(self): + """Prints all of the commands and options for bash-completion.""" + commands = set() + options = set() + + for option, _action in self.parser._option_string_actions.items(): + options.add(option) + + for command_name, command in self.command_manager: + commands.add(command_name) + cmd_factory = command.load() + cmd = cmd_factory(self, None) + cmd_parser = cmd.get_parser('') + for option, _action in cmd_parser._option_string_actions.items(): + options.add(option) + + print(' '.join(commands | options)) + + def run(self, argv): + """Equivalent to the main program for the application. + + :param argv: input arguments and options + :paramtype argv: list of str + """ + + try: + self.options, remainder = self.parser.parse_known_args(argv) + + self.api_version = 'v%s' % self.options.os_reservation_api_version + for k, v in self.commands[self.api_version].items(): + self.command_manager.add_command(k, v) + + index = 0 + command_pos = -1 + help_pos = -1 + help_command_pos = -1 + + for arg in argv: + if arg == 'bash-completion': + self._bash_completion() + return 0 + if arg in self.commands[self.api_version]: + if command_pos == -1: + command_pos = index + elif arg in ('-h', '--help'): + if help_pos == -1: + help_pos = index + elif arg == 'help': + if help_command_pos == -1: + help_command_pos = index + index += 1 + + if -1 < command_pos < help_pos: + argv = ['help', argv[command_pos]] + if help_command_pos > -1 and command_pos == -1: + argv[help_command_pos] = '--help' + + self.configure_logging() + self.interactive_mode = not remainder + self.initialize_app(remainder) + + except Exception as err: + if self.options.debug: + self.log.exception(unicode(err)) + raise + else: + self.log.error(unicode(err)) + return 1 + if self.interactive_mode: + _argv = [sys.argv[0]] + sys.argv = _argv + result = self.interact() + else: + result = self.run_subcommand(remainder) + return result + + def run_subcommand(self, argv): + subcommand = self.command_manager.find_command(argv) + cmd_factory, cmd_name, sub_argv = subcommand + cmd = cmd_factory(self, self.options) + result = 1 + try: + self.prepare_to_run_command(cmd) + full_name = (cmd_name if self.interactive_mode else + ' '.join([self.NAME, cmd_name])) + cmd_parser = cmd.get_parser(full_name) + return run_command(cmd, cmd_parser, sub_argv) + except Exception as err: + if self.options.debug: + self.log.exception(unicode(err)) + else: + self.log.error(unicode(err)) + try: + self.clean_up(cmd, result, err) + except Exception as err2: + if self.options.debug: + self.log.exception(unicode(err2)) + else: + self.log.error('Could not clean up: %s', unicode(err2)) + if self.options.debug: + raise + else: + try: + self.clean_up(cmd, result, None) + except Exception as err3: + if self.options.debug: + self.log.exception(unicode(err3)) + else: + self.log.error('Could not clean up: %s', unicode(err3)) + return result + + def authenticate_user(self): + """Make sure the user has provided all of the authentication + info we need. + """ + if not self.options.os_token: + if not self.options.os_username: + raise exception.CommandError( + "You must provide a username via" + " either --os-username or env[OS_USERNAME]") + + if not self.options.os_password: + raise exception.CommandError( + "You must provide a password via" + " either --os-password or env[OS_PASSWORD]") + + if (not self.options.os_tenant_name and + not self.options.os_tenant_id): + raise exception.CommandError( + "You must provide a tenant_name or tenant_id via" + " --os-tenant-name, env[OS_TENANT_NAME]" + " --os-tenant-id, or via env[OS_TENANT_ID]") + + if not self.options.os_auth_url: + raise exception.CommandError( + "You must provide an auth url via" + " either --os-auth-url or via env[OS_AUTH_URL]") + + keystone = keystone_client.Client( + token=self.options.os_token, + auth_url=self.options.os_auth_url, + tenant_id=self.options.os_tenant_id, + tenant_name=self.options.os_tenant_name, + password=self.options.os_password, + region_name=self.options.os_region_name, + username=self.options.os_username, + insecure=self.options.insecure, + cert=self.options.os_cacert + ) + + auth = keystone.authenticate() + + if auth: + try: + climate_url = keystone.service_catalog.url_for( + service_type='reservation' + ) + except keystone_exceptions.EndpointNotFound: + raise exception.NoClimateEndpoint() + else: + raise exception.NotAuthorized("User %s is not authorized." % + self.options.os_username) + + client = climate_client.Client(self.options.os_reservation_api_version, + climate_url=climate_url, + auth_token=keystone.auth_token) + self.client = client + return + + def initialize_app(self, argv): + """Global app init bits: + + * set up API versions + * validate authentication info + """ + + super(ClimateShell, self).initialize_app(argv) + + cmd_name = None + if argv: + cmd_info = self.command_manager.find_command(argv) + cmd_factory, cmd_name, sub_argv = cmd_info + if self.interactive_mode or cmd_name != 'help': + self.authenticate_user() + + def clean_up(self, cmd, result, err): + self.log.debug('clean_up %s', cmd.__class__.__name__) + if err: + self.log.debug('got an error: %s', unicode(err)) + + def configure_logging(self): + """Create logging handlers for any log output.""" + root_logger = logging.getLogger('') + + # Set up logging to a file + root_logger.setLevel(logging.DEBUG) + + # Send higher-level messages to the console via stderr + console = logging.StreamHandler(self.stderr) + console_level = {0: logging.WARNING, + 1: logging.INFO, + 2: logging.DEBUG}.get(self.options.verbose_level, + logging.DEBUG) + console.setLevel(console_level) + if logging.DEBUG == console_level: + formatter = logging.Formatter(self.DEBUG_MESSAGE_FORMAT) + else: + formatter = logging.Formatter(self.CONSOLE_MESSAGE_FORMAT) + console.setFormatter(formatter) + root_logger.addHandler(console) + return + + +def main(argv=sys.argv[1:]): + try: + return ClimateShell().run(map(strutils.safe_decode, argv)) + except exception.ClimateClientException: + return 1 + except Exception as e: + print(unicode(e)) + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/climateclient/utils.py b/climateclient/utils.py new file mode 100644 index 0000000..64189a7 --- /dev/null +++ b/climateclient/utils.py @@ -0,0 +1,137 @@ +# Copyright (c) 2013 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import json +import os +import re +import six + +from climateclient import exception + + +HEX_ELEM = '[0-9A-Fa-f]' +UUID_PATTERN = '-'.join([HEX_ELEM + '{8}', HEX_ELEM + '{4}', + HEX_ELEM + '{4}', HEX_ELEM + '{4}', + HEX_ELEM + '{12}']) + + +def env(*args, **kwargs): + """Returns the first environment variable set. + + if none are non-empty, defaults to '' or keyword arg default. + """ + for v in args: + value = os.environ.get(v) + if value: + return value + return kwargs.get('default', '') + + +def to_primitive(value): + if isinstance(value, list) or isinstance(value, tuple): + o = [] + for v in value: + o.append(to_primitive(v)) + return o + elif isinstance(value, dict): + o = {} + for k, v in six.iteritems(value): + o[k] = to_primitive(v) + return o + elif isinstance(value, datetime.datetime): + return str(value) + elif hasattr(value, 'iteritems'): + return to_primitive(dict(six.iteritems(value))) + elif hasattr(value, '__iter__'): + return to_primitive(list(value)) + else: + return value + + +def dumps(value, indent=None): + try: + return json.dumps(value, indent=indent) + except TypeError: + pass + return json.dumps(to_primitive(value)) + + +def get_item_properties(item, fields, mixed_case_fields=None, formatters=None): + """Return a tuple containing the item properties. + + :param item: a single item resource (e.g. Server, Tenant, etc) + :param fields: tuple of strings with the desired field names + :param mixed_case_fields: tuple of field names to preserve case + :param formatters: dictionary mapping field names to callables + to format the values + """ + row = [] + if mixed_case_fields is None: + mixed_case_fields = [] + if formatters is None: + formatters = {} + + for field in fields: + if field in formatters: + row.append(formatters[field](item)) + else: + if field in mixed_case_fields: + field_name = field.replace(' ', '_') + else: + field_name = field.lower().replace(' ', '_') + if not hasattr(item, field_name) and isinstance(item, dict): + data = item[field_name] + else: + data = getattr(item, field_name, '') + if data is None: + data = '' + row.append(data) + return tuple(row) + + +def find_resource_id_by_name_or_id(client, resource, name_or_id): + resource_manager = getattr(client, resource) + is_id = re.match(UUID_PATTERN, name_or_id) + if is_id: + resources = resource_manager.list() + for resource in resources: + if resource['id'] == name_or_id: + return name_or_id + raise exception.ClimateClientException('No %s found with ID %s' % + (resource, name_or_id)) + return _find_resource_id_by_name(client, resource, name_or_id) + + +def _find_resource_id_by_name(client, resource, name): + resource_manager = getattr(client, resource) + resources = resource_manager.list() + + named_resources = [] + + for resource in resources: + if resource['name'] == name: + named_resources.append(resource['id']) + if len(named_resources) > 1: + raise exception.NoUniqueMatch(message="There are more than one " + "appropriate resources for the " + "name '%s' and type '%s'" % + (name, resource)) + elif named_resources: + return named_resources[0] + else: + message = "Unable to find %s with name '%s'" % (resource, name) + raise exception.ClimateClientException(message=message, + status_code=404) diff --git a/climateclient/v1/__init__.py b/climateclient/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/climateclient/v1/client.py b/climateclient/v1/client.py new file mode 100644 index 0000000..eb023a7 --- /dev/null +++ b/climateclient/v1/client.py @@ -0,0 +1,38 @@ +# Copyright (c) 2013 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from climateclient.v1 import leases + + +class Client(object): + """Top level object to communicate with Climate. + + Contains managers to control requests that should be passed to each type of + resources - leases, events, etc. + + **Examples** + client = Client() + client.lease.list() + client.event.list() + ... + """ + + def __init__(self, climate_url, auth_token): + self.climate_url = climate_url + self.auth_token = auth_token + + self.lease = leases.LeaseClientManager(self.climate_url, + self.auth_token) diff --git a/climateclient/v1/leases.py b/climateclient/v1/leases.py new file mode 100644 index 0000000..809307f --- /dev/null +++ b/climateclient/v1/leases.py @@ -0,0 +1,77 @@ +# Copyright (c) 2013 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime + +from climateclient import base +from climateclient import exception +from climateclient.openstack.common.gettextutils import _ # noqa + + +class LeaseClientManager(base.BaseClientManager): + """Manager for the lease connected requests.""" + + def create(self, name, start, end, reservations, events): + """Creates lease from values passed.""" + values = {'name': name, 'start_date': start, 'end_date': end, + 'reservations': reservations, 'events': events} + + return self._create('/leases', values, 'lease') + + def get(self, lease_id): + """Describes lease specifications such as name, status and locked + condition. + """ + return self._get('/leases/%s' % lease_id, 'lease') + + def update(self, lease_id, name=None, prolong_for=None): + """Update attributes of the lease.""" + values = {} + if name: + values['name'] = name + if prolong_for: + if prolong_for.endswith('s'): + coefficient = 1 + elif prolong_for.endswith('m'): + coefficient = 60 + elif prolong_for.endswith('h'): + coefficient = 60 * 60 + elif prolong_for.endswith('d'): + coefficient = 24 * 60 * 60 + else: + raise exception.ClimateClientException(_("Unsupportable date " + "format for lease " + "prolonging.")) + lease = self.get(lease_id) + cur_end_date = datetime.datetime.strptime(lease['end_date'], + '%Y-%m-%dT%H:%M:%S.%f') + seconds = int(prolong_for[:-1]) * coefficient + delta_sec = datetime.timedelta(seconds=seconds) + new_end_date = cur_end_date + delta_sec + values['end_date'] = datetime.datetime.strftime( + new_end_date, '%Y-%m-%dT%H:%M:%S.%f' + ) + if not values: + return _('No values to update passed.') + return self._update('/leases/%s' % lease_id, values, + response_key='lease') + + def delete(self, lease_id): + """Deletes lease with specified ID.""" + self._delete('/leases/%s' % lease_id) + + def list(self): + """List all leases.""" + return self._get('/leases', 'leases') diff --git a/climateclient/v1/shell_commands/__init__.py b/climateclient/v1/shell_commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/climateclient/v1/shell_commands/leases.py b/climateclient/v1/shell_commands/leases.py new file mode 100644 index 0000000..d061cc1 --- /dev/null +++ b/climateclient/v1/shell_commands/leases.py @@ -0,0 +1,81 @@ +# Copyright (c) 2013 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import argparse +import logging + +from climateclient import command + + +class ListLeases(command.ListCommand): + resource = 'lease' + log = logging.getLogger(__name__ + '.ListLeases') + list_columns = ['id', 'name', 'start_date', 'end_date'] + + +class ShowLease(command.ShowCommand): + resource = 'lease' + json_indent = 4 + log = logging.getLogger(__name__ + '.ShowLease') + + +class CreateLease(command.CreateCommand): + """Comprehended only for physical reservations. + + For physical reservations lease is created manually. + + For virtual reservations we need id of the reserved resource to create + lease. When service creates reserved resource (Nova-VM, Cinder-volume, + etc.) it comes to Climate and creates lease via Python client. + + """ + pass + + +class UpdateLease(command.UpdateCommand): + resource = 'lease' + log = logging.getLogger(__name__ + '.UpdateLease') + + def get_parser(self, prog_name): + parser = super(UpdateLease, self).get_parser(prog_name) + parser.add_argument( + '--name', + help='New name for the lease', + default=None + ) + parser.add_argument( + '--prolong-for', + help='Time to prolong lease for', + default=None + ) + parser.add_argument( + '--prolong_for', + help=argparse.SUPPRESS, + default=None + ) + return parser + + def args2body(self, parsed_args): + params = {} + if parsed_args.name: + params['name'] = parsed_args.name + if parsed_args.prolong_for: + params['prolong_for'] = parsed_args.prolong_for + return params + + +class DeleteLease(command.DeleteCommand): + resource = 'lease' + log = logging.getLogger(__name__ + '.DeleteLease') diff --git a/climateclient/version.py b/climateclient/version.py new file mode 100644 index 0000000..43a56fb --- /dev/null +++ b/climateclient/version.py @@ -0,0 +1,19 @@ +# Copyright (c) 2013 Mirantis Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pbr.version + + +__version__ = pbr.version.VersionInfo('python-climateclient').version_string() diff --git a/openstack-common.conf b/openstack-common.conf new file mode 100644 index 0000000..6e76583 --- /dev/null +++ b/openstack-common.conf @@ -0,0 +1,9 @@ +[DEFAULT] + +# The list of modules to copy from oslo-incubator +module=gettextutils +module=importutils +module=strutils + +# The base module to hold the copy of openstack.common +base=climateclient diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..099e3c6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +cliff>=1.4.3 +PrettyTable>=0.7,<0.8 +python-keystoneclient>=0.4.1 +requests>=1.1 +six>=1.4.1 +Babel>=1.3 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..b480809 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,21 @@ +[metadata] +name = python-climateclient +version = 2014.1a0 +summary = Client for OpenStack Reservation Service +description-file = README.rst +license = Apache Software License +author = OpenStack +author_email = climate@lists.launchpad.net +home-page = https://launchpad.net/climate + +[global] +setup-hooks = pbr.hooks.setup_hook + +[build_sphinx] +all_files = 1 +build-dir = doc/build +source-dir = doc/source + +[entry_points] +console_scripts = + climate = climateclient.shell:main \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..70c2b3f --- /dev/null +++ b/setup.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +# 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 +# +# 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 FILE IS MANAGED BY THE GLOBAL REQUIREMENTS REPO - DO NOT EDIT +import setuptools + +setuptools.setup( + setup_requires=['pbr'], + pbr=True) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..5251358 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,4 @@ +pep8==1.4.5 +pyflakes>=0.7.2,<0.7.4 +flake8==2.0 +hacking>=0.8.0,<0.9 diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..3f119fe --- /dev/null +++ b/tox.ini @@ -0,0 +1,22 @@ +[tox] +envlist = py27,pep8 + +[testenv] +deps = + -r{toxinidir}/requirements.txt + +[tox:jenkins] +downloadcache = ~/cache/pip + +[testenv:pep8] +deps = + -r{toxinidir}/test-requirements.txt +commands = flake8 + +[flake8] +show-source = true +builtins = _ +exclude=.venv,.git,.tox,dist,doc,*lib/python*,*egg + +[testenv:venv] +commands = {posargs}