summaryrefslogtreecommitdiff
path: root/src/mailman/utilities
diff options
context:
space:
mode:
Diffstat (limited to 'src/mailman/utilities')
-rw-r--r--src/mailman/utilities/i18n.py200
-rw-r--r--src/mailman/utilities/tests/test_templates.py289
2 files changed, 489 insertions, 0 deletions
diff --git a/src/mailman/utilities/i18n.py b/src/mailman/utilities/i18n.py
new file mode 100644
index 000000000..afaf26bc0
--- /dev/null
+++ b/src/mailman/utilities/i18n.py
@@ -0,0 +1,200 @@
+# Copyright (C) 2011 by the Free Software Foundation, Inc.
+#
+# This file is part of GNU Mailman.
+#
+# GNU Mailman is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
+
+"""i18n template search and interpolation."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'TemplateNotFoundError',
+ 'find',
+ 'make',
+ ]
+
+
+import os
+import errno
+
+from itertools import product
+from zope.component import getUtility
+
+from mailman.config import config
+from mailman.core.constants import system_preferences
+from mailman.core.i18n import _
+from mailman.interfaces.languages import ILanguageManager
+from mailman.utilities.string import expand
+
+from mailman.Utils import wrap as wrap_text
+
+
+
+class TemplateNotFoundError(Exception):
+ """The named template was not found."""
+
+ def __init__(self, template_file):
+ self.template_file = template_file
+
+
+
+def _search(template_file, mailing_list=None, language=None):
+ """Generator that provides file system search order."""
+
+ languages = ['en', system_preferences.preferred_language.code]
+ if mailing_list is not None:
+ languages.append(mailing_list.preferred_language.code)
+ if language is not None:
+ languages.append(language)
+ languages.reverse()
+ # File system locations to search.
+ paths = [config.TEMPLATE_DIR,
+ os.path.join(config.TEMPLATE_DIR, 'site')]
+ if mailing_list is not None:
+ paths.append(os.path.join(config.TEMPLATE_DIR,
+ mailing_list.host_name))
+ paths.append(os.path.join(config.LIST_DATA_DIR,
+ mailing_list.fqdn_listname))
+ paths.reverse()
+ for language, path in product(languages, paths):
+ yield os.path.join(path, language, template_file)
+
+
+
+def find(template_file, mailing_list=None, language=None):
+ """Locate an i18n template file.
+
+ When something in Mailman needs a template file, it always asks for the
+ file through this interface. The results of the search is path to the
+ 'matching' template, with the search order depending on whether
+ `mailing_list` and `language` are provided.
+
+ When looking for a template in a specific language, there are 4 locations
+ that are searched, in this order:
+
+ * The list-specific language directory
+ <var_dir>/lists/<fqdn_listname>/<language>
+
+ * The domain-specific language directory
+ <template_dir>/<list-host-name>/<language>
+
+ * The site-wide language directory
+ <template_dir>/site/<language>
+
+ * The global default language directory
+ <template_dir>/<language>
+
+ The first match stops the search. In this way, you can specialize
+ templates at the desired level, or if you only use the default templates,
+ you don't need to change anything. NEVER modify files in
+ <template_dir>/<language> since Mailman will overwrite these when you
+ upgrade. Instead you can use <template_dir>/site.
+
+ The <language> path component is calculated as follows, in this order:
+
+ * The `language` parameter if given
+ * `mailing_list.preferred_language` if given
+ * The server's default language
+ * English ('en')
+
+ Languages are iterated after each of the four locations are searched. So
+ for example, when searching for the 'foo.txt' template, where the server's
+ default language is 'fr', the mailing list's (test@example.com) language
+ is 'de' and the `language` parameter is 'it', these locations are searched
+ in order:
+
+ * <var_dir>/lists/test@example.com/it/foo.txt
+ * <template_dir>/example.com/it/foo.txt
+ * <template_dir>/site/it/foo.txt
+ * <template_dir>/it/foo.txt
+
+ * <var_dir>/lists/test@example.com/de/foo.txt
+ * <template_dir>/example.com/de/foo.txt
+ * <template_dir>/site/de/foo.txt
+ * <template_dir>/de/foo.txt
+
+ * <var_dir>/lists/test@example.com/fr/foo.txt
+ * <template_dir>/example.com/fr/foo.txt
+ * <template_dir>/site/fr/foo.txt
+ * <template_dir>/fr/foo.txt
+
+ * <var_dir>/lists/test@example.com/en/foo.txt
+ * <template_dir>/example.com/en/foo.txt
+ * <template_dir>/site/en/foo.txt
+ * <template_dir>/en/foo.txt
+
+ :param template_file: The name of the template file to search for.
+ :type template_file: string
+ :param mailing_list: Optional mailing list used as the context for
+ searching for the template file. The list's preferred language will
+ influence the search, as will the list's data directory.
+ :type mailing_list: `IMailingList`
+ :param language: Optional language code, which influences the search.
+ :type language: string
+ :return: A tuple of the file system path to the first matching template,
+ and an open file object allowing reading of the file.
+ :rtype: (string, file)
+ :raises TemplateNotFoundError: when the template could not be found.
+ """
+ raw_search_order = _search(template_file, mailing_list, language)
+ for path in raw_search_order:
+ try:
+ fp = open(path)
+ except IOError as error:
+ if error.errno != errno.ENOENT:
+ raise
+ else:
+ return path, fp
+ raise TemplateNotFoundError(template_file)
+
+
+def make(template_file, mailing_list=None, language=None, wrap=True, **kw):
+ """Locate and 'make' a template file.
+
+ The template file is located as with `find()`, and the resulting text is
+ optionally wrapped and interpolated with the keyword argument dictionary.
+
+ :param template_file: The name of the template file to search for.
+ :type template_file: string
+ :param mailing_list: Optional mailing list used as the context for
+ searching for the template file. The list's preferred language will
+ influence the search, as will the list's data directory.
+ :type mailing_list: `IMailingList`
+ :param language: Optional language code, which influences the search.
+ :type language: string
+ :param wrap: When True, wrap the text.
+ :type wrap: bool
+ :param **kw: Keyword arguments for template interpolation.
+ :return: A tuple of the file system path to the first matching template,
+ and an open file object allowing reading of the file.
+ :rtype: (string, file)
+ :raises TemplateNotFoundError: when the template could not be found.
+ """
+ path, fp = find(template_file, mailing_list, language)
+ try:
+ # XXX Removing the trailing newline is a hack carried over from
+ # Mailman 2. The (stripped) template text is then passed through the
+ # translation catalog. This ensures that the translated text is
+ # unicode, and also allows for volunteers to translate the templates
+ # into the language catalogs.
+ template = _(fp.read()[:-1])
+ finally:
+ fp.close()
+ assert isinstance(template, unicode), 'Translated template is not unicode'
+ text = expand(template, kw)
+ if wrap:
+ return wrap_text(text)
+ return text
diff --git a/src/mailman/utilities/tests/test_templates.py b/src/mailman/utilities/tests/test_templates.py
new file mode 100644
index 000000000..2de43ae3c
--- /dev/null
+++ b/src/mailman/utilities/tests/test_templates.py
@@ -0,0 +1,289 @@
+# Copyright (C) 2011 by the Free Software Foundation, Inc.
+#
+# This file is part of GNU Mailman.
+#
+# GNU Mailman is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
+
+"""Testing i18n template search and interpolation."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'test_suite',
+ ]
+
+
+import os
+import shutil
+import tempfile
+import unittest
+
+from zope.component import getUtility
+
+from mailman.app.lifecycle import create_list
+from mailman.config import config
+from mailman.interfaces.languages import ILanguageManager
+from mailman.testing.layers import ConfigLayer
+from mailman.utilities.i18n import TemplateNotFoundError, _search, find, make
+
+
+
+class TestSearchOrder(unittest.TestCase):
+ """Test internal search order for language templates."""
+
+ layer = ConfigLayer
+
+ def setUp(self):
+ self.template_dir = tempfile.mkdtemp()
+ config.push('no template dir', """\
+ [mailman]
+ default_language: fr
+ [paths.testing]
+ template_dir: {0}/t
+ var_dir: {0}/v
+ """.format(self.template_dir))
+ language_manager = getUtility(ILanguageManager)
+ language_manager.add('de', 'utf-8', 'German')
+ language_manager.add('it', 'utf-8', 'Italian')
+ self.mlist = create_list('l@example.com')
+ self.mlist.preferred_language = 'de'
+
+ def tearDown(self):
+ config.pop('no template dir')
+ shutil.rmtree(self.template_dir)
+
+ def _stripped_search_order(self, template_file,
+ mailing_list=None, language=None):
+ raw_search_order = _search(template_file, mailing_list, language)
+ for path in raw_search_order:
+ yield path[len(self.template_dir):]
+
+ def test_fully_specified_search_order(self):
+ search_order = self._stripped_search_order('foo.txt', self.mlist, 'it')
+ # language argument
+ self.assertEqual(next(search_order),
+ '/v/lists/l@example.com/it/foo.txt')
+ self.assertEqual(next(search_order), '/t/example.com/it/foo.txt')
+ self.assertEqual(next(search_order), '/t/site/it/foo.txt')
+ self.assertEqual(next(search_order), '/t/it/foo.txt')
+ # mlist.preferred_language
+ self.assertEqual(next(search_order),
+ '/v/lists/l@example.com/de/foo.txt')
+ self.assertEqual(next(search_order), '/t/example.com/de/foo.txt')
+ self.assertEqual(next(search_order), '/t/site/de/foo.txt')
+ self.assertEqual(next(search_order), '/t/de/foo.txt')
+ # site's default language
+ self.assertEqual(next(search_order),
+ '/v/lists/l@example.com/fr/foo.txt')
+ self.assertEqual(next(search_order), '/t/example.com/fr/foo.txt')
+ self.assertEqual(next(search_order), '/t/site/fr/foo.txt')
+ self.assertEqual(next(search_order), '/t/fr/foo.txt')
+ # English
+ self.assertEqual(next(search_order),
+ '/v/lists/l@example.com/en/foo.txt')
+ self.assertEqual(next(search_order), '/t/example.com/en/foo.txt')
+ self.assertEqual(next(search_order), '/t/site/en/foo.txt')
+ self.assertEqual(next(search_order), '/t/en/foo.txt')
+
+ def test_no_language_argument_search_order(self):
+ search_order = self._stripped_search_order('foo.txt', self.mlist)
+ # mlist.preferred_language
+ self.assertEqual(next(search_order),
+ '/v/lists/l@example.com/de/foo.txt')
+ self.assertEqual(next(search_order), '/t/example.com/de/foo.txt')
+ self.assertEqual(next(search_order), '/t/site/de/foo.txt')
+ self.assertEqual(next(search_order), '/t/de/foo.txt')
+ # site's default language
+ self.assertEqual(next(search_order),
+ '/v/lists/l@example.com/fr/foo.txt')
+ self.assertEqual(next(search_order), '/t/example.com/fr/foo.txt')
+ self.assertEqual(next(search_order), '/t/site/fr/foo.txt')
+ self.assertEqual(next(search_order), '/t/fr/foo.txt')
+ # English
+ self.assertEqual(next(search_order),
+ '/v/lists/l@example.com/en/foo.txt')
+ self.assertEqual(next(search_order), '/t/example.com/en/foo.txt')
+ self.assertEqual(next(search_order), '/t/site/en/foo.txt')
+ self.assertEqual(next(search_order), '/t/en/foo.txt')
+
+ def test_no_mailing_list_argument_search_order(self):
+ search_order = self._stripped_search_order('foo.txt', language='it')
+ # language argument
+ self.assertEqual(next(search_order), '/t/site/it/foo.txt')
+ self.assertEqual(next(search_order), '/t/it/foo.txt')
+ # site's default language
+ self.assertEqual(next(search_order), '/t/site/fr/foo.txt')
+ self.assertEqual(next(search_order), '/t/fr/foo.txt')
+ # English
+ self.assertEqual(next(search_order), '/t/site/en/foo.txt')
+ self.assertEqual(next(search_order), '/t/en/foo.txt')
+
+ def test_no_optional_arguments_search_order(self):
+ search_order = self._stripped_search_order('foo.txt')
+ # site's default language
+ self.assertEqual(next(search_order), '/t/site/fr/foo.txt')
+ self.assertEqual(next(search_order), '/t/fr/foo.txt')
+ # English
+ self.assertEqual(next(search_order), '/t/site/en/foo.txt')
+ self.assertEqual(next(search_order), '/t/en/foo.txt')
+
+
+
+class TestFind(unittest.TestCase):
+ """Test template search."""
+
+ layer = ConfigLayer
+
+ def setUp(self):
+ self.template_dir = tempfile.mkdtemp()
+ config.push('template config', """\
+ [paths.testing]
+ template_dir: {0}
+ """.format(self.template_dir))
+ # The following MUST happen AFTER the push() above since pushing a new
+ # config also clears out the language manager.
+ getUtility(ILanguageManager).add('xx', 'utf-8', 'Xlandia')
+ self.mlist = create_list('test@example.com')
+ self.mlist.preferred_language = 'xx'
+ self.fp = None
+ # Populate global tempdir with a few fake templates.
+ self.xxdir = os.path.join(self.template_dir, 'xx')
+ os.mkdir(self.xxdir)
+ with open(os.path.join(self.xxdir, 'global.txt'), 'w') as fp:
+ fp.write('Global template')
+ self.sitedir = os.path.join(self.template_dir, 'site', 'xx')
+ os.makedirs(self.sitedir)
+ with open(os.path.join(self.sitedir, 'site.txt'), 'w') as fp:
+ fp.write('Site template')
+ self.domaindir = os.path.join(self.template_dir, 'example.com', 'xx')
+ os.makedirs(self.domaindir)
+ with open(os.path.join(self.domaindir, 'domain.txt'), 'w') as fp:
+ fp.write('Domain template')
+ self.listdir = os.path.join(self.mlist.data_path, 'xx')
+ os.makedirs(self.listdir)
+ with open(os.path.join(self.listdir, 'list.txt'), 'w') as fp:
+ fp.write('List template')
+
+ def tearDown(self):
+ if self.fp is not None:
+ self.fp.close()
+ config.pop('template config')
+ shutil.rmtree(self.template_dir)
+ shutil.rmtree(self.listdir)
+
+ def test_find_global_template(self):
+ filename, self.fp = find('global.txt', language='xx')
+ self.assertEqual(filename, os.path.join(self.xxdir, 'global.txt'))
+ self.assertEqual(self.fp.read(), 'Global template')
+
+ def test_find_site_template(self):
+ filename, self.fp = find('site.txt', language='xx')
+ self.assertEqual(filename, os.path.join(self.sitedir, 'site.txt'))
+ self.assertEqual(self.fp.read(), 'Site template')
+
+ def test_find_domain_template(self):
+ filename, self.fp = find('domain.txt', self.mlist)
+ self.assertEqual(filename, os.path.join(self.domaindir, 'domain.txt'))
+ self.assertEqual(self.fp.read(), 'Domain template')
+
+ def test_find_list_template(self):
+ filename, self.fp = find('list.txt', self.mlist)
+ self.assertEqual(filename, os.path.join(self.listdir, 'list.txt'))
+ self.assertEqual(self.fp.read(), 'List template')
+
+ def test_template_not_found(self):
+ # Python 2.6 compatibility.
+ try:
+ find('missing.txt', self.mlist)
+ except TemplateNotFoundError as error:
+ self.assertEqual(error.template_file, 'missing.txt')
+ else:
+ raise AssertionError('TemplateNotFoundError expected')
+
+
+
+class TestMake(unittest.TestCase):
+ """Test template interpolation."""
+
+ layer = ConfigLayer
+
+ def setUp(self):
+ self.template_dir = tempfile.mkdtemp()
+ config.push('template config', """\
+ [paths.testing]
+ template_dir: {0}
+ """.format(self.template_dir))
+ # The following MUST happen AFTER the push() above since pushing a new
+ # config also clears out the language manager.
+ getUtility(ILanguageManager).add('xx', 'utf-8', 'Xlandia')
+ self.mlist = create_list('test@example.com')
+ self.mlist.preferred_language = 'xx'
+ # Populate the template directory with some samples.
+ self.xxdir = os.path.join(self.template_dir, 'xx')
+ os.mkdir(self.xxdir)
+ with open(os.path.join(self.xxdir, 'nosub.txt'), 'w') as fp:
+ print >> fp, """\
+This is a global template.
+It has no substitutions.
+It will be wrapped.
+"""
+ with open(os.path.join(self.xxdir, 'subs.txt'), 'w') as fp:
+ print >> fp, """\
+This is a $kind template.
+It has $howmany substitutions.
+It will be wrapped.
+"""
+ with open(os.path.join(self.xxdir, 'nowrap.txt'), 'w') as fp:
+ print >> fp, """\
+This is a $kind template.
+It has $howmany substitutions.
+It will not be wrapped.
+"""
+
+ def tearDown(self):
+ config.pop('template config')
+ shutil.rmtree(self.template_dir)
+
+ def test_no_substitutions(self):
+ self.assertEqual(make('nosub.txt', self.mlist), """\
+This is a global template. It has no substitutions. It will be
+wrapped.
+""")
+
+ def test_substitutions(self):
+ self.assertEqual(make('subs.txt', self.mlist,
+ kind='very nice',
+ howmany='a few'), """\
+This is a very nice template. It has a few substitutions. It will be
+wrapped.
+""")
+
+ def test_substitutions_no_wrap(self):
+ self.assertEqual(make('nowrap.txt', self.mlist, wrap=False,
+ kind='very nice',
+ howmany='a few'), """\
+This is a very nice template.
+It has a few substitutions.
+It will not be wrapped.
+""")
+
+
+
+def test_suite():
+ suite = unittest.TestSuite()
+ suite.addTest(unittest.makeSuite(TestSearchOrder))
+ suite.addTest(unittest.makeSuite(TestFind))
+ suite.addTest(unittest.makeSuite(TestMake))
+ return suite