diff --git a/conf/locale/config b/conf/locale/config index fe811ee02e..2d01e1ea43 100644 --- a/conf/locale/config +++ b/conf/locale/config @@ -1 +1 @@ -{"locales" : ["en", "fr", "de"]} +{"locales" : ["en"]} diff --git a/conf/locale/en/LC_MESSAGES/messages.po b/conf/locale/en/LC_MESSAGES/messages.po new file mode 100644 index 0000000000..1bb8bf6d7f --- /dev/null +++ b/conf/locale/en/LC_MESSAGES/messages.po @@ -0,0 +1 @@ +# empty diff --git a/i18n/dummy.py b/i18n/dummy.py index 798ee525b5..78bfdc3b58 100644 --- a/i18n/dummy.py +++ b/i18n/dummy.py @@ -86,7 +86,7 @@ class Dummy (Converter): def init_msgs(self, msgs): """ Make sure the first msg in msgs has a plural property. - msgs is list of instances of pofile.Msg + msgs is list of instances of polib.POEntry """ if len(msgs)==0: return @@ -100,8 +100,8 @@ class Dummy (Converter): def convert_msg(self, msg): """ - Takes one Msg object and converts it (adds a dummy translation to it) - msg is an instance of pofile.Msg + Takes one POEntry object and converts it (adds a dummy translation to it) + msg is an instance of polib.POEntry """ source = msg.msgid if len(source)==0: diff --git a/i18n/execute.py b/i18n/execute.py new file mode 100644 index 0000000000..3c3416b65d --- /dev/null +++ b/i18n/execute.py @@ -0,0 +1,86 @@ +import os, subprocess, logging, json + +def init_module(): + """ + Initializes module parameters + """ + global BASE_DIR, LOCALE_DIR, CONFIG_FILENAME, SOURCE_MSGS_DIR, SOURCE_LOCALE, LOG + + # BASE_DIR is the working directory to execute django-admin commands from. + # Typically this should be the 'mitx' directory. + BASE_DIR = os.path.normpath(os.path.dirname(os.path.abspath(__file__))+'/..') + + # Source language is English + SOURCE_LOCALE = 'en' + + # LOCALE_DIR contains the locale files. + # Typically this should be 'mitx/conf/locale' + LOCALE_DIR = BASE_DIR + '/conf/locale' + + # CONFIG_FILENAME contains localization configuration in json format + CONFIG_FILENAME = LOCALE_DIR + '/config' + + # SOURCE_MSGS_DIR contains the English po files. + SOURCE_MSGS_DIR = messages_dir(SOURCE_LOCALE) + + # Default logger. + LOG = get_logger() + + +def messages_dir(locale): + """ + Returns the name of the directory holding the po files for locale. + Example: mitx/conf/locale/en/LC_MESSAGES + """ + return os.path.join(LOCALE_DIR, locale, 'LC_MESSAGES') + +def get_logger(): + """Returns a default logger""" + log = logging.getLogger(__name__) + log.setLevel(logging.INFO) + log_handler = logging.StreamHandler() + log_handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s')) + log.addHandler(log_handler) + return log + +# Run this after defining messages_dir and get_logger, because it depends on these. +init_module() + +def execute (command, working_directory=BASE_DIR, log=LOG): + """ + Executes shell command in a given working_directory. + Command is a string to pass to the shell. + Output is logged to log. + """ + log.info(command) + subprocess.call(command.split(' '), cwd=working_directory) + +def get_config(): + """Returns data found in config file, or returns None if file not found""" + config_path = os.path.abspath(CONFIG_FILENAME) + if not os.path.exists(config_path): + log.warn("Configuration file cannot be found: %s" % \ + os.path.relpath(config_path, BASE_DIR)) + return None + with open(config_path) as stream: + return json.load(stream) + +def create_dir_if_necessary(pathname): + dirname = os.path.dirname(pathname) + if not os.path.exists(dirname): + os.makedirs(dirname) + + +def remove_file(filename, log=LOG, verbose=True): + """ + Attempt to delete filename. + Log a warning if file does not exist. + Logging filenames are releative to BASE_DIR to cut down on noise in output. + """ + if verbose: + log.info('Deleting file %s' % os.path.relpath(filename, BASE_DIR)) + if not os.path.exists(filename): + log.warn("File does not exist: %s" % os.path.relpath(filename, BASE_DIR)) + else: + os.remove(filename) + diff --git a/i18n/extract.py b/i18n/extract.py new file mode 100755 index 0000000000..c6fedd3bfa --- /dev/null +++ b/i18n/extract.py @@ -0,0 +1,145 @@ +#!/usr/bin/python + +""" +See https://edx-wiki.atlassian.net/wiki/display/ENG/PO+File+workflow + + This task extracts all English strings from all source code + and produces three human-readable files: + conf/locale/en/LC_MESSAGES/django-partial.po + conf/locale/en/LC_MESSAGES/djangojs.po + conf/locale/en/LC_MESSAGES/mako.po + + This task will clobber any existing django.po file. + This is because django-admin.py makemessages hardcodes this filename + and it cannot be overridden. + +""" + +import os +from datetime import datetime +from polib import pofile +from execute import execute, create_dir_if_necessary, remove_file, \ + BASE_DIR, LOCALE_DIR, SOURCE_MSGS_DIR, LOG + + +# BABEL_CONFIG contains declarations for Babel to extract strings from mako template files +# Use relpath to reduce noise in logs +BABEL_CONFIG = os.path.relpath(LOCALE_DIR + '/babel.cfg', BASE_DIR) + +# Strings from mako template files are written to BABEL_OUT +# Use relpath to reduce noise in logs +BABEL_OUT = os.path.relpath(SOURCE_MSGS_DIR + '/mako.po', BASE_DIR) + + +def main (): + create_dir_if_necessary(LOCALE_DIR) + generated_files = ('django-partial.po', 'djangojs.po', 'mako.po') + + for filename in generated_files: + remove_file(os.path.join(SOURCE_MSGS_DIR, filename)) + + # Extract strings from mako templates + babel_mako_cmd = 'pybabel extract -F %s -c "TRANSLATORS:" . -o %s' % (BABEL_CONFIG, BABEL_OUT) + + # Extract strings from django source files + make_django_cmd = 'django-admin.py makemessages -l en --ignore=src/* --ignore=i18n/* ' \ + + '--extension html' + + # Extract strings from javascript source files + make_djangojs_cmd = 'django-admin.py makemessages -l en -d djangojs --ignore=src/* ' \ + + '--ignore=i18n/* --extension js' + execute(babel_mako_cmd, working_directory=BASE_DIR) + execute(make_django_cmd, working_directory=BASE_DIR) + # makemessages creates 'django.po'. This filename is hardcoded. + # Rename it to django-partial.po to enable merging into django.po later. + os.rename(os.path.join(SOURCE_MSGS_DIR, 'django.po'), + os.path.join(SOURCE_MSGS_DIR, 'django-partial.po')) + execute(make_djangojs_cmd, working_directory=BASE_DIR) + + for filename in generated_files: + LOG.info('Cleaning %s' % filename) + po = pofile(os.path.join(SOURCE_MSGS_DIR, filename)) + # replace default headers with edX headers + fix_header(po) + # replace default metadata with edX metadata + fix_metadata(po) + # remove key strings which belong in messages.po + strip_key_strings(po) + po.save() + +# By default, django-admin.py makemessages creates this header: +""" +SOME DESCRIPTIVE TITLE. +Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +This file is distributed under the same license as the PACKAGE package. +FIRST AUTHOR , YEAR. +""" + +def fix_header(po): + """ + Replace default headers with edX headers + """ + header = po.header + fixes = ( + ('SOME DESCRIPTIVE TITLE', 'edX translation file'), + ('Translations template for PROJECT.', 'edX translation file'), + ('YEAR', '%s' % datetime.utcnow().year), + ('ORGANIZATION', 'edX'), + ("THE PACKAGE'S COPYRIGHT HOLDER", "EdX"), + ('This file is distributed under the same license as the PROJECT project.', + 'This file is distributed under the GNU AFFERO GENERAL PUBLIC LICENSE.'), + ('This file is distributed under the same license as the PACKAGE package.', + 'This file is distributed under the GNU AFFERO GENERAL PUBLIC LICENSE.'), + ('FIRST AUTHOR ', + 'EdX Team ') + ) + for (src, dest) in fixes: + header = header.replace(src, dest) + po.header = header + +# By default, django-admin.py makemessages creates this metadata: +""" +{u'PO-Revision-Date': u'YEAR-MO-DA HO:MI+ZONE', + u'Language': u'', + u'Content-Transfer-Encoding': u'8bit', + u'Project-Id-Version': u'PACKAGE VERSION', + u'Report-Msgid-Bugs-To': u'', + u'Last-Translator': u'FULL NAME ', + u'Language-Team': u'LANGUAGE ', + u'POT-Creation-Date': u'2013-04-25 14:14-0400', + u'Content-Type': u'text/plain; charset=UTF-8', + u'MIME-Version': u'1.0'} +""" + +def fix_metadata(po): + """ + Replace default metadata with edX metadata + """ + fixes = {'PO-Revision-Date': datetime.utcnow(), + 'Report-Msgid-Bugs-To': 'translation_team@edx.org', + 'Project-Id-Version': '0.1a', + 'Language' : 'en', + 'Language-Team': 'translation team ', + } + if po.metadata.has_key('Last-Translator'): + del po.metadata['Last-Translator'] + po.metadata.update(fixes) + +def strip_key_strings(po): + """ + Removes all entries in PO which are key strings. + These entries should appear only in messages.po, not in any other po files. + """ + newlist = [entry for entry in po if not is_key_string(entry.msgid)] + del po[:] + po += newlist + +def is_key_string(string): + """ + returns True if string is a key string. + Key strings begin with underscore. + """ + return len(string)>1 and string[0]=='_' + +if __name__ == '__main__': + main() diff --git a/i18n/generate.py b/i18n/generate.py new file mode 100755 index 0000000000..ddbaadfa70 --- /dev/null +++ b/i18n/generate.py @@ -0,0 +1,64 @@ +#!/usr/bin/python + +""" + See https://edx-wiki.atlassian.net/wiki/display/ENG/PO+File+workflow + + + This task merges and compiles the human-readable .pofiles on the + local filesystem into machine-readable .mofiles. This is typically + necessary as part of the build process since these .mofiles are + needed by Django when serving the web app. + + The configuration file (in mitx/conf/locale/config) specifies which + languages to generate. +""" + +import os +from execute import execute, get_config, messages_dir, remove_file, \ + BASE_DIR, LOG, SOURCE_LOCALE + +def merge(locale, target='django.po'): + """ + For the given locale, merge django-partial.po, messages.po, mako.po -> django.po + """ + LOG.info('Merging locale={0}'.format(locale)) + locale_directory = messages_dir(locale) + files_to_merge = ('django-partial.po', 'messages.po', 'mako.po') + validate_files(locale_directory, files_to_merge) + + # merged file is merged.po + merge_cmd = 'msgcat -o merged.po ' + ' '.join(files_to_merge) + execute(merge_cmd, working_directory=locale_directory) + + # rename merged.po -> django.po (default) + merged_filename = os.path.join(locale_directory, 'merged.po') + django_filename = os.path.join(locale_directory, target) + os.rename(merged_filename, django_filename) # can't overwrite file on Windows + +def validate_files(dir, files_to_merge): + """ + Asserts that the given files exist. + files_to_merge is a list of file names (no directories). + dir is the directory in which the files should appear. + raises an Exception if any of the files are not in dir. + """ + for path in files_to_merge: + pathname = os.path.join(dir, path) + if not os.path.exists(pathname): + raise Exception("File not found: {0}".format(pathname)) + +def main (): + configuration = get_config() + if configuration == None: + LOG.warn('Configuration file not found, using only English.') + locales = (SOURCE_LOCALE,) + else: + locales = configuration['locales'] + for locale in locales: + merge(locale) + + compile_cmd = 'django-admin.py compilemessages' + execute(compile_cmd, working_directory=BASE_DIR) + +if __name__ == '__main__': + main() diff --git a/i18n/make_dummy.py b/i18n/make_dummy.py index 4ccfb0d5f1..c8dcde861a 100755 --- a/i18n/make_dummy.py +++ b/i18n/make_dummy.py @@ -18,20 +18,12 @@ import os, sys import polib from dummy import Dummy +from execute import create_dir_if_necessary -# Dummy language -# two letter language codes reference: -# see http://www.loc.gov/standards/iso639-2/php/code_list.php -# -# Django will not localize in languages that django itself has not been -# localized for. So we are using a well-known language: 'fr'. - -OUT_LANG = 'fr' - -def main(file): +def main(file, locale): """ Takes a source po file, reads it, and writes out a new po file - containing a dummy translation. + in :param locale: containing a dummy translation. """ if not os.path.exists(file): raise IOError('File does not exist: %s' % file) @@ -40,29 +32,36 @@ def main(file): converter.init_msgs(pofile.translated_entries()) for msg in pofile: converter.convert_msg(msg) - new_file = new_filename(file, OUT_LANG) + new_file = new_filename(file, locale) create_dir_if_necessary(new_file) pofile.save(new_file) - - -def new_filename(original_filename, new_lang): - """Returns a filename derived from original_filename, using new_lang as the locale""" +def new_filename(original_filename, new_locale): + """Returns a filename derived from original_filename, using new_locale as the locale""" orig_dir = os.path.dirname(original_filename) msgs_dir = os.path.basename(orig_dir) orig_file = os.path.basename(original_filename) - return '%s/%s/%s/%s' % (os.path.abspath(orig_dir + '/../..'), - new_lang, - msgs_dir, - orig_file) + return os.path.join(orig_dir, + '/../..', + new_locale, + msgs_dir, + orig_file) -def create_dir_if_necessary(pathname): - dirname = os.path.dirname(pathname) - if not os.path.exists(dirname): - os.makedirs(dirname) +# Dummy language +# two letter language codes reference: +# see http://www.loc.gov/standards/iso639-2/php/code_list.php +# +# Django will not localize in languages that django itself has not been +# localized for. So we are using a well-known language: 'fr'. + +DEFAULT_LOCALE = 'fr' if __name__ == '__main__': if len(sys.argv)<2: raise Exception("missing file argument") - main(sys.argv[1]) + if len(sys.argv)<2: + locale = DEFAULT_LOCALE + else: + locale = sys.argv[2] + main(sys.argv[1], locale) diff --git a/i18n/tests/__init__.py b/i18n/tests/__init__.py new file mode 100644 index 0000000000..d60515c712 --- /dev/null +++ b/i18n/tests/__init__.py @@ -0,0 +1,4 @@ +from test_extract import TestExtract +from test_generate import TestGenerate +from test_converter import TestConverter +from test_dummy import TestDummy diff --git a/i18n/tests/test_converter.py b/i18n/tests/test_converter.py new file mode 100644 index 0000000000..4dd5f02e3f --- /dev/null +++ b/i18n/tests/test_converter.py @@ -0,0 +1,42 @@ +import os +from unittest import TestCase + +import converter + +class UpcaseConverter (converter.Converter): + """ + Converts a string to uppercase. Just used for testing. + """ + def inner_convert_string(self, string): + return string.upper() + + +class TestConverter(TestCase): + """ + Tests functionality of i18n/converter.py + """ + + def test_converter(self): + """ + Tests with a simple converter (converts strings to uppercase). + Assert that embedded HTML and python tags are not converted. + """ + c = UpcaseConverter() + test_cases = ( + # no tags + ('big bad wolf', 'BIG BAD WOLF'), + # one html tag + ('big bad wolf', 'BIG BAD WOLF'), + # two html tags + ('big bad wolf', 'BIG BAD WOLF'), + # one python tag + ('big %(adjective)s wolf', 'BIG %(adjective)s WOLF'), + # two python tags + ('big %(adjective)s %(noun)s', 'BIG %(adjective)s %(noun)s'), + # both kinds of tags + ('big %(adjective)s %(noun)s', + 'BIG %(adjective)s %(noun)s'), + ) + for (source, expected) in test_cases: + result = c.convert(source) + self.assertEquals(result, expected) diff --git a/i18n/tests/test_dummy.py b/i18n/tests/test_dummy.py new file mode 100644 index 0000000000..88addb5a95 --- /dev/null +++ b/i18n/tests/test_dummy.py @@ -0,0 +1,50 @@ +import os, string, random +from unittest import TestCase +from polib import POEntry + +import dummy + + +class TestDummy(TestCase): + """ + Tests functionality of i18n/dummy.py + """ + + def setUp(self): + self.converter = dummy.Dummy() + + def test_dummy(self): + """ + Tests with a dummy converter (adds spurious accents to strings). + Assert that embedded HTML and python tags are not converted. + """ + test_cases = (("hello my name is Bond, James Bond", + u'h\xe9ll\xf6 my n\xe4m\xe9 \xefs B\xf6nd, J\xe4m\xe9s B\xf6nd Lorem i#'), + + ('don\'t convert tag ids', + u'd\xf6n\'t \xe7\xf6nv\xe9rt t\xe4g \xefds Lorem ipsu#'), + + ('don\'t convert %(name)s tags on %(date)s', + u"d\xf6n't \xe7\xf6nv\xe9rt %(name)s t\xe4gs \xf6n %(date)s Lorem ips#") + ) + for (source, expected) in test_cases: + result = self.converter.convert(source) + self.assertEquals(result, expected) + + def test_singular(self): + entry = POEntry() + entry.msgid = 'A lovely day for a cup of tea.' + expected = u'\xc0 l\xf6v\xe9ly d\xe4y f\xf6r \xe4 \xe7\xfcp \xf6f t\xe9\xe4. Lorem i#' + self.converter.convert_msg(entry) + self.assertEquals(entry.msgstr, expected) + + def test_plural(self): + entry = POEntry() + entry.msgid = 'A lovely day for a cup of tea.' + entry.msgid_plural = 'A lovely day for some cups of tea.' + expected_s = u'\xc0 l\xf6v\xe9ly d\xe4y f\xf6r \xe4 \xe7\xfcp \xf6f t\xe9\xe4. Lorem i#' + expected_p = u'\xc0 l\xf6v\xe9ly d\xe4y f\xf6r s\xf6m\xe9 \xe7\xfcps \xf6f t\xe9\xe4. Lorem ip#' + self.converter.convert_msg(entry) + result = entry.msgstr_plural + self.assertEquals(result['0'], expected_s) + self.assertEquals(result['1'], expected_p) diff --git a/i18n/tests/test_extract.py b/i18n/tests/test_extract.py new file mode 100644 index 0000000000..b14ae9872d --- /dev/null +++ b/i18n/tests/test_extract.py @@ -0,0 +1,85 @@ +import os, polib +from unittest import TestCase +from nose.plugins.skip import SkipTest +from datetime import datetime, timedelta + +import extract +from execute import SOURCE_MSGS_DIR + +# Make sure setup runs only once +SETUP_HAS_RUN = False + +class TestExtract(TestCase): + """ + Tests functionality of i18n/extract.py + """ + generated_files = ('django-partial.po', 'djangojs.po', 'mako.po') + + def setUp(self): + # Skip this test because it takes too long (>1 minute) + # TODO: figure out how to declare a "long-running" test suite + # and add this test to it. + raise SkipTest() + + global SETUP_HAS_RUN + + # Subtract 1 second to help comparisons with file-modify time succeed, + # since os.path.getmtime() is not millisecond-accurate + self.start_time = datetime.now() - timedelta(seconds=1) + super(TestExtract, self).setUp() + if not SETUP_HAS_RUN: + # Run extraction script. Warning, this takes 1 minute or more + extract.main() + SETUP_HAS_RUN = True + + def get_files (self): + """ + This is a generator. + Returns the fully expanded filenames for all extracted files + Fails assertion if one of the files doesn't exist. + """ + for filename in self.generated_files: + path = os.path.join(SOURCE_MSGS_DIR, filename) + exists = os.path.exists(path) + self.assertTrue(exists, msg='Missing file: %s' % filename) + if exists: + yield path + + def test_files(self): + """ + Asserts that each auto-generated file has been modified since 'extract' was launched. + Intended to show that the file has been touched by 'extract'. + """ + + for path in self.get_files(): + self.assertTrue(datetime.fromtimestamp(os.path.getmtime(path)) > self.start_time, + msg='File not recently modified: %s' % os.path.basename(path)) + + def test_is_keystring(self): + """ + Verifies is_keystring predicate + """ + entry1 = polib.POEntry() + entry2 = polib.POEntry() + entry1.msgid = "_.lms.admin.warning.keystring" + entry2.msgid = "This is not a keystring" + self.assertTrue(extract.is_key_string(entry1.msgid)) + self.assertFalse(extract.is_key_string(entry2.msgid)) + + def test_headers(self): + """Verify all headers have been modified""" + for path in self.get_files(): + po = polib.pofile(path) + header = po.header + self.assertEqual(header.find('edX translation file'), 0, + msg='Missing header in %s:\n"%s"' % \ + (os.path.basename(path), header)) + + def test_metadata(self): + """Verify all metadata has been modified""" + for path in self.get_files(): + po = polib.pofile(path) + metadata = po.metadata + value = metadata['Report-Msgid-Bugs-To'] + expected = 'translation_team@edx.org' + self.assertEquals(expected, value) diff --git a/i18n/tests/test_generate.py b/i18n/tests/test_generate.py new file mode 100644 index 0000000000..fc22988251 --- /dev/null +++ b/i18n/tests/test_generate.py @@ -0,0 +1,61 @@ +import os, string, random +from unittest import TestCase +from datetime import datetime, timedelta + +import generate +from execute import get_config, messages_dir, SOURCE_MSGS_DIR, SOURCE_LOCALE + +class TestGenerate(TestCase): + """ + Tests functionality of i18n/generate.py + """ + generated_files = ('django-partial.po', 'djangojs.po', 'mako.po') + + def setUp(self): + self.configuration = get_config() + + # Subtract 1 second to help comparisons with file-modify time succeed, + # since os.path.getmtime() is not millisecond-accurate + self.start_time = datetime.now() - timedelta(seconds=1) + + def test_configuration(self): + """ + Make sure we have a valid configuration file, + and that it contains an 'en' locale. + """ + self.assertIsNotNone(self.configuration) + locales = self.configuration['locales'] + self.assertIsNotNone(locales) + self.assertIsInstance(locales, list) + self.assertIn('en', locales) + + def test_merge(self): + """ + Tests merge script on English source files. + """ + filename = os.path.join(SOURCE_MSGS_DIR, random_name()) + generate.merge(SOURCE_LOCALE, target=filename) + self.assertTrue(os.path.exists(filename)) + os.remove(filename) + + def test_main(self): + """ + Runs generate.main() which should merge source files, + then compile all sources in all configured languages. + Validates output by checking all .mo files in all configured languages. + .mo files should exist, and be recently created (modified + after start of test suite) + """ + generate.main() + for locale in self.configuration['locales']: + for filename in ('django.mo', 'djangojs.mo'): + path = os.path.join(messages_dir(locale), filename) + exists = os.path.exists(path) + self.assertTrue(exists, msg='Missing file in locale %s: %s' % (locale, filename)) + self.assertTrue(datetime.fromtimestamp(os.path.getmtime(path)) >= self.start_time, + msg='File not recently modified: %s' % path) + +def random_name(size=6): + """Returns random filename as string, like test-4BZ81W""" + chars = string.ascii_uppercase + string.digits + return 'test-' + ''.join(random.choice(chars) for x in range(size)) diff --git a/i18n/update.py b/i18n/update.py deleted file mode 100755 index 447dcf71d5..0000000000 --- a/i18n/update.py +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/python - -import os, subprocess, logging, json -from make_dummy import create_dir_if_necessary, main as dummy_main - -''' -Generate or update all translation files - Usage: - $ update.py - - - 1. extracts files from mako templates - 2. extracts files from django templates and python source files - 3. extracts files from django javascript files - 4. generates dummy text translations - 5. compiles po files to mo files - - Configuration (e.g. known languages) declared in mitx/conf/locale/config -''' - -# ----------------------------------- -# BASE_DIR is the working directory to execute django-admin commands from. -# Typically this should be the 'mitx' directory. -BASE_DIR = os.path.abspath(os.path.dirname(os.path.abspath(__file__))+'/..') - -# LOCALE_DIR contains the locale files. -# Typically this should be 'mitx/conf/locale' -LOCALE_DIR = BASE_DIR + '/conf/locale' - -# MSGS_DIR contains the English po files -MSGS_DIR = LOCALE_DIR + '/en/LC_MESSAGES' - -# CONFIG_FILENAME contains localization configuration in json format -CONFIG_FILENAME = LOCALE_DIR + '/config' - -# BABEL_CONFIG contains declarations for Babel to extract strings from mako template files -BABEL_CONFIG = LOCALE_DIR + '/babel.cfg' - -# Strings from mako template files are written to BABEL_OUT -BABEL_OUT = MSGS_DIR + '/mako.po' - -# These are the shell commands invoked by main() -COMMANDS = { - 'babel_mako': 'pybabel extract -F %s -c "TRANSLATORS:" . -o %s' % (BABEL_CONFIG, BABEL_OUT), - 'make_django': 'django-admin.py makemessages --all --ignore=src/* --extension html -l en', - 'make_djangojs': 'django-admin.py makemessages --all -d djangojs --ignore=src/* --extension js -l en', - 'msgcat' : 'msgcat -o merged.po django.po %s' % BABEL_OUT, - 'rename_django' : 'mv django.po django_old.po', - 'rename_merged' : 'mv merged.po django.po', - 'compile': 'django-admin.py compilemessages' - - } - -def execute (command_kwd, log, working_directory=BASE_DIR): - ''' - Executes command_kwd, which references a shell command in COMMANDS. - ''' - full_cmd = COMMANDS[command_kwd] - log.info('%s' % full_cmd) - subprocess.call(full_cmd.split(' '), cwd=working_directory) - -def make_log (): - '''returns a logger''' - log = logging.getLogger(__name__) - log.setLevel(logging.INFO) - log_handler = logging.StreamHandler() - log_handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s')) - log.addHandler(log_handler) - return log - -def get_config (): - '''Returns data found in config file, or returns None if file not found''' - config_path = os.path.abspath(CONFIG_FILENAME) - if not os.path.exists(config_path): - return None - with open(config_path) as stream: - return json.load(stream) - -def main (): - log = make_log() - create_dir_if_necessary(LOCALE_DIR) - log.info('Executing all commands from %s' % BASE_DIR) - - remove_files = ['django.po', 'djangojs.po', 'nonesuch'] - for filename in remove_files: - path = MSGS_DIR + '/' + filename - log.info('Deleting file %s' % path) - if not os.path.exists(path): - log.warn("File does not exist: %s" % path) - else: - os.remove(path) - - # Generate or update human-readable .po files from all source code. - execute('babel_mako', log=log) - execute('make_django', log=log) - execute('make_djangojs', log=log) - execute('msgcat', log=log, working_directory=MSGS_DIR) - execute('rename_django', log=log, working_directory=MSGS_DIR) - execute('rename_merged', log=log, working_directory=MSGS_DIR) - - # Generate dummy text files from the English .po files - log.info('Generating dummy text.') - dummy_main(LOCALE_DIR + '/en/LC_MESSAGES/django.po') - dummy_main(LOCALE_DIR + '/en/LC_MESSAGES/djangojs.po') - - # Generate machine-readable .mo files - execute('compile', log) - -if __name__ == '__main__': - main() diff --git a/rakefile b/rakefile index 766dd8a914..2b9cb9fd57 100644 --- a/rakefile +++ b/rakefile @@ -330,6 +330,12 @@ task :migrate, [:env] do |t, args| sh(django_admin(:lms, args.env, 'migrate')) end +desc "Run tests for the internationalization library" +task :test_i18n do + test = File.join(REPO_ROOT, "i18n", "tests") + sh("nosetests #{test}") +end + Dir["common/lib/*"].select{|lib| File.directory?(lib)}.each do |lib| task_name = "test_#{lib}" @@ -501,6 +507,30 @@ task :autodeploy_properties do end end +# --- Internationalization tasks + +desc "Extract localizable strings from sources" +task :extract_dev_strings do + sh(File.join(REPO_ROOT, "i18n", "extract.py")) +end + +desc "Compile localizable strings from sources. With optional flag 'extract', will extract strings first." +task :generate_i18n do + if ARGV.last.downcase == 'extract' + Rake::Task["extract_dev_strings"].execute + end + sh(File.join(REPO_ROOT, "i18n", "generate.py")) +end + +desc "Simulate international translation by generating dummy strings corresponding to source strings." +task :dummy_i18n do + source_files = Dir["#{REPO_ROOT}/conf/locale/en/LC_MESSAGES/*.po"] + dummy_locale = 'fr' + cmd = File.join(REPO_ROOT, "i18n", "make_dummy.py") + for file in source_files do + sh("#{cmd} #{file} #{dummy_locale}") + end +end # --- Develop and public documentation --- desc "Invoke sphinx 'make build' to generate docs." diff --git a/requirements.txt b/requirements.txt index 77239a4d50..d3fdd46b81 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ -r repo-requirements.txt -Babel==0.9.6 beautifulsoup4==4.1.3 beautifulsoup==3.2.1 boto==2.6.0 @@ -62,6 +61,10 @@ newrelic==1.8.0.13 # Used for documentation gathering sphinx==1.1.3 +# Used for Internationalization and localization +Babel==0.9.6 +transifex-client==0.8 + # Used for testing coverage==3.6 factory_boy==2.0.2