From 7316dd16b8850270db27c1298dcf5a2223f2f1e1 Mon Sep 17 00:00:00 2001 From: gordon chung Date: Wed, 3 Sep 2014 16:21:46 -0400 Subject: sync oslo code - sync code up to Change-Id: Ie6064e73abe4b0729498a0343d50e1be35684b75 - includes fix to resolve alarm-evaluator failure - does not include openstack/common/strutils sync as it requires test update Co-Authored-By: Vaibhav Kale Closes-Bug: #1357343 Change-Id: Ieec08520cb39c5bf2e795dfeb3e36e52c6fd2a82 --- .../openstack/common/apiclient/auth.py | 10 +- .../openstack/common/apiclient/base.py | 63 ++- .../openstack/common/apiclient/client.py | 21 +- .../openstack/common/apiclient/exceptions.py | 98 ++-- .../openstack/common/apiclient/fake_client.py | 6 +- ceilometerclient/openstack/common/cliutils.py | 128 ++++- ceilometerclient/openstack/common/gettextutils.py | 592 ++++++++++++--------- ceilometerclient/openstack/common/importutils.py | 11 +- ceilometerclient/openstack/common/uuidutils.py | 37 ++ tools/install_venv_common.py | 2 +- 10 files changed, 643 insertions(+), 325 deletions(-) create mode 100644 ceilometerclient/openstack/common/uuidutils.py diff --git a/ceilometerclient/openstack/common/apiclient/auth.py b/ceilometerclient/openstack/common/apiclient/auth.py index df9b674..2d9babe 100644 --- a/ceilometerclient/openstack/common/apiclient/auth.py +++ b/ceilometerclient/openstack/common/apiclient/auth.py @@ -19,7 +19,6 @@ import abc import argparse -import logging import os import six @@ -28,9 +27,6 @@ from stevedore import extension from ceilometerclient.openstack.common.apiclient import exceptions -logger = logging.getLogger(__name__) - - _discovered_plugins = {} @@ -80,7 +76,7 @@ def load_plugin_from_args(args): alphabetical order. :type args: argparse.Namespace - :raises: AuthorizationFailure + :raises: AuthPluginOptionsMissing """ auth_system = args.os_auth_system if auth_system: @@ -217,8 +213,8 @@ class BaseAuthPlugin(object): :type service_type: string :param endpoint_type: Type of endpoint. Possible values: public or publicURL, - internal or internalURL, - admin or adminURL + internal or internalURL, + admin or adminURL :type endpoint_type: string :returns: tuple of token and endpoint strings :raises: EndpointException diff --git a/ceilometerclient/openstack/common/apiclient/base.py b/ceilometerclient/openstack/common/apiclient/base.py index f8531f8..b5394f8 100644 --- a/ceilometerclient/openstack/common/apiclient/base.py +++ b/ceilometerclient/openstack/common/apiclient/base.py @@ -24,11 +24,13 @@ Base utilities to build API operation managers and objects on top of. # pylint: disable=E1102 import abc +import copy import six from six.moves.urllib import parse from ceilometerclient.openstack.common.apiclient import exceptions +from ceilometerclient.openstack.common.gettextutils import _ from ceilometerclient.openstack.common import strutils @@ -73,8 +75,8 @@ class HookableMixin(object): :param cls: class that registers hooks :param hook_type: hook type, e.g., '__pre_parse_args__' - :param **args: args to be passed to every hook function - :param **kwargs: kwargs to be passed to every hook function + :param args: args to be passed to every hook function + :param kwargs: kwargs to be passed to every hook function """ hook_funcs = cls._hooks_map.get(hook_type) or [] for hook_func in hook_funcs: @@ -97,12 +99,13 @@ class BaseManager(HookableMixin): super(BaseManager, self).__init__() self.client = client - def _list(self, url, response_key, obj_class=None, json=None): + def _list(self, url, response_key=None, obj_class=None, json=None): """List the collection. :param url: a partial URL, e.g., '/servers' :param response_key: the key to be looked up in response dictionary, - e.g., 'servers' + e.g., 'servers'. If response_key is None - all response body + will be used. :param obj_class: class for constructing the returned objects (self.resource_class will be used by default) :param json: data that will be encoded as JSON and passed in POST @@ -116,7 +119,7 @@ class BaseManager(HookableMixin): if obj_class is None: obj_class = self.resource_class - data = body[response_key] + data = body[response_key] if response_key is not None else body # NOTE(ja): keystone returns values as list as {'values': [ ... ]} # unlike other services which just return the list... try: @@ -126,15 +129,17 @@ class BaseManager(HookableMixin): return [obj_class(self, res, loaded=True) for res in data if res] - def _get(self, url, response_key): + def _get(self, url, response_key=None): """Get an object from collection. :param url: a partial URL, e.g., '/servers' :param response_key: the key to be looked up in response dictionary, - e.g., 'server' + e.g., 'server'. If response_key is None - all response body + will be used. """ body = self.client.get(url).json() - return self.resource_class(self, body[response_key], loaded=True) + data = body[response_key] if response_key is not None else body + return self.resource_class(self, data, loaded=True) def _head(self, url): """Retrieve request headers for an object. @@ -144,21 +149,23 @@ class BaseManager(HookableMixin): resp = self.client.head(url) return resp.status_code == 204 - def _post(self, url, json, response_key, return_raw=False): + def _post(self, url, json, response_key=None, return_raw=False): """Create an object. :param url: a partial URL, e.g., '/servers' :param json: data that will be encoded as JSON and passed in POST request (GET will be sent by default) :param response_key: the key to be looked up in response dictionary, - e.g., 'servers' + e.g., 'server'. If response_key is None - all response body + will be used. :param return_raw: flag to force returning raw JSON instead of Python object of self.resource_class """ body = self.client.post(url, json=json).json() + data = body[response_key] if response_key is not None else body if return_raw: - return body[response_key] - return self.resource_class(self, body[response_key]) + return data + return self.resource_class(self, data) def _put(self, url, json=None, response_key=None): """Update an object with PUT method. @@ -167,7 +174,8 @@ class BaseManager(HookableMixin): :param json: data that will be encoded as JSON and passed in POST request (GET will be sent by default) :param response_key: the key to be looked up in response dictionary, - e.g., 'servers' + e.g., 'servers'. If response_key is None - all response body + will be used. """ resp = self.client.put(url, json=json) # PUT requests may not return a body @@ -185,7 +193,8 @@ class BaseManager(HookableMixin): :param json: data that will be encoded as JSON and passed in POST request (GET will be sent by default) :param response_key: the key to be looked up in response dictionary, - e.g., 'servers' + e.g., 'servers'. If response_key is None - all response body + will be used. """ body = self.client.patch(url, json=json).json() if response_key is not None: @@ -218,7 +227,10 @@ class ManagerWithFind(BaseManager): matches = self.findall(**kwargs) num_matches = len(matches) if num_matches == 0: - msg = "No %s matching %s." % (self.resource_class.__name__, kwargs) + msg = _("No %(name)s matching %(args)s.") % { + 'name': self.resource_class.__name__, + 'args': kwargs + } raise exceptions.NotFound(msg) elif num_matches > 1: raise exceptions.NoUniqueMatch() @@ -372,7 +384,10 @@ class CrudManager(BaseManager): num = len(rl) if num == 0: - msg = "No %s matching %s." % (self.resource_class.__name__, kwargs) + msg = _("No %(name)s matching %(args)s.") % { + 'name': self.resource_class.__name__, + 'args': kwargs + } raise exceptions.NotFound(404, msg) elif num > 1: raise exceptions.NoUniqueMatch @@ -440,8 +455,10 @@ class Resource(object): def human_id(self): """Human-readable ID which can be used for bash completion. """ - if self.NAME_ATTR in self.__dict__ and self.HUMAN_ID: - return strutils.to_slug(getattr(self, self.NAME_ATTR)) + if self.HUMAN_ID: + name = getattr(self, self.NAME_ATTR, None) + if name is not None: + return strutils.to_slug(name) return None def _add_details(self, info): @@ -455,7 +472,7 @@ class Resource(object): def __getattr__(self, k): if k not in self.__dict__: - #NOTE(bcwaldon): disallow lazy-loading if already loaded once + # NOTE(bcwaldon): disallow lazy-loading if already loaded once if not self.is_loaded(): self.get() return self.__getattr__(k) @@ -465,6 +482,11 @@ class Resource(object): return self.__dict__[k] def get(self): + """Support for lazy loading details. + + Some clients, such as novaclient have the option to lazy load the + details, details which can be loaded with this function. + """ # set_loaded() first ... so if we have to bail, we know we tried. self.set_loaded(True) if not hasattr(self.manager, 'get'): @@ -489,3 +511,6 @@ class Resource(object): def set_loaded(self, val): self._loaded = val + + def to_dict(self): + return copy.deepcopy(self._info) diff --git a/ceilometerclient/openstack/common/apiclient/client.py b/ceilometerclient/openstack/common/apiclient/client.py index 1b68aa9..bb17b0c 100644 --- a/ceilometerclient/openstack/common/apiclient/client.py +++ b/ceilometerclient/openstack/common/apiclient/client.py @@ -36,6 +36,7 @@ except ImportError: import requests from ceilometerclient.openstack.common.apiclient import exceptions +from ceilometerclient.openstack.common.gettextutils import _ from ceilometerclient.openstack.common import importutils @@ -46,6 +47,7 @@ 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; @@ -151,10 +153,10 @@ class HTTPClient(object): :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` + requests.Session.request (such as `headers`) or `json` that will be encoded as JSON and used as `data` argument """ - kwargs.setdefault("headers", kwargs.get("headers", {})) + kwargs.setdefault("headers", {}) kwargs["headers"]["User-Agent"] = self.user_agent if self.original_ip: kwargs["headers"]["Forwarded"] = "for=%s;by=%s" % ( @@ -206,7 +208,7 @@ class HTTPClient(object): :param method: method of HTTP request :param url: URL of HTTP request :param kwargs: any other parameter that can be passed to -' `HTTPClient.request` + `HTTPClient.request` """ filter_args = { @@ -228,7 +230,7 @@ class HTTPClient(object): **filter_args) if not (token and endpoint): raise exceptions.AuthorizationFailure( - "Cannot find endpoint or token for request") + _("Cannot find endpoint or token for request")) old_token_endpoint = (token, endpoint) kwargs.setdefault("headers", {})["X-Auth-Token"] = token @@ -245,6 +247,10 @@ class HTTPClient(object): raise self.cached_token = None client.cached_endpoint = None + if self.auth_plugin.opts.get('token'): + self.auth_plugin.opts['token'] = None + if self.auth_plugin.opts.get('endpoint'): + self.auth_plugin.opts['endpoint'] = None self.authenticate() try: token, endpoint = self.auth_plugin.token_and_endpoint( @@ -351,8 +357,11 @@ class BaseClient(object): try: client_path = version_map[str(version)] except (KeyError, ValueError): - msg = "Invalid %s client version '%s'. must be one of: %s" % ( - (api_name, version, ', '.join(version_map.keys()))) + 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/ceilometerclient/openstack/common/apiclient/exceptions.py b/ceilometerclient/openstack/common/apiclient/exceptions.py index 4776d58..2f68ffb 100644 --- a/ceilometerclient/openstack/common/apiclient/exceptions.py +++ b/ceilometerclient/openstack/common/apiclient/exceptions.py @@ -25,6 +25,8 @@ import sys import six +from ceilometerclient.openstack.common.gettextutils import _ + class ClientException(Exception): """The base exception class for all exceptions this library raises. @@ -36,7 +38,7 @@ class MissingArgs(ClientException): """Supplied arguments are not sufficient for calling a function.""" def __init__(self, missing): self.missing = missing - msg = "Missing argument(s): %s" % ", ".join(missing) + msg = _("Missing arguments: %s") % ", ".join(missing) super(MissingArgs, self).__init__(msg) @@ -69,16 +71,16 @@ class AuthPluginOptionsMissing(AuthorizationFailure): """Auth plugin misses some options.""" def __init__(self, opt_names): super(AuthPluginOptionsMissing, self).__init__( - "Authentication failed. Missing options: %s" % + _("Authentication failed. Missing options: %s") % ", ".join(opt_names)) self.opt_names = opt_names class AuthSystemNotFound(AuthorizationFailure): - """User has specified a AuthSystem that is not installed.""" + """User has specified an AuthSystem that is not installed.""" def __init__(self, auth_system): super(AuthSystemNotFound, self).__init__( - "AuthSystemNotFound: %s" % repr(auth_system)) + _("AuthSystemNotFound: %s") % repr(auth_system)) self.auth_system = auth_system @@ -101,7 +103,7 @@ class AmbiguousEndpoints(EndpointException): """Found more than one matching endpoint in Service Catalog.""" def __init__(self, endpoints=None): super(AmbiguousEndpoints, self).__init__( - "AmbiguousEndpoints: %s" % repr(endpoints)) + _("AmbiguousEndpoints: %s") % repr(endpoints)) self.endpoints = endpoints @@ -109,7 +111,7 @@ class HttpError(ClientException): """The base exception class for all HTTP exceptions. """ http_status = 0 - message = "HTTP Error" + message = _("HTTP Error") def __init__(self, message=None, details=None, response=None, request_id=None, @@ -127,12 +129,17 @@ class HttpError(ClientException): super(HttpError, self).__init__(formatted_string) +class HTTPRedirection(HttpError): + """HTTP Redirection.""" + message = _("HTTP Redirection") + + class HTTPClientError(HttpError): """Client-side HTTP error. Exception for cases in which the client seems to have erred. """ - message = "HTTP Client Error" + message = _("HTTP Client Error") class HttpServerError(HttpError): @@ -141,7 +148,17 @@ class HttpServerError(HttpError): Exception for cases in which the server is aware that it has erred or is incapable of performing the request. """ - message = "HTTP Server Error" + message = _("HTTP Server Error") + + +class MultipleChoices(HTTPRedirection): + """HTTP 300 - Multiple Choices. + + Indicates multiple options for the resource that the client may follow. + """ + + http_status = 300 + message = _("Multiple Choices") class BadRequest(HTTPClientError): @@ -150,7 +167,7 @@ class BadRequest(HTTPClientError): The request cannot be fulfilled due to bad syntax. """ http_status = 400 - message = "Bad Request" + message = _("Bad Request") class Unauthorized(HTTPClientError): @@ -160,7 +177,7 @@ class Unauthorized(HTTPClientError): is required and has failed or has not yet been provided. """ http_status = 401 - message = "Unauthorized" + message = _("Unauthorized") class PaymentRequired(HTTPClientError): @@ -169,7 +186,7 @@ class PaymentRequired(HTTPClientError): Reserved for future use. """ http_status = 402 - message = "Payment Required" + message = _("Payment Required") class Forbidden(HTTPClientError): @@ -179,7 +196,7 @@ class Forbidden(HTTPClientError): to it. """ http_status = 403 - message = "Forbidden" + message = _("Forbidden") class NotFound(HTTPClientError): @@ -189,7 +206,7 @@ class NotFound(HTTPClientError): in the future. """ http_status = 404 - message = "Not Found" + message = _("Not Found") class MethodNotAllowed(HTTPClientError): @@ -199,7 +216,7 @@ class MethodNotAllowed(HTTPClientError): by that resource. """ http_status = 405 - message = "Method Not Allowed" + message = _("Method Not Allowed") class NotAcceptable(HTTPClientError): @@ -209,7 +226,7 @@ class NotAcceptable(HTTPClientError): acceptable according to the Accept headers sent in the request. """ http_status = 406 - message = "Not Acceptable" + message = _("Not Acceptable") class ProxyAuthenticationRequired(HTTPClientError): @@ -218,7 +235,7 @@ class ProxyAuthenticationRequired(HTTPClientError): The client must first authenticate itself with the proxy. """ http_status = 407 - message = "Proxy Authentication Required" + message = _("Proxy Authentication Required") class RequestTimeout(HTTPClientError): @@ -227,7 +244,7 @@ class RequestTimeout(HTTPClientError): The server timed out waiting for the request. """ http_status = 408 - message = "Request Timeout" + message = _("Request Timeout") class Conflict(HTTPClientError): @@ -237,7 +254,7 @@ class Conflict(HTTPClientError): in the request, such as an edit conflict. """ http_status = 409 - message = "Conflict" + message = _("Conflict") class Gone(HTTPClientError): @@ -247,7 +264,7 @@ class Gone(HTTPClientError): not be available again. """ http_status = 410 - message = "Gone" + message = _("Gone") class LengthRequired(HTTPClientError): @@ -257,7 +274,7 @@ class LengthRequired(HTTPClientError): required by the requested resource. """ http_status = 411 - message = "Length Required" + message = _("Length Required") class PreconditionFailed(HTTPClientError): @@ -267,7 +284,7 @@ class PreconditionFailed(HTTPClientError): put on the request. """ http_status = 412 - message = "Precondition Failed" + message = _("Precondition Failed") class RequestEntityTooLarge(HTTPClientError): @@ -276,7 +293,7 @@ class RequestEntityTooLarge(HTTPClientError): The request is larger than the server is willing or able to process. """ http_status = 413 - message = "Request Entity Too Large" + message = _("Request Entity Too Large") def __init__(self, *args, **kwargs): try: @@ -293,7 +310,7 @@ class RequestUriTooLong(HTTPClientError): The URI provided was too long for the server to process. """ http_status = 414 - message = "Request-URI Too Long" + message = _("Request-URI Too Long") class UnsupportedMediaType(HTTPClientError): @@ -303,7 +320,7 @@ class UnsupportedMediaType(HTTPClientError): not support. """ http_status = 415 - message = "Unsupported Media Type" + message = _("Unsupported Media Type") class RequestedRangeNotSatisfiable(HTTPClientError): @@ -313,7 +330,7 @@ class RequestedRangeNotSatisfiable(HTTPClientError): supply that portion. """ http_status = 416 - message = "Requested Range Not Satisfiable" + message = _("Requested Range Not Satisfiable") class ExpectationFailed(HTTPClientError): @@ -322,7 +339,7 @@ class ExpectationFailed(HTTPClientError): The server cannot meet the requirements of the Expect request-header field. """ http_status = 417 - message = "Expectation Failed" + message = _("Expectation Failed") class UnprocessableEntity(HTTPClientError): @@ -332,7 +349,7 @@ class UnprocessableEntity(HTTPClientError): errors. """ http_status = 422 - message = "Unprocessable Entity" + message = _("Unprocessable Entity") class InternalServerError(HttpServerError): @@ -341,7 +358,7 @@ class InternalServerError(HttpServerError): A generic error message, given when no more specific message is suitable. """ http_status = 500 - message = "Internal Server Error" + message = _("Internal Server Error") # NotImplemented is a python keyword. @@ -352,7 +369,7 @@ class HttpNotImplemented(HttpServerError): the ability to fulfill the request. """ http_status = 501 - message = "Not Implemented" + message = _("Not Implemented") class BadGateway(HttpServerError): @@ -362,7 +379,7 @@ class BadGateway(HttpServerError): response from the upstream server. """ http_status = 502 - message = "Bad Gateway" + message = _("Bad Gateway") class ServiceUnavailable(HttpServerError): @@ -371,7 +388,7 @@ class ServiceUnavailable(HttpServerError): The server is currently unavailable. """ http_status = 503 - message = "Service Unavailable" + message = _("Service Unavailable") class GatewayTimeout(HttpServerError): @@ -381,7 +398,7 @@ class GatewayTimeout(HttpServerError): response from the upstream server. """ http_status = 504 - message = "Gateway Timeout" + message = _("Gateway Timeout") class HttpVersionNotSupported(HttpServerError): @@ -390,7 +407,7 @@ class HttpVersionNotSupported(HttpServerError): The server does not support the HTTP protocol version used in the request. """ http_status = 505 - message = "HTTP Version Not Supported" + message = _("HTTP Version Not Supported") # _code_map contains all the classes that have http_status attribute. @@ -408,12 +425,17 @@ def from_response(response, method, url): :param method: HTTP method used for request :param url: URL used for request """ + + req_id = response.headers.get("x-openstack-request-id") + # NOTE(hdd) true for older versions of nova and cinder + if not req_id: + req_id = response.headers.get("x-compute-request-id") kwargs = { "http_status": response.status_code, "response": response, "method": method, "url": url, - "request_id": response.headers.get("x-compute-request-id"), + "request_id": req_id, } if "retry-after" in response.headers: kwargs["retry_after"] = response.headers["retry-after"] @@ -425,10 +447,10 @@ def from_response(response, method, url): except ValueError: pass else: - if hasattr(body, "keys"): - error = body[body.keys()[0]] - kwargs["message"] = error.get("message", None) - kwargs["details"] = error.get("details", None) + if isinstance(body, dict) and isinstance(body.get("error"), dict): + error = body["error"] + kwargs["message"] = error.get("message") + kwargs["details"] = error.get("details") elif content_type.startswith("text/"): kwargs["details"] = response.text diff --git a/ceilometerclient/openstack/common/apiclient/fake_client.py b/ceilometerclient/openstack/common/apiclient/fake_client.py index e99947b..24807ad 100644 --- a/ceilometerclient/openstack/common/apiclient/fake_client.py +++ b/ceilometerclient/openstack/common/apiclient/fake_client.py @@ -33,7 +33,9 @@ from six.moves.urllib import parse from ceilometerclient.openstack.common.apiclient import client -def assert_has_keys(dct, required=[], optional=[]): +def assert_has_keys(dct, required=None, optional=None): + required = required or [] + optional = optional or [] for k in required: try: assert k in dct @@ -79,7 +81,7 @@ class FakeHTTPClient(client.HTTPClient): def __init__(self, *args, **kwargs): self.callstack = [] self.fixtures = kwargs.pop("fixtures", None) or {} - if not args and not "auth_plugin" in kwargs: + if not args and "auth_plugin" not in kwargs: args = (None, ) super(FakeHTTPClient, self).__init__(*args, **kwargs) diff --git a/ceilometerclient/openstack/common/cliutils.py b/ceilometerclient/openstack/common/cliutils.py index 96725ed..c690028 100644 --- a/ceilometerclient/openstack/common/cliutils.py +++ b/ceilometerclient/openstack/common/cliutils.py @@ -16,6 +16,8 @@ # W0621: Redefining name %s from outer scope # pylint: disable=W0603,W0621 +from __future__ import print_function + import getpass import inspect import os @@ -27,7 +29,9 @@ import six from six import moves from ceilometerclient.openstack.common.apiclient import exceptions +from ceilometerclient.openstack.common.gettextutils import _ from ceilometerclient.openstack.common import strutils +from ceilometerclient.openstack.common import uuidutils def validate_args(fn, *args, **kwargs): @@ -52,7 +56,7 @@ def validate_args(fn, *args, **kwargs): required_args = argspec.args[:len(argspec.args) - num_defaults] def isbound(method): - return getattr(method, 'im_self', None) is not None + return getattr(method, '__self__', None) is not None if isbound(fn): required_args.pop(0) @@ -84,7 +88,7 @@ def env(*args, **kwargs): If all are empty, defaults to '' or keyword arg `default`. """ for arg in args: - value = os.environ.get(arg, None) + value = os.environ.get(arg) if value: return value return kwargs.get('default', '') @@ -128,7 +132,7 @@ def isunauthenticated(func): def print_list(objs, fields, formatters=None, sortby_index=0, - mixed_case_fields=None): + mixed_case_fields=None, field_labels=None): """Print a list or objects as a table, one row per object. :param objs: iterable of :class:`Resource` @@ -137,14 +141,22 @@ def print_list(objs, fields, formatters=None, sortby_index=0, :param sortby_index: index of the field for sorting table rows :param mixed_case_fields: fields corresponding to object attributes that have mixed case names (e.g., 'serverId') + :param field_labels: Labels to use in the heading of the table, default to + fields. """ formatters = formatters or {} mixed_case_fields = mixed_case_fields or [] + field_labels = field_labels or fields + if len(field_labels) != len(fields): + raise ValueError(_("Field labels list %(labels)s has different number " + "of elements than fields list %(fields)s"), + {'labels': field_labels, 'fields': fields}) + if sortby_index is None: kwargs = {} else: - kwargs = {'sortby': fields[sortby_index]} - pt = prettytable.PrettyTable(fields, caching=False) + kwargs = {'sortby': field_labels[sortby_index]} + pt = prettytable.PrettyTable(field_labels, caching=False) pt.align = 'l' for o in objs: @@ -176,9 +188,9 @@ def print_dict(dct, dict_property="Property", wrap=0): for k, v in six.iteritems(dct): # convert dict to str to check length if isinstance(v, dict): - v = str(v) + v = six.text_type(v) if wrap > 0: - v = textwrap.fill(str(v), wrap) + v = textwrap.fill(six.text_type(v), wrap) # if value has a newline, add in multiple rows # e.g. fault with stacktrace if v and isinstance(v, six.string_types) and r'\n' in v: @@ -199,7 +211,7 @@ def get_password(max_password_prompts=3): if hasattr(sys.stdin, "isatty") and sys.stdin.isatty(): # Check for Ctrl-D try: - for _ in moves.range(max_password_prompts): + for __ in moves.range(max_password_prompts): pw1 = getpass.getpass("OS Password: ") if verify: pw2 = getpass.getpass("Please verify: ") @@ -211,3 +223,103 @@ def get_password(max_password_prompts=3): except EOFError: pass return pw + + +def find_resource(manager, name_or_id, **find_args): + """Look for resource in a given manager. + + Used as a helper for the _find_* methods. + Example: + + .. code-block:: python + + def _find_hypervisor(cs, hypervisor): + #Get a hypervisor by name or ID. + return cliutils.find_resource(cs.hypervisors, hypervisor) + """ + # first try to get entity as integer id + try: + return manager.get(int(name_or_id)) + except (TypeError, ValueError, exceptions.NotFound): + pass + + # now try to get entity as uuid + try: + if six.PY2: + tmp_id = strutils.safe_encode(name_or_id) + else: + tmp_id = strutils.safe_decode(name_or_id) + + if uuidutils.is_uuid_like(tmp_id): + return manager.get(tmp_id) + except (TypeError, ValueError, exceptions.NotFound): + pass + + # for str id which is not uuid + if getattr(manager, 'is_alphanum_id_allowed', False): + try: + return manager.get(name_or_id) + except exceptions.NotFound: + pass + + try: + try: + return manager.find(human_id=name_or_id, **find_args) + except exceptions.NotFound: + pass + + # finally try to find entity by name + try: + resource = getattr(manager, 'resource_class', None) + name_attr = resource.NAME_ATTR if resource else 'name' + kwargs = {name_attr: name_or_id} + kwargs.update(find_args) + return manager.find(**kwargs) + except exceptions.NotFound: + msg = _("No %(name)s with a name or " + "ID of '%(name_or_id)s' exists.") % \ + { + "name": manager.resource_class.__name__.lower(), + "name_or_id": name_or_id + } + raise exceptions.CommandError(msg) + except exceptions.NoUniqueMatch: + msg = _("Multiple %(name)s matches found for " + "'%(name_or_id)s', use an ID to be more specific.") % \ + { + "name": manager.resource_class.__name__.lower(), + "name_or_id": name_or_id + } + raise exceptions.CommandError(msg) + + +def service_type(stype): + """Adds 'service_type' attribute to decorated function. + + Usage: + + .. code-block:: python + + @service_type('volume') + def mymethod(f): + ... + """ + def inner(f): + f.service_type = stype + return f + return inner + + +def get_service_type(f): + """Retrieves service type from function.""" + return getattr(f, 'service_type', None) + + +def pretty_choice_list(l): + return ', '.join("'%s'" % i for i in l) + + +def exit(msg=''): + if msg: + print (msg, file=sys.stderr) + sys.exit(1) diff --git a/ceilometerclient/openstack/common/gettextutils.py b/ceilometerclient/openstack/common/gettextutils.py index c28d730..75082d7 100644 --- a/ceilometerclient/openstack/common/gettextutils.py +++ b/ceilometerclient/openstack/common/gettextutils.py @@ -24,24 +24,122 @@ Usual usage in an openstack.common module: import copy import gettext -import logging +import locale +from logging import handlers import os -import re -try: - import UserString as _userString -except ImportError: - import collections as _userString from babel import localedata import six -_localedir = os.environ.get('ceilometerclient'.upper() + '_LOCALEDIR') -_t = gettext.translation('ceilometerclient', localedir=_localedir, fallback=True) - _AVAILABLE_LANGUAGES = {} + +# FIXME(dhellmann): Remove this when moving to oslo.i18n. USE_LAZY = False +class TranslatorFactory(object): + """Create translator functions + """ + + def __init__(self, domain, localedir=None): + """Establish a set of translation functions for the domain. + + :param domain: Name of translation domain, + specifying a message catalog. + :type domain: str + :param lazy: Delays translation until a message is emitted. + Defaults to False. + :type lazy: Boolean + :param localedir: Directory with translation catalogs. + :type localedir: str + """ + self.domain = domain + if localedir is None: + localedir = os.environ.get(domain.upper() + '_LOCALEDIR') + self.localedir = localedir + + def _make_translation_func(self, domain=None): + """Return a new translation function ready for use. + + Takes into account whether or not lazy translation is being + done. + + The domain can be specified to override the default from the + factory, but the localedir from the factory is always used + because we assume the log-level translation catalogs are + installed in the same directory as the main application + catalog. + + """ + if domain is None: + domain = self.domain + t = gettext.translation(domain, + localedir=self.localedir, + fallback=True) + # Use the appropriate method of the translation object based + # on the python version. + m = t.gettext if six.PY3 else t.ugettext + + def f(msg): + """oslo.i18n.gettextutils translation function.""" + if USE_LAZY: + return Message(msg, domain=domain) + return m(msg) + return f + + @property + def primary(self): + "The default translation function." + return self._make_translation_func() + + def _make_log_translation_func(self, level): + return self._make_translation_func(self.domain + '-log-' + level) + + @property + def log_info(self): + "Translate info-level log messages." + return self._make_log_translation_func('info') + + @property + def log_warning(self): + "Translate warning-level log messages." + return self._make_log_translation_func('warning') + + @property + def log_error(self): + "Translate error-level log messages." + return self._make_log_translation_func('error') + + @property + def log_critical(self): + "Translate critical-level log messages." + return self._make_log_translation_func('critical') + + +# NOTE(dhellmann): When this module moves out of the incubator into +# oslo.i18n, these global variables can be moved to an integration +# module within each application. + +# Create the global translation functions. +_translators = TranslatorFactory('ceilometerclient') + +# The primary translation function using the well-known name "_" +_ = _translators.primary + +# Translators for log levels. +# +# The abbreviated names are meant to reflect the usual use of a short +# name like '_'. The "L" is for "log" and the other letter comes from +# the level. +_LI = _translators.log_info +_LW = _translators.log_warning +_LE = _translators.log_error +_LC = _translators.log_critical + +# NOTE(dhellmann): End of globals that will move to the application's +# integration module. + + def enable_lazy(): """Convenience function for configuring _() to use lazy gettext @@ -54,16 +152,7 @@ def enable_lazy(): USE_LAZY = True -def _(msg): - if USE_LAZY: - return Message(msg, 'ceilometerclient') - else: - if six.PY3: - return _t.gettext(msg) - return _t.ugettext(msg) - - -def install(domain, lazy=False): +def install(domain): """Install a _() function using the given translation domain. Given a translation domain, install a _() function using gettext's @@ -74,226 +163,155 @@ def install(domain, lazy=False): a translation-domain-specific environment variable (e.g. NOVA_LOCALEDIR). + Note that to enable lazy translation, enable_lazy must be + called. + :param domain: the translation domain - :param lazy: indicates whether or not to install the lazy _() function. - The lazy _() introduces a way to do deferred translation - of messages by installing a _ that builds Message objects, - instead of strings, which can then be lazily translated into - any available locale. """ - if lazy: - # NOTE(mrodden): Lazy gettext functionality. - # - # The following introduces a deferred way to do translations on - # messages in OpenStack. We override the standard _() function - # and % (format string) operation to build Message objects that can - # later be translated when we have more information. - # - # Also included below is an example LocaleHandler that translates - # Messages to an associated locale, effectively allowing many logs, - # each with their own locale. - - def _lazy_gettext(msg): - """Create and return a Message object. - - Lazy gettext function for a given domain, it is a factory method - for a project/module to get a lazy gettext function for its own - translation domain (i.e. nova, glance, cinder, etc.) - - Message encapsulates a string so that we can translate - it later when needed. - """ - return Message(msg, domain) - - from six import moves - moves.builtins.__dict__['_'] = _lazy_gettext - else: - localedir = '%s_LOCALEDIR' % domain.upper() - if six.PY3: - gettext.install(domain, - localedir=os.environ.get(localedir)) - else: - gettext.install(domain, - localedir=os.environ.get(localedir), - unicode=True) - - -class Message(_userString.UserString, object): - """Class used to encapsulate translatable messages.""" - def __init__(self, msg, domain): - # _msg is the gettext msgid and should never change - self._msg = msg - self._left_extra_msg = '' - self._right_extra_msg = '' - self._locale = None - self.params = None - self.domain = domain + from six import moves + tf = TranslatorFactory(domain) + moves.builtins.__dict__['_'] = tf.primary - @property - def data(self): - # NOTE(mrodden): this should always resolve to a unicode string - # that best represents the state of the message currently - - localedir = os.environ.get(self.domain.upper() + '_LOCALEDIR') - if self.locale: - lang = gettext.translation(self.domain, - localedir=localedir, - languages=[self.locale], - fallback=True) - else: - # use system locale for translations - lang = gettext.translation(self.domain, - localedir=localedir, - fallback=True) - if six.PY3: - ugettext = lang.gettext - else: - ugettext = lang.ugettext +class Message(six.text_type): + """A Message object is a unicode object that can be translated. - full_msg = (self._left_extra_msg + - ugettext(self._msg) + - self._right_extra_msg) + Translation of Message is done explicitly using the translate() method. + For all non-translation intents and purposes, a Message is simply unicode, + and can be treated as such. + """ - if self.params is not None: - full_msg = full_msg % self.params + def __new__(cls, msgid, msgtext=None, params=None, + domain='ceilometerclient', *args): + """Create a new Message object. - return six.text_type(full_msg) + In order for translation to work gettext requires a message ID, this + msgid will be used as the base unicode text. It is also possible + for the msgid and the base unicode text to be different by passing + the msgtext parameter. + """ + # If the base msgtext is not given, we use the default translation + # of the msgid (which is in English) just in case the system locale is + # not English, so that the base text will be in that locale by default. + if not msgtext: + msgtext = Message._translate_msgid(msgid, domain) + # We want to initialize the parent unicode with the actual object that + # would have been plain unicode if 'Message' was not enabled. + msg = super(Message, cls).__new__(cls, msgtext) + msg.msgid = msgid + msg.domain = domain + msg.params = params + return msg + + def translate(self, desired_locale=None): + """Translate this message to the desired locale. + + :param desired_locale: The desired locale to translate the message to, + if no locale is provided the message will be + translated to the system's default locale. + + :returns: the translated message in unicode + """ - @property - def locale(self): - return self._locale - - @locale.setter - def locale(self, value): - self._locale = value - if not self.params: - return - - # This Message object may have been constructed with one or more - # Message objects as substitution parameters, given as a single - # Message, or a tuple or Map containing some, so when setting the - # locale for this Message we need to set it for those Messages too. - if isinstance(self.params, Message): - self.params.locale = value - return - if isinstance(self.params, tuple): - for param in self.params: - if isinstance(param, Message): - param.locale = value - return - if isinstance(self.params, dict): - for param in self.params.values(): - if isinstance(param, Message): - param.locale = value - - def _save_dictionary_parameter(self, dict_param): - full_msg = self.data - # look for %(blah) fields in string; - # ignore %% and deal with the - # case where % is first character on the line - keys = re.findall('(?:[^%]|^)?%\((\w*)\)[a-z]', full_msg) - - # if we don't find any %(blah) blocks but have a %s - if not keys and re.findall('(?:[^%]|^)%[a-z]', full_msg): - # apparently the full dictionary is the parameter - params = copy.deepcopy(dict_param) + translated_message = Message._translate_msgid(self.msgid, + self.domain, + desired_locale) + if self.params is None: + # No need for more translation + return translated_message + + # This Message object may have been formatted with one or more + # Message objects as substitution arguments, given either as a single + # argument, part of a tuple, or as one or more values in a dictionary. + # When translating this Message we need to translate those Messages too + translated_params = _translate_args(self.params, desired_locale) + + translated_message = translated_message % translated_params + + return translated_message + + @staticmethod + def _translate_msgid(msgid, domain, desired_locale=None): + if not desired_locale: + system_locale = locale.getdefaultlocale() + # If the system locale is not available to the runtime use English + if not system_locale[0]: + desired_locale = 'en_US' + else: + desired_locale = system_locale[0] + + locale_dir = os.environ.get(domain.upper() + '_LOCALEDIR') + lang = gettext.translation(domain, + localedir=locale_dir, + languages=[desired_locale], + fallback=True) + if six.PY3: + translator = lang.gettext else: - params = {} - for key in keys: - try: - params[key] = copy.deepcopy(dict_param[key]) - except TypeError: - # cast uncopyable thing to unicode string - params[key] = six.text_type(dict_param[key]) + translator = lang.ugettext - return params + translated_message = translator(msgid) + return translated_message - def _save_parameters(self, other): - # we check for None later to see if - # we actually have parameters to inject, - # so encapsulate if our parameter is actually None + def __mod__(self, other): + # When we mod a Message we want the actual operation to be performed + # by the parent class (i.e. unicode()), the only thing we do here is + # save the original msgid and the parameters in case of a translation + params = self._sanitize_mod_params(other) + unicode_mod = super(Message, self).__mod__(params) + modded = Message(self.msgid, + msgtext=unicode_mod, + params=params, + domain=self.domain) + return modded + + def _sanitize_mod_params(self, other): + """Sanitize the object being modded with this Message. + + - Add support for modding 'None' so translation supports it + - Trim the modded object, which can be a large dictionary, to only + those keys that would actually be used in a translation + - Snapshot the object being modded, in case the message is + translated, it will be used as it was when the Message was created + """ if other is None: - self.params = (other, ) + params = (other,) elif isinstance(other, dict): - self.params = self._save_dictionary_parameter(other) + # Merge the dictionaries + # Copy each item in case one does not support deep copy. + params = {} + if isinstance(self.params, dict): + for key, val in self.params.items(): + params[key] = self._copy_param(val) + for key, val in other.items(): + params[key] = self._copy_param(val) else: - # fallback to casting to unicode, - # this will handle the problematic python code-like - # objects that cannot be deep-copied - try: - self.params = copy.deepcopy(other) - except TypeError: - self.params = six.text_type(other) - - return self - - # overrides to be more string-like - def __unicode__(self): - return self.data - - def __str__(self): - if six.PY3: - return self.__unicode__() - return self.data.encode('utf-8') - - def __getstate__(self): - to_copy = ['_msg', '_right_extra_msg', '_left_extra_msg', - 'domain', 'params', '_locale'] - new_dict = self.__dict__.fromkeys(to_copy) - for attr in to_copy: - new_dict[attr] = copy.deepcopy(self.__dict__[attr]) - - return new_dict + params = self._copy_param(other) + return params - def __setstate__(self, state): - for (k, v) in state.items(): - setattr(self, k, v) + def _copy_param(self, param): + try: + return copy.deepcopy(param) + except Exception: + # Fallback to casting to unicode this will handle the + # python code-like objects that can't be deep-copied + return six.text_type(param) - # operator overloads def __add__(self, other): - copied = copy.deepcopy(self) - copied._right_extra_msg += other.__str__() - return copied + msg = _('Message objects do not support addition.') + raise TypeError(msg) def __radd__(self, other): - copied = copy.deepcopy(self) - copied._left_extra_msg += other.__str__() - return copied + return self.__add__(other) - def __mod__(self, other): - # do a format string to catch and raise - # any possible KeyErrors from missing parameters - self.data % other - copied = copy.deepcopy(self) - return copied._save_parameters(other) - - def __mul__(self, other): - return self.data * other - - def __rmul__(self, other): - return other * self.data - - def __getitem__(self, key): - return self.data[key] - - def __getslice__(self, start, end): - return self.data.__getslice__(start, end) - - def __getattribute__(self, name): - # NOTE(mrodden): handle lossy operations that we can't deal with yet - # These override the UserString implementation, since UserString - # uses our __class__ attribute to try and build a new message - # after running the inner data string through the operation. - # At that point, we have lost the gettext message id and can just - # safely resolve to a string instead. - ops = ['capitalize', 'center', 'decode', 'encode', - 'expandtabs', 'ljust', 'lstrip', 'replace', 'rjust', 'rstrip', - 'strip', 'swapcase', 'title', 'translate', 'upper', 'zfill'] - if name in ops: - return getattr(self.data, name) - else: - return _userString.UserString.__getattribute__(self, name) + if six.PY2: + def __str__(self): + # NOTE(luisg): Logging in python 2.6 tries to str() log records, + # and it expects specifically a UnicodeError in order to proceed. + msg = _('Message objects do not support str() because they may ' + 'contain non-ascii characters. ' + 'Please use unicode() or translate() instead.') + raise UnicodeError(msg) def get_available_languages(domain): @@ -319,53 +337,143 @@ def get_available_languages(domain): list_identifiers = (getattr(localedata, 'list', None) or getattr(localedata, 'locale_identifiers')) locale_identifiers = list_identifiers() + for i in locale_identifiers: if find(i) is not None: language_list.append(i) + + # NOTE(luisg): Babel>=1.0,<1.3 has a bug where some OpenStack supported + # locales (e.g. 'zh_CN', and 'zh_TW') aren't supported even though they + # are perfectly legitimate locales: + # https://github.com/mitsuhiko/babel/issues/37 + # In Babel 1.3 they fixed the bug and they support these locales, but + # they are still not explicitly "listed" by locale_identifiers(). + # That is why we add the locales here explicitly if necessary so that + # they are listed as supported. + aliases = {'zh': 'zh_CN', + 'zh_Hant_HK': 'zh_HK', + 'zh_Hant': 'zh_TW', + 'fil': 'tl_PH'} + for (locale_, alias) in six.iteritems(aliases): + if locale_ in language_list and alias not in language_list: + language_list.append(alias) + _AVAILABLE_LANGUAGES[domain] = language_list return copy.copy(language_list) -def get_localized_message(message, user_locale): - """Gets a localized version of the given message in the given locale. +def translate(obj, desired_locale=None): + """Gets the translated unicode representation of the given object. - If the message is not a Message object the message is returned as-is. - If the locale is None the message is translated to the default locale. + If the object is not translatable it is returned as-is. + If the locale is None the object is translated to the system locale. - :returns: the translated message in unicode, or the original message if + :param obj: the object to translate + :param desired_locale: the locale to translate the message to, if None the + default system locale will be used + :returns: the translated object in unicode, or the original object if it could not be translated """ - translated = message + message = obj + if not isinstance(message, Message): + # If the object to translate is not already translatable, + # let's first get its unicode representation + message = six.text_type(obj) if isinstance(message, Message): - original_locale = message.locale - message.locale = user_locale - translated = six.text_type(message) - message.locale = original_locale - return translated + # Even after unicoding() we still need to check if we are + # running with translatable unicode before translating + return message.translate(desired_locale) + return obj + +def _translate_args(args, desired_locale=None): + """Translates all the translatable elements of the given arguments object. -class LocaleHandler(logging.Handler): - """Handler that can have a locale associated to translate Messages. + This method is used for translating the translatable values in method + arguments which include values of tuples or dictionaries. + If the object is not a tuple or a dictionary the object itself is + translated if it is translatable. - A quick example of how to utilize the Message class above. - LocaleHandler takes a locale and a target logging.Handler object - to forward LogRecord objects to after translating the internal Message. + If the locale is None the object is translated to the system locale. + + :param args: the args to translate + :param desired_locale: the locale to translate the args to, if None the + default system locale will be used + :returns: a new args object with the translated contents of the original """ + if isinstance(args, tuple): + return tuple(translate(v, desired_locale) for v in args) + if isinstance(args, dict): + translated_dict = {} + for (k, v) in six.iteritems(args): + translated_v = translate(v, desired_locale) + translated_dict[k] = translated_v + return translated_dict + return translate(args, desired_locale) + - def __init__(self, locale, target): - """Initialize a LocaleHandler +class TranslationHandler(handlers.MemoryHandler): + """Handler that translates records before logging them. + + The TranslationHandler takes a locale and a target logging.Handler object + to forward LogRecord objects to after translating them. This handler + depends on Message objects being logged, instead of regular strings. + + The handler can be configured declaratively in the logging.conf as follows: + + [handlers] + keys = translatedlog, translator + + [handler_translatedlog] + class = handlers.WatchedFileHandler + args = ('/var/log/api-localized.log',) + formatter = context + + [handler_translator] + class = openstack.common.log.TranslationHandler + target = translatedlog + args = ('zh_CN',) + + If the specified locale is not available in the system, the handler will + log in the default locale. + """ + + def __init__(self, locale=None, target=None): + """Initialize a TranslationHandler :param locale: locale to use for translating messages :param target: logging.Handler object to forward LogRecord objects to after translation """ - logging.Handler.__init__(self) + # NOTE(luisg): In order to allow this handler to be a wrapper for + # other handlers, such as a FileHandler, and still be able to + # configure it using logging.conf, this handler has to extend + # MemoryHandler because only the MemoryHandlers' logging.conf + # parsing is implemented such that it accepts a target handler. + handlers.MemoryHandler.__init__(self, capacity=0, target=target) self.locale = locale - self.target = target + + def setFormatter(self, fmt): + self.target.setFormatter(fmt) def emit(self, record): - if isinstance(record.msg, Message): - # set the locale and resolve to a string - record.msg.locale = self.locale + # We save the message from the original record to restore it + # after translation, so other handlers are not affected by this + original_msg = record.msg + original_args = record.args + + try: + self._translate_and_log_record(record) + finally: + record.msg = original_msg + record.args = original_args + + def _translate_and_log_record(self, record): + record.msg = translate(record.msg, self.locale) + + # In addition to translating the message, we also need to translate + # arguments that were passed to the log method that were not part + # of the main message e.g., log.info(_('Some message %s'), this_one)) + record.args = _translate_args(record.args, self.locale) self.target.emit(record) diff --git a/ceilometerclient/openstack/common/importutils.py b/ceilometerclient/openstack/common/importutils.py index 4fd9ae2..a7972e0 100644 --- a/ceilometerclient/openstack/common/importutils.py +++ b/ceilometerclient/openstack/common/importutils.py @@ -24,10 +24,10 @@ import traceback def import_class(import_str): """Returns a class from a string including module and class.""" mod_str, _sep, class_str = import_str.rpartition('.') + __import__(mod_str) try: - __import__(mod_str) return getattr(sys.modules[mod_str], class_str) - except (ValueError, AttributeError): + except AttributeError: raise ImportError('Class %s cannot be found (%s)' % (class_str, traceback.format_exception(*sys.exc_info()))) @@ -58,6 +58,13 @@ def import_module(import_str): return sys.modules[import_str] +def import_versioned_module(version, submodule=None): + module = 'ceilometerclient.v%s' % version + if submodule: + module = '.'.join((module, submodule)) + return import_module(module) + + def try_import(import_str, default=None): """Try to import a module and if it fails return default.""" try: diff --git a/ceilometerclient/openstack/common/uuidutils.py b/ceilometerclient/openstack/common/uuidutils.py new file mode 100644 index 0000000..234b880 --- /dev/null +++ b/ceilometerclient/openstack/common/uuidutils.py @@ -0,0 +1,37 @@ +# Copyright (c) 2012 Intel Corporation. +# 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. + +""" +UUID related utilities and helper functions. +""" + +import uuid + + +def generate_uuid(): + return str(uuid.uuid4()) + + +def is_uuid_like(val): + """Returns validation of a value as a UUID. + + For our purposes, a UUID is a canonical form string: + aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa + + """ + try: + return str(uuid.UUID(val)) == val + except (TypeError, ValueError, AttributeError): + return False diff --git a/tools/install_venv_common.py b/tools/install_venv_common.py index 46822e3..e279159 100644 --- a/tools/install_venv_common.py +++ b/tools/install_venv_common.py @@ -125,7 +125,7 @@ class InstallVenv(object): parser.add_option('-n', '--no-site-packages', action='store_true', help="Do not inherit packages from global Python " - "install") + "install.") return parser.parse_args(argv[1:])[0] -- cgit v1.2.1