Trap formatting errors

Trap errors when trying to use translated messages as format strings so
translations don't break applications. Log the error, with the format
string (without interpolated values) and the original message so a bug
report can include it to make fixing the translation easier.

Co-Authored-By: Doug Hellmann <doug@doughellmann.com>

Change-Id: I5c711b4654b5b2e591bcf365401ff35f7224fe82
Closes-bug: #1339337
This commit is contained in:
Balazs Gibizer 2015-08-14 17:19:50 +02:00
parent a86eb886fe
commit 8d49c279cf
2 changed files with 114 additions and 14 deletions

View File

@ -19,6 +19,7 @@
import copy
import gettext
import locale
import logging
import os
import six
@ -30,6 +31,9 @@ from oslo_i18n import _translate
CONTEXT_SEPARATOR = "\x04"
LOG = logging.getLogger(__name__)
class Message(six.text_type):
"""A Message object is a unicode object that can be translated.
@ -90,9 +94,7 @@ class Message(six.text_type):
translated_params = _translate.translate_args(self.params,
desired_locale)
translated_message = translated_message % translated_params
return translated_message
return self._safe_translate(translated_message, translated_params)
@staticmethod
def _translate_msgid(msgid, domain, desired_locale=None,
@ -138,12 +140,32 @@ class Message(six.text_type):
return translated_message
def _safe_translate(self, translated_message, translated_params):
try:
translated_message = translated_message % translated_params
except (KeyError, TypeError) as err:
# KeyError for parameters named in the translated_message
# but not found in translated_params and TypeError for
# type strings that do not match the type of the
# parameter.
#
# Log the error translating the message and use the
# original message string so the translator's bad message
# catalog doesn't break the caller.
LOG.debug(
(u'Failed to insert replacement values into translated '
u'message %s (Original: %r): %s'),
translated_message, self.msgid, err)
translated_message = self.msgid % translated_params
return translated_message
def __mod__(self, other):
# When we mod a Message we want the actual operation to be performed
# by the parent class (i.e. unicode()), the only thing we do here is
# save the original msgid and the parameters in case of a translation
params = self._sanitize_mod_params(other)
unicode_mod = super(Message, self).__mod__(params)
unicode_mod = self._safe_translate(six.text_type(self), params)
modded = Message(self.msgid,
msgtext=unicode_mod,
params=params,

View File

@ -157,6 +157,41 @@ class MessageTestCase(test_base.BaseTestCase):
self.assertEqual(expected, result)
self.assertEqual(expected, result.translate())
def test_mod_with_wrong_field_type_in_trans(self):
msgid = "Correct type %(arg1)s"
params = {'arg1': 'test1'}
with mock.patch('gettext.translation') as trans:
# Set up ugettext to return the original message with the
# correct format string.
trans.return_value.ugettext.return_value = msgid
# Build a message and give it some parameters.
result = _message.Message(msgid) % params
# Now set up ugettext to return the translated version of
# the original message, with a bad format string.
wrong_type = u'Wrong type %(arg1)d'
if six.PY3:
trans.return_value.gettext.return_value = wrong_type
else:
trans.return_value.ugettext.return_value = wrong_type
trans_result = result.translate()
expected = msgid % params
self.assertEqual(expected, trans_result)
def test_mod_with_wrong_field_type(self):
msgid = "Test that we handle unused args %(arg1)d"
params = {'arg1': 'test1'}
self.assertRaises(TypeError, lambda: _message.Message(msgid) % params)
def test_mod_with_missing_arg(self):
msgid = "Test that we handle missing args %(arg1)s %(arg2)s"
params = {'arg1': 'test1'}
e = self.assertRaises(KeyError,
lambda: _message.Message(msgid) % params)
self.assertIn('arg2', six.text_type(e),
'Missing key \'arg2\' was not flagged')
def test_mod_with_integer_parameters(self):
msgid = "Some string with params: %d"
params = [0, 1, 10, 24124]
@ -258,16 +293,6 @@ class MessageTestCase(test_base.BaseTestCase):
# Make sure unused params still there
self.assertEqual(result.params.keys(), params.keys())
def test_mod_with_missing_named_parameters(self):
msgid = ("Some string with params: %(param1)s %(param2)s"
" and a missing one %(missing)s")
params = {'param1': 'test',
'param2': 'test2'}
test_me = lambda: _message.Message(msgid) % params
# Just like with strings missing named parameters raise KeyError
self.assertRaises(KeyError, test_me)
def test_add_disabled(self):
msgid = "A message"
test_me = lambda: _message.Message(msgid) + ' some string'
@ -352,6 +377,59 @@ class MessageTestCase(test_base.BaseTestCase):
self.assertEqual(expected_translation, msg.translate('es'))
self.assertEqual(default_translation, msg.translate('XX'))
@mock.patch('gettext.translation')
@mock.patch('oslo_i18n._message.LOG')
def test_translate_message_bad_translation(self, mock_log,
mock_translation):
message_with_params = 'A message: %s'
es_translation = 'A message in Spanish: %s %s'
param = 'A Message param'
translations = {message_with_params: es_translation}
translator = fakes.FakeTranslations.translator({'es': translations})
mock_translation.side_effect = translator
msg = _message.Message(message_with_params)
msg = msg % param
self.assertFalse(mock_log.debug.called)
default_translation = message_with_params % param
self.assertEqual(default_translation, msg.translate('es'))
mock_log.debug.assert_called_with(('Failed to insert replacement '
'values into translated message %s '
'(Original: %r): %s'),
es_translation,
message_with_params,
mock.ANY)
@mock.patch('gettext.translation')
@mock.patch('locale.getdefaultlocale', return_value=('es', ''))
@mock.patch('oslo_i18n._message.LOG')
def test_translate_message_bad_default_translation(self, mock_log,
mock_local,
mock_translation):
message_with_params = 'A message: %s'
es_translation = 'A message in Spanish: %s %s'
param = 'A Message param'
translations = {message_with_params: es_translation}
translator = fakes.FakeTranslations.translator({'es': translations})
mock_translation.side_effect = translator
msg = _message.Message(message_with_params)
msg = msg % param
mock_log.debug.assert_called_with(('Failed to insert replacement '
'values into translated message %s '
'(Original: %r): %s'),
es_translation,
message_with_params,
mock.ANY)
mock_log.reset_mock()
default_translation = message_with_params % param
self.assertEqual(default_translation, msg)
self.assertFalse(mock_log.debug.called)
@mock.patch('gettext.translation')
def test_translate_message_with_object_param(self, mock_translation):
message_with_params = 'A message: %s'