Commit 64c94cf3 by Sarina Canelake

Merge pull request #4686 from Stanford-Online/stv/i18n/gettext

Monkey patch 'django.utils.translation'
parents d9c7a42e 86633df3
......@@ -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()
......
"""
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
"""
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
# -*- 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()
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment