diff options
60 files changed, 1203 insertions, 1297 deletions
diff --git a/.coveragerc b/.coveragerc index 092ee58..457eb50 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,7 +1,6 @@ [run] branch = True source = glanceclient -omit = glanceclient/openstack/* [report] ignore_errors = True @@ -1,3 +1,23 @@ +======================== +Team and repository tags +======================== + +.. image:: http://governance.openstack.org/badges/python-glanceclient.svg + :target: http://governance.openstack.org/reference/tags/index.html + :alt: The following tags have been asserted for Python bindings to the + OpenStack Images API: + "project:official", + "stable:follows-policy", + "vulnerability:managed", + "team:diverse-affiliation". + Follow the link for an explanation of these tags. +.. NOTE(rosmaita): the alt text above will have to be updated when + additional tags are asserted for python-glanceclient. (The SVG in the + governance repo is updated automatically.) + +.. Change things from this point on + +=========================================== Python bindings to the OpenStack Images API =========================================== diff --git a/doc/source/index.rst b/doc/source/index.rst index d13fcea..e933cf9 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -23,7 +23,6 @@ In order to use the python api directly, you must first obtain an auth token and Python API Reference ~~~~~~~~~~~~~~~~~~~~ - .. toctree:: :maxdepth: 2 @@ -31,6 +30,17 @@ Python API Reference ref/v1/index ref/v2/index +.. toctree:: + :maxdepth: 1 + + How to use the v2 API <apiv2> + +Command-line Tool Reference +~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. toctree:: + :maxdepth: 1 + + man/glance Command-line Tool ----------------- diff --git a/glanceclient/client.py b/glanceclient/client.py index db2a4f7..714c96a 100644 --- a/glanceclient/client.py +++ b/glanceclient/client.py @@ -25,8 +25,8 @@ def Client(version=None, endpoint=None, session=None, *args, **kwargs): for specific details. :param string version: The version of API to use. - :param session: A keystoneclient session that should be used for transport. - :type session: keystoneclient.session.Session + :param session: A keystoneauth1 session that should be used for transport. + :type session: keystoneauth1.session.Session """ # FIXME(jamielennox): Add a deprecation warning if no session is passed. # Leaving it as an option until we can ensure nothing break when we switch. diff --git a/glanceclient/common/base.py b/glanceclient/common/base.py deleted file mode 100644 index 55f265e..0000000 --- a/glanceclient/common/base.py +++ /dev/null @@ -1,35 +0,0 @@ -# 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. - -""" -Base utilities to build API operation managers and objects on top of. - -DEPRECATED post v.0.12.0. Use 'glanceclient.openstack.common.apiclient.base' -instead of this module." -""" - -import warnings - -from glanceclient.openstack.common.apiclient import base - - -warnings.warn("The 'glanceclient.common.base' module is deprecated post " - "v.0.12.0. Use 'glanceclient.openstack.common.apiclient.base' " - "instead of this one.", DeprecationWarning) - - -getid = base.getid -Manager = base.ManagerWithFind -Resource = base.Resource diff --git a/glanceclient/common/exceptions.py b/glanceclient/common/exceptions.py index 64cc01e..1512557 100644 --- a/glanceclient/common/exceptions.py +++ b/glanceclient/common/exceptions.py @@ -1,3 +1,15 @@ +# 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 is here for compatibility purposes. Once all known OpenStack clients # are updated to use glanceclient.exc, this file should be removed -from glanceclient.exc import * +from glanceclient.exc import * # noqa diff --git a/glanceclient/common/http.py b/glanceclient/common/http.py index 1157381..67a8d06 100644 --- a/glanceclient/common/http.py +++ b/glanceclient/common/http.py @@ -17,8 +17,8 @@ import copy import logging import socket -from keystoneclient import adapter -from keystoneclient import exceptions as ksc_exc +from keystoneauth1 import adapter +from keystoneauth1 import exceptions as ksa_exc from oslo_utils import importutils from oslo_utils import netutils import requests @@ -42,6 +42,20 @@ USER_AGENT = 'python-glanceclient' CHUNKSIZE = 1024 * 64 # 64kB +def encode_headers(headers): + """Encodes headers. + + Note: This should be used right before + sending anything out. + + :param headers: Headers to encode + :returns: Dictionary with encoded headers' + names and values + """ + return dict((encodeutils.safe_encode(h), encodeutils.safe_encode(v)) + for h, v in six.iteritems(headers) if v is not None) + + class _BaseHTTPClient(object): @staticmethod @@ -49,7 +63,7 @@ class _BaseHTTPClient(object): chunk = body while chunk: chunk = body.read(CHUNKSIZE) - if chunk == '': + if not chunk: break yield chunk @@ -78,8 +92,18 @@ class _BaseHTTPClient(object): return data def _handle_response(self, resp): + # log request-id for each api cal + request_id = resp.headers.get('x-openstack-request-id') + if request_id: + LOG.debug('%(method)s call to glance-api for ' + '%(url)s used request id ' + '%(response_request_id)s', + {'method': resp.request.method, + 'url': resp.url, + 'response_request_id': request_id}) + if not resp.ok: - LOG.debug("Request returned failure status %s." % resp.status_code) + LOG.debug("Request returned failure status %s.", resp.status_code) raise exc.from_response(resp, resp.content) elif (resp.status_code == requests.codes.MULTIPLE_CHOICES and resp.request.path_url != '/versions'): @@ -124,10 +148,6 @@ class HTTPClient(_BaseHTTPClient): self.session = requests.Session() self.session.headers["User-Agent"] = USER_AGENT - if self.auth_token: - self.session.headers["X-Auth-Token"] = encodeutils.safe_encode( - self.auth_token) - if self.language_header: self.session.headers["Accept-Language"] = self.language_header @@ -197,20 +217,6 @@ class HTTPClient(_BaseHTTPClient): LOG.debug('\n'.join([encodeutils.safe_decode(x, errors='ignore') for x in dump])) - @staticmethod - def encode_headers(headers): - """Encodes headers. - - Note: This should be used right before - sending anything out. - - :param headers: Headers to encode - :returns: Dictionary with encoded headers' - names and values - """ - return dict((encodeutils.safe_encode(h), encodeutils.safe_encode(v)) - for h, v in six.iteritems(headers) if v is not None) - def _request(self, method, url, **kwargs): """Send an http request with the specified characteristics. @@ -226,13 +232,17 @@ class HTTPClient(_BaseHTTPClient): data = self._set_common_request_kwargs(headers, kwargs) + # add identity header to the request + if not headers.get('X-Auth-Token'): + headers['X-Auth-Token'] = self.auth_token + if osprofiler_web: headers.update(osprofiler_web.get_trace_id_headers()) # Note(flaper87): Before letting headers / url fly, # they should be encoded otherwise httplib will # complain. - headers = self.encode_headers(headers) + headers = encode_headers(headers) if self.endpoint.endswith("/") or url.startswith("/"): conn_url = "%s%s" % (self.endpoint, url) @@ -306,7 +316,7 @@ class SessionClient(adapter.Adapter, _BaseHTTPClient): super(SessionClient, self).__init__(session, **kwargs) def request(self, url, method, **kwargs): - headers = kwargs.pop('headers', {}) + headers = encode_headers(kwargs.pop('headers', {})) kwargs['raise_exc'] = False data = self._set_common_request_kwargs(headers, kwargs) @@ -316,13 +326,13 @@ class SessionClient(adapter.Adapter, _BaseHTTPClient): headers=headers, data=data, **kwargs) - except ksc_exc.RequestTimeout as e: + except ksa_exc.ConnectTimeout as e: conn_url = self.get_endpoint(auth=kwargs.get('auth')) conn_url = "%s/%s" % (conn_url.rstrip('/'), url.lstrip('/')) message = ("Error communicating with %(url)s %(e)s" % dict(url=conn_url, e=e)) raise exc.InvalidEndpoint(message=message) - except ksc_exc.ConnectionRefused as e: + except ksa_exc.ConnectFailure as e: conn_url = self.get_endpoint(auth=kwargs.get('auth')) conn_url = "%s/%s" % (conn_url.rstrip('/'), url.lstrip('/')) message = ("Error finding address for %(url)s: %(e)s" % diff --git a/glanceclient/common/https.py b/glanceclient/common/https.py index d2991cd..36015e5 100644 --- a/glanceclient/common/https.py +++ b/glanceclient/common/https.py @@ -52,7 +52,7 @@ from glanceclient import exc def verify_callback(host=None): - """ + """Provide wrapper for do_verify_callback. We use a partial around the 'real' verify_callback function so that we can stash the host value without holding a @@ -87,7 +87,7 @@ def do_verify_callback(connection, x509, errnum, def host_matches_cert(host, x509): - """ + """Verify the certificate identifies the host. Verify that the x509 certificate we have received from 'host' correctly identifies the server we are @@ -187,7 +187,7 @@ class HTTPSAdapter(adapters.HTTPAdapter): class HTTPSConnectionPool(connectionpool.HTTPSConnectionPool): - """ + """A replacement for the default HTTPSConnectionPool. HTTPSConnectionPool will be instantiated when a new connection is requested to the HTTPSAdapter. This @@ -232,10 +232,8 @@ class OpenSSLConnectionDelegator(object): class VerifiedHTTPSConnection(HTTPSConnection): - """ + """Extended OpenSSL HTTPSConnection for enhanced SSL support. - Extended HTTPSConnection which uses the OpenSSL library - for enhanced SSL support. Note: Much of this functionality can eventually be replaced with native Python 3.3 code. """ @@ -325,10 +323,9 @@ class VerifiedHTTPSConnection(HTTPSConnection): self.context.set_default_verify_paths() def connect(self): - """ + """Connect to an SSL port using the OpenSSL library. - Connect to an SSL port using the OpenSSL library - and apply per-connection parameters. + This method also applies per-connection parameters to the connection. """ result = socket.getaddrinfo(self.host, self.port, 0, socket.SOCK_STREAM) diff --git a/glanceclient/common/progressbar.py b/glanceclient/common/progressbar.py index d372e5c..6cf0df3 100644 --- a/glanceclient/common/progressbar.py +++ b/glanceclient/common/progressbar.py @@ -19,7 +19,7 @@ import six class _ProgressBarBase(object): - """ + """A progress bar provider for a wrapped obect. Base abstract class used by specific class wrapper to show a progress bar when the wrapped object are consumed. @@ -51,10 +51,10 @@ class _ProgressBarBase(object): class VerboseFileWrapper(_ProgressBarBase): - """ + """A file wrapper with a progress bar. - A file wrapper that show and advance a progress bar - whenever file's read method is called. + The file wrapper shows and advances a progress bar whenever the + wrapped file's read method is called. """ def read(self, *args, **kwargs): @@ -70,10 +70,11 @@ class VerboseFileWrapper(_ProgressBarBase): class VerboseIteratorWrapper(_ProgressBarBase): - """ + """An iterator wrapper with a progress bar. + + The iterator wrapper shows and advances a progress bar whenever the + wrapped data is consumed from the iterator. - An iterator wrapper that show and advance a progress bar whenever - data is consumed from the iterator. :note: Use only with iterator that yield strings. """ diff --git a/glanceclient/common/utils.py b/glanceclient/common/utils.py index 878c1a5..9f3a1fe 100644 --- a/glanceclient/common/utils.py +++ b/glanceclient/common/utils.py @@ -51,7 +51,7 @@ REQUIRED_FIELDS_ON_DATA = ('disk_format', 'container_format') # Decorator for cli-args def arg(*args, **kwargs): def _decorator(func): - # Because of the sematics of decorator composition if we just append + # Because of the semantics 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 @@ -133,7 +133,7 @@ def schema_args(schema_getter, omit=None): if isinstance(type_str, list): # NOTE(flaper87): This means the server has # returned something like `['null', 'string']`, - # therfore we use the first non-`null` type as + # therefore we use the first non-`null` type as # the valid type. for t in type_str: if t != 'null': @@ -159,7 +159,7 @@ def schema_args(schema_getter, omit=None): # NOTE(flaper87): Make sure all values are `str/unicode` # for the `join` to succeed. Enum types can also be `None` - # therfore, join's call would fail without the following + # therefore, join's call would fail without the following # list comprehension vals = [six.text_type(val) for val in property.get('enum')] description += ('Valid values: ' + ', '.join(vals)) @@ -246,21 +246,6 @@ def find_resource(manager, name_or_id): return matches[0] -def skip_authentication(f): - """Function decorator used to indicate a caller may be unauthenticated.""" - f.require_authentication = False - return f - - -def is_authentication_required(f): - """Checks to see if the function requires authentication. - - Use the skip_authentication decorator to indicate a caller may - skip the authentication step. - """ - return getattr(f, 'require_authentication', True) - - def env(*vars, **kwargs): """Search for the first defined of possibly many env vars. @@ -298,10 +283,10 @@ def save_image(data, path): :param path: path to save the image to """ if path is None: - if six.PY3: - image = sys.stdout.buffer - else: - image = sys.stdout + # NOTE(kragniz): for py3 compatibility: sys.stdout.buffer is only + # present on py3, otherwise fall back to sys.stdout + image = getattr(sys.stdout, 'buffer', + sys.stdout) else: image = open(path, 'wb') try: @@ -375,9 +360,12 @@ def get_data_file(args): return None if not sys.stdin.isatty(): # (2) image data is provided through standard input + image = sys.stdin + if hasattr(sys.stdin, 'buffer'): + image = sys.stdin.buffer if msvcrt: - msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY) - return sys.stdin + msvcrt.setmode(image.fileno(), os.O_BINARY) + return image else: # (3) no image data provided return None @@ -467,6 +455,14 @@ def endpoint_version_from_url(endpoint, default_version=None): return None, default_version +def debug_enabled(argv): + if bool(env('GLANCECLIENT_DEBUG')) is True: + return True + if '--debug' in argv or '-d' in argv: + return True + return False + + class IterableWithLength(object): def __init__(self, iterable, length): self.iterable = iterable diff --git a/glanceclient/exc.py b/glanceclient/exc.py index 29189e4..c8616c3 100644 --- a/glanceclient/exc.py +++ b/glanceclient/exc.py @@ -16,6 +16,8 @@ import re import sys +import six + class BaseException(Exception): """An error occurred.""" @@ -177,6 +179,8 @@ def from_response(response, body=None): details = ': '.join(details_temp) return cls(details=details) elif body: + if six.PY3: + body = body.decode('utf-8') details = body.replace('\n\n', '\n') return cls(details=details) diff --git a/glanceclient/openstack/common/__init__.py b/glanceclient/openstack/common/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/glanceclient/openstack/common/__init__.py +++ /dev/null diff --git a/glanceclient/openstack/common/_i18n.py b/glanceclient/openstack/common/_i18n.py deleted file mode 100644 index d1339ad..0000000 --- a/glanceclient/openstack/common/_i18n.py +++ /dev/null @@ -1,45 +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. - -"""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='glanceclient') - - # 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/glanceclient/openstack/common/apiclient/__init__.py b/glanceclient/openstack/common/apiclient/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/glanceclient/openstack/common/apiclient/__init__.py +++ /dev/null diff --git a/glanceclient/openstack/common/apiclient/auth.py b/glanceclient/openstack/common/apiclient/auth.py deleted file mode 100644 index 771df04..0000000 --- a/glanceclient/openstack/common/apiclient/auth.py +++ /dev/null @@ -1,234 +0,0 @@ -# Copyright 2013 OpenStack Foundation -# Copyright 2013 Spanish National Research Council. -# 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. - -# 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-glanceclient-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 - -import six -from stevedore import extension - -from glanceclient.openstack.common.apiclient import exceptions - - -_discovered_plugins = {} - - -def discover_auth_systems(): - """Discover the available auth-systems. - - This won't take into account the old style auth-systems. - """ - global _discovered_plugins - _discovered_plugins = {} - - def add_plugin(ext): - _discovered_plugins[ext.name] = ext.plugin - - ep_namespace = "glanceclient.openstack.common.apiclient.auth" - mgr = extension.ExtensionManager(ep_namespace) - mgr.map(add_plugin) - - -def load_auth_system_opts(parser): - """Load options needed by the available auth-systems into a parser. - - This function will try to populate the parser with options from the - available plugins. - """ - group = parser.add_argument_group("Common auth options") - BaseAuthPlugin.add_common_opts(group) - for name, auth_plugin in six.iteritems(_discovered_plugins): - group = parser.add_argument_group( - "Auth-system '%s' options" % name, - conflict_handler="resolve") - auth_plugin.add_opts(group) - - -def load_plugin(auth_system): - try: - plugin_class = _discovered_plugins[auth_system] - except KeyError: - raise exceptions.AuthSystemNotFound(auth_system) - return plugin_class(auth_system=auth_system) - - -def load_plugin_from_args(args): - """Load required plugin and populate it with options. - - Try to guess auth system if it is not specified. Systems are tried in - alphabetical order. - - :type args: argparse.Namespace - :raises: AuthPluginOptionsMissing - """ - auth_system = args.os_auth_system - if auth_system: - plugin = load_plugin(auth_system) - plugin.parse_opts(args) - plugin.sufficient_options() - return plugin - - for plugin_auth_system in sorted(six.iterkeys(_discovered_plugins)): - plugin_class = _discovered_plugins[plugin_auth_system] - plugin = plugin_class() - plugin.parse_opts(args) - try: - plugin.sufficient_options() - except exceptions.AuthPluginOptionsMissing: - continue - return plugin - raise exceptions.AuthPluginOptionsMissing(["auth_system"]) - - -@six.add_metaclass(abc.ABCMeta) -class BaseAuthPlugin(object): - """Base class for authentication plugins. - - An authentication plugin needs to override at least the authenticate - method to be a valid plugin. - """ - - auth_system = None - opt_names = [] - common_opt_names = [ - "auth_system", - "username", - "password", - "tenant_name", - "token", - "auth_url", - ] - - def __init__(self, auth_system=None, **kwargs): - self.auth_system = auth_system or self.auth_system - self.opts = dict((name, kwargs.get(name)) - for name in self.opt_names) - - @staticmethod - def _parser_add_opt(parser, opt): - """Add an option to parser in two variants. - - :param opt: option name (with underscores) - """ - dashed_opt = opt.replace("_", "-") - env_var = "OS_%s" % opt.upper() - arg_default = os.environ.get(env_var, "") - arg_help = "Defaults to env[%s]." % env_var - parser.add_argument( - "--os-%s" % dashed_opt, - metavar="<%s>" % dashed_opt, - default=arg_default, - help=arg_help) - parser.add_argument( - "--os_%s" % opt, - metavar="<%s>" % dashed_opt, - help=argparse.SUPPRESS) - - @classmethod - def add_opts(cls, parser): - """Populate the parser with the options for this plugin. - """ - for opt in cls.opt_names: - # use `BaseAuthPlugin.common_opt_names` since it is never - # changed in child classes - if opt not in BaseAuthPlugin.common_opt_names: - cls._parser_add_opt(parser, opt) - - @classmethod - def add_common_opts(cls, parser): - """Add options that are common for several plugins. - """ - for opt in cls.common_opt_names: - cls._parser_add_opt(parser, opt) - - @staticmethod - def get_opt(opt_name, args): - """Return option name and value. - - :param opt_name: name of the option, e.g., "username" - :param args: parsed arguments - """ - return (opt_name, getattr(args, "os_%s" % opt_name, None)) - - def parse_opts(self, args): - """Parse the actual auth-system options if any. - - This method is expected to populate the attribute `self.opts` with a - dict containing the options and values needed to make authentication. - """ - self.opts.update(dict(self.get_opt(opt_name, args) - for opt_name in self.opt_names)) - - def authenticate(self, http_client): - """Authenticate using plugin defined method. - - The method usually analyses `self.opts` and performs - a request to authentication server. - - :param http_client: client object that needs authentication - :type http_client: HTTPClient - :raises: AuthorizationFailure - """ - self.sufficient_options() - self._do_authenticate(http_client) - - @abc.abstractmethod - def _do_authenticate(self, http_client): - """Protected method for authentication. - """ - - def sufficient_options(self): - """Check if all required options are present. - - :raises: AuthPluginOptionsMissing - """ - missing = [opt - for opt in self.opt_names - if not self.opts.get(opt)] - if missing: - raise exceptions.AuthPluginOptionsMissing(missing) - - @abc.abstractmethod - def token_and_endpoint(self, endpoint_type, service_type): - """Return token and endpoint. - - :param service_type: Service type of the endpoint - :type service_type: string - :param endpoint_type: Type of endpoint. - Possible values: public or publicURL, - internal or internalURL, - admin or adminURL - :type endpoint_type: string - :returns: tuple of token and endpoint strings - :raises: EndpointException - """ diff --git a/glanceclient/openstack/common/apiclient/client.py b/glanceclient/openstack/common/apiclient/client.py deleted file mode 100644 index 0759a95..0000000 --- a/glanceclient/openstack/common/apiclient/client.py +++ /dev/null @@ -1,388 +0,0 @@ -# Copyright 2010 Jacob Kaplan-Moss -# Copyright 2011 OpenStack Foundation -# Copyright 2011 Piston Cloud Computing, Inc. -# Copyright 2013 Alessio Ababilov -# Copyright 2013 Grid Dynamics -# Copyright 2013 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. - -""" -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 - -try: - import simplejson as json -except ImportError: - import json - -from oslo_utils import encodeutils -from oslo_utils import importutils -import requests - -from glanceclient._i18n import _ -from glanceclient.openstack.common.apiclient import exceptions - -_logger = logging.getLogger(__name__) -SENSITIVE_HEADERS = ('X-Auth-Token', 'X-Subject-Token',) - - -class HTTPClient(object): - """This client handles sending HTTP requests to OpenStack servers. - - Features: - - - share authentication information between several clients to different - services (e.g., for compute and image clients); - - reissue authentication request for expired tokens; - - encode/decode JSON bodies; - - raise exceptions on HTTP errors; - - pluggable authentication; - - store authentication information in a keyring; - - store time spent for requests; - - register clients for particular services, so one can use - `http_client.identity` or `http_client.compute`; - - log requests and responses in a format that is easy to copy-and-paste - into terminal and send the same request with curl. - """ - - user_agent = "glanceclient.openstack.common.apiclient" - - def __init__(self, - auth_plugin, - region_name=None, - endpoint_type="publicURL", - original_ip=None, - verify=True, - cert=None, - timeout=None, - timings=False, - keyring_saver=None, - debug=False, - user_agent=None, - http=None): - self.auth_plugin = auth_plugin - - self.endpoint_type = endpoint_type - self.region_name = region_name - - self.original_ip = original_ip - self.timeout = timeout - self.verify = verify - self.cert = cert - - self.keyring_saver = keyring_saver - self.debug = debug - self.user_agent = user_agent or self.user_agent - - self.times = [] # [("item", starttime, endtime), ...] - self.timings = timings - - # requests within the same session can reuse TCP connections from pool - 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 -g -i", - "-X '%s'" % method, - "'%s'" % url, - ] - - for element in kwargs['headers']: - header = ("-H '%s: %s'" % - self._safe_header(element, kwargs['headers'][element])) - string_parts.append(header) - - _logger.debug("REQ: %s" % " ".join(string_parts)) - if 'data' in kwargs: - _logger.debug("REQ BODY: %s\n" % (kwargs['data'])) - - def _http_log_resp(self, resp): - if not self.debug: - return - _logger.debug( - "RESP: [%s] %s\n", - resp.status_code, - resp.headers) - if resp._content_consumed: - _logger.debug( - "RESP BODY: %s\n", - resp.text) - - def serialize(self, kwargs): - if kwargs.get('json') is not None: - kwargs['headers']['Content-Type'] = 'application/json' - kwargs['data'] = json.dumps(kwargs['json']) - try: - del kwargs['json'] - except KeyError: - pass - - def get_timings(self): - return self.times - - def reset_timings(self): - self.times = [] - - 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["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) - - self.last_request_id = resp.headers.get('x-openstack-request-id') - - if resp.status_code >= 400: - _logger.debug( - "Request returned failure status: %s", - resp.status_code) - raise exceptions.from_response(resp, method, url) - - return resp - - @staticmethod - def concat_url(endpoint, url): - """Concatenate endpoint and final URL. - - E.g., "http://keystone/v2.0/" and "/tokens" are concatenated to - "http://keystone/v2.0/tokens". - - :param endpoint: the base URL - :param url: the final URL - """ - return "%s/%s" % (endpoint.rstrip("/"), url.strip("/")) - - def client_request(self, client, method, url, **kwargs): - """Send an http request using `client`'s endpoint and specified `url`. - - If request was rejected as unauthorized (possibly because the token is - expired), issue one authorization attempt and send the request once - again. - - :param client: instance of BaseClient descendant - :param method: method of HTTP request - :param url: URL of HTTP request - :param kwargs: any other parameter that can be passed to - `HTTPClient.request` - """ - - filter_args = { - "endpoint_type": client.endpoint_type or self.endpoint_type, - "service_type": client.service_type, - } - token, endpoint = (self.cached_token, client.cached_endpoint) - just_authenticated = False - if not (token and endpoint): - try: - token, endpoint = self.auth_plugin.token_and_endpoint( - **filter_args) - except exceptions.EndpointException: - pass - if not (token and endpoint): - self.authenticate() - just_authenticated = True - token, endpoint = self.auth_plugin.token_and_endpoint( - **filter_args) - if not (token and endpoint): - raise exceptions.AuthorizationFailure( - _("Cannot find endpoint or token for request")) - - old_token_endpoint = (token, endpoint) - kwargs.setdefault("headers", {})["X-Auth-Token"] = token - self.cached_token = token - client.cached_endpoint = endpoint - # Perform the request once. If we get Unauthorized, then it - # might be because the auth token expired, so try to - # re-authenticate and try again. If it still fails, bail. - try: - return self.request( - method, self.concat_url(endpoint, url), **kwargs) - except exceptions.Unauthorized as unauth_ex: - if just_authenticated: - 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( - **filter_args) - except exceptions.EndpointException: - raise unauth_ex - if (not (token and endpoint) or - old_token_endpoint == (token, endpoint)): - raise unauth_ex - self.cached_token = token - client.cached_endpoint = endpoint - kwargs["headers"]["X-Auth-Token"] = token - return self.request( - method, self.concat_url(endpoint, url), **kwargs) - - def add_client(self, base_client_instance): - """Add a new instance of :class:`BaseClient` descendant. - - `self` will store a reference to `base_client_instance`. - - Example: - - >>> def test_clients(): - ... from keystoneclient.auth import keystone - ... from openstack.common.apiclient import client - ... auth = keystone.KeystoneAuthPlugin( - ... username="user", password="pass", tenant_name="tenant", - ... auth_url="http://auth:5000/v2.0") - ... openstack_client = client.HTTPClient(auth) - ... # create nova client - ... from novaclient.v1_1 import client - ... client.Client(openstack_client) - ... # create keystone client - ... from keystoneclient.v2_0 import client - ... client.Client(openstack_client) - ... # use them - ... openstack_client.identity.tenants.list() - ... openstack_client.compute.servers.list() - """ - service_type = base_client_instance.service_type - if service_type and not hasattr(self, service_type): - setattr(self, service_type, base_client_instance) - - def authenticate(self): - self.auth_plugin.authenticate(self) - # Store the authentication results in the keyring for later requests - if self.keyring_saver: - self.keyring_saver.save(self) - - -class BaseClient(object): - """Top-level object to access the OpenStack API. - - This client uses :class:`HTTPClient` to send requests. :class:`HTTPClient` - will handle a bunch of issues such as authentication. - """ - - service_type = None - endpoint_type = None # "publicURL" will be used - cached_endpoint = None - - def __init__(self, http_client, extensions=None): - self.http_client = http_client - http_client.add_client(self) - - # Add in any extensions... - if extensions: - for extension in extensions: - if extension.manager_class: - setattr(self, extension.name, - extension.manager_class(self)) - - def client_request(self, method, url, **kwargs): - 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) - - def get(self, url, **kwargs): - return self.client_request("GET", url, **kwargs) - - def post(self, url, **kwargs): - return self.client_request("POST", url, **kwargs) - - def put(self, url, **kwargs): - return self.client_request("PUT", url, **kwargs) - - def delete(self, url, **kwargs): - return self.client_request("DELETE", url, **kwargs) - - def patch(self, url, **kwargs): - return self.client_request("PATCH", url, **kwargs) - - @staticmethod - def get_class(api_name, version, version_map): - """Returns the client class for the requested API version. - - :param api_name: the name of the API, e.g. 'compute', 'image', etc - :param version: the requested API version - :param version_map: a dict of client classes keyed by version - :rtype: a client class for the requested API version - """ - try: - client_path = version_map[str(version)] - except (KeyError, ValueError): - msg = _("Invalid %(api_name)s client version '%(version)s'. " - "Must be one of: %(version_map)s") % { - 'api_name': api_name, - 'version': version, - 'version_map': ', '.join(version_map.keys())} - raise exceptions.UnsupportedVersion(msg) - - return importutils.import_class(client_path) diff --git a/glanceclient/openstack/common/apiclient/fake_client.py b/glanceclient/openstack/common/apiclient/fake_client.py deleted file mode 100644 index b152933..0000000 --- a/glanceclient/openstack/common/apiclient/fake_client.py +++ /dev/null @@ -1,187 +0,0 @@ -# Copyright 2013 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. - -""" -A fake server that "responds" to API methods with pre-canned responses. - -All of these responses come from the spec, so if for some reason the spec's -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-glanceclient-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 - -import json - -import requests -import six -from six.moves.urllib import parse - -from glanceclient.openstack.common.apiclient import client - - -def assert_has_keys(dct, required=None, optional=None): - required = required or [] - optional = optional or [] - for k in required: - try: - assert k in dct - except AssertionError: - extra_keys = set(dct.keys()).difference(set(required + optional)) - raise AssertionError("found unexpected keys: %s" % - list(extra_keys)) - - -class TestResponse(requests.Response): - """Wrap requests.Response and provide a convenient initialization.""" - - def __init__(self, data): - super(TestResponse, self).__init__() - self._content_consumed = True - if isinstance(data, dict): - self.status_code = data.get('status_code', 200) - # Fake the text attribute to streamline Response creation - text = data.get('text', "") - if isinstance(text, (dict, list)): - self._content = json.dumps(text) - default_headers = { - "Content-Type": "application/json", - } - else: - self._content = text - default_headers = {} - if six.PY3 and isinstance(self._content, six.string_types): - self._content = self._content.encode('utf-8', 'strict') - self.headers = data.get('headers') or default_headers - else: - self.status_code = data - - def __eq__(self, other): - return (self.status_code == other.status_code and - self.headers == other.headers and - self._content == other._content) - - -class FakeHTTPClient(client.HTTPClient): - - def __init__(self, *args, **kwargs): - self.callstack = [] - self.fixtures = kwargs.pop("fixtures", None) or {} - if not args and "auth_plugin" not in kwargs: - args = (None, ) - super(FakeHTTPClient, self).__init__(*args, **kwargs) - - def assert_called(self, method, url, body=None, pos=-1): - """Assert than an API method was just called.""" - expected = (method, url) - called = self.callstack[pos][0:2] - assert self.callstack, \ - "Expected %s %s but no calls were made." % expected - - assert expected == called, 'Expected %s %s; got %s %s' % \ - (expected + called) - - if body is not None: - if self.callstack[pos][3] != body: - raise AssertionError('%r != %r' % - (self.callstack[pos][3], body)) - - def assert_called_anytime(self, method, url, body=None): - """Assert than an API method was called anytime in the test.""" - expected = (method, url) - - assert self.callstack, \ - "Expected %s %s but no calls were made." % expected - - found = False - entry = None - for entry in self.callstack: - if expected == entry[0:2]: - found = True - break - - assert found, 'Expected %s %s; got %s' % \ - (method, url, self.callstack) - if body is not None: - assert entry[3] == body, "%s != %s" % (entry[3], body) - - self.callstack = [] - - def clear_callstack(self): - self.callstack = [] - - def authenticate(self): - pass - - def client_request(self, client, method, url, **kwargs): - # Check that certain things are called correctly - if method in ["GET", "DELETE"]: - assert "json" not in kwargs - - # Note the call - self.callstack.append( - (method, - url, - kwargs.get("headers") or {}, - kwargs.get("json") or kwargs.get("data"))) - try: - fixture = self.fixtures[url][method] - except KeyError: - pass - else: - return TestResponse({"headers": fixture[0], - "text": fixture[1]}) - - # Call the method - args = parse.parse_qsl(parse.urlparse(url)[4]) - kwargs.update(args) - munged_url = url.rsplit('?', 1)[0] - munged_url = munged_url.strip('/').replace('/', '_').replace('.', '_') - munged_url = munged_url.replace('-', '_') - - callback = "%s_%s" % (method.lower(), munged_url) - - if not hasattr(self, callback): - raise AssertionError('Called unknown API method: %s %s, ' - 'expected fakes method name: %s' % - (method, url, callback)) - - resp = getattr(self, callback)(**kwargs) - if len(resp) == 3: - status, headers, body = resp - else: - status, body = resp - headers = {} - self.last_request_id = headers.get('x-openstack-request-id', - 'req-test') - return TestResponse({ - "status_code": status, - "text": body, - "headers": headers, - }) diff --git a/glanceclient/shell.py b/glanceclient/shell.py index 86e6107..51e02a6 100755..100644 --- a/glanceclient/shell.py +++ b/glanceclient/shell.py @@ -38,11 +38,11 @@ from glanceclient._i18n import _ from glanceclient.common import utils from glanceclient import exc -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 keystoneauth1 import discover +from keystoneauth1 import exceptions as ks_exc +from keystoneauth1.identity import v2 as v2_auth +from keystoneauth1.identity import v3 as v3_auth +from keystoneauth1 import loading osprofiler_profiler = importutils.try_import("osprofiler.profiler") @@ -51,10 +51,14 @@ SUPPORTED_VERSIONS = [1, 2] class OpenStackImagesShell(object): - def _append_global_identity_args(self, parser): + def _append_global_identity_args(self, parser, argv): # register common identity args - session.Session.register_cli_options(parser) - v3_auth.Password.register_argparse_arguments(parser) + parser.set_defaults(os_auth_url=utils.env('OS_AUTH_URL')) + + parser.set_defaults(os_project_name=utils.env( + 'OS_PROJECT_NAME', 'OS_TENANT_NAME')) + parser.set_defaults(os_project_id=utils.env( + 'OS_PROJECT_ID', 'OS_TENANT_ID')) parser.add_argument('--key-file', dest='os_key', @@ -68,17 +72,9 @@ class OpenStackImagesShell(object): dest='os_cert', help='DEPRECATED! Use --os-cert.') - parser.add_argument('--os-tenant-id', - default=utils.env('OS_TENANT_ID'), - help='Defaults to env[OS_TENANT_ID].') - parser.add_argument('--os_tenant_id', help=argparse.SUPPRESS) - parser.add_argument('--os-tenant-name', - default=utils.env('OS_TENANT_NAME'), - help='Defaults to env[OS_TENANT_NAME].') - parser.add_argument('--os_tenant_name', help=argparse.SUPPRESS) @@ -110,7 +106,19 @@ class OpenStackImagesShell(object): parser.add_argument('--os_endpoint_type', help=argparse.SUPPRESS) - def get_base_parser(self): + loading.register_session_argparse_arguments(parser) + # Peek into argv to see if os-auth-token (or the deprecated + # os_auth_token) or the new os-token or the environment variable + # OS_AUTH_TOKEN were given. In which case, the token auth plugin is + # what the user wants. Else, we'll default to password. + default_auth_plugin = 'password' + token_opts = ['os-token', 'os-auth-token', 'os_auth-token'] + if argv and any(i in token_opts for i in argv): + default_auth_plugin = 'token' + loading.register_auth_argparse_arguments( + parser, argv, default=default_auth_plugin) + + def get_base_parser(self, argv): parser = argparse.ArgumentParser( prog='glance', description=__doc__.strip(), @@ -194,12 +202,12 @@ class OpenStackImagesShell(object): 'the profiling will not be triggered even ' 'if osprofiler is enabled on server side.') - self._append_global_identity_args(parser) + self._append_global_identity_args(parser, argv) return parser - def get_subcommand_parser(self, version): - parser = self.get_base_parser() + def get_subcommand_parser(self, version, argv=None): + parser = self.get_base_parser(argv) self.subcommands = {} subparsers = parser.add_subparsers(metavar='<subcommand>') @@ -214,7 +222,8 @@ class OpenStackImagesShell(object): 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. + # Replace underscores with hyphens in the commands + # displayed to the user command = attr[3:].replace('_', '-') callback = getattr(actions_module, attr) desc = callback.__doc__ or '' @@ -260,7 +269,7 @@ class OpenStackImagesShell(object): v2_auth_url = None v3_auth_url = None try: - ks_discover = discover.Discover(session=session, auth_url=auth_url) + ks_discover = discover.Discover(session=session, url=auth_url) v2_auth_url = ks_discover.url_for('2.0') v3_auth_url = ks_discover.url_for('3.0') except ks_exc.ClientException as e: @@ -284,9 +293,7 @@ class OpenStackImagesShell(object): return (v2_auth_url, v3_auth_url) - def _get_keystone_session(self, **kwargs): - ks_session = session.Session.construct(kwargs) - + def _get_keystone_auth_plugin(self, ks_session, **kwargs): # discover the supported keystone versions using the given auth url auth_url = kwargs.pop('auth_url', None) (v2_auth_url, v3_auth_url) = self._discover_auth_versions( @@ -346,10 +353,9 @@ class OpenStackImagesShell(object): "may not able to handle Keystone V3 credentials. " "Please provide a correct Keystone V3 auth_url.") - ks_session.auth = auth - return ks_session + return auth - def _get_kwargs_for_create_session(self, args): + def _get_kwargs_to_create_auth_plugin(self, args): if not args.os_username: raise exc.CommandError( _("You must provide a username via" @@ -374,16 +380,11 @@ class OpenStackImagesShell(object): "or prompted response")) # Validate password flow auth - project_info = ( - args.os_tenant_name or args.os_tenant_id or ( - args.os_project_name and ( - args.os_project_domain_name or - args.os_project_domain_id - ) - ) or args.os_project_id - ) - - if not project_info: + os_project_name = getattr( + args, 'os_project_name', getattr(args, 'os_tenant_name', None)) + os_project_id = getattr( + args, 'os_project_id', getattr(args, 'os_tenant_id', None)) + if not any([os_project_name, os_project_id]): # tenant is deprecated in Keystone v3. Use the latest # terminology instead. raise exc.CommandError( @@ -416,10 +417,6 @@ class OpenStackImagesShell(object): 'project_id': args.os_project_id, 'project_domain_name': args.os_project_domain_name, 'project_domain_id': args.os_project_domain_id, - 'insecure': args.insecure, - 'cacert': args.os_cacert, - 'cert': args.os_cert, - 'key': args.os_key } return kwargs @@ -427,9 +424,7 @@ class OpenStackImagesShell(object): endpoint = self._get_image_url(args) auth_token = args.os_auth_token - auth_req = (hasattr(args, 'func') and - utils.is_authentication_required(args.func)) - if not auth_req or (endpoint and auth_token): + if endpoint and auth_token: kwargs = { 'token': auth_token, 'insecure': args.insecure, @@ -440,8 +435,19 @@ class OpenStackImagesShell(object): 'ssl_compression': args.ssl_compression } else: - kwargs = self._get_kwargs_for_create_session(args) - kwargs = {'session': self._get_keystone_session(**kwargs)} + ks_session = loading.load_session_from_argparse_arguments(args) + auth_plugin_kwargs = self._get_kwargs_to_create_auth_plugin(args) + ks_session.auth = self._get_keystone_auth_plugin( + ks_session=ks_session, **auth_plugin_kwargs) + kwargs = {'session': ks_session} + + if endpoint is None: + endpoint_type = args.os_endpoint_type or 'public' + service_type = args.os_service_type or 'image' + endpoint = ks_session.get_endpoint( + service_type=service_type, + interface=endpoint_type, + region_name=args.os_region_name) return glanceclient.Client(api_version, endpoint, **kwargs) @@ -457,7 +463,7 @@ class OpenStackImagesShell(object): except OSError as e: # This avoids glanceclient to crash if it can't write to # ~/.glanceclient, which may happen on some env (for me, - # it happens in Jenkins, as Glanceclient can't write to + # it happens in Jenkins, as glanceclient can't write to # /var/lib/jenkins). msg = '%s' % e print(encodeutils.safe_decode(msg), file=sys.stderr) @@ -487,27 +493,21 @@ class OpenStackImagesShell(object): def _get_subparser(api_version): try: - return self.get_subcommand_parser(api_version) + return self.get_subcommand_parser(api_version, argv) except ImportError as e: - if options.debug: - traceback.print_exc() if not str(e): # Add a generic import error message if the raised # ImportError has none. raise ImportError('Unable to import module. Re-run ' 'with --debug for more info.') raise - except Exception: - if options.debug: - traceback.print_exc() - raise # Parse args once to find version # NOTE(flepied) Under Python3, parsed arguments are removed # from the list so make a copy for the first parsing base_argv = copy.deepcopy(argv) - parser = self.get_base_parser() + parser = self.get_base_parser(argv) (options, args) = parser.parse_known_args(base_argv) try: @@ -519,7 +519,7 @@ class OpenStackImagesShell(object): endpoint = self._get_image_url(options) endpoint, url_version = utils.strip_version(endpoint) except ValueError: - # NOTE(flaper87): ValueError is raised if no endpoint is povided + # NOTE(flaper87): ValueError is raised if no endpoint is provided url_version = None # build available subcommands based on version @@ -535,6 +535,7 @@ class OpenStackImagesShell(object): # Handle top-level --help/-h before attempting to parse # a command off the command line if options.help or not argv: + parser = _get_subparser(api_version) self.do_help(options, parser=parser) return 0 @@ -581,6 +582,12 @@ class OpenStackImagesShell(object): if not args.os_password and options.os_password: args.os_password = options.os_password + if args.debug: + # Set up the root logger to debug so that the submodules can + # print debug messages + logging.basicConfig(level=logging.DEBUG) + # for iso8601 < 0.1.11 + logging.getLogger('iso8601').setLevel(logging.WARNING) LOG = logging.getLogger('glanceclient') LOG.addHandler(logging.StreamHandler()) LOG.setLevel(logging.DEBUG if args.debug else logging.INFO) @@ -595,12 +602,6 @@ class OpenStackImagesShell(object): args.func(client, args) except exc.Unauthorized: raise exc.CommandError("Invalid OpenStack Identity credentials.") - except Exception: - # NOTE(kragniz) Print any exceptions raised to stderr if the - # --debug flag is set - if args.debug: - traceback.print_exc() - raise finally: if profile: trace_id = osprofiler_profiler.get().get_base_id() @@ -671,4 +672,6 @@ def main(): except KeyboardInterrupt: utils.exit('... terminating glance client', exit_code=130) except Exception as e: + if utils.debug_enabled(argv) is True: + traceback.print_exc() utils.exit(encodeutils.exception_to_unicode(e)) diff --git a/glanceclient/tests/functional/base.py b/glanceclient/tests/functional/base.py index 6029a06..a6306bf 100644 --- a/glanceclient/tests/functional/base.py +++ b/glanceclient/tests/functional/base.py @@ -13,7 +13,7 @@ import os import os_client_config -from tempest_lib.cli import base +from tempest.lib.cli import base def credentials(cloud='devstack-admin'): diff --git a/glanceclient/tests/functional/test_readonly_glance.py b/glanceclient/tests/functional/test_readonly_glance.py index 9c9989d..822bbcc 100644 --- a/glanceclient/tests/functional/test_readonly_glance.py +++ b/glanceclient/tests/functional/test_readonly_glance.py @@ -12,7 +12,7 @@ import re -from tempest_lib import exceptions +from tempest.lib import exceptions from glanceclient.tests.functional import base @@ -52,7 +52,7 @@ class SimpleReadOnlyGlanceClientTest(base.ClientTestBase): def test_member_list_v2(self): try: # NOTE(flwang): If set disk-format and container-format, Jenkins - # will raise an error said can't recognize the params, thouhg it + # will raise an error said can't recognize the params, though it # works fine at local. Without the two params, Glance will # complain. So we just catch the exception can skip it. self.glance('--os-image-api-version 2 image-create --name temp') diff --git a/glanceclient/tests/unit/test_base.py b/glanceclient/tests/unit/test_base.py index ddbc3d7..43bb71d 100644 --- a/glanceclient/tests/unit/test_base.py +++ b/glanceclient/tests/unit/test_base.py @@ -16,7 +16,7 @@ import testtools -from glanceclient.openstack.common.apiclient import base +from glanceclient.v1.apiclient import base class TestBase(testtools.TestCase): diff --git a/glanceclient/tests/unit/test_exc.py b/glanceclient/tests/unit/test_exc.py index 575c62b..9a2d01f 100644 --- a/glanceclient/tests/unit/test_exc.py +++ b/glanceclient/tests/unit/test_exc.py @@ -68,3 +68,11 @@ class TestHTTPExceptions(testtools.TestCase): self.assertIsInstance(err, exc.HTTPNotFound) self.assertEqual("404 Entity Not Found: Entity could not be found", err.details) + + def test_format_no_content_type(self): + mock_resp = mock.Mock() + mock_resp.status_code = 400 + mock_resp.headers = {'content-type': 'application/octet-stream'} + body = b'Error \n\n' + err = exc.from_response(mock_resp, body) + self.assertEqual('Error \n', err.details) diff --git a/glanceclient/tests/unit/test_http.py b/glanceclient/tests/unit/test_http.py index c18660e..ae19231 100644 --- a/glanceclient/tests/unit/test_http.py +++ b/glanceclient/tests/unit/test_http.py @@ -15,8 +15,8 @@ import functools import json -from keystoneclient.auth import token_endpoint -from keystoneclient import session +from keystoneauth1 import session +from keystoneauth1 import token_endpoint import mock import requests from requests_mock.contrib import fixture @@ -157,10 +157,10 @@ class TestClient(testtools.TestCase): http_client.get(path) headers = self.mock.last_request.headers - self.assertTrue('Accept-Language' not in headers) + self.assertNotIn('Accept-Language', headers) def test_connection_timeout(self): - """Should receive an InvalidEndpoint if connection timeout.""" + """Verify a InvalidEndpoint is received if connection times out.""" def cb(request, context): raise requests.exceptions.Timeout @@ -172,11 +172,9 @@ class TestClient(testtools.TestCase): self.assertIn(self.endpoint, comm_err.message) def test_connection_refused(self): - """ + """Verify a CommunicationError is received if connection is refused. - Should receive a CommunicationError if connection refused. - And the error should list the host and port that refused the - connection + The error should list the host and port that refused the connection. """ def cb(request, context): raise requests.exceptions.ConnectionError() @@ -201,23 +199,12 @@ class TestClient(testtools.TestCase): self.assertEqual(text, resp.text) def test_headers_encoding(self): - if not hasattr(self.client, 'encode_headers'): - self.skipTest('Cannot do header encoding check on SessionClient') - value = u'ni\xf1o' headers = {"test": value, "none-val": None} - encoded = self.client.encode_headers(headers) + encoded = http.encode_headers(headers) self.assertEqual(b"ni\xc3\xb1o", encoded[b"test"]) self.assertNotIn("none-val", encoded) - def test_auth_token_header_encoding(self): - # Tests that X-Auth-Token header is converted to ascii string, as - # httplib in python 2.6 won't do the conversion - value = u'ni\xf1o' - http_client_object = http.HTTPClient(self.endpoint, token=value) - self.assertEqual(b'ni\xc3\xb1o', - http_client_object.session.headers['X-Auth-Token']) - def test_raw_request(self): """Verify the path being used for HTTP requests reflects accurately.""" headers = {"Content-Type": "text/plain"} @@ -242,7 +229,15 @@ class TestClient(testtools.TestCase): def test_get_connections_kwargs_http(self): endpoint = 'http://example.com:9292' test_client = http.HTTPClient(endpoint, token=u'adc123') - self.assertEqual(test_client.timeout, 600.0) + self.assertEqual(600.0, test_client.timeout) + + def test__chunk_body_exact_size_chunk(self): + test_client = http._BaseHTTPClient() + bytestring = b'x' * http.CHUNKSIZE + data = six.BytesIO(bytestring) + chunk = list(test_client._chunk_body(data)) + self.assertEqual(1, len(chunk)) + self.assertEqual([bytestring], chunk) def test_http_chunked_request(self): text = "Ok" @@ -382,3 +377,26 @@ class TestClient(testtools.TestCase): self.assertThat(mock_log.call_args[0][0], matchers.Not(matchers.MatchesRegex(token_regex)), 'token found in LOG.debug parameter') + + def test_expired_token_has_changed(self): + # instantiate client with some token + fake_token = b'fake-token' + http_client = http.HTTPClient(self.endpoint, + token=fake_token) + path = '/v1/images/my-image' + self.mock.get(self.endpoint + path) + http_client.get(path) + headers = self.mock.last_request.headers + self.assertEqual(fake_token, headers['X-Auth-Token']) + # refresh the token + refreshed_token = b'refreshed-token' + http_client.auth_token = refreshed_token + http_client.get(path) + headers = self.mock.last_request.headers + self.assertEqual(refreshed_token, headers['X-Auth-Token']) + # regression check for bug 1448080 + unicode_token = u'ni\xf1o' + http_client.auth_token = unicode_token + http_client.get(path) + headers = self.mock.last_request.headers + self.assertEqual(b'ni\xc3\xb1o', headers['X-Auth-Token']) diff --git a/glanceclient/tests/unit/test_shell.py b/glanceclient/tests/unit/test_shell.py index 2a33dc0..d175852 100644 --- a/glanceclient/tests/unit/test_shell.py +++ b/glanceclient/tests/unit/test_shell.py @@ -20,21 +20,24 @@ try: except ImportError: from ordereddict import OrderedDict import hashlib +import logging import os import sys +import traceback import uuid import fixtures -from keystoneclient import exceptions as ks_exc -from keystoneclient import fixture as ks_fixture +from keystoneauth1 import exceptions as ks_exc +from keystoneauth1 import fixture as ks_fixture import mock -import requests from requests_mock.contrib import fixture as rm_fixture import six from glanceclient.common import utils from glanceclient import exc from glanceclient import shell as openstack_shell +from glanceclient.tests.unit.v2.fixtures import image_show_fixture +from glanceclient.tests.unit.v2.fixtures import image_versions_fixture from glanceclient.tests import utils as testutils # NOTE (esheffield) Used for the schema caching tests @@ -42,7 +45,8 @@ from glanceclient.v2 import schemas as schemas import json -DEFAULT_IMAGE_URL = 'http://127.0.0.1:5000/' +DEFAULT_IMAGE_URL = 'http://127.0.0.1:9292/' +DEFAULT_IMAGE_URL_INTERNAL = 'http://127.0.0.1:9191/' DEFAULT_USERNAME = 'username' DEFAULT_PASSWORD = 'password' DEFAULT_TENANT_ID = 'tenant_id' @@ -54,6 +58,8 @@ DEFAULT_V2_AUTH_URL = '%sv2.0' % DEFAULT_UNVERSIONED_AUTH_URL DEFAULT_V3_AUTH_URL = '%sv3' % DEFAULT_UNVERSIONED_AUTH_URL DEFAULT_AUTH_TOKEN = ' 3bcc3d3a03f44e3d8377f9247b0ad155' TEST_SERVICE_URL = 'http://127.0.0.1:5000/' +DEFAULT_SERVICE_TYPE = 'image' +DEFAULT_ENDPOINT_TYPE = 'public' FAKE_V2_ENV = {'OS_USERNAME': DEFAULT_USERNAME, 'OS_PASSWORD': DEFAULT_PASSWORD, @@ -68,6 +74,15 @@ FAKE_V3_ENV = {'OS_USERNAME': DEFAULT_USERNAME, 'OS_AUTH_URL': DEFAULT_V3_AUTH_URL, 'OS_IMAGE_URL': DEFAULT_IMAGE_URL} +FAKE_V4_ENV = {'OS_USERNAME': DEFAULT_USERNAME, + 'OS_PASSWORD': DEFAULT_PASSWORD, + 'OS_PROJECT_ID': DEFAULT_PROJECT_ID, + 'OS_USER_DOMAIN_NAME': DEFAULT_USER_DOMAIN_NAME, + 'OS_AUTH_URL': DEFAULT_V3_AUTH_URL, + 'OS_SERVICE_TYPE': DEFAULT_SERVICE_TYPE, + 'OS_ENDPOINT_TYPE': DEFAULT_ENDPOINT_TYPE, + 'OS_AUTH_TOKEN': DEFAULT_AUTH_TOKEN} + TOKEN_ID = uuid.uuid4().hex V2_TOKEN = ks_fixture.V2Token(token_id=TOKEN_ID) @@ -78,7 +93,8 @@ _s.add_endpoint(DEFAULT_IMAGE_URL) V3_TOKEN = ks_fixture.V3Token() V3_TOKEN.set_project_scope() _s = V3_TOKEN.add_service('image', name='glance') -_s.add_standard_endpoints(public=DEFAULT_IMAGE_URL) +_s.add_standard_endpoints(public=DEFAULT_IMAGE_URL, + internal=DEFAULT_IMAGE_URL_INTERNAL) class ShellTest(testutils.TestCase): @@ -100,7 +116,9 @@ class ShellTest(testutils.TestCase): self.requests = self.useFixture(rm_fixture.Fixture()) json_list = ks_fixture.DiscoveryList(DEFAULT_UNVERSIONED_AUTH_URL) - self.requests.get(DEFAULT_IMAGE_URL, json=json_list, status_code=300) + self.requests.get(DEFAULT_UNVERSIONED_AUTH_URL, + json=json_list, + status_code=300) json_v2 = {'version': ks_fixture.V2Discovery(DEFAULT_V2_AUTH_URL)} self.requests.get(DEFAULT_V2_AUTH_URL, json=json_v2) @@ -150,17 +168,55 @@ class ShellTest(testutils.TestCase): argstr = '--os-image-api-version 2 help foofoo' self.assertRaises(exc.CommandError, shell.main, argstr.split()) + @mock.patch('sys.stdout', six.StringIO()) + @mock.patch('sys.stderr', six.StringIO()) + @mock.patch('sys.argv', ['glance', 'help', 'foofoo']) + def test_no_stacktrace_when_debug_disabled(self): + with mock.patch.object(traceback, 'print_exc') as mock_print_exc: + try: + openstack_shell.main() + except SystemExit: + pass + self.assertFalse(mock_print_exc.called) + + @mock.patch('sys.stdout', six.StringIO()) + @mock.patch('sys.stderr', six.StringIO()) + @mock.patch('sys.argv', ['glance', 'help', 'foofoo']) + def test_stacktrace_when_debug_enabled_by_env(self): + old_environment = os.environ.copy() + os.environ = {'GLANCECLIENT_DEBUG': '1'} + try: + with mock.patch.object(traceback, 'print_exc') as mock_print_exc: + try: + openstack_shell.main() + except SystemExit: + pass + self.assertTrue(mock_print_exc.called) + finally: + os.environ = old_environment + + @mock.patch('sys.stdout', six.StringIO()) + @mock.patch('sys.stderr', six.StringIO()) + @mock.patch('sys.argv', ['glance', '--debug', 'help', 'foofoo']) + def test_stacktrace_when_debug_enabled(self): + with mock.patch.object(traceback, 'print_exc') as mock_print_exc: + try: + openstack_shell.main() + except SystemExit: + pass + self.assertTrue(mock_print_exc.called) + def test_help(self): shell = openstack_shell.OpenStackImagesShell() argstr = '--os-image-api-version 2 help' - with mock.patch.object(shell, '_get_keystone_session') as et_mock: + with mock.patch.object(shell, '_get_keystone_auth_plugin') as et_mock: actual = shell.main(argstr.split()) self.assertEqual(0, actual) self.assertFalse(et_mock.called) def test_blank_call(self): shell = openstack_shell.OpenStackImagesShell() - with mock.patch.object(shell, '_get_keystone_session') as et_mock: + with mock.patch.object(shell, '_get_keystone_auth_plugin') as et_mock: actual = shell.main('') self.assertEqual(0, actual) self.assertFalse(et_mock.called) @@ -172,7 +228,21 @@ class ShellTest(testutils.TestCase): def test_help_v2_no_schema(self): shell = openstack_shell.OpenStackImagesShell() argstr = '--os-image-api-version 2 help image-create' - with mock.patch.object(shell, '_get_keystone_session') as et_mock: + with mock.patch.object(shell, '_get_keystone_auth_plugin') as et_mock: + actual = shell.main(argstr.split()) + self.assertEqual(0, actual) + self.assertNotIn('<unavailable>', actual) + self.assertFalse(et_mock.called) + + argstr = '--os-image-api-version 2 help md-namespace-create' + with mock.patch.object(shell, '_get_keystone_auth_plugin') as et_mock: + actual = shell.main(argstr.split()) + self.assertEqual(0, actual) + self.assertNotIn('<unavailable>', actual) + self.assertFalse(et_mock.called) + + argstr = '--os-image-api-version 2 help md-resource-type-associate' + with mock.patch.object(shell, '_get_keystone_auth_plugin') as et_mock: actual = shell.main(argstr.split()) self.assertEqual(0, actual) self.assertNotIn('<unavailable>', actual) @@ -180,7 +250,9 @@ class ShellTest(testutils.TestCase): def test_get_base_parser(self): test_shell = openstack_shell.OpenStackImagesShell() - actual_parser = test_shell.get_base_parser() + # NOTE(stevemar): Use the current sys.argv for base_parser since it + # doesn't matter for this test, it just needs to initialize the CLI + actual_parser = test_shell.get_base_parser(sys.argv) description = 'Command-line interface to the OpenStack Images API.' expected = argparse.ArgumentParser( prog='glance', usage=None, @@ -328,18 +400,6 @@ class ShellTest(testutils.TestCase): glance_shell.main(args) self.assertEqual(1, mock_client.call_count) - @mock.patch('glanceclient.v2.client.Client') - def test_password_prompted_with_v2(self, v2_client): - self.requests.post(self.token_url, exc=requests.ConnectionError) - - cli2 = mock.MagicMock() - v2_client.return_value = cli2 - cli2.http_client.get.return_value = (None, {'versions': []}) - glance_shell = openstack_shell.OpenStackImagesShell() - os.environ['OS_PASSWORD'] = 'password' - self.assertRaises(exc.CommunicationError, - glance_shell.main, ['image-list']) - @mock.patch('sys.stdin', side_effect=mock.MagicMock) @mock.patch('getpass.getpass', side_effect=EOFError) @mock.patch('glanceclient.v2.client.Client') @@ -357,7 +417,7 @@ class ShellTest(testutils.TestCase): mock_getpass.assert_called_with('OS Password: ') @mock.patch( - 'glanceclient.shell.OpenStackImagesShell._get_keystone_session') + 'glanceclient.shell.OpenStackImagesShell._get_keystone_auth_plugin') @mock.patch.object(openstack_shell.OpenStackImagesShell, '_cache_schemas', return_value=False) def test_no_auth_with_proj_name(self, cache_schemas, session): @@ -523,9 +583,28 @@ class ShellTest(testutils.TestCase): except SystemExit: self.fail('Unexpected SystemExit') - # We expect the normal usage as a result - self.assertIn('Command-line interface to the OpenStack Images API', - sys.stdout.getvalue()) + # We expect the normal v2 usage as a result + expected = ['Command-line interface to the OpenStack Images API', + 'image-list', + 'image-deactivate', + 'location-add'] + for output in expected: + self.assertIn(output, + sys.stdout.getvalue()) + + @mock.patch('glanceclient.v2.client.Client') + @mock.patch('glanceclient.v1.shell.do_image_list') + @mock.patch('glanceclient.shell.logging.basicConfig') + def test_setup_debug(self, conf, func, v2_client): + cli2 = mock.MagicMock() + v2_client.return_value = cli2 + cli2.http_client.get.return_value = (None, {'versions': []}) + args = '--debug image-list' + glance_shell = openstack_shell.OpenStackImagesShell() + glance_shell.main(args.split()) + glance_logger = logging.getLogger('glanceclient') + self.assertEqual(glance_logger.getEffectiveLevel(), logging.DEBUG) + conf.assert_called_with(level=logging.DEBUG) class ShellTestWithKeystoneV3Auth(ShellTest): @@ -560,7 +639,7 @@ class ShellTestWithKeystoneV3Auth(ShellTest): glance_shell.main(args.split()) self.assertEqual(0, self.v3_auth.call_count) - @mock.patch('keystoneclient.discover.Discover', + @mock.patch('keystoneauth1.discover.Discover', side_effect=ks_exc.ClientException()) def test_api_discovery_failed_with_unversioned_auth_url(self, discover): @@ -586,6 +665,65 @@ class ShellTestWithKeystoneV3Auth(ShellTest): self.assertNotIn(r, stdout.split()) +class ShellTestWithNoOSImageURLPublic(ShellTestWithKeystoneV3Auth): + # auth environment to use + # default uses public + auth_env = FAKE_V4_ENV.copy() + + def setUp(self): + super(ShellTestWithNoOSImageURLPublic, self).setUp() + self.image_url = DEFAULT_IMAGE_URL + self.requests.get(DEFAULT_IMAGE_URL + 'v2/images', + text='{"images": []}') + + @mock.patch('glanceclient.v1.client.Client') + def test_auth_plugin_invocation_with_v1(self, v1_client): + args = '--os-image-api-version 1 image-list' + glance_shell = openstack_shell.OpenStackImagesShell() + glance_shell.main(args.split()) + self.assertEqual(1, self.v3_auth.call_count) + self._assert_auth_plugin_args() + + @mock.patch('glanceclient.v2.client.Client') + def test_auth_plugin_invocation_with_v2(self, v2_client): + args = '--os-image-api-version 2 image-list' + glance_shell = openstack_shell.OpenStackImagesShell() + glance_shell.main(args.split()) + self.assertEqual(1, self.v3_auth.call_count) + self._assert_auth_plugin_args() + + @mock.patch('glanceclient.v2.client.Client') + def test_endpoint_from_interface(self, v2_client): + args = ('--os-image-api-version 2 image-list') + glance_shell = openstack_shell.OpenStackImagesShell() + glance_shell.main(args.split()) + assert v2_client.called + (args, kwargs) = v2_client.call_args + self.assertEqual(self.image_url, kwargs['endpoint_override']) + + def test_endpoint_real_from_interface(self): + args = ('--os-image-api-version 2 image-list') + glance_shell = openstack_shell.OpenStackImagesShell() + glance_shell.main(args.split()) + self.assertEqual(self.requests.request_history[2].url, + self.image_url + "v2/images?limit=20&" + "sort_key=name&sort_dir=asc") + + +class ShellTestWithNoOSImageURLInternal(ShellTestWithNoOSImageURLPublic): + # auth environment to use + # this uses internal + FAKE_V5_ENV = FAKE_V4_ENV.copy() + FAKE_V5_ENV['OS_ENDPOINT_TYPE'] = 'internal' + auth_env = FAKE_V5_ENV.copy() + + def setUp(self): + super(ShellTestWithNoOSImageURLPublic, self).setUp() + self.image_url = DEFAULT_IMAGE_URL_INTERNAL + self.requests.get(DEFAULT_IMAGE_URL_INTERNAL + 'v2/images', + text='{"images": []}') + + class ShellCacheSchemaTest(testutils.TestCase): def setUp(self): super(ShellCacheSchemaTest, self).setUp() @@ -709,4 +847,116 @@ class ShellCacheSchemaTest(testutils.TestCase): switch_version = self.shell._cache_schemas(self._make_args(options), client, home_dir=self.cache_dir) - self.assertEqual(switch_version, True) + self.assertEqual(True, switch_version) + + +class ShellTestRequests(testutils.TestCase): + """Shell tests using the requests mock library.""" + def _make_args(self, args): + # NOTE(venkatesh): this conversion from a dict to an object + # is required because the test_shell.do_xxx(gc, args) methods + # expects the args to be attributes of an object. If passed as + # dict directly, it throws an AttributeError. + class Args(object): + def __init__(self, entries): + self.__dict__.update(entries) + + return Args(args) + + def setUp(self): + super(ShellTestRequests, self).setUp() + self._old_env = os.environ + os.environ = {} + + def tearDown(self): + super(ShellTestRequests, self).tearDown() + os.environ = self._old_env + + def test_download_has_no_stray_output_to_stdout(self): + """Regression test for bug 1488914""" + saved_stdout = sys.stdout + try: + sys.stdout = output = testutils.FakeNoTTYStdout() + id = image_show_fixture['id'] + self.requests = self.useFixture(rm_fixture.Fixture()) + self.requests.get('http://example.com/versions', + json=image_versions_fixture) + + headers = {'Content-Length': '4', + 'Content-type': 'application/octet-stream'} + fake = testutils.FakeResponse(headers, six.StringIO('DATA')) + self.requests.get('http://example.com/v1/images/%s' % id, + raw=fake) + + self.requests.get('http://example.com/v1/images/detail' + '?sort_key=name&sort_dir=asc&limit=20') + + headers = {'X-Image-Meta-Id': id} + self.requests.head('http://example.com/v1/images/%s' % id, + headers=headers) + + with mock.patch.object(openstack_shell.OpenStackImagesShell, + '_cache_schemas') as mocked_cache_schema: + mocked_cache_schema.return_value = True + shell = openstack_shell.OpenStackImagesShell() + argstr = ('--os-auth-token faketoken ' + '--os-image-url http://example.com ' + 'image-download %s' % id) + shell.main(argstr.split()) + self.assertTrue(mocked_cache_schema.called) + # Ensure we have *only* image data + self.assertEqual('DATA', output.getvalue()) + finally: + sys.stdout = saved_stdout + + def test_v1_download_has_no_stray_output_to_stdout(self): + """Ensure no stray print statements corrupt the image""" + saved_stdout = sys.stdout + try: + sys.stdout = output = testutils.FakeNoTTYStdout() + id = image_show_fixture['id'] + + self.requests = self.useFixture(rm_fixture.Fixture()) + headers = {'X-Image-Meta-Id': id} + self.requests.head('http://example.com/v1/images/%s' % id, + headers=headers) + + headers = {'Content-Length': '4', + 'Content-type': 'application/octet-stream'} + fake = testutils.FakeResponse(headers, six.StringIO('DATA')) + self.requests.get('http://example.com/v1/images/%s' % id, + headers=headers, raw=fake) + + shell = openstack_shell.OpenStackImagesShell() + argstr = ('--os-image-api-version 1 --os-auth-token faketoken ' + '--os-image-url http://example.com ' + 'image-download %s' % id) + shell.main(argstr.split()) + # Ensure we have *only* image data + self.assertEqual('DATA', output.getvalue()) + finally: + sys.stdout = saved_stdout + + def test_v2_download_has_no_stray_output_to_stdout(self): + """Ensure no stray print statements corrupt the image""" + saved_stdout = sys.stdout + try: + sys.stdout = output = testutils.FakeNoTTYStdout() + id = image_show_fixture['id'] + headers = {'Content-Length': '4', + 'Content-type': 'application/octet-stream'} + fake = testutils.FakeResponse(headers, six.StringIO('DATA')) + + self.requests = self.useFixture(rm_fixture.Fixture()) + self.requests.get('http://example.com/v2/images/%s/file' % id, + headers=headers, raw=fake) + + shell = openstack_shell.OpenStackImagesShell() + argstr = ('--os-image-api-version 2 --os-auth-token faketoken ' + '--os-image-url http://example.com ' + 'image-download %s' % id) + shell.main(argstr.split()) + # Ensure we have *only* image data + self.assertEqual('DATA', output.getvalue()) + finally: + sys.stdout = saved_stdout diff --git a/glanceclient/tests/unit/v1/test_images.py b/glanceclient/tests/unit/v1/test_images.py index 1de85ce..1f43b83 100644 --- a/glanceclient/tests/unit/v1/test_images.py +++ b/glanceclient/tests/unit/v1/test_images.py @@ -669,8 +669,8 @@ class ImageManagerTest(testtools.TestCase): 'x-image-meta-property-c': 'd', } expect = [('POST', '/v1/images', expect_headers, None)] - self.assertEqual(self.api.calls, expect) - self.assertEqual(image.id, '1') + self.assertEqual(expect, self.api.calls) + self.assertEqual('1', image.id) expect_req_id = ['req-1234'] self.assertEqual(expect_req_id, params['return_req_id']) @@ -738,7 +738,7 @@ class ImageManagerTest(testtools.TestCase): self.mgr.update('4', **fields) expect_headers = {'x-glance-registry-purge-props': 'true'} expect = [('PUT', '/v1/images/4', expect_headers, None)] - self.assertEqual(self.api.calls, expect) + self.assertEqual(expect, self.api.calls) expect_req_id = ['req-1234'] self.assertEqual(expect_req_id, fields['return_req_id']) @@ -765,7 +765,7 @@ class ImageManagerTest(testtools.TestCase): } images = self.mgr.list(**fields) next(images) - self.assertEqual(fields['return_req_id'], ['req-1234']) + self.assertEqual(['req-1234'], fields['return_req_id']) def test_image_list_with_notfound_owner(self): images = self.mgr.list(owner='X', page_size=20) diff --git a/glanceclient/tests/unit/v1/test_shell.py b/glanceclient/tests/unit/v1/test_shell.py index dd3e3cf..95bbd07 100644 --- a/glanceclient/tests/unit/v1/test_shell.py +++ b/glanceclient/tests/unit/v1/test_shell.py @@ -440,11 +440,10 @@ class ShellInvalidEndpointandParameterTest(utils.TestCase): class ShellStdinHandlingTests(testtools.TestCase): def _fake_update_func(self, *args, **kwargs): - """ + """Replace glanceclient.images.update with a fake. - Function to replace glanceclient.images.update, - to determine the parameters that would be supplied with the update - request + To determine the parameters that would be supplied with the update + request. """ # Store passed in args @@ -523,7 +522,7 @@ class ShellStdinHandlingTests(testtools.TestCase): ) def test_image_update_closed_stdin(self): - """ + """Test image update with a closed stdin. Supply glanceclient with a closed stdin, and perform an image update to an active image. Glanceclient should not attempt to read @@ -542,7 +541,7 @@ class ShellStdinHandlingTests(testtools.TestCase): ) def test_image_update_opened_stdin(self): - """ + """Test image update with an opened stdin. Supply glanceclient with a stdin, and perform an image update to an active image. Glanceclient should not allow it. @@ -575,7 +574,7 @@ class ShellStdinHandlingTests(testtools.TestCase): self.assertIn('data', self.collected_args[1]) self.assertIsInstance(self.collected_args[1]['data'], file_type) - self.assertEqual('Some Data', + self.assertEqual(b'Some Data', self.collected_args[1]['data'].read()) finally: @@ -600,7 +599,7 @@ class ShellStdinHandlingTests(testtools.TestCase): self.assertIn('data', self.collected_args[1]) self.assertIsInstance(self.collected_args[1]['data'], file_type) - self.assertEqual('Some Data\n', + self.assertEqual(b'Some Data\n', self.collected_args[1]['data'].read()) finally: diff --git a/glanceclient/tests/unit/v2/fixtures.py b/glanceclient/tests/unit/v2/fixtures.py index 703d43b..e4ece7e 100644 --- a/glanceclient/tests/unit/v2/fixtures.py +++ b/glanceclient/tests/unit/v2/fixtures.py @@ -90,8 +90,8 @@ schema_fixture = { "properties": { "architecture": { "description": "Operating system architecture as specified in " - "http://docs.openstack.org/trunk/openstack-compute" - "/admin/content/adding-images.html", + "http://docs.openstack.org/user-guide/common" + "/cli_manage_images.html", "is_base": "false", "type": "string" }, @@ -319,3 +319,68 @@ schema_fixture = { } } } + +image_versions_fixture = { + "versions": [ + { + "id": "v2.3", + "links": [ + { + "href": "http://localhost:9292/v2/", + "rel": "self" + } + ], + "status": "CURRENT" + }, + { + "id": "v2.2", + "links": [ + { + "href": "http://localhost:9292/v2/", + "rel": "self" + } + ], + "status": "SUPPORTED" + }, + { + "id": "v2.1", + "links": [ + { + "href": "http://localhost:9292/v2/", + "rel": "self" + } + ], + "status": "SUPPORTED" + }, + { + "id": "v2.0", + "links": [ + { + "href": "http://localhost:9292/v2/", + "rel": "self" + } + ], + "status": "SUPPORTED" + }, + { + "id": "v1.1", + "links": [ + { + "href": "http://localhost:9292/v1/", + "rel": "self" + } + ], + "status": "SUPPORTED" + }, + { + "id": "v1.0", + "links": [ + { + "href": "http://localhost:9292/v1/", + "rel": "self" + } + ], + "status": "SUPPORTED" + } + ] +} diff --git a/glanceclient/tests/unit/v2/test_images.py b/glanceclient/tests/unit/v2/test_images.py index b095634..0ae3836 100644 --- a/glanceclient/tests/unit/v2/test_images.py +++ b/glanceclient/tests/unit/v2/test_images.py @@ -1056,10 +1056,8 @@ class TestController(testtools.TestCase): new_loc = {'url': 'http://spam.com/', 'metadata': {'spam': 'ham'}} add_patch = {'path': '/locations/-', 'value': new_loc, 'op': 'add'} self.controller.add_location(image_id, **new_loc) - self.assertEqual(self.api.calls, [ - self._patch_req(image_id, [add_patch]), - self._empty_get(image_id) - ]) + self.assertEqual([self._patch_req(image_id, [add_patch]), + self._empty_get(image_id)], self.api.calls) @mock.patch.object(images.Controller, '_send_image_update_request', side_effect=exc.HTTPBadRequest) @@ -1077,10 +1075,9 @@ class TestController(testtools.TestCase): del_patches = [{'path': '/locations/1', 'op': 'remove'}, {'path': '/locations/0', 'op': 'remove'}] self.controller.delete_locations(image_id, url_set) - self.assertEqual(self.api.calls, [ - self._empty_get(image_id), - self._patch_req(image_id, del_patches) - ]) + self.assertEqual([self._empty_get(image_id), + self._patch_req(image_id, del_patches)], + self.api.calls) def test_remove_missing_location(self): image_id = 'a2b83adc-888e-11e3-8872-78acc0b951d8' @@ -1100,15 +1097,12 @@ class TestController(testtools.TestCase): loc_map = dict([(l['url'], l) for l in orig_locations]) loc_map[new_loc['url']] = new_loc mod_patch = [{'path': '/locations', 'op': 'replace', - 'value': []}, - {'path': '/locations', 'op': 'replace', 'value': list(loc_map.values())}] self.controller.update_location(image_id, **new_loc) - self.assertEqual(self.api.calls, [ - self._empty_get(image_id), - self._patch_req(image_id, mod_patch), - self._empty_get(image_id) - ]) + self.assertEqual([self._empty_get(image_id), + self._patch_req(image_id, mod_patch), + self._empty_get(image_id)], + self.api.calls) def test_update_tags(self): image_id = 'a2b83adc-888e-11e3-8872-78acc0b951d8' diff --git a/glanceclient/tests/unit/v2/test_schemas.py b/glanceclient/tests/unit/v2/test_schemas.py index 60442a8..c01d8bd 100644 --- a/glanceclient/tests/unit/v2/test_schemas.py +++ b/glanceclient/tests/unit/v2/test_schemas.py @@ -130,7 +130,7 @@ class TestSchemaBasedModel(testtools.TestCase): def setUp(self): super(TestSchemaBasedModel, self).setUp() self.model = warlock.model_factory(_SCHEMA.raw(), - schemas.SchemaBasedModel) + base_class=schemas.SchemaBasedModel) def test_patch_should_replace_missing_core_properties(self): obj = { diff --git a/glanceclient/tests/unit/v2/test_shell_v2.py b/glanceclient/tests/unit/v2/test_shell_v2.py index cddb925..e79a42c 100644 --- a/glanceclient/tests/unit/v2/test_shell_v2.py +++ b/glanceclient/tests/unit/v2/test_shell_v2.py @@ -644,6 +644,20 @@ class ShellV2Test(testtools.TestCase): self.assertEqual(2, mocked_print_err.call_count) mocked_utils_exit.assert_called_once_with() + @mock.patch.object(utils, 'exit') + @mock.patch.object(utils, 'print_err') + def test_do_image_delete_with_image_in_use(self, mocked_print_err, + mocked_utils_exit): + args = argparse.Namespace(id=['image1', 'image2']) + with mock.patch.object(self.gc.images, 'delete') as mocked_delete: + mocked_delete.side_effect = exc.HTTPConflict + + test_shell.do_image_delete(self.gc, args) + + self.assertEqual(2, mocked_delete.call_count) + self.assertEqual(2, mocked_print_err.call_count) + mocked_utils_exit.assert_called_once_with() + def test_do_image_delete_deleted(self): image_id = 'deleted-img' args = argparse.Namespace(id=[image_id]) diff --git a/glanceclient/tests/unit/v2/test_tasks.py b/glanceclient/tests/unit/v2/test_tasks.py index 349a880..860b569 100644 --- a/glanceclient/tests/unit/v2/test_tasks.py +++ b/glanceclient/tests/unit/v2/test_tasks.py @@ -257,45 +257,45 @@ class TestController(testtools.TestCase): def test_list_tasks(self): # NOTE(flwang): cast to list since the controller returns a generator tasks = list(self.controller.list()) - self.assertEqual(tasks[0].id, _PENDING_ID) - self.assertEqual(tasks[0].type, 'import') - self.assertEqual(tasks[0].status, 'pending') - self.assertEqual(tasks[1].id, _PROCESSING_ID) - self.assertEqual(tasks[1].type, 'import') - self.assertEqual(tasks[1].status, 'processing') + self.assertEqual(_PENDING_ID, tasks[0].id) + self.assertEqual('import', tasks[0].type) + self.assertEqual('pending', tasks[0].status) + self.assertEqual(_PROCESSING_ID, tasks[1].id) + self.assertEqual('import', tasks[1].type) + self.assertEqual('processing', tasks[1].status) def test_list_tasks_paginated(self): # NOTE(flwang): cast to list since the controller returns a generator tasks = list(self.controller.list(page_size=1)) - self.assertEqual(tasks[0].id, _PENDING_ID) - self.assertEqual(tasks[0].type, 'import') - self.assertEqual(tasks[1].id, _PROCESSING_ID) - self.assertEqual(tasks[1].type, 'import') + self.assertEqual(_PENDING_ID, tasks[0].id) + self.assertEqual('import', tasks[0].type) + self.assertEqual(_PROCESSING_ID, tasks[1].id) + self.assertEqual('import', tasks[1].type) def test_list_tasks_with_status(self): filters = {'filters': {'status': 'processing'}} tasks = list(self.controller.list(**filters)) - self.assertEqual(tasks[0].id, _OWNED_TASK_ID) + self.assertEqual(_OWNED_TASK_ID, tasks[0].id) def test_list_tasks_with_wrong_status(self): filters = {'filters': {'status': 'fake'}} tasks = list(self.controller.list(**filters)) - self.assertEqual(len(tasks), 0) + self.assertEqual(0, len(tasks)) def test_list_tasks_with_type(self): filters = {'filters': {'type': 'import'}} tasks = list(self.controller.list(**filters)) - self.assertEqual(tasks[0].id, _OWNED_TASK_ID) + self.assertEqual(_OWNED_TASK_ID, tasks[0].id) def test_list_tasks_with_wrong_type(self): filters = {'filters': {'type': 'fake'}} tasks = list(self.controller.list(**filters)) - self.assertEqual(len(tasks), 0) + self.assertEqual(0, len(tasks)) def test_list_tasks_for_owner(self): filters = {'filters': {'owner': _OWNER_ID}} tasks = list(self.controller.list(**filters)) - self.assertEqual(tasks[0].id, _OWNED_TASK_ID) + self.assertEqual(_OWNED_TASK_ID, tasks[0].id) def test_list_tasks_for_fake_owner(self): filters = {'filters': {'owner': _FAKE_OWNER_ID}} @@ -347,8 +347,8 @@ class TestController(testtools.TestCase): def test_get_task(self): task = self.controller.get(_PENDING_ID) - self.assertEqual(task.id, _PENDING_ID) - self.assertEqual(task.type, 'import') + self.assertEqual(_PENDING_ID, task.id) + self.assertEqual('import', task.type) def test_create_task(self): properties = { @@ -357,8 +357,8 @@ class TestController(testtools.TestCase): 'swift://cloud.foo/myaccount/mycontainer/path'}, } task = self.controller.create(**properties) - self.assertEqual(task.id, _PENDING_ID) - self.assertEqual(task.type, 'import') + self.assertEqual(_PENDING_ID, task.id) + self.assertEqual('import', task.type) def test_create_task_invalid_property(self): properties = { diff --git a/glanceclient/tests/utils.py b/glanceclient/tests/utils.py index 2dc510c..6b03f31 100644 --- a/glanceclient/tests/utils.py +++ b/glanceclient/tests/utils.py @@ -74,7 +74,7 @@ class FakeSchemaAPI(FakeAPI): class RawRequest(object): def __init__(self, headers, body=None, version=1.0, status=200, reason="Ok"): - """ + """A crafted request object used for testing. :param headers: dict representing HTTP response headers :param body: file-like object @@ -101,7 +101,7 @@ class RawRequest(object): class FakeResponse(object): def __init__(self, headers=None, body=None, version=1.0, status_code=200, reason="Ok"): - """ + """A crafted response object used for testing. :param headers: dict representing HTTP response headers :param body: file-like object @@ -118,6 +118,10 @@ class FakeResponse(object): version=version, status=status_code) @property + def status(self): + return self.status_code + + @property def ok(self): return (self.status_code < 400 or self.status_code >= 600) @@ -151,6 +155,9 @@ class FakeResponse(object): break yield chunk + def release_conn(self, **kwargs): + pass + class TestCase(testtools.TestCase): TEST_REQUEST_BASE = { diff --git a/glanceclient/openstack/__init__.py b/glanceclient/v1/apiclient/__init__.py index e69de29..e69de29 100644 --- a/glanceclient/openstack/__init__.py +++ b/glanceclient/v1/apiclient/__init__.py diff --git a/glanceclient/openstack/common/apiclient/base.py b/glanceclient/v1/apiclient/base.py index c86613e..cb48096 100644 --- a/glanceclient/openstack/common/apiclient/base.py +++ b/glanceclient/v1/apiclient/base.py @@ -45,7 +45,7 @@ import six from six.moves.urllib import parse from glanceclient._i18n import _ -from glanceclient.openstack.common.apiclient import exceptions +from glanceclient.v1.apiclient import exceptions def getid(obj): diff --git a/glanceclient/openstack/common/apiclient/exceptions.py b/glanceclient/v1/apiclient/exceptions.py index 5bda5f0..5bda5f0 100644 --- a/glanceclient/openstack/common/apiclient/exceptions.py +++ b/glanceclient/v1/apiclient/exceptions.py diff --git a/glanceclient/openstack/common/apiclient/utils.py b/glanceclient/v1/apiclient/utils.py index e5d6926..814a37b 100644 --- a/glanceclient/openstack/common/apiclient/utils.py +++ b/glanceclient/v1/apiclient/utils.py @@ -29,7 +29,7 @@ from oslo_utils import uuidutils import six from glanceclient._i18n import _ -from glanceclient.openstack.common.apiclient import exceptions +from glanceclient.v1.apiclient import exceptions def find_resource(manager, name_or_id, **find_args): @@ -84,17 +84,15 @@ def find_resource(manager, name_or_id, **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.") % \ - { + "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/glanceclient/v1/image_members.py b/glanceclient/v1/image_members.py index 79242b5..144eeb5 100644 --- a/glanceclient/v1/image_members.py +++ b/glanceclient/v1/image_members.py @@ -13,7 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. -from glanceclient.openstack.common.apiclient import base +from glanceclient.v1.apiclient import base class ImageMember(base.Resource): diff --git a/glanceclient/v1/images.py b/glanceclient/v1/images.py index 14c1921..182f1e5 100644 --- a/glanceclient/v1/images.py +++ b/glanceclient/v1/images.py @@ -21,7 +21,7 @@ import six import six.moves.urllib.parse as urlparse from glanceclient.common import utils -from glanceclient.openstack.common.apiclient import base +from glanceclient.v1.apiclient import base UPDATE_PARAMS = ('name', 'disk_format', 'container_format', 'min_disk', 'min_ram', 'owner', 'size', 'is_public', 'protected', @@ -202,7 +202,7 @@ class ImageManager(base.ManagerWithFind): :param owner: If provided, only images with this owner (tenant id) will be listed. An empty string ('') matches ownerless images. - :param return_request_id: If an empty list is provided, populate this + :param return_req_id: If an empty list is provided, populate this list with the request ID value from the header x-openstack-request-id :rtype: list of :class:`Image` diff --git a/glanceclient/v1/shell.py b/glanceclient/v1/shell.py index 3372951..eb14f6e 100644 --- a/glanceclient/v1/shell.py +++ b/glanceclient/v1/shell.py @@ -155,7 +155,7 @@ def do_image_show(gc, args): @utils.arg('--file', metavar='<FILE>', help='Local file to save downloaded image data to. ' 'If this is not specified and there is no redirection ' - 'the image data will be not be saved.') + 'the image data will not be saved.') @utils.arg('image', metavar='<IMAGE>', help='Name or ID of image to download.') @utils.arg('--progress', action='store_true', default=False, help='Show download progress bar.') diff --git a/glanceclient/v1/versions.py b/glanceclient/v1/versions.py index d65c2f6..fe4253f 100644 --- a/glanceclient/v1/versions.py +++ b/glanceclient/v1/versions.py @@ -14,7 +14,7 @@ # License for the specific language governing permissions and limitations # under the License. -from glanceclient.openstack.common.apiclient import base +from glanceclient.v1.apiclient import base class VersionManager(base.ManagerWithFind): diff --git a/glanceclient/v2/image_schema.py b/glanceclient/v2/image_schema.py index 1e1d3bf..d31f0f5 100644 --- a/glanceclient/v2/image_schema.py +++ b/glanceclient/v2/image_schema.py @@ -13,7 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. -_doc_url = "http://docs.openstack.org/trunk/openstack-compute/admin/content/adding-images.html" # noqa +_doc_url = "http://docs.openstack.org/user-guide/common/cli-manage-images.html" # noqa # NOTE(flaper87): Keep a copy of the current default schema so that # we can react on cases where there's no connection to an OpenStack # deployment. See #1481729 @@ -22,32 +22,21 @@ _BASE_SCHEMA = { "type": "string" }, "name": "image", - "links": [ - { - "href": "{self}", - "rel": "self" - }, - { - "href": "{file}", - "rel": "enclosure" - }, - { - "href": "{schema}", - "rel": "describedby" - } - ], + "links": [{ + "href": "{self}", + "rel": "self" + }, { + "href": "{file}", + "rel": "enclosure" + }, { + "href": "{schema}", + "rel": "describedby" + }], "properties": { "container_format": { - "enum": [ - "ami", - "ari", - "aki", - "bare", - "ovf", - "ova", - "docker" - ], - "type": "string", + "enum": [None, "ami", "ari", "aki", "bare", "ovf", "ova", + "docker"], + "type": ["null", "string"], "description": "Format of the container" }, "min_ram": { @@ -58,17 +47,15 @@ _BASE_SCHEMA = { "pattern": ("^([0-9a-fA-F]){8}-([0-9a-fA-F]){4}" "-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}" "-([0-9a-fA-F]){12}$"), - "type": "string", + "type": ["null", "string"], "description": ("ID of image stored in Glance that should be " "used as the ramdisk when booting an AMI-style " - "image.") + "image."), + "is_base": False }, "locations": { "items": { - "required": [ - "url", - "metadata" - ], + "required": ["url", "metadata"], "type": "object", "properties": { "url": { @@ -85,12 +72,12 @@ _BASE_SCHEMA = { "file kept in external store") }, "file": { - "type": "string", "readOnly": True, - "description": ("An image file url") + "type": "string", + "description": "An image file url" }, "owner": { - "type": "string", + "type": ["null", "string"], "description": "Owner of the image", "maxLength": 255 }, @@ -102,62 +89,49 @@ _BASE_SCHEMA = { "description": "An identifier for the image" }, "size": { - "type": "integer", "readOnly": True, + "type": ["null", "integer"], "description": "Size of image file in bytes" }, "os_distro": { "type": "string", "description": ("Common name of operating system distribution " - "as specified in %s" % _doc_url) + "as specified in %s" % _doc_url), + "is_base": False }, "self": { - "type": "string", "readOnly": True, - "description": ("An image self url") + "type": "string", + "description": "An image self url" }, "disk_format": { - "enum": [ - "ami", - "ari", - "aki", - "vhd", - "vmdk", - "raw", - "qcow2", - "vdi", - "iso" - ], - "type": "string", + "enum": [None, "ami", "ari", "aki", "vhd", "vmdk", "raw", + "qcow2", "vdi", "iso"], + "type": ["null", "string"], "description": "Format of the disk" }, "os_version": { "type": "string", - "description": ("Operating system version as " - "specified by the distributor") + "description": "Operating system version as specified by the" + " distributor", + "is_base": False }, "direct_url": { - "type": "string", "readOnly": True, - "description": ("URL to access the image file kept in " - "external store") + "type": "string", + "description": "URL to access the image file kept in external" + " store" }, "schema": { - "type": "string", "readOnly": True, - "description": ("An image schema url") + "type": "string", + "description": "An image schema url" }, "status": { - "enum": [ - "queued", - "saving", - "active", - "killed", - "deleted", - "pending_delete" - ], - "type": "string", "readOnly": True, + "enum": ["queued", "saving", "active", "killed", "deleted", + "pending_delete", "deactivated"], + "type": "string", "description": "Status of the image" }, "tags": { @@ -169,60 +143,57 @@ _BASE_SCHEMA = { "description": "List of strings related to the image" }, "kernel_id": { - "pattern": ("^([0-9a-fA-F]){8}-([0-9a-fA-F]){4}-" - "([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-" - "([0-9a-fA-F]){12}$"), - "type": "string", - "description": ("ID of image stored in Glance that should be " - "used as the kernel when booting an AMI-style " - "image.") + "pattern": "^([0-9a-fA-F]){8}-([0-9a-fA-F]){4}-([0-9a-fA-F])" + "{4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){12}$", + "type": ["null", "string"], + "description": "ID of image stored in Glance that should be " + "used as the kernel when booting an " + "AMI-style image.", + "is_base": False }, "visibility": { - "enum": [ - "public", - "private" - ], + "enum": ["public", "private"], "type": "string", "description": "Scope of image accessibility" }, "updated_at": { - "type": "string", "readOnly": True, - "description": ("Date and time of the last " - "image modification") + "type": "string", + "description": "Date and time of the last image modification" }, "min_disk": { "type": "integer", - "description": ("Amount of disk space (in GB) " - "required to boot image.") + "description": "Amount of disk space (in GB) required to boot " + "image." }, "virtual_size": { - "type": "integer", "readOnly": True, + "type": ["null", "integer"], "description": "Virtual size of image in bytes" }, "instance_uuid": { "type": "string", - "description": ("Metadata which can be used to record which " - "instance this image is associated with. " - "(Informational only, does not create an instance " - "snapshot.)") + "description": "Metadata which can be used to record which " + "instance this image is associated with. " + "(Informational only, does not create an " + "instance snapshot.)", + "is_base": False }, "name": { - "type": "string", + "type": ["null", "string"], "description": "Descriptive name for the image", "maxLength": 255 }, "checksum": { - "type": "string", "readOnly": True, + "type": ["null", "string"], "description": "md5 hash of image contents.", "maxLength": 32 }, "created_at": { - "type": "string", "readOnly": True, - "description": "Date and time of image registration " + "type": "string", + "description": "Date and time of image registration" }, "protected": { "type": "boolean", @@ -231,7 +202,8 @@ _BASE_SCHEMA = { "architecture": { "type": "string", "description": ("Operating system architecture as specified " - "in %s" % _doc_url) + "in %s" % _doc_url), + "is_base": False } } } diff --git a/glanceclient/v2/image_tags.py b/glanceclient/v2/image_tags.py index bcecd01..deebce2 100644 --- a/glanceclient/v2/image_tags.py +++ b/glanceclient/v2/image_tags.py @@ -27,7 +27,8 @@ class Controller(object): @utils.memoized_property def model(self): schema = self.schema_client.get('image') - return warlock.model_factory(schema.raw(), schemas.SchemaBasedModel) + return warlock.model_factory(schema.raw(), + base_class=schemas.SchemaBasedModel) def update(self, image_id, tag_value): """Update an image with the given tag. diff --git a/glanceclient/v2/images.py b/glanceclient/v2/images.py index ed6a001..f69fed5 100644 --- a/glanceclient/v2/images.py +++ b/glanceclient/v2/images.py @@ -39,16 +39,16 @@ class Controller(object): @utils.memoized_property def model(self): schema = self.schema_client.get('image') - warlock_model = warlock.model_factory(schema.raw(), - schemas.SchemaBasedModel) + warlock_model = warlock.model_factory( + schema.raw(), base_class=schemas.SchemaBasedModel) return warlock_model @utils.memoized_property def unvalidated_model(self): """A model which does not validate the image against the v2 schema.""" schema = self.schema_client.get('image') - warlock_model = warlock.model_factory(schema.raw(), - schemas.SchemaBasedModel) + warlock_model = warlock.model_factory( + schema.raw(), base_class=schemas.SchemaBasedModel) warlock_model.validate = lambda *args, **kwargs: None return warlock_model @@ -253,7 +253,7 @@ class Controller(object): :param image_id: ID of the image to modify. :param remove_props: List of property names to remove - :param \*\*kwargs: Image attribute names and their new values. + :param kwargs: Image attribute names and their new values. """ unvalidated_image = self.get(image_id) image = self.model(**unvalidated_image) @@ -348,20 +348,17 @@ class Controller(object): image = self._get_image_with_locations_or_fail(image_id) url_map = dict([(l['url'], l) for l in image.locations]) if url not in url_map: - raise exc.HTTPNotFound('Unknown URL: %s' % url) + raise exc.HTTPNotFound('Unknown URL: %s, the URL must be one of' + ' existing locations of current image' % + url) if url_map[url]['metadata'] == metadata: return image - # NOTE: The server (as of now) doesn't support modifying individual - # location entries. So we must: - # 1. Empty existing list of locations. - # 2. Send another request to set 'locations' to the new list - # of locations. url_map[url]['metadata'] = metadata patches = [{'op': 'replace', 'path': '/locations', - 'value': p} for p in ([], list(url_map.values()))] + 'value': list(url_map.values())}] self._send_image_update_request(image_id, patches) return self.get(image_id) diff --git a/glanceclient/v2/metadefs.py b/glanceclient/v2/metadefs.py index 2344e33..4bee224 100644 --- a/glanceclient/v2/metadefs.py +++ b/glanceclient/v2/metadefs.py @@ -34,7 +34,8 @@ class NamespaceController(object): @utils.memoized_property def model(self): schema = self.schema_client.get('metadefs/namespace') - return warlock.model_factory(schema.raw(), schemas.SchemaBasedModel) + return warlock.model_factory(schema.raw(), + base_class=schemas.SchemaBasedModel) def create(self, **kwargs): """Create a namespace. @@ -102,7 +103,8 @@ class NamespaceController(object): in a subsequent limited request. :param sort_key: The field to sort on (for example, 'created_at') :param sort_dir: The direction to sort ('asc' or 'desc') - :returns generator over list of Namespaces + :returns: generator over list of Namespaces + """ ori_validate_fun = self.model.validate @@ -185,7 +187,8 @@ class ResourceTypeController(object): @utils.memoized_property def model(self): schema = self.schema_client.get('metadefs/resource_type') - return warlock.model_factory(schema.raw(), schemas.SchemaBasedModel) + return warlock.model_factory(schema.raw(), + base_class=schemas.SchemaBasedModel) def associate(self, namespace, **kwargs): """Associate a resource type with a namespace.""" @@ -201,7 +204,7 @@ class ResourceTypeController(object): return self.model(**body) def deassociate(self, namespace, resource): - """Deasociate a resource type with a namespace.""" + """Deassociate a resource type with a namespace.""" url = '/v2/metadefs/namespaces/{0}/resource_types/{1}'. \ format(namespace, resource) self.http_client.delete(url) @@ -209,7 +212,7 @@ class ResourceTypeController(object): def list(self): """Retrieve a listing of available resource types. - :returns generator over list of resource_types + :returns: generator over list of resource_types """ url = '/v2/metadefs/resource_types' @@ -233,7 +236,8 @@ class PropertyController(object): @utils.memoized_property def model(self): schema = self.schema_client.get('metadefs/property') - return warlock.model_factory(schema.raw(), schemas.SchemaBasedModel) + return warlock.model_factory(schema.raw(), + base_class=schemas.SchemaBasedModel) def create(self, namespace, **kwargs): """Create a property. @@ -283,7 +287,7 @@ class PropertyController(object): def list(self, namespace, **kwargs): """Retrieve a listing of metadata properties. - :returns generator over list of objects + :returns: generator over list of objects """ url = '/v2/metadefs/namespaces/{0}/properties'.format(namespace) @@ -313,7 +317,8 @@ class ObjectController(object): @utils.memoized_property def model(self): schema = self.schema_client.get('metadefs/object') - return warlock.model_factory(schema.raw(), schemas.SchemaBasedModel) + return warlock.model_factory(schema.raw(), + base_class=schemas.SchemaBasedModel) def create(self, namespace, **kwargs): """Create an object. @@ -336,7 +341,7 @@ class ObjectController(object): """Update an object. :param namespace: Name of a namespace the object belongs. - :param prop_name: Name of an object (old one). + :param object_name: Name of an object (old one). :param kwargs: Unpacked object. """ obj = self.get(namespace, object_name) @@ -368,7 +373,7 @@ class ObjectController(object): def list(self, namespace, **kwargs): """Retrieve a listing of metadata objects. - :returns generator over list of objects + :returns: generator over list of objects """ url = '/v2/metadefs/namespaces/{0}/objects'.format(namespace,) resp, body = self.http_client.get(url) @@ -396,7 +401,8 @@ class TagController(object): @utils.memoized_property def model(self): schema = self.schema_client.get('metadefs/tag') - return warlock.model_factory(schema.raw(), schemas.SchemaBasedModel) + return warlock.model_factory(schema.raw(), + base_class=schemas.SchemaBasedModel) def create(self, namespace, tag_name): """Create a tag. @@ -440,7 +446,7 @@ class TagController(object): """Update a tag. :param namespace: Name of a namespace the Tag belongs. - :param prop_name: Name of the Tag (old one). + :param tag_name: Name of the Tag (old one). :param kwargs: Unpacked tag. """ tag = self.get(namespace, tag_name) @@ -472,7 +478,7 @@ class TagController(object): def list(self, namespace, **kwargs): """Retrieve a listing of metadata tags. - :returns generator over list of tags. + :returns: generator over list of tags. """ url = '/v2/metadefs/namespaces/{0}/tags'.format(namespace) resp, body = self.http_client.get(url) diff --git a/glanceclient/v2/namespace_schema.py b/glanceclient/v2/namespace_schema.py new file mode 100644 index 0000000..36c8833 --- /dev/null +++ b/glanceclient/v2/namespace_schema.py @@ -0,0 +1,243 @@ +# Copyright 2015 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. + + +# NOTE(flaper87): Keep a copy of the current default schema so that +# we can react on cases where there's no connection to an OpenStack +# deployment. See #1481729 +BASE_SCHEMA = { + "additionalProperties": False, + "definitions": { + "positiveInteger": { + "minimum": 0, + "type": "integer" + }, + "positiveIntegerDefault0": { + "allOf": [ + {"$ref": "#/definitions/positiveInteger"}, + {"default": 0} + ] + }, + "stringArray": { + "type": "array", + "items": {"type": "string"}, + "uniqueItems": True + }, + "property": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["title", "type"], + "properties": { + "name": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "operators": { + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "type": "string", + "enum": [ + "array", + "boolean", + "integer", + "number", + "object", + "string", + None + ] + }, + "required": { + "$ref": "#/definitions/stringArray" + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "maxLength": { + "$ref": "#/definitions/positiveInteger" + }, + "minLength": { + "$ref": "#/definitions/positiveIntegerDefault0" + }, + "pattern": { + "type": "string", + "format": "regex" + }, + "enum": { + "type": "array" + }, + "readonly": { + "type": "boolean" + }, + "default": {}, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "array", + "boolean", + "integer", + "number", + "object", + "string", + None + ] + }, + "enum": { + "type": "array" + } + } + }, + "maxItems": { + "$ref": "#/definitions/positiveInteger" + }, + "minItems": { + "$ref": "#/definitions/positiveIntegerDefault0" + }, + "uniqueItems": { + "type": "boolean", + "default": False + }, + "additionalItems": { + "type": "boolean" + }, + } + } + } + }, + "required": ["namespace"], + "name": "namespace", + "properties": { + "namespace": { + "type": "string", + "description": "The unique namespace text.", + "maxLength": 80 + }, + "display_name": { + "type": "string", + "description": "The user friendly name for the namespace. Used by " + "UI if available.", + "maxLength": 80 + }, + "description": { + "type": "string", + "description": "Provides a user friendly description of the " + "namespace.", + "maxLength": 500 + }, + "visibility": { + "enum": [ + "public", + "private" + ], + "type": "string", + "description": "Scope of namespace accessibility." + }, + "protected": { + "type": "boolean", + "description": "If true, namespace will not be deletable." + }, + "owner": { + "type": "string", + "description": "Owner of the namespace.", + "maxLength": 255 + }, + "created_at": { + "type": "string", + "readOnly": True, + "description": "Date and time of namespace creation.", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "readOnly": True, + "description": "Date and time of the last namespace modification.", + "format": "date-time" + }, + "schema": { + "readOnly": True, + "type": "string" + }, + "self": { + "readOnly": True, + "type": "string" + }, + "resource_type_associations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "prefix": { + "type": "string" + }, + "properties_target": { + "type": "string" + } + } + } + }, + "properties": { + "$ref": "#/definitions/property" + }, + "objects": { + "items": { + "type": "object", + "properties": { + "required": { + "$ref": "#/definitions/stringArray" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "properties": { + "$ref": "#/definitions/property" + } + } + }, + "type": "array" + }, + "tags": { + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "type": "array" + }, + } +} diff --git a/glanceclient/v2/resource_type_schema.py b/glanceclient/v2/resource_type_schema.py new file mode 100644 index 0000000..8ad04bf --- /dev/null +++ b/glanceclient/v2/resource_type_schema.py @@ -0,0 +1,67 @@ +# Copyright 2015 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. + + +# NOTE(flaper87): Keep a copy of the current default schema so that +# we can react on cases where there's no connection to an OpenStack +# deployment. See #1481729 +BASE_SCHEMA = { + "additionalProperties": False, + "required": ["name"], + "name": "resource_type_association", + "properties": { + "name": { + "type": "string", + "description": "Resource type names should be aligned with Heat " + "resource types whenever possible: http://docs." + "openstack.org/developer/heat/template_guide/" + "openstack.html", + "maxLength": 80 + + }, + "prefix": { + "type": "string", + "description": "Specifies the prefix to use for the given resource" + " type. Any properties in the namespace should be" + " prefixed with this prefix when being applied to" + " the specified resource type. Must include prefix" + " separator (e.g. a colon :).", + "maxLength": 80 + }, + "properties_target": { + "type": "string", + "description": "Some resource types allow more than one key / " + "value pair per instance. For example, Cinder " + "allows user and image metadata on volumes. Only " + "the image properties metadata is evaluated by Nova" + " (scheduling or drivers). This property allows a " + "namespace target to remove the ambiguity.", + "maxLength": 80 + }, + "created_at": { + "type": "string", + "readOnly": True, + "description": "Date and time of resource type association.", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "readOnly": True, + "description": "Date and time of the last resource type " + "association modification.", + "format": "date-time" + } + } +} diff --git a/glanceclient/v2/schemas.py b/glanceclient/v2/schemas.py index 9ba72c3..8247d31 100644 --- a/glanceclient/v2/schemas.py +++ b/glanceclient/v2/schemas.py @@ -71,7 +71,7 @@ class SchemaProperty(object): def translate_schema_properties(schema_properties): """Parse the properties dictionary of a schema document. - :returns list of SchemaProperty objects + :returns: list of SchemaProperty objects """ properties = [] for (name, prop) in schema_properties.items(): @@ -87,11 +87,10 @@ class Schema(object): self.properties = translate_schema_properties(raw_properties) def is_core_property(self, property_name): - """ + """Check if a property with a given name is known to the schema. - Checks if a property with a given name is known to the schema, - i.e. is either a base property or a custom one registered in - schema-image.json file + Determines if it is either a base property or a custom one + registered in schema-image.json file :param property_name: name of the property :returns: True if the property is known, False otherwise diff --git a/glanceclient/v2/shell.py b/glanceclient/v2/shell.py index eaae374..dfd91fb 100644 --- a/glanceclient/v2/shell.py +++ b/glanceclient/v2/shell.py @@ -22,6 +22,8 @@ from glanceclient import exc from glanceclient.v2 import image_members from glanceclient.v2 import image_schema from glanceclient.v2 import images +from glanceclient.v2 import namespace_schema +from glanceclient.v2 import resource_type_schema from glanceclient.v2 import tasks import json import os @@ -270,7 +272,7 @@ def do_explain(gc, args): @utils.arg('--file', metavar='<FILE>', help=_('Local file to save downloaded image data to. ' 'If this is not specified and there is no redirection ' - 'the image data will be not be saved.')) + 'the image data will not be saved.')) @utils.arg('id', metavar='<IMAGE_ID>', help=_('ID of image to download.')) @utils.arg('--progress', action='store_true', default=False, help=_('Show download progress bar.')) @@ -338,6 +340,10 @@ def do_image_delete(gc, args): msg = "No image with an ID of '%s' exists." % args_id utils.print_err(msg) failure_flag = True + except exc.HTTPConflict: + msg = "Unable to delete image '%s' because it is in use." % args_id + utils.print_err(msg) + failure_flag = True except exc.HTTPException as e: msg = "'%s': Unable to delete image '%s'" % (e, args_id) utils.print_err(msg) @@ -426,6 +432,10 @@ def do_location_update(gc, args): """Update metadata of an image's location.""" try: metadata = json.loads(args.metadata) + + if metadata == {}: + print("WARNING -- The location's metadata will be updated to " + "an empty JSON object.") except ValueError: utils.exit('Metadata is not a valid JSON object.') else: @@ -446,6 +456,8 @@ def get_namespace_schema(): with open(schema_path, "r") as f: schema_raw = f.read() NAMESPACE_SCHEMA = json.loads(schema_raw) + else: + return namespace_schema.BASE_SCHEMA return NAMESPACE_SCHEMA @@ -593,6 +605,8 @@ def get_resource_type_schema(): with open(schema_path, "r") as f: schema_raw = f.read() RESOURCE_TYPE_SCHEMA = json.loads(schema_raw) + else: + return resource_type_schema.BASE_SCHEMA return RESOURCE_TYPE_SCHEMA diff --git a/glanceclient/v2/tasks.py b/glanceclient/v2/tasks.py index 4c06181..9c78020 100644 --- a/glanceclient/v2/tasks.py +++ b/glanceclient/v2/tasks.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright 2013 OpenStack Foundation # Copyright 2013 IBM Corp. # All Rights Reserved. # @@ -35,13 +35,15 @@ class Controller(object): @utils.memoized_property def model(self): schema = self.schema_client.get('task') - return warlock.model_factory(schema.raw(), schemas.SchemaBasedModel) + return warlock.model_factory(schema.raw(), + base_class=schemas.SchemaBasedModel) def list(self, **kwargs): """Retrieve a listing of Task objects. :param page_size: Number of tasks to request in each paginated request - :returns generator over list of Tasks + :returns: generator over list of Tasks + """ def paginate(url): resp, body = self.http_client.get(url) diff --git a/releasenotes/notes/bp-use-keystoneauth-e12f300e58577b13.yaml b/releasenotes/notes/bp-use-keystoneauth-e12f300e58577b13.yaml new file mode 100644 index 0000000..04eb2b9 --- /dev/null +++ b/releasenotes/notes/bp-use-keystoneauth-e12f300e58577b13.yaml @@ -0,0 +1,11 @@ +--- +prelude: > + Switch to using keystoneauth for session and auth plugins. +other: + - > + [`bp use-keystoneauth <https://blueprints.launchpad.net/python-glanceclient/+spec/use-keystoneauth>`_] + As of keystoneclient 2.2.0, the session and auth plugins code has + been deprecated. These modules have been moved to the keystoneauth + library. Consumers of the session and plugin modules are encouraged + to move to keystoneauth. Note that there should be no change to + end users of glanceclient. diff --git a/releasenotes/notes/log-request-id-e7f67a23a0ed5c7b.yaml b/releasenotes/notes/log-request-id-e7f67a23a0ed5c7b.yaml new file mode 100644 index 0000000..452246f --- /dev/null +++ b/releasenotes/notes/log-request-id-e7f67a23a0ed5c7b.yaml @@ -0,0 +1,6 @@ +--- +features: + - Added support to log 'x-openstack-request-id' for each api call. + Please refer, + https://blueprints.launchpad.net/python-glanceclient/+spec/log-request-id + for more details. diff --git a/releasenotes/source/conf.py b/releasenotes/source/conf.py index 1afe4be..01beeaa 100644 --- a/releasenotes/source/conf.py +++ b/releasenotes/source/conf.py @@ -275,3 +275,6 @@ texinfo_documents = [ # If true, do not generate a @detailmenu in the "Top" node's menu. # texinfo_no_detailmenu = False + +# -- Options for Internationalization output ------------------------------ +locale_dirs = ['locale/'] diff --git a/releasenotes/source/index.rst b/releasenotes/source/index.rst index f90c430..8d2c2b0 100644 --- a/releasenotes/source/index.rst +++ b/releasenotes/source/index.rst @@ -6,3 +6,5 @@ glanceclient Release Notes :maxdepth: 1 unreleased + newton + mitaka diff --git a/releasenotes/source/mitaka.rst b/releasenotes/source/mitaka.rst new file mode 100644 index 0000000..e545609 --- /dev/null +++ b/releasenotes/source/mitaka.rst @@ -0,0 +1,6 @@ +=================================== + Mitaka Series Release Notes +=================================== + +.. release-notes:: + :branch: origin/stable/mitaka diff --git a/releasenotes/source/newton.rst b/releasenotes/source/newton.rst new file mode 100644 index 0000000..97036ed --- /dev/null +++ b/releasenotes/source/newton.rst @@ -0,0 +1,6 @@ +=================================== + Newton Series Release Notes +=================================== + +.. release-notes:: + :branch: origin/stable/newton diff --git a/requirements.txt b/requirements.txt index fbc914c..47e7814 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,12 @@ # 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>=1.6 # Apache-2.0 -Babel>=1.3 # BSD -PrettyTable<0.8,>=0.7 # BSD -python-keystoneclient!=1.8.0,!=2.1.0,>=1.6.0 # Apache-2.0 -requests!=2.9.0,>=2.8.1 # Apache-2.0 -warlock<2,>=1.0.1 # Apache-2.0 +pbr>=1.8 # Apache-2.0 +Babel>=2.3.4 # BSD +PrettyTable<0.8,>=0.7.1 # BSD +keystoneauth1>=2.14.0 # Apache-2.0 +requests!=2.12.2,>=2.10.0 # Apache-2.0 +warlock!=1.3.0,<2,>=1.0.1 # Apache-2.0 six>=1.9.0 # MIT -oslo.utils>=3.5.0 # Apache-2.0 +oslo.utils>=3.18.0 # Apache-2.0 oslo.i18n>=2.1.0 # Apache-2.0 @@ -6,7 +6,7 @@ description-file = license = Apache License, Version 2.0 author = OpenStack author-email = openstack-dev@lists.openstack.org -home-page = http://www.openstack.org/ +home-page = http://docs.openstack.org/developer/python-glanceclient classifier = Development Status :: 5 - Production/Stable Environment :: Console @@ -20,6 +20,7 @@ classifier = Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 Programming Language :: Python :: 3.4 + Programming Language :: Python :: 3.5 [files] packages = diff --git a/test-requirements.txt b/test-requirements.txt index 4f56ff1..0fa3ae7 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -3,17 +3,16 @@ # process, which may cause wedges in the gate later. hacking<0.11,>=0.10.0 -coverage>=3.6 # Apache-2.0 -discover # BSD -mock>=1.2 # BSD +coverage>=4.0 # Apache-2.0 +mock>=2.0 # BSD ordereddict # MIT -os-client-config>=1.13.1 # Apache-2.0 -oslosphinx!=3.4.0,>=2.5.0 # Apache-2.0 -reno>=0.1.1 # Apache2 -sphinx!=1.2.0,!=1.3b1,<1.3,>=1.1.2 # BSD +os-client-config>=1.22.0 # Apache-2.0 +oslosphinx>=4.7.0 # Apache-2.0 +reno>=1.8.0 # Apache-2.0 +sphinx!=1.3b1,<1.4,>=1.2.1 # BSD testrepository>=0.0.18 # Apache-2.0/BSD testtools>=1.4.0 # MIT testscenarios>=0.4 # Apache-2.0/BSD -fixtures>=1.3.1 # Apache-2.0/BSD -requests-mock>=0.7.0 # Apache-2.0 -tempest-lib>=0.14.0 # Apache-2.0 +fixtures>=3.0.0 # Apache-2.0/BSD +requests-mock>=1.1 # Apache-2.0 +tempest>=12.1.0 # Apache-2.0 diff --git a/tools/tox_install.sh b/tools/tox_install.sh new file mode 100755 index 0000000..ee2df0c --- /dev/null +++ b/tools/tox_install.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +# Client constraint file contains this client version pin that is in conflict +# with installing the client from source. We should replace the version pin in +# the constraints file before applying it for from-source installation. + +ZUUL_CLONER=/usr/zuul-env/bin/zuul-cloner +BRANCH_NAME=master +CLIENT_NAME=python-glanceclient +requirements_installed=$(echo "import openstack_requirements" | python 2>/dev/null ; echo $?) + +set -e + +CONSTRAINTS_FILE=$1 +shift + +install_cmd="pip install" +mydir=$(mktemp -dt "$CLIENT_NAME-tox_install-XXXXXXX") +trap "rm -rf $mydir" EXIT +localfile=$mydir/upper-constraints.txt +if [[ $CONSTRAINTS_FILE != http* ]]; then + CONSTRAINTS_FILE=file://$CONSTRAINTS_FILE +fi +curl $CONSTRAINTS_FILE -k -o $localfile +install_cmd="$install_cmd -c$localfile" + +if [ $requirements_installed -eq 0 ]; then + echo "ALREADY INSTALLED" > /tmp/tox_install.txt + echo "Requirements already installed; using existing package" +elif [ -x "$ZUUL_CLONER" ]; then + echo "ZUUL CLONER" > /tmp/tox_install.txt + pushd $mydir + $ZUUL_CLONER --cache-dir \ + /opt/git \ + --branch $BRANCH_NAME \ + git://git.openstack.org \ + openstack/requirements + cd openstack/requirements + $install_cmd -e . + popd +else + echo "PIP HARDCODE" > /tmp/tox_install.txt + if [ -z "$REQUIREMENTS_PIP_LOCATION" ]; then + REQUIREMENTS_PIP_LOCATION="git+https://git.openstack.org/openstack/requirements@$BRANCH_NAME#egg=requirements" + fi + $install_cmd -U -e ${REQUIREMENTS_PIP_LOCATION} +fi + +# This is the main purpose of the script: Allow local installation of +# the current repo. It is listed in constraints file and thus any +# install will be constrained and we need to unconstrain it. +edit-constraints $localfile -- $CLIENT_NAME "-e file://$PWD#egg=$CLIENT_NAME" + +$install_cmd -U $* +exit $? @@ -1,11 +1,12 @@ [tox] -envlist = py34,py27,pep8 +envlist = py35,py34,py27,pep8 minversion = 1.6 skipsdist = True [testenv] usedevelop = True -install_command = pip install -U {opts} {packages} +install_command = + {toxinidir}/tools/tox_install.sh {env:UPPER_CONSTRAINTS_FILE:https://git.openstack.org/cgit/openstack/requirements/plain/upper-constraints.txt} {opts} {packages} setenv = VIRTUAL_ENV={envdir} OS_STDOUT_NOCAPTURE=False OS_STDERR_NOCAPTURE=False @@ -21,6 +22,9 @@ commands = flake8 [testenv:venv] commands = {posargs} +[pbr] +warnerror = True + [testenv:functional] # See glanceclient/tests/functional/README.rst # for information on running the functional tests. @@ -38,13 +42,9 @@ commands= commands = sphinx-build -a -E -W -d releasenotes/build/doctrees -b html releasenotes/source releasenotes/build/html [flake8] -# H233 Python 3.x incompatible use of print operator -# H303 no wildcard import -# H404 multi line docstring should start with a summary - -ignore = F403,F812,F821,H233,H303,H404 +ignore = F403,F812,F821 show-source = True -exclude = .venv*,.tox,dist,*egg,build,.git,doc,*openstack/common*,*lib/python*,.update-venv +exclude = .venv*,.tox,dist,*egg,build,.git,doc,*lib/python*,.update-venv [hacking] import_exceptions = six.moves,glanceclient._i18n |