Monkey patch 'django.utils.translation'
Modify Django's translation library, such that the gettext family of
functions return an empty string when attempting to translate a
falsey value. This overrides the default behavior [0]:
> It is convention with GNU gettext to include meta-data as the
> translation for the empty string.
This patch provides a holistic solution to replace the current piecemeal
approach [1][2].
Affected Methods:
- gettext
- ugettext
[0] https://docs.python.org/2.7/library/gettext.html#the-gnutranslations-class
[1] bad803e451
[2] https://github.com/edx/edx-platform/pull/4653
This commit is contained in:
@@ -7,12 +7,15 @@ from django.conf import settings
|
||||
settings.INSTALLED_APPS # pylint: disable=W0104
|
||||
|
||||
from django_startup import autostartup
|
||||
from monkey_patch import django_utils_translation
|
||||
|
||||
|
||||
def run():
|
||||
"""
|
||||
Executed during django startup
|
||||
"""
|
||||
django_utils_translation.patch()
|
||||
|
||||
autostartup()
|
||||
|
||||
add_mimetypes()
|
||||
|
||||
80
common/djangoapps/monkey_patch/__init__.py
Normal file
80
common/djangoapps/monkey_patch/__init__.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
Monkey-patch the edX platform
|
||||
|
||||
Here be dragons (and simians!)
|
||||
|
||||
* USE WITH CAUTION *
|
||||
No, but seriously, you probably never really want to make changes here.
|
||||
This module contains methods to monkey-patch [0] the edx-platform.
|
||||
Patches are to be applied as early as possible in the callstack
|
||||
(currently lms/startup.py and cms/startup.py). Consequently, changes
|
||||
made here will affect the entire platform.
|
||||
|
||||
That said, if you've decided you really need to monkey-patch the
|
||||
platform (and you've convinced enough people that this is best
|
||||
solution), kindly follow these guidelines:
|
||||
- Reference django_utils_translation.py for a sample implementation.
|
||||
- Name your module by replacing periods with underscores for the
|
||||
module to be patched:
|
||||
- patching 'django.utils.translation'
|
||||
becomes 'django_utils_translation'
|
||||
- patching 'your.module'
|
||||
becomes 'your_module'
|
||||
- Implement argumentless function wrappers in
|
||||
monkey_patch.your_module for the following:
|
||||
- is_patched
|
||||
- patch
|
||||
- unpatch
|
||||
- Add the following code where needed (typically cms/startup.py and
|
||||
lms/startup.py):
|
||||
```
|
||||
from monkey_patch import your_module
|
||||
your_module.patch()
|
||||
```
|
||||
- Write tests! All code should be tested anyway, but with code that
|
||||
patches the platform runtime, we must be extra sure there are no
|
||||
unintended consequences.
|
||||
|
||||
[0] http://en.wikipedia.org/wiki/Monkey_patch
|
||||
"""
|
||||
# Use this key to store a reference to the unpatched copy
|
||||
__BACKUP_ATTRIBUTE_NAME = '__monkey_patch'
|
||||
|
||||
|
||||
def is_patched(module, attribute_name):
|
||||
"""
|
||||
Check if an attribute has been monkey-patched
|
||||
"""
|
||||
attribute = getattr(module, attribute_name)
|
||||
return hasattr(attribute, __BACKUP_ATTRIBUTE_NAME)
|
||||
|
||||
|
||||
def patch(module, attribute_name, attribute_replacement):
|
||||
"""
|
||||
Monkey-patch an attribute
|
||||
|
||||
A backup of the original attribute is preserved in the patched
|
||||
attribute (see: __BACKUP_ATTRIBUTE_NAME).
|
||||
"""
|
||||
attribute = getattr(module, attribute_name)
|
||||
setattr(attribute_replacement, __BACKUP_ATTRIBUTE_NAME, attribute)
|
||||
setattr(module, attribute_name, attribute_replacement)
|
||||
return is_patched(module, attribute_name)
|
||||
|
||||
|
||||
def unpatch(module, attribute_name):
|
||||
"""
|
||||
Un-monkey-patch an attribute
|
||||
|
||||
Restore a backup of the original attribute from the patched
|
||||
attribute, iff it exists (see: __BACKUP_ATTRIBUTE_NAME).
|
||||
|
||||
Return boolean whether or not the attribute could be unpatched
|
||||
"""
|
||||
was_patched = False
|
||||
attribute = getattr(module, attribute_name)
|
||||
if hasattr(attribute, __BACKUP_ATTRIBUTE_NAME):
|
||||
attribute_old = getattr(attribute, __BACKUP_ATTRIBUTE_NAME)
|
||||
setattr(module, attribute_name, attribute_old)
|
||||
was_patched = True
|
||||
return was_patched
|
||||
91
common/djangoapps/monkey_patch/django_utils_translation.py
Normal file
91
common/djangoapps/monkey_patch/django_utils_translation.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Monkey-patch `django.utils.translation` to not dump header info
|
||||
|
||||
Modify Django's translation module, such that the *gettext functions
|
||||
always return an empty string when attempting to translate an empty
|
||||
string. This overrides the default behavior [0]:
|
||||
> It is convention with GNU gettext to include meta-data as the
|
||||
> translation for the empty string.
|
||||
|
||||
Affected Methods:
|
||||
- gettext
|
||||
- ugettext
|
||||
|
||||
Note: The *ngettext and *pgettext functions are intentionally omitted,
|
||||
as they already behave as expected. The *_lazy functions are implicitly
|
||||
patched, as they wrap their nonlazy equivalents.
|
||||
|
||||
Django's translation module contains a good deal of indirection. For us
|
||||
to patch the module with our own functions, we have to patch
|
||||
`django.utils.translation._trans`. This ensures that the patched
|
||||
behavior will still be used, even if code elsewhere caches a reference
|
||||
to one of the translation functions. If you're curious, check out
|
||||
Django's source code [1].
|
||||
|
||||
[0] https://docs.python.org/2.7/library/gettext.html#the-gnutranslations-class
|
||||
[1] https://github.com/django/django/blob/1.4.8/django/utils/translation/__init__.py#L66
|
||||
"""
|
||||
from django.utils.translation import _trans as translation
|
||||
|
||||
import monkey_patch
|
||||
|
||||
ATTRIBUTES = [
|
||||
'gettext',
|
||||
'ugettext',
|
||||
]
|
||||
|
||||
|
||||
def is_patched():
|
||||
"""
|
||||
Check if the translation module has been monkey-patched
|
||||
"""
|
||||
patched = True
|
||||
for attribute in ATTRIBUTES:
|
||||
if not monkey_patch.is_patched(translation, attribute):
|
||||
patched = False
|
||||
break
|
||||
return patched
|
||||
|
||||
|
||||
def patch():
|
||||
"""
|
||||
Monkey-patch the translation functions
|
||||
|
||||
Affected Methods:
|
||||
- gettext
|
||||
- ugettext
|
||||
"""
|
||||
def decorate(function, message_default=u''):
|
||||
"""
|
||||
Decorate a translation function
|
||||
|
||||
Default message is a unicode string, but gettext overrides this
|
||||
value to return a UTF8 string.
|
||||
"""
|
||||
def dont_translate_empty_string(message):
|
||||
"""
|
||||
Return the empty string when passed a falsey message
|
||||
"""
|
||||
if message:
|
||||
message = function(message)
|
||||
else:
|
||||
message = message_default
|
||||
return message
|
||||
return dont_translate_empty_string
|
||||
gettext = decorate(translation.gettext, '')
|
||||
ugettext = decorate(translation.ugettext)
|
||||
monkey_patch.patch(translation, 'gettext', gettext)
|
||||
monkey_patch.patch(translation, 'ugettext', ugettext)
|
||||
return is_patched()
|
||||
|
||||
|
||||
def unpatch():
|
||||
"""
|
||||
Un-monkey-patch the translation functions
|
||||
"""
|
||||
was_patched = False
|
||||
for name in ATTRIBUTES:
|
||||
# was_patched must be the second half of the or-clause, to avoid
|
||||
# short-circuiting the expression
|
||||
was_patched = monkey_patch.unpatch(translation, name) or was_patched
|
||||
return was_patched
|
||||
@@ -0,0 +1,333 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Test methods exposed in common/lib/monkey_patch/django_utils_translation.py
|
||||
|
||||
Verify that the Django translation functions (gettext, ngettext,
|
||||
pgettext, ugettext, and derivatives) all return the correct values
|
||||
before, during, and after monkey-patching the django.utils.translation
|
||||
module.
|
||||
|
||||
gettext, ngettext, pgettext, and ugettext must return a translation as
|
||||
output for nonempty input.
|
||||
|
||||
ngettext, pgettext, npgettext, and ungettext must return an empty string
|
||||
for an empty string as input.
|
||||
|
||||
gettext and ugettext will return translation headers, before and after
|
||||
patching.
|
||||
|
||||
gettext and ugettext must return the empty string for any falsey input,
|
||||
while patched.
|
||||
|
||||
*_noop must return the input text.
|
||||
|
||||
*_lazy must return the same text as their non-lazy counterparts.
|
||||
"""
|
||||
# pylint: disable=invalid-name
|
||||
# Let names like `gettext_*` stay lowercase; makes matching easier.
|
||||
# pylint: disable=missing-docstring
|
||||
# All major functions are documented, the rest are self-evident shells.
|
||||
# pylint: disable=no-member
|
||||
# Pylint doesn't see our decorator `translate_with` add the `_` method.
|
||||
from unittest import TestCase
|
||||
|
||||
from ddt import data
|
||||
from ddt import ddt
|
||||
from django.utils.translation import _trans
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy
|
||||
from django.utils.translation import gettext_noop
|
||||
from django.utils.translation import ngettext
|
||||
from django.utils.translation import ngettext_lazy
|
||||
from django.utils.translation import npgettext
|
||||
from django.utils.translation import npgettext_lazy
|
||||
from django.utils.translation import pgettext
|
||||
from django.utils.translation import pgettext_lazy
|
||||
from django.utils.translation import ugettext
|
||||
from django.utils.translation import ugettext_lazy
|
||||
from django.utils.translation import ugettext_noop
|
||||
from django.utils.translation import ungettext
|
||||
from django.utils.translation import ungettext_lazy
|
||||
|
||||
from monkey_patch.django_utils_translation import ATTRIBUTES as attributes_patched
|
||||
from monkey_patch.django_utils_translation import is_patched
|
||||
from monkey_patch.django_utils_translation import patch
|
||||
from monkey_patch.django_utils_translation import unpatch
|
||||
|
||||
# Note: The commented-out function names are explicitly excluded, as
|
||||
# they are not attributes of `django.utils.translation._trans`.
|
||||
# https://github.com/django/django/blob/1.4.8/django/utils/translation/__init__.py#L69
|
||||
attributes_not_patched = [
|
||||
'gettext_noop',
|
||||
'ngettext',
|
||||
'npgettext',
|
||||
'pgettext',
|
||||
'ungettext',
|
||||
# 'gettext_lazy',
|
||||
# 'ngettext_lazy',
|
||||
# 'npgettext_lazy',
|
||||
# 'pgettext_lazy',
|
||||
# 'ugettext_lazy',
|
||||
# 'ugettext_noop',
|
||||
# 'ungettext_lazy',
|
||||
]
|
||||
|
||||
|
||||
class MonkeyPatchTest(TestCase):
|
||||
def setUp(self):
|
||||
"""
|
||||
Remember the current state, then reset
|
||||
"""
|
||||
self.was_patched = unpatch()
|
||||
self.unpatch_all()
|
||||
self.addCleanup(self.cleanup)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Revert translation functions to previous state
|
||||
|
||||
Since the end state varies, we always unpatch to remove any
|
||||
changes, then repatch again iff the module was already
|
||||
patched when the test began.
|
||||
"""
|
||||
self.unpatch_all()
|
||||
if self.was_patched:
|
||||
patch()
|
||||
|
||||
def unpatch_all(self):
|
||||
"""
|
||||
Unpatch the module recursively
|
||||
"""
|
||||
while is_patched():
|
||||
unpatch()
|
||||
|
||||
|
||||
@ddt
|
||||
class PatchTest(MonkeyPatchTest):
|
||||
"""
|
||||
Verify monkey-patching and un-monkey-patching
|
||||
"""
|
||||
@data(*attributes_not_patched)
|
||||
def test_not_patch(self, attribute_name):
|
||||
"""
|
||||
Test that functions are not patched unintentionally
|
||||
"""
|
||||
self.unpatch_all()
|
||||
old_attribute = getattr(_trans, attribute_name)
|
||||
patch()
|
||||
new_attribute = getattr(_trans, attribute_name)
|
||||
self.assertIs(old_attribute, new_attribute)
|
||||
|
||||
@data(*attributes_patched)
|
||||
def test_unpatch(self, attribute):
|
||||
"""
|
||||
Test that unpatch gracefully handles unpatched functions
|
||||
"""
|
||||
patch()
|
||||
self.assertTrue(is_patched())
|
||||
self.unpatch_all()
|
||||
self.assertFalse(is_patched())
|
||||
old_attribute = getattr(_trans, attribute)
|
||||
self.unpatch_all()
|
||||
new_attribute = getattr(_trans, attribute)
|
||||
self.assertIs(old_attribute, new_attribute)
|
||||
self.assertFalse(is_patched())
|
||||
|
||||
@data(*attributes_patched)
|
||||
def test_patch_attributes(self, attribute):
|
||||
"""
|
||||
Test that patch changes the attribute
|
||||
"""
|
||||
self.unpatch_all()
|
||||
self.assertFalse(is_patched())
|
||||
old_attribute = getattr(_trans, attribute)
|
||||
patch()
|
||||
new_attribute = getattr(_trans, attribute)
|
||||
self.assertIsNot(old_attribute, new_attribute)
|
||||
self.assertTrue(is_patched())
|
||||
old_attribute = getattr(_trans, attribute)
|
||||
patch()
|
||||
new_attribute = getattr(_trans, attribute)
|
||||
self.assertIsNot(old_attribute, new_attribute)
|
||||
self.assertTrue(is_patched())
|
||||
|
||||
|
||||
def translate_with(function):
|
||||
"""
|
||||
Decorate a class by setting its `_` translation function
|
||||
"""
|
||||
def decorate(cls):
|
||||
def _(self, *args):
|
||||
# pylint: disable=unused-argument
|
||||
return function(*args)
|
||||
cls._ = _
|
||||
return cls
|
||||
return decorate
|
||||
|
||||
|
||||
@translate_with(ugettext)
|
||||
class UgettextTest(MonkeyPatchTest):
|
||||
"""
|
||||
Test a Django translation function
|
||||
|
||||
Here we consider `ugettext` to be the base/default case. All other
|
||||
translation functions extend, as needed.
|
||||
"""
|
||||
is_unicode = True
|
||||
needs_patched = True
|
||||
header = 'Project-Id-Version: '
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Restore translation text and functions
|
||||
"""
|
||||
super(UgettextTest, self).setUp()
|
||||
if self.is_unicode:
|
||||
self.empty = u''
|
||||
self.nonempty = u'(╯°□°)╯︵ ┻━┻'
|
||||
else:
|
||||
self.empty = ''
|
||||
self.nonempty = 'Hey! Where are you?!'
|
||||
|
||||
def assert_translations(self):
|
||||
"""
|
||||
Assert that the empty and nonempty translations are correct
|
||||
|
||||
The `empty = empty[:]` syntax is intentional. Since subclasses
|
||||
may implement a lazy translation, we must perform a "string
|
||||
operation" to coerce it to a string value. We don't use `str` or
|
||||
`unicode` because we also assert the string type.
|
||||
"""
|
||||
empty, nonempty = self.get_translations()
|
||||
empty = empty[:]
|
||||
nonempty = nonempty[:]
|
||||
if self.is_unicode:
|
||||
self.assertTrue(isinstance(empty, unicode))
|
||||
self.assertTrue(isinstance(nonempty, unicode))
|
||||
else:
|
||||
self.assertTrue(isinstance(empty, str))
|
||||
self.assertTrue(isinstance(nonempty, str))
|
||||
if self.needs_patched and not is_patched():
|
||||
self.assertIn(self.header, empty)
|
||||
else:
|
||||
self.assertNotIn(self.header, empty)
|
||||
self.assertNotIn(self.header, nonempty)
|
||||
|
||||
def get_translations(self):
|
||||
"""
|
||||
Translate the empty and nonempty strings, per `self._`
|
||||
"""
|
||||
empty = self._(self.empty)
|
||||
nonempty = self._(self.nonempty)
|
||||
return (empty, nonempty)
|
||||
|
||||
def test_patch(self):
|
||||
"""
|
||||
Test that `self._` correctly translates text before, during, and
|
||||
after being monkey-patched.
|
||||
"""
|
||||
self.assert_translations()
|
||||
was_successful = patch()
|
||||
self.assertTrue(was_successful)
|
||||
self.assert_translations()
|
||||
was_successful = unpatch()
|
||||
self.assertTrue(was_successful)
|
||||
self.assert_translations()
|
||||
|
||||
|
||||
@translate_with(gettext)
|
||||
class GettextTest(UgettextTest):
|
||||
is_unicode = False
|
||||
|
||||
|
||||
@translate_with(pgettext)
|
||||
class PgettextTest(UgettextTest):
|
||||
needs_patched = False
|
||||
l18n_context = 'monkey_patch'
|
||||
|
||||
def get_translations(self):
|
||||
empty = self._(self.l18n_context, self.empty)
|
||||
nonempty = self._(self.l18n_context, self.nonempty)
|
||||
return (empty, nonempty)
|
||||
|
||||
|
||||
@translate_with(ngettext)
|
||||
class NgettextTest(GettextTest):
|
||||
number = 1
|
||||
needs_patched = False
|
||||
|
||||
def get_translations(self):
|
||||
empty = self._(self.empty, self.empty, self.number)
|
||||
nonempty = self._(self.nonempty, self.nonempty, self.number)
|
||||
return (empty, nonempty)
|
||||
|
||||
|
||||
@translate_with(npgettext)
|
||||
class NpgettextTest(PgettextTest):
|
||||
number = 1
|
||||
|
||||
def get_translations(self):
|
||||
empty = self._(self.l18n_context, self.empty, self.empty, self.number)
|
||||
nonempty = self._(self.l18n_context, self.nonempty, self.nonempty, self.number)
|
||||
return (empty, nonempty)
|
||||
|
||||
|
||||
class NpgettextPluralTest(NpgettextTest):
|
||||
number = 2
|
||||
|
||||
|
||||
class NgettextPluralTest(NgettextTest):
|
||||
number = 2
|
||||
|
||||
|
||||
@translate_with(gettext_noop)
|
||||
class GettextNoopTest(GettextTest):
|
||||
needs_patched = False
|
||||
|
||||
|
||||
@translate_with(ugettext_noop)
|
||||
class UgettextNoopTest(UgettextTest):
|
||||
needs_patched = False
|
||||
|
||||
|
||||
@translate_with(ungettext)
|
||||
class UngettextTest(NgettextTest):
|
||||
is_unicode = True
|
||||
|
||||
|
||||
class UngettextPluralTest(UngettextTest):
|
||||
number = 2
|
||||
|
||||
|
||||
@translate_with(gettext_lazy)
|
||||
class GettextLazyTest(GettextTest):
|
||||
pass
|
||||
|
||||
|
||||
@translate_with(ugettext_lazy)
|
||||
class UgettextLazyTest(UgettextTest):
|
||||
pass
|
||||
|
||||
|
||||
@translate_with(pgettext_lazy)
|
||||
class PgettextLazyTest(PgettextTest):
|
||||
pass
|
||||
|
||||
|
||||
@translate_with(ngettext_lazy)
|
||||
class NgettextLazyTest(NgettextTest):
|
||||
pass
|
||||
|
||||
|
||||
@translate_with(npgettext_lazy)
|
||||
class NpgettextLazyTest(NpgettextTest):
|
||||
pass
|
||||
|
||||
|
||||
class NpgettextLazyPluralTest(NpgettextLazyTest):
|
||||
number = 2
|
||||
|
||||
|
||||
@translate_with(ungettext_lazy)
|
||||
class UngettextLazyTest(UngettextTest):
|
||||
pass
|
||||
@@ -10,6 +10,7 @@ settings.INSTALLED_APPS # pylint: disable=W0104
|
||||
from django_startup import autostartup
|
||||
import edxmako
|
||||
import logging
|
||||
from monkey_patch import django_utils_translation
|
||||
import analytics
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -19,6 +20,8 @@ def run():
|
||||
"""
|
||||
Executed during django startup
|
||||
"""
|
||||
django_utils_translation.patch()
|
||||
|
||||
autostartup()
|
||||
|
||||
add_mimetypes()
|
||||
|
||||
Reference in New Issue
Block a user