Merge pull request #1896 from MITx/feature/straz/i18n
Feature/straz/i18n - rake tasks to generate strings
This commit is contained in:
@@ -1 +1 @@
|
||||
{"locales" : ["en", "fr", "de"]}
|
||||
{"locales" : ["en"]}
|
||||
|
||||
1
conf/locale/en/LC_MESSAGES/messages.po
Normal file
1
conf/locale/en/LC_MESSAGES/messages.po
Normal file
@@ -0,0 +1 @@
|
||||
# empty
|
||||
@@ -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:
|
||||
|
||||
86
i18n/execute.py
Normal file
86
i18n/execute.py
Normal file
@@ -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)
|
||||
|
||||
145
i18n/extract.py
Executable file
145
i18n/extract.py
Executable file
@@ -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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>',
|
||||
'EdX Team <info@edx.org>')
|
||||
)
|
||||
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 <EMAIL@ADDRESS>',
|
||||
u'Language-Team': u'LANGUAGE <LL@li.org>',
|
||||
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 <translation_team@edx.org>',
|
||||
}
|
||||
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()
|
||||
64
i18n/generate.py
Executable file
64
i18n/generate.py
Executable file
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
4
i18n/tests/__init__.py
Normal file
4
i18n/tests/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from test_extract import TestExtract
|
||||
from test_generate import TestGenerate
|
||||
from test_converter import TestConverter
|
||||
from test_dummy import TestDummy
|
||||
42
i18n/tests/test_converter.py
Normal file
42
i18n/tests/test_converter.py
Normal file
@@ -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 <strong>bad</strong> wolf', 'BIG <strong>BAD</strong> WOLF'),
|
||||
# two html tags
|
||||
('big <b>bad</b> <i>wolf</i>', 'BIG <b>BAD</b> <i>WOLF</i>'),
|
||||
# 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
|
||||
('<strong>big</strong> %(adjective)s %(noun)s',
|
||||
'<strong>BIG</strong> %(adjective)s %(noun)s'),
|
||||
)
|
||||
for (source, expected) in test_cases:
|
||||
result = c.convert(source)
|
||||
self.assertEquals(result, expected)
|
||||
50
i18n/tests/test_dummy.py
Normal file
50
i18n/tests/test_dummy.py
Normal file
@@ -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 <a href="href">tag ids</a>',
|
||||
u'd\xf6n\'t \xe7\xf6nv\xe9rt <a href="href">t\xe4g \xefds</a> 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)
|
||||
85
i18n/tests/test_extract.py
Normal file
85
i18n/tests/test_extract.py
Normal file
@@ -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)
|
||||
61
i18n/tests/test_generate.py
Normal file
61
i18n/tests/test_generate.py
Normal file
@@ -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))
|
||||
110
i18n/update.py
110
i18n/update.py
@@ -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()
|
||||
30
rakefile
30
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."
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user