From 86633df3dbd76174d7691b13e7b06ab8f9c207d2 Mon Sep 17 00:00:00 2001 From: stv Date: Fri, 1 Aug 2014 17:17:16 -0700 Subject: [PATCH] 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] bad803e451ce63b4693ecf52b66cedfce5b43160 [2] https://github.com/edx/edx-platform/pull/4653 --- cms/startup.py | 3 + common/djangoapps/monkey_patch/__init__.py | 80 +++++ .../monkey_patch/django_utils_translation.py | 91 +++++ .../tests/test_django_utils_translation.py | 333 ++++++++++++++++++ lms/startup.py | 3 + 5 files changed, 510 insertions(+) create mode 100644 common/djangoapps/monkey_patch/__init__.py create mode 100644 common/djangoapps/monkey_patch/django_utils_translation.py create mode 100644 common/djangoapps/monkey_patch/tests/test_django_utils_translation.py diff --git a/cms/startup.py b/cms/startup.py index 893cd6a2a8..87b8287369 100644 --- a/cms/startup.py +++ b/cms/startup.py @@ -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() diff --git a/common/djangoapps/monkey_patch/__init__.py b/common/djangoapps/monkey_patch/__init__.py new file mode 100644 index 0000000000..f718200a98 --- /dev/null +++ b/common/djangoapps/monkey_patch/__init__.py @@ -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 diff --git a/common/djangoapps/monkey_patch/django_utils_translation.py b/common/djangoapps/monkey_patch/django_utils_translation.py new file mode 100644 index 0000000000..ae1940677c --- /dev/null +++ b/common/djangoapps/monkey_patch/django_utils_translation.py @@ -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 diff --git a/common/djangoapps/monkey_patch/tests/test_django_utils_translation.py b/common/djangoapps/monkey_patch/tests/test_django_utils_translation.py new file mode 100644 index 0000000000..31a58f8c9e --- /dev/null +++ b/common/djangoapps/monkey_patch/tests/test_django_utils_translation.py @@ -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 diff --git a/lms/startup.py b/lms/startup.py index b073a16334..61926b0782 100644 --- a/lms/startup.py +++ b/lms/startup.py @@ -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()