diff --git a/babel-djangojs.cfg b/babel-djangojs.cfg
index e5feb20588..a8273b623e 100644
--- a/babel-djangojs.cfg
+++ b/babel-djangojs.cfg
@@ -1 +1,14 @@
+[extractors]
+# We use a custom extractor to find translatable strings in AngularJS
+# templates. The extractor is included in horizon.utils for now.
+# See http://babel.pocoo.org/docs/messages/#referencing-extraction-methods for
+# details on how this works.
+angular = horizon.utils.babel_extract_angular:extract_angular
+
[javascript: **.js]
+
+# We need to look into all static folders for HTML files.
+# The **/static ensures that we also search within
+# /openstack_dashboard/dashboards/XYZ/static which will ensure
+# that plugins are also translated.
+[angular: **/static/**.html]
diff --git a/horizon/test/tests/utils.py b/horizon/test/tests/utils.py
index eee95fcf8f..4895a40e60 100644
--- a/horizon/test/tests/utils.py
+++ b/horizon/test/tests/utils.py
@@ -14,6 +14,7 @@
import datetime
import os
+from StringIO import StringIO
from django.core.exceptions import ValidationError # noqa
import django.template
@@ -21,6 +22,7 @@ from django.template import defaultfilters
from horizon import forms
from horizon.test import helpers as test
+from horizon.utils.babel_extract_angular import extract_angular
from horizon.utils import filters
# we have to import the filter in order to register it
from horizon.utils.filters import parse_isotime # noqa
@@ -474,3 +476,152 @@ class UnitsTests(test.TestCase):
self.assertEqual(units.normalize(1, 'unknown_unit'),
(1, 'unknown_unit'))
+
+
+default_keys = []
+
+
+class ExtractAngularTestCase(test.TestCase):
+
+ def test_extract_no_tags(self):
+ buf = StringIO('')
+
+ messages = list(extract_angular(buf, default_keys, [], {}))
+ self.assertEqual([], messages)
+
+ def test_simple_string(self):
+ buf = StringIO(
+ """hello world!'
+
hello world!
"""
+ )
+
+ messages = list(extract_angular(buf, default_keys, [], {}))
+ self.assertEqual(
+ [
+ (1, u'gettext', 'hello world!', []),
+ (2, u'gettext', 'hello world!', [])
+ ],
+ messages)
+
+ def test_interpolation(self):
+ buf = StringIO(
+ """
+ hello {$name$}!
+ hello {$name$}!
+
+ """
+ )
+
+ messages = list(extract_angular(buf, default_keys, [], {}))
+ self.assertEqual(
+ [
+ (2, u'gettext', 'hello %(name)!', []),
+ (3, u'gettext', 'hello %(name)!', [])
+ ], messages)
+
+ def test_interpolation_func_call(self):
+ buf = StringIO(
+ """hello {$func(name)$}!
+ 'hello {$func(name)$}!"""
+ )
+
+ messages = list(extract_angular(buf, default_keys, [], {}))
+ self.assertEqual(
+ [
+ (1, u'gettext', 'hello %(func(name))!', []),
+ (2, u'gettext', 'hello %(func(name))!', [])
+ ],
+ messages)
+
+ def test_interpolation_list(self):
+ buf = StringIO(
+ """hello {$name[1]$}!
+ hello {$name[1]$}!"""
+ )
+
+ messages = list(extract_angular(buf, default_keys, [], {}))
+ self.assertEqual(
+ [
+ (1, 'gettext', 'hello %(name[1])!', []),
+ (2, 'gettext', 'hello %(name[1])!', [])
+ ],
+ messages)
+
+ def test_interpolation_dict(self):
+ buf = StringIO(
+ """hello {$name['key']$}!
+ hello {$name['key']$}!"""
+ )
+
+ messages = list(extract_angular(buf, default_keys, [], {}))
+ self.assertEqual(
+ [
+ (1, 'gettext', r"hello %(name['key'])!", []),
+ (2, 'gettext', r"hello %(name['key'])!", [])
+ ],
+ messages)
+
+ def test_interpolation_dict_double_quote(self):
+ buf = StringIO(
+ """hello {$name["key"]$}!
+ hello {$name["key"]$}!""")
+
+ messages = list(extract_angular(buf, default_keys, [], {}))
+ self.assertEqual(
+ [
+ (1, 'gettext', r'hello %(name["key"])!', []),
+ (2, 'gettext', r'hello %(name["key"])!', [])
+ ],
+ messages)
+
+ def test_interpolation_object(self):
+ buf = StringIO(
+ """hello {$name.attr$}!
+ hello {$name.attr$}!""")
+
+ messages = list(extract_angular(buf, default_keys, [], {}))
+ self.assertEqual(
+ [
+ (1, 'gettext', 'hello %(name.attr)!', []),
+ (2, 'gettext', 'hello %(name.attr)!', [])
+ ],
+ messages)
+
+ def test_interpolation_spaces(self):
+ """Spaces are not valid in interpolation expressions, but we don't
+ currently complain about them
+ """
+ buf = StringIO("""hello {$name attr$}!
+ hello {$name attr$}!""")
+
+ messages = list(extract_angular(buf, default_keys, [], {}))
+ self.assertEqual(
+ [
+ (1, 'gettext', 'hello {$name attr$}!', []),
+ (2, 'gettext', 'hello {$name attr$}!', [])
+ ],
+ messages)
+
+ def test_attr_value(self):
+ """We should not translate tags that have translate as the value of an
+ attribute.
+ """
+ buf = StringIO('hello world!
')
+
+ messages = list(extract_angular(buf, [], [], {}))
+ self.assertEqual([], messages)
+
+ def test_attr_value_plus_directive(self):
+ """Unless they also have a translate directive.
+ """
+ buf = StringIO(
+ 'hello world!
')
+
+ messages = list(extract_angular(buf, [], [], {}))
+ self.assertEqual([(1, 'gettext', 'hello world!', [])], messages)
+
+ def test_translate_tag(self):
+ buf = StringIO('hello world!')
+
+ messages = list(extract_angular(buf, [], [], {}))
+ self.assertEqual([(1, 'gettext', 'hello world!', [])], messages)
diff --git a/horizon/utils/babel_extract_angular.py b/horizon/utils/babel_extract_angular.py
new file mode 100644
index 0000000000..4021544270
--- /dev/null
+++ b/horizon/utils/babel_extract_angular.py
@@ -0,0 +1,92 @@
+# Copyright 2015, Rackspace, US, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+try:
+ from html.parser import HTMLParser
+except ImportError:
+ from HTMLParser import HTMLParser
+
+import re
+
+
+class AngularGettextHTMLParser(HTMLParser):
+ """Parse HTML to find translate directives.
+
+ Note: This will not cope with nested tags (which I don't think make any
+ sense)
+ """
+
+ def __init__(self):
+ try:
+ super(self.__class__, self).__init__()
+ except TypeError:
+ HTMLParser.__init__(self)
+
+ self.in_translate = False
+ self.data = ''
+ self.strings = []
+
+ def handle_starttag(self, tag, attrs):
+ if tag == 'translate' or \
+ (attrs and 'translate' in [attr[0] for attr in attrs]):
+ self.in_translate = True
+ self.line = self.getpos()[0]
+
+ def handle_data(self, data):
+ if self.in_translate:
+ self.data += data
+
+ def handle_endtag(self, tag):
+ if self.in_translate:
+ self.strings.append(
+ (self.line, u'gettext', self.interpolate(), [])
+ )
+ self.in_translate = False
+ self.data = ''
+
+ def interpolate(self):
+ interpolation_regex = r"""{\$([\w\."'\]\[\(\)]+)\$}"""
+ return re.sub(interpolation_regex, r'%(\1)', self.data)
+
+
+def extract_angular(fileobj, keywords, comment_tags, options):
+ """Extract messages from angular template (HTML) files that use the
+ angular-gettext translate directive as per
+ https://angular-gettext.rocketeer.be/ .
+
+ :param fileobj: the file-like object the messages should be extracted
+ from
+ :param keywords: This is a standard parameter so it isaccepted but ignored.
+
+ :param comment_tags: This is a standard parameter so it is accepted but
+ ignored.
+ :param options: Another standard parameter that is accepted but ignored.
+ :return: an iterator over ``(lineno, funcname, message, comments)``
+ tuples
+ :rtype: ``iterator``
+
+ This particular extractor is quite simple because it is intended to only
+ deal with angular templates which do not need comments, or the more
+ complicated forms of translations.
+
+ A later version will address pluralization.
+ """
+
+ parser = AngularGettextHTMLParser()
+
+ for line in fileobj:
+ parser.feed(line)
+
+ for string in parser.strings:
+ yield(string)
diff --git a/run_tests.sh b/run_tests.sh
index 241c972118..15d1faef88 100755
--- a/run_tests.sh
+++ b/run_tests.sh
@@ -15,7 +15,7 @@ function usage {
echo " environment. Useful when dependencies have"
echo " been added."
echo " -m, --manage Run a Django management command."
- echo " --makemessages Create/Update English translation files using babel."
+ echo " --makemessages Create/Update English translation files."
echo " --compilemessages Compile all translation files."
echo " --check-only Do not update translation files (--makemessages only)."
echo " --pseudo Pseudo translate a language."
diff --git a/tools/with_venv.sh b/tools/with_venv.sh
index 7303990bd8..f4170c9a78 100755
--- a/tools/with_venv.sh
+++ b/tools/with_venv.sh
@@ -4,4 +4,10 @@ VENV_PATH=${VENV_PATH:-${TOOLS_PATH}}
VENV_DIR=${VENV_NAME:-/../.venv}
TOOLS=${TOOLS_PATH}
VENV=${VENV:-${VENV_PATH}/${VENV_DIR}}
+HORIZON_DIR=${TOOLS%/tools}
+
+# This horrible mangling of the PYTHONPATH is required to get the
+# babel-angular-gettext extractor to work. To fix this the extractor needs to
+# be packaged on pypi and added to global requirements. That work is in progress.
+export PYTHONPATH="$HORIZON_DIR"
source ${VENV}/bin/activate && "$@"