diff options
| -rw-r--r-- | src/mailman/Archiver/Archiver.py | 12 | ||||
| -rw-r--r-- | src/mailman/Archiver/HyperArch.py | 5 | ||||
| -rw-r--r-- | src/mailman/Utils.py | 128 | ||||
| -rw-r--r-- | src/mailman/app/membership.py | 12 | ||||
| -rw-r--r-- | src/mailman/app/moderator.py | 40 | ||||
| -rw-r--r-- | src/mailman/app/notifications.py | 36 | ||||
| -rw-r--r-- | src/mailman/chains/hold.py | 33 | ||||
| -rw-r--r-- | src/mailman/commands/cli_lists.py | 4 | ||||
| -rw-r--r-- | src/mailman/commands/docs/info.txt | 1 | ||||
| -rw-r--r-- | src/mailman/config/config.py | 6 | ||||
| -rw-r--r-- | src/mailman/config/schema.cfg | 6 | ||||
| -rw-r--r-- | src/mailman/pipeline/acknowledge.py | 18 | ||||
| -rw-r--r-- | src/mailman/queue/digest.py | 20 | ||||
| -rw-r--r-- | src/mailman/utilities/i18n.py | 200 | ||||
| -rw-r--r-- | src/mailman/utilities/tests/test_templates.py | 289 |
15 files changed, 597 insertions, 213 deletions
diff --git a/src/mailman/Archiver/Archiver.py b/src/mailman/Archiver/Archiver.py index f8d1baa46..fe1dec252 100644 --- a/src/mailman/Archiver/Archiver.py +++ b/src/mailman/Archiver/Archiver.py @@ -32,9 +32,9 @@ from cStringIO import StringIO from string import Template from zope.component import getUtility -from mailman import Utils from mailman.config import config from mailman.interfaces.domain import IDomainManager +from mailman.utilities.i18n import make log = logging.getLogger('mailman.error') @@ -111,11 +111,11 @@ class Archiver: fp = open(indexfile, 'w') finally: os.umask(omask) - fp.write(Utils.maketext( - 'emptyarchive.html', - {'listname': self.real_name, - 'listinfo': self.GetScriptURL('listinfo'), - }, mlist=self)) + fp.write(make('emptyarchive.html', + mailing_list=self, + listname=self.real_name, + listinfo=self.GetScriptURL('listinfo'), + )) if fp: fp.close() finally: diff --git a/src/mailman/Archiver/HyperArch.py b/src/mailman/Archiver/HyperArch.py index 218c46875..92b9de2f0 100644 --- a/src/mailman/Archiver/HyperArch.py +++ b/src/mailman/Archiver/HyperArch.py @@ -50,6 +50,7 @@ from mailman.Archiver import pipermail from mailman.config import config from mailman.core.i18n import _, ctime from mailman.interfaces.listmanager import IListManager +from mailman.utilities.i18n import find from mailman.utilities.string import uncanonstr, websafe @@ -183,8 +184,8 @@ def quick_maketext(templatefile, dict=None, lang=None, mlist=None): template = _templatecache.get(filepath) if filepath is None or template is None: # Use the basic maketext, with defaults to get the raw template - template, filepath = Utils.findtext(templatefile, lang=lang.code, - raw=True, mlist=mlist) + template, filepath = find(templatefile, mailing_list=mlist, + language=lang.code) _templatefilepathcache[cachekey] = filepath _templatecache[filepath] = template # Copied from Utils.maketext() diff --git a/src/mailman/Utils.py b/src/mailman/Utils.py index a58ed1d24..cfd61eb7a 100644 --- a/src/mailman/Utils.py +++ b/src/mailman/Utils.py @@ -31,20 +31,13 @@ __all__ = [ import os import re -import errno import logging # pylint: disable-msg=E0611,W0403 from string import ascii_letters, digits, whitespace -from zope.component import getUtility import mailman.templates -from mailman.config import config -from mailman.core.i18n import _ -from mailman.interfaces.languages import ILanguageManager -from mailman.utilities.string import expand - AT = '@' CR = '\r' @@ -139,124 +132,3 @@ def wrap(text, column=70, honor_leading_ws=True): # end for text in lines # the last two newlines are bogus return wrapped[:-2] - - - -class OuterExit(Exception): - pass - -def findtext(templatefile, raw_dict=None, raw=False, lang=None, mlist=None): - # Make some text from a template file. The order of searches depends on - # whether mlist and lang are provided. Once the templatefile is found, - # string substitution is performed by interpolation in `dict'. If `raw' - # is false, the resulting text is wrapped/filled by calling wrap(). - # - # When looking for a template in a specific language, there are 4 places - # that are searched, in this order: - # - # 1. the list-specific language directory - # lists/<listname>/<language> - # - # 2. the domain-specific language directory - # templates/<list.host_name>/<language> - # - # 3. the site-wide language directory - # templates/site/<language> - # - # 4. the global default language directory - # templates/<language> - # - # The first match found stops the search. In this way, you can specialize - # templates at the desired level, or, if you use only the default - # templates, you don't need to change anything. You should never modify - # files in the templates/<language> subdirectory, since Mailman will - # overwrite these when you upgrade. That's what the templates/site - # language directories are for. - # - # A further complication is that the language to search for is determined - # by both the `lang' and `mlist' arguments. The search order there is - # that if lang is given, then the 4 locations above are searched, - # substituting lang for <language>. If no match is found, and mlist is - # given, then the 4 locations are searched using the list's preferred - # language. After that, the server default language is used for - # <language>. If that still doesn't yield a template, then the standard - # distribution's English language template is used as an ultimate - # fallback, and when lang is not 'en', the resulting template is passed - # through the translation service. If this template is missing you've got - # big problems. ;) - # - # A word on backwards compatibility: Mailman versions prior to 2.1 stored - # templates in templates/*.{html,txt} and lists/<listname>/*.{html,txt}. - # Those directories are no longer searched so if you've got customizations - # in those files, you should move them to the appropriate directory based - # on the above description. Mailman's upgrade script cannot do this for - # you. - # - # The function has been revised and renamed as it now returns both the - # template text and the path from which it retrieved the template. The - # original function is now a wrapper which just returns the template text - # as before, by calling this renamed function and discarding the second - # item returned. - # - # Calculate the languages to scan - languages = set() - if lang is not None: - languages.add(lang) - if mlist is not None: - languages.add(mlist.preferred_language.code) - languages.add(config.mailman.default_language) - assert None not in languages, 'None in languages' - # Calculate the locations to scan - searchdirs = [] - if mlist is not None: - searchdirs.append(mlist.data_path) - searchdirs.append(os.path.join(TEMPLATE_DIR, mlist.host_name)) - searchdirs.append(os.path.join(TEMPLATE_DIR, 'site')) - searchdirs.append(TEMPLATE_DIR) - # Start scanning - fp = None - try: - for lang in languages: - for dir in searchdirs: - filename = os.path.join(dir, lang, templatefile) - try: - fp = open(filename) - raise OuterExit - except IOError, e: - if e.errno != errno.ENOENT: - raise - # Okay, it doesn't exist, keep looping - fp = None - except OuterExit: - pass - if fp is None: - # Try one last time with the distro English template, which, unless - # you've got a really broken installation, must be there. - try: - filename = os.path.join(TEMPLATE_DIR, 'en', templatefile) - fp = open(filename) - except IOError, e: - if e.errno != errno.ENOENT: - raise - # We never found the template. BAD! - raise IOError(errno.ENOENT, 'No template file found', templatefile) - else: - # XXX BROKEN HACK - data = fp.read()[:-1] - template = _(data) - fp.close() - else: - template = fp.read() - fp.close() - charset = getUtility(ILanguageManager)[lang].charset - template = unicode(template, charset, 'replace') - text = template - if raw_dict is not None: - text = expand(template, raw_dict) - if raw: - return text, filename - return wrap(text), filename - - -def maketext(templatefile, dict=None, raw=False, lang=None, mlist=None): - return findtext(templatefile, dict, raw, lang, mlist)[0] diff --git a/src/mailman/app/membership.py b/src/mailman/app/membership.py index 8723fd781..fcbedc2f5 100644 --- a/src/mailman/app/membership.py +++ b/src/mailman/app/membership.py @@ -29,7 +29,6 @@ __all__ = [ from email.utils import formataddr from zope.component import getUtility -from mailman import Utils from mailman.app.notifications import send_goodbye_message from mailman.core.i18n import _ from mailman.email.message import OwnerNotification @@ -39,6 +38,7 @@ from mailman.interfaces.member import ( AlreadySubscribedError, MemberRole, MembershipIsBannedError, NotAMemberError) from mailman.interfaces.usermanager import IUserManager +from mailman.utilities.i18n import make @@ -149,10 +149,10 @@ def delete_member(mlist, address, admin_notif=None, userack=None): user = getUtility(IUserManager).get_user(address) realname = user.real_name subject = _('$mlist.real_name unsubscription notification') - text = Utils.maketext( - 'adminunsubscribeack.txt', - {'listname': mlist.real_name, - 'member' : formataddr((realname, address)), - }, mlist=mlist) + text = make('adminunsubscribeack.txt', + mailing_list=mlist, + listname=mlist.real_name, + member=formataddr((realname, address)), + ) msg = OwnerNotification(mlist, subject, text) msg.send(mlist) diff --git a/src/mailman/app/moderator.py b/src/mailman/app/moderator.py index cdfedd44b..50a03c833 100644 --- a/src/mailman/app/moderator.py +++ b/src/mailman/app/moderator.py @@ -48,6 +48,7 @@ from mailman.interfaces.member import ( AlreadySubscribedError, DeliveryMode, NotAMemberError) from mailman.interfaces.messages import IMessageStore from mailman.interfaces.requests import IRequests, RequestType +from mailman.utilities.i18n import make NL = '\n' @@ -209,12 +210,12 @@ def hold_subscription(mlist, address, realname, password, mode, language): if mlist.admin_immed_notify: subject = _( 'New subscription request to list $mlist.real_name from $address') - text = Utils.maketext( - 'subauth.txt', - {'username' : address, - 'listname' : mlist.fqdn_listname, - 'admindb_url': mlist.script_url('admindb'), - }, mlist=mlist) + text = make('subauth.txt', + mailing_list=mlist, + username=address, + listname=mlist.fqdn_listname, + admindb_url=mlist.script_url('admindb'), + ) # This message should appear to come from the <list>-owner so as # to avoid any useless bounce processing. msg = UserNotification( @@ -281,12 +282,12 @@ def hold_unsubscription(mlist, address): if mlist.admin_immed_notify: subject = _( 'New unsubscription request from $mlist.real_name by $address') - text = Utils.maketext( - 'unsubauth.txt', - {'address' : address, - 'listname' : mlist.fqdn_listname, - 'admindb_url': mlist.script_url('admindb'), - }, mlist=mlist) + text = make('unsubauth.txt', + mailing_list=mlist, + address=address, + listname=mlist.fqdn_listname, + admindb_url=mlist.script_url('admindb'), + ) # This message should appear to come from the <list>-owner so as # to avoid any useless bounce processing. msg = UserNotification( @@ -336,13 +337,14 @@ def _refuse(mlist, request, recip, comment, origmsg=None, lang=None): lang = (mlist.preferred_language if member is None else member.preferred_language) - text = Utils.maketext( - 'refuse.txt', - {'listname' : mlist.fqdn_listname, - 'request' : request, - 'reason' : comment, - 'adminaddr': mlist.owner_address, - }, lang=lang.code, mlist=mlist) + text = make('refuse.txt', + mailing_list=mlist, + language=lang.code, + listname=mlist.fqdn_listname, + request=request, + reason=comment, + adminaddr=mlist.owner_address, + ) with _.using(lang.code): # add in original message, but not wrap/filled if origmsg: diff --git a/src/mailman/app/notifications.py b/src/mailman/app/notifications.py index 985f4eece..d7e64a020 100644 --- a/src/mailman/app/notifications.py +++ b/src/mailman/app/notifications.py @@ -30,11 +30,12 @@ __all__ = [ from email.utils import formataddr from lazr.config import as_boolean -from mailman import Utils +from mailman.Utils import wrap from mailman.config import config from mailman.core.i18n import _ from mailman.email.message import OwnerNotification, UserNotification from mailman.interfaces.member import DeliveryMode +from mailman.utilities.i18n import make @@ -54,7 +55,7 @@ def send_welcome_message(mlist, address, language, delivery_mode, text=''): :type delivery_mode: DeliveryMode """ if mlist.welcome_msg: - welcome = Utils.wrap(mlist.welcome_msg) + '\n' + welcome = wrap(mlist.welcome_msg) + '\n' else: welcome = '' # Find the IMember object which is subscribed to the mailing list, because @@ -62,15 +63,16 @@ def send_welcome_message(mlist, address, language, delivery_mode, text=''): member = mlist.members.get_member(address) options_url = member.options_url # Get the text from the template. - text += Utils.maketext( - 'subscribeack.txt', { - 'real_name' : mlist.real_name, - 'posting_address' : mlist.fqdn_listname, - 'listinfo_url' : mlist.script_url('listinfo'), - 'optionsurl' : options_url, - 'request_address' : mlist.request_address, - 'welcome' : welcome, - }, lang=language.code, mlist=mlist) + text += make('subscribeack.txt', + mailing_list=mlist, + language=language.code, + real_name=mlist.real_name, + posting_address=mlist.fqdn_listname, + listinfo_url=mlist.script_url('listinfo'), + optionsurl=options_url, + request_address=mlist.request_address, + welcome=welcome, + ) if delivery_mode is not DeliveryMode.regular: digmode = _(' (Digest mode)') else: @@ -98,7 +100,7 @@ def send_goodbye_message(mlist, address, language): :type language: string """ if mlist.goodbye_msg: - goodbye = Utils.wrap(mlist.goodbye_msg) + '\n' + goodbye = wrap(mlist.goodbye_msg) + '\n' else: goodbye = '' msg = UserNotification( @@ -124,10 +126,10 @@ def send_admin_subscription_notice(mlist, address, full_name, language): with _.using(mlist.preferred_language.code): subject = _('$mlist.real_name subscription notification') full_name = full_name.encode(language.charset, 'replace') - text = Utils.maketext( - 'adminsubscribeack.txt', - {'listname' : mlist.real_name, - 'member' : formataddr((full_name, address)), - }, mlist=mlist) + text = make('adminsubscribeack.txt', + mailing_list=mlist, + listname=mlist.real_name, + member=formataddr((full_name, address)), + ) msg = OwnerNotification(mlist, subject, text) msg.send(mlist) diff --git a/src/mailman/chains/hold.py b/src/mailman/chains/hold.py index ff53621ab..fcd11ca72 100644 --- a/src/mailman/chains/hold.py +++ b/src/mailman/chains/hold.py @@ -35,7 +35,7 @@ from zope.component import getUtility from zope.event import notify from zope.interface import implements -from mailman.Utils import maketext, wrap +from mailman.Utils import wrap from mailman.app.moderator import hold_message from mailman.app.replybot import can_acknowledge from mailman.chains.base import ChainNotification, TerminalChainBase @@ -46,6 +46,7 @@ from mailman.interfaces.autorespond import IAutoResponseSet, Response from mailman.interfaces.languages import ILanguageManager from mailman.interfaces.pending import IPendable, IPendings from mailman.interfaces.usermanager import IUserManager +from mailman.utilities.i18n import make from mailman.utilities.string import oneline @@ -103,14 +104,13 @@ def autorespond_to_sender(mlist, sender, lang=None): log.info('hold autoresponse limit hit: %s', sender) response_set.response_sent(address, Response.hold) # Send this notification message instead. - text = maketext( - 'nomoretoday.txt', - {'sender' : sender, - 'listname': mlist.fqdn_listname, - 'num' : todays_count, - 'owneremail': mlist.owner_address, - }, - lang=lang) + text = make('nomoretoday.txt', + language=lang, + sender=sender, + listname=mlist.fqdn_listname, + num=todays_count, + owneremail=mlist.owner_address, + ) with _.using(lang.code): msg = UserNotification( sender, mlist.owner_address, @@ -194,8 +194,10 @@ class HoldChain(TerminalChainBase): subject = _( 'Your message to $mlist.fqdn_listname awaits moderator approval') send_language_code = msgdata.get('lang', language.code) - text = maketext('postheld.txt', substitutions, - lang=send_language_code, mlist=mlist) + text = make('postheld.txt', + mailing_list=mlist, + language=send_language_code, + **substitutions) adminaddr = mlist.bounces_address nmsg = UserNotification( msg.sender, adminaddr, subject, text, @@ -222,10 +224,11 @@ class HoldChain(TerminalChainBase): mlist.owner_address, subject, lang=language) nmsg.set_type('multipart/mixed') - text = MIMEText( - maketext('postauth.txt', substitutions, - raw=True, mlist=mlist), - _charset=charset) + text = MIMEText(make('postauth.txt', + mailing_list=mlist, + wrap=False, + **substitutions), + _charset=charset) dmsg = MIMEText(wrap(_("""\ If you reply to this message, keeping the Subject: header intact, Mailman will discard the held message. Do this if the message is spam. If you reply to diff --git a/src/mailman/commands/cli_lists.py b/src/mailman/commands/cli_lists.py index a127dd816..021ce5991 100644 --- a/src/mailman/commands/cli_lists.py +++ b/src/mailman/commands/cli_lists.py @@ -30,7 +30,6 @@ __all__ = [ from zope.component import getUtility from zope.interface import implements -from mailman.Utils import maketext from mailman.app.lifecycle import create_list, remove_list from mailman.config import config from mailman.core.constants import system_preferences @@ -42,6 +41,7 @@ from mailman.interfaces.domain import ( BadDomainSpecificationError, IDomainManager) from mailman.interfaces.languages import ILanguageManager from mailman.interfaces.listmanager import IListManager, ListAlreadyExistsError +from mailman.utilities.i18n import make @@ -213,7 +213,7 @@ class Create: requestaddr = mlist.request_address, siteowner = mlist.no_reply_address, ) - text = maketext('newlist.txt', d, mlist=mlist) + text = make('newlist.txt', mailing_list=mlist, **d) # Set the I18N language to the list's preferred language so the # header will match the template language. Stashing and restoring # the old translation context is just (healthy? :) paranoia. diff --git a/src/mailman/commands/docs/info.txt b/src/mailman/commands/docs/info.txt index bccb78fda..12fce3223 100644 --- a/src/mailman/commands/docs/info.txt +++ b/src/mailman/commands/docs/info.txt @@ -74,6 +74,7 @@ The File System Hierarchy layout is the same every by definition. PUBLIC_ARCHIVE_FILE_DIR = /var/lib/mailman/archives/public QUEUE_DIR = /var/spool/mailman SITE_PW_FILE = /var/lib/mailman/data/adm.pw + TEMPLATE_DIR = .../mailman/templates VAR_DIR = /var/lib/mailman diff --git a/src/mailman/config/config.py b/src/mailman/config/config.py index 4cfb6b5a5..636b9ef9e 100644 --- a/src/mailman/config/config.py +++ b/src/mailman/config/config.py @@ -34,6 +34,8 @@ from string import Template from zope.component import getUtility from zope.interface import Interface, implements +import mailman.templates + from mailman import version from mailman.interfaces.languages import ILanguageManager from mailman.styles.manager import StyleManager @@ -176,6 +178,10 @@ class Configuration: pipermail_public_dir = category.pipermail_public_dir, queue_dir = category.queue_dir, var_dir = var_dir, + template_dir = ( + os.path.dirname(mailman.templates.__file__) + if category.template_dir == ':source:' + else category.template_dir), # Files. creator_pw_file = category.creator_pw_file, lock_file = category.lock_file, diff --git a/src/mailman/config/schema.cfg b/src/mailman/config/schema.cfg index f789d28f9..09f575459 100644 --- a/src/mailman/config/schema.cfg +++ b/src/mailman/config/schema.cfg @@ -103,6 +103,12 @@ pipermail_public_dir: $var_dir/archives/public # Directory for private Pipermail archiver artifacts. pipermail_private_dir: $var_dir/archives/private # +# Where Mailman looks for its templates. This can either be a file system +# path or the special symbol ':source:' to locate them within the source tree +# (specifically, inside the mailman.templates package directory). +# +template_dir: :source: +# # There are also a number of paths to specific file locations that can be # defined. For these, the directory containing the file must already exist, # or be one of the directories created by Mailman as per above. diff --git a/src/mailman/pipeline/acknowledge.py b/src/mailman/pipeline/acknowledge.py index fcde6d6a5..e5b49ffa0 100644 --- a/src/mailman/pipeline/acknowledge.py +++ b/src/mailman/pipeline/acknowledge.py @@ -31,11 +31,11 @@ __all__ = [ from zope.component import getUtility from zope.interface import implements -from mailman import Utils from mailman.core.i18n import _ from mailman.email.message import UserNotification from mailman.interfaces.handler import IHandler from mailman.interfaces.languages import ILanguageManager +from mailman.utilities.i18n import make from mailman.utilities.string import oneline @@ -71,13 +71,15 @@ class Acknowledge: charset = language_manager[language.code].charset # Now get the acknowledgement template. realname = mlist.real_name - text = Utils.maketext( - 'postack.txt', - {'subject' : oneline(original_subject, charset), - 'listname' : realname, - 'listinfo_url': mlist.script_url('listinfo'), - 'optionsurl' : member.options_url, - }, lang=language.code, mlist=mlist, raw=True) + text = make('postack.txt', + mailing_list=mlist, + language=language.code, + wrap=False, + subject=oneline(original_subject, charset), + listname=realname, + listinfo_url=mlist.script_url('listinfo'), + optionsurl=member.options_url, + ) # Craft the outgoing message, with all headers and attributes # necessary for general delivery. Then enqueue it to the outgoing # queue. diff --git a/src/mailman/queue/digest.py b/src/mailman/queue/digest.py index a2feff448..2541e14ed 100644 --- a/src/mailman/queue/digest.py +++ b/src/mailman/queue/digest.py @@ -38,7 +38,7 @@ from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.utils import formatdate, getaddresses, make_msgid -from mailman.Utils import maketext, wrap +from mailman.Utils import wrap from mailman.config import config from mailman.core.errors import DiscardMessage from mailman.core.i18n import _ @@ -46,6 +46,7 @@ from mailman.interfaces.member import DeliveryMode, DeliveryStatus from mailman.pipeline.decorate import decorate from mailman.pipeline.scrubber import process as scrubber from mailman.queue import Runner +from mailman.utilities.i18n import make from mailman.utilities.mailbox import Mailbox from mailman.utilities.string import oneline @@ -76,15 +77,14 @@ class Digester: # digest header are separate MIME subobjects. In either case, it's # the first thing in the digest, and we can calculate it now, so go # ahead and add it now. - self._masthead = maketext( - 'masthead.txt', dict( - real_name=mlist.real_name, - got_list_email=mlist.posting_address, - got_listinfo_url=mlist.script_url('listinfo'), - got_request_email=mlist.request_address, - got_owner_email=mlist.owner_address, - ), - mlist=mlist) + self._masthead = make('masthead.txt', + mailing_list=mlist, + real_name=mlist.real_name, + got_list_email=mlist.posting_address, + got_listinfo_url=mlist.script_url('listinfo'), + got_request_email=mlist.request_address, + got_owner_email=mlist.owner_address, + ) # Set things up for the table of contents. self._header = decorate(mlist, mlist.digest_header) self._toc = StringIO() 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 |
