1# Copyright 2012 Red Hat, Inc.
2# Copyright 2013 IBM Corp.
3# All Rights Reserved.
4#
5#    Licensed under the Apache License, Version 2.0 (the "License"); you may
6#    not use this file except in compliance with the License. You may obtain
7#    a copy of the License at
8#
9#         http://www.apache.org/licenses/LICENSE-2.0
10#
11#    Unless required by applicable law or agreed to in writing, software
12#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14#    License for the specific language governing permissions and limitations
15#    under the License.
16"""Private Message class for lazy translation support.
17"""
18
19import copy
20import gettext
21import locale
22import logging
23import os
24import warnings
25
26from oslo_i18n import _locale
27from oslo_i18n import _translate
28
29# magic gettext number to separate context from message
30CONTEXT_SEPARATOR = "\x04"
31
32
33LOG = logging.getLogger(__name__)
34
35
36class Message(str):
37    """A Message object is a unicode object that can be translated.
38
39    Translation of Message is done explicitly using the translate() method.
40    For all non-translation intents and purposes, a Message is simply unicode,
41    and can be treated as such.
42    """
43
44    def __new__(cls, msgid, msgtext=None, params=None,
45                domain='oslo', has_contextual_form=False,
46                has_plural_form=False, *args):
47        """Create a new Message object.
48
49        In order for translation to work gettext requires a message ID, this
50        msgid will be used as the base unicode text. It is also possible
51        for the msgid and the base unicode text to be different by passing
52        the msgtext parameter.
53        """
54        # If the base msgtext is not given, we use the default translation
55        # of the msgid (which is in English) just in case the system locale is
56        # not English, so that the base text will be in that locale by default.
57        if not msgtext:
58            msgtext = Message._translate_msgid(msgid, domain)
59        # We want to initialize the parent unicode with the actual object that
60        # would have been plain unicode if 'Message' was not enabled.
61        msg = super(Message, cls).__new__(cls, msgtext)
62        msg.msgid = msgid
63        msg.domain = domain
64        msg.params = params
65        msg.has_contextual_form = has_contextual_form
66        msg.has_plural_form = has_plural_form
67        return msg
68
69    def translation(self, desired_locale=None):
70        """Translate this message to the desired locale.
71
72        :param desired_locale: The desired locale to translate the message to,
73                               if no locale is provided the message will be
74                               translated to the system's default locale.
75
76        :returns: the translated message in unicode
77        """
78        translated_message = Message._translate_msgid(self.msgid,
79                                                      self.domain,
80                                                      desired_locale,
81                                                      self.has_contextual_form,
82                                                      self.has_plural_form)
83
84        if self.params is None:
85            # No need for more translation
86            return translated_message
87
88        # This Message object may have been formatted with one or more
89        # Message objects as substitution arguments, given either as a single
90        # argument, part of a tuple, or as one or more values in a dictionary.
91        # When translating this Message we need to translate those Messages too
92        translated_params = _translate.translate_args(self.params,
93                                                      desired_locale)
94
95        return self._safe_translate(translated_message, translated_params)
96
97    @staticmethod
98    def _translate_msgid(msgid, domain, desired_locale=None,
99                         has_contextual_form=False, has_plural_form=False):
100        if not desired_locale:
101            system_locale = locale.getdefaultlocale()
102            # If the system locale is not available to the runtime use English
103            if not system_locale or not system_locale[0]:
104                desired_locale = 'en_US'
105            else:
106                desired_locale = system_locale[0]
107
108        locale_dir = os.environ.get(
109            _locale.get_locale_dir_variable_name(domain)
110        )
111        lang = gettext.translation(domain,
112                                   localedir=locale_dir,
113                                   languages=[desired_locale],
114                                   fallback=True)
115
116        if not has_contextual_form and not has_plural_form:
117            # This is the most common case, so check it first.
118            translator = lang.gettext
119            translated_message = translator(msgid)
120
121        elif has_contextual_form and has_plural_form:
122            # Reserved for contextual and plural translation function,
123            # which is not yet implemented.
124            raise ValueError("Unimplemented.")
125
126        elif has_contextual_form:
127            (msgctx, msgtxt) = msgid
128            translator = lang.gettext
129
130            msg_with_ctx = "%s%s%s" % (msgctx, CONTEXT_SEPARATOR, msgtxt)
131            translated_message = translator(msg_with_ctx)
132
133            if CONTEXT_SEPARATOR in translated_message:
134                # Translation not found, use the original text
135                translated_message = msgtxt
136
137        elif has_plural_form:
138            (msgsingle, msgplural, msgcount) = msgid
139            translator = lang.ngettext
140            translated_message = translator(msgsingle, msgplural, msgcount)
141
142        return translated_message
143
144    def _safe_translate(self, translated_message, translated_params):
145        """Trap translation errors and fall back to default translation.
146
147        :param translated_message: the requested translation
148
149        :param translated_params: the params to be inserted
150
151        :return: if parameter insertion is successful then it is the
152                 translated_message with the translated_params inserted, if the
153                 requested translation fails then it is the default translation
154                 with the params
155        """
156
157        try:
158            translated_message = translated_message % translated_params
159        except (KeyError, TypeError) as err:
160            # KeyError for parameters named in the translated_message
161            # but not found in translated_params and TypeError for
162            # type strings that do not match the type of the
163            # parameter.
164            #
165            # Log the error translating the message and use the
166            # original message string so the translator's bad message
167            # catalog doesn't break the caller.
168            # Do not translate this log message even if it is used as a
169            # warning message as a wrong translation of this message could
170            # cause infinite recursion
171            msg = (u'Failed to insert replacement values into translated '
172                   u'message %s (Original: %r): %s')
173            warnings.warn(msg % (translated_message, self.msgid, err))
174            LOG.debug(msg, translated_message, self.msgid, err)
175
176            translated_message = self.msgid % translated_params
177
178        return translated_message
179
180    def __mod__(self, other):
181        # When we mod a Message we want the actual operation to be performed
182        # by the base class (i.e. unicode()), the only thing  we do here is
183        # save the original msgid and the parameters in case of a translation
184        params = self._sanitize_mod_params(other)
185        unicode_mod = self._safe_translate(str(self), params)
186        modded = Message(self.msgid,
187                         msgtext=unicode_mod,
188                         params=params,
189                         domain=self.domain)
190        return modded
191
192    def _sanitize_mod_params(self, other):
193        """Sanitize the object being modded with this Message.
194
195        - Add support for modding 'None' so translation supports it
196        - Trim the modded object, which can be a large dictionary, to only
197        those keys that would actually be used in a translation
198        - Snapshot the object being modded, in case the message is
199        translated, it will be used as it was when the Message was created
200        """
201        if other is None:
202            params = (other,)
203        elif isinstance(other, dict):
204            # Merge the dictionaries
205            # Copy each item in case one does not support deep copy.
206            params = {}
207            if isinstance(self.params, dict):
208                params.update((key, self._copy_param(val))
209                              for key, val in self.params.items())
210            params.update((key, self._copy_param(val))
211                          for key, val in other.items())
212        else:
213            params = self._copy_param(other)
214        return params
215
216    def _copy_param(self, param):
217        try:
218            return copy.deepcopy(param)
219        except Exception:
220            # Fallback to casting to unicode this will handle the
221            # python code-like objects that can't be deep-copied
222            return str(param)
223
224    def __add__(self, other):
225        from oslo_i18n._i18n import _
226        msg = _('Message objects do not support addition.')
227        raise TypeError(msg)
228
229    def __radd__(self, other):
230        return self.__add__(other)
231