Edgewall Software

source: branches/stable/0.5.x/genshi/filters/i18n.py

Last change on this file was 947, checked in by cmlenz, 15 years ago

Ported [913], [927], and [928] to the 0.5.x branch.

  • Property svn:eol-style set to native
File size: 20.6 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2007 Edgewall Software
4# All rights reserved.
5#
6# This software is licensed as described in the file COPYING, which
7# you should have received as part of this distribution. The terms
8# are also available at http://genshi.edgewall.org/wiki/License.
9#
10# This software consists of voluntary contributions made by many
11# individuals. For the exact contribution history, see the revision
12# history and logs, available at http://genshi.edgewall.org/log/.
13
14"""Utilities for internationalization and localization of templates.
15
16:since: version 0.4
17"""
18
19from compiler import ast
20try:
21    frozenset
22except NameError:
23    from sets import ImmutableSet as frozenset
24from gettext import gettext
25import re
26
27from genshi.core import Attrs, Namespace, QName, START, END, TEXT, START_NS, \
28                        END_NS, XML_NAMESPACE, _ensure
29from genshi.template.base import Template, EXPR, SUB
30from genshi.template.markup import MarkupTemplate, EXEC
31
32__all__ = ['Translator', 'extract']
33__docformat__ = 'restructuredtext en'
34
35I18N_NAMESPACE = Namespace('http://genshi.edgewall.org/i18n')
36
37
38class Translator(object):
39    """Can extract and translate localizable strings from markup streams and
40    templates.
41   
42    For example, assume the followng template:
43   
44    >>> from genshi.template import MarkupTemplate
45    >>>
46    >>> tmpl = MarkupTemplate('''<html xmlns:py="http://genshi.edgewall.org/">
47    ...   <head>
48    ...     <title>Example</title>
49    ...   </head>
50    ...   <body>
51    ...     <h1>Example</h1>
52    ...     <p>${_("Hello, %(name)s") % dict(name=username)}</p>
53    ...   </body>
54    ... </html>''', filename='example.html')
55   
56    For demonstration, we define a dummy ``gettext``-style function with a
57    hard-coded translation table, and pass that to the `Translator` initializer:
58   
59    >>> def pseudo_gettext(string):
60    ...     return {
61    ...         'Example': 'Beispiel',
62    ...         'Hello, %(name)s': 'Hallo, %(name)s'
63    ...     }[string]
64    >>>
65    >>> translator = Translator(pseudo_gettext)
66   
67    Next, the translator needs to be prepended to any already defined filters
68    on the template:
69   
70    >>> tmpl.filters.insert(0, translator)
71   
72    When generating the template output, our hard-coded translations should be
73    applied as expected:
74   
75    >>> print tmpl.generate(username='Hans', _=pseudo_gettext)
76    <html>
77      <head>
78        <title>Beispiel</title>
79      </head>
80      <body>
81        <h1>Beispiel</h1>
82        <p>Hallo, Hans</p>
83      </body>
84    </html>
85
86    Note that elements defining ``xml:lang`` attributes that do not contain
87    variable expressions are ignored by this filter. That can be used to
88    exclude specific parts of a template from being extracted and translated.
89    """
90
91    IGNORE_TAGS = frozenset([
92        QName('script'), QName('http://www.w3.org/1999/xhtml}script'),
93        QName('style'), QName('http://www.w3.org/1999/xhtml}style')
94    ])
95    INCLUDE_ATTRS = frozenset(['abbr', 'alt', 'label', 'prompt', 'standby',
96                               'summary', 'title'])
97
98    def __init__(self, translate=gettext, ignore_tags=IGNORE_TAGS,
99                 include_attrs=INCLUDE_ATTRS, extract_text=True):
100        """Initialize the translator.
101       
102        :param translate: the translation function, for example ``gettext`` or
103                          ``ugettext``.
104        :param ignore_tags: a set of tag names that should not be localized
105        :param include_attrs: a set of attribute names should be localized
106        :param extract_text: whether the content of text nodes should be
107                             extracted, or only text in explicit ``gettext``
108                             function calls
109        """
110        self.translate = translate
111        self.ignore_tags = ignore_tags
112        self.include_attrs = include_attrs
113        self.extract_text = extract_text
114
115    def __call__(self, stream, ctxt=None, search_text=True, msgbuf=None):
116        """Translate any localizable strings in the given stream.
117       
118        This function shouldn't be called directly. Instead, an instance of
119        the `Translator` class should be registered as a filter with the
120        `Template` or the `TemplateLoader`, or applied as a regular stream
121        filter. If used as a template filter, it should be inserted in front of
122        all the default filters.
123       
124        :param stream: the markup event stream
125        :param ctxt: the template context (not used)
126        :param search_text: whether text nodes should be translated (used
127                            internally)
128        :param msgbuf: a `MessageBuffer` object or `None` (used internally)
129        :return: the localized stream
130        """
131        ignore_tags = self.ignore_tags
132        include_attrs = self.include_attrs
133        translate = self.translate
134        if not self.extract_text:
135            search_text = False
136        skip = 0
137        i18n_msg = I18N_NAMESPACE['msg']
138        ns_prefixes = []
139        xml_lang = XML_NAMESPACE['lang']
140
141        for kind, data, pos in stream:
142
143            # skip chunks that should not be localized
144            if skip:
145                if kind is START:
146                    skip += 1
147                elif kind is END:
148                    skip -= 1
149                yield kind, data, pos
150                continue
151
152            # handle different events that can be localized
153            if kind is START:
154                tag, attrs = data
155                if tag in self.ignore_tags or \
156                        isinstance(attrs.get(xml_lang), basestring):
157                    skip += 1
158                    yield kind, data, pos
159                    continue
160
161                new_attrs = []
162                changed = False
163                for name, value in attrs:
164                    newval = value
165                    if search_text and isinstance(value, basestring):
166                        if name in include_attrs:
167                            newval = self.translate(value)
168                    else:
169                        newval = list(self(_ensure(value), ctxt,
170                            search_text=False)
171                        )
172                    if newval != value:
173                        value = newval
174                        changed = True
175                    new_attrs.append((name, value))
176                if changed:
177                    attrs = Attrs(new_attrs)
178
179                if msgbuf:
180                    msgbuf.append(kind, data, pos)
181                    continue
182                elif i18n_msg in attrs:
183                    params = attrs.get(i18n_msg)
184                    if params and type(params) is list: # event tuple
185                        params = params[0][1]
186                    msgbuf = MessageBuffer(params)
187                    attrs -= i18n_msg
188
189                yield kind, (tag, attrs), pos
190
191            elif search_text and kind is TEXT:
192                if not msgbuf:
193                    text = data.strip()
194                    if text:
195                        data = data.replace(text, unicode(translate(text)))
196                    yield kind, data, pos
197                else:
198                    msgbuf.append(kind, data, pos)
199
200            elif msgbuf and kind is EXPR:
201                msgbuf.append(kind, data, pos)
202
203            elif not skip and msgbuf and kind is END:
204                msgbuf.append(kind, data, pos)
205                if not msgbuf.depth:
206                    for event in msgbuf.translate(translate(msgbuf.format())):
207                        yield event
208                    msgbuf = None
209                    yield kind, data, pos
210
211            elif kind is SUB:
212                subkind, substream = data
213                new_substream = list(self(substream, ctxt, msgbuf=msgbuf))
214                yield kind, (subkind, new_substream), pos
215
216            elif kind is START_NS and data[1] == I18N_NAMESPACE:
217                ns_prefixes.append(data[0])
218
219            elif kind is END_NS and data in ns_prefixes:
220                ns_prefixes.remove(data)
221
222            else:
223                yield kind, data, pos
224
225    GETTEXT_FUNCTIONS = ('_', 'gettext', 'ngettext', 'dgettext', 'dngettext',
226                         'ugettext', 'ungettext')
227
228    def extract(self, stream, gettext_functions=GETTEXT_FUNCTIONS,
229                search_text=True, msgbuf=None):
230        """Extract localizable strings from the given template stream.
231       
232        For every string found, this function yields a ``(lineno, function,
233        message)`` tuple, where:
234       
235        * ``lineno`` is the number of the line on which the string was found,
236        * ``function`` is the name of the ``gettext`` function used (if the
237          string was extracted from embedded Python code), and
238        *  ``message`` is the string itself (a ``unicode`` object, or a tuple
239           of ``unicode`` objects for functions with multiple string arguments).
240       
241        >>> from genshi.template import MarkupTemplate
242        >>>
243        >>> tmpl = MarkupTemplate('''<html xmlns:py="http://genshi.edgewall.org/">
244        ...   <head>
245        ...     <title>Example</title>
246        ...   </head>
247        ...   <body>
248        ...     <h1>Example</h1>
249        ...     <p>${_("Hello, %(name)s") % dict(name=username)}</p>
250        ...     <p>${ngettext("You have %d item", "You have %d items", num)}</p>
251        ...   </body>
252        ... </html>''', filename='example.html')
253        >>>
254        >>> for lineno, funcname, message in Translator().extract(tmpl.stream):
255        ...    print "%d, %r, %r" % (lineno, funcname, message)
256        3, None, u'Example'
257        6, None, u'Example'
258        7, '_', u'Hello, %(name)s'
259        8, 'ngettext', (u'You have %d item', u'You have %d items', None)
260       
261        :param stream: the event stream to extract strings from; can be a
262                       regular stream or a template stream
263        :param gettext_functions: a sequence of function names that should be
264                                  treated as gettext-style localization
265                                  functions
266        :param search_text: whether the content of text nodes should be
267                            extracted (used internally)
268       
269        :note: Changed in 0.4.1: For a function with multiple string arguments
270               (such as ``ngettext``), a single item with a tuple of strings is
271               yielded, instead an item for each string argument.
272        """
273        if not self.extract_text:
274            search_text = False
275        skip = 0
276        i18n_msg = I18N_NAMESPACE['msg']
277        xml_lang = XML_NAMESPACE['lang']
278
279        for kind, data, pos in stream:
280
281            if skip:
282                if kind is START:
283                    skip += 1
284                if kind is END:
285                    skip -= 1
286
287            if kind is START and not skip:
288                tag, attrs = data
289
290                if tag in self.ignore_tags or \
291                        isinstance(attrs.get(xml_lang), basestring):
292                    skip += 1
293                    continue
294
295                for name, value in attrs:
296                    if search_text and isinstance(value, basestring):
297                        if name in self.include_attrs:
298                            text = value.strip()
299                            if text:
300                                yield pos[1], None, text
301                    else:
302                        for lineno, funcname, text in self.extract(
303                                _ensure(value), gettext_functions,
304                                search_text=False):
305                            yield lineno, funcname, text
306
307                if msgbuf:
308                    msgbuf.append(kind, data, pos)
309                elif i18n_msg in attrs:
310                    params = attrs.get(i18n_msg)
311                    if params and type(params) is list: # event tuple
312                        params = params[0][1]
313                    msgbuf = MessageBuffer(params, pos[1])
314
315            elif not skip and search_text and kind is TEXT:
316                if not msgbuf:
317                    text = data.strip()
318                    if text and filter(None, [ch.isalpha() for ch in text]):
319                        yield pos[1], None, text
320                else:
321                    msgbuf.append(kind, data, pos)
322
323            elif not skip and msgbuf and kind is END:
324                msgbuf.append(kind, data, pos)
325                if not msgbuf.depth:
326                    yield msgbuf.lineno, None, msgbuf.format()
327                    msgbuf = None
328
329            elif kind is EXPR or kind is EXEC:
330                if msgbuf:
331                    msgbuf.append(kind, data, pos)
332                for funcname, strings in extract_from_code(data,
333                                                           gettext_functions):
334                    yield pos[1], funcname, strings
335
336            elif kind is SUB:
337                subkind, substream = data
338                messages = self.extract(substream, gettext_functions,
339                                        search_text=search_text and not skip,
340                                        msgbuf=msgbuf)
341                for lineno, funcname, text in messages:
342                    yield lineno, funcname, text
343
344
345class MessageBuffer(object):
346    """Helper class for managing internationalized mixed content.
347   
348    :since: version 0.5
349    """
350
351    def __init__(self, params=u'', lineno=-1):
352        """Initialize the message buffer.
353       
354        :param params: comma-separated list of parameter names
355        :type params: `basestring`
356        :param lineno: the line number on which the first stream event
357                       belonging to the message was found
358        """
359        self.params = [name.strip() for name in params.split(',')]
360        self.lineno = lineno
361        self.string = []
362        self.events = {}
363        self.values = {}
364        self.depth = 1
365        self.order = 1
366        self.stack = [0]
367
368    def append(self, kind, data, pos):
369        """Append a stream event to the buffer.
370       
371        :param kind: the stream event kind
372        :param data: the event data
373        :param pos: the position of the event in the source
374        """
375        if kind is TEXT:
376            self.string.append(data)
377            self.events.setdefault(self.stack[-1], []).append(None)
378        elif kind is EXPR:
379            param = self.params.pop(0)
380            self.string.append('%%(%s)s' % param)
381            self.events.setdefault(self.stack[-1], []).append(None)
382            self.values[param] = (kind, data, pos)
383        else:
384            if kind is START:
385                self.string.append(u'[%d:' % self.order)
386                self.events.setdefault(self.order, []).append((kind, data, pos))
387                self.stack.append(self.order)
388                self.depth += 1
389                self.order += 1
390            elif kind is END:
391                self.depth -= 1
392                if self.depth:
393                    self.events[self.stack[-1]].append((kind, data, pos))
394                    self.string.append(u']')
395                    self.stack.pop()
396
397    def format(self):
398        """Return a message identifier representing the content in the
399        buffer.
400        """
401        return u''.join(self.string).strip()
402
403    def translate(self, string, regex=re.compile(r'%\((\w+)\)s')):
404        """Interpolate the given message translation with the events in the
405        buffer and return the translated stream.
406       
407        :param string: the translated message string
408        """
409        parts = parse_msg(string)
410        for order, string in parts:
411            events = self.events[order]
412            while events:
413                event = events.pop(0)
414                if event:
415                    yield event
416                else:
417                    if not string:
418                        break
419                    for idx, part in enumerate(regex.split(string)):
420                        if idx % 2:
421                            yield self.values[part]
422                        elif part:
423                            yield TEXT, part, (None, -1, -1)
424                    if not self.events[order] or not self.events[order][0]:
425                        break
426
427
428def parse_msg(string, regex=re.compile(r'(?:\[(\d+)\:)|\]')):
429    """Parse a translated message using Genshi mixed content message
430    formatting.
431
432    >>> parse_msg("See [1:Help].")
433    [(0, 'See '), (1, 'Help'), (0, '.')]
434
435    >>> parse_msg("See [1:our [2:Help] page] for details.")
436    [(0, 'See '), (1, 'our '), (2, 'Help'), (1, ' page'), (0, ' for details.')]
437
438    >>> parse_msg("[2:Details] finden Sie in [1:Hilfe].")
439    [(2, 'Details'), (0, ' finden Sie in '), (1, 'Hilfe'), (0, '.')]
440
441    >>> parse_msg("[1:] Bilder pro Seite anzeigen.")
442    [(1, ''), (0, ' Bilder pro Seite anzeigen.')]
443
444    :param string: the translated message string
445    :return: a list of ``(order, string)`` tuples
446    :rtype: `list`
447    """
448    parts = []
449    stack = [0]
450    while True:
451        mo = regex.search(string)
452        if not mo:
453            break
454
455        if mo.start() or stack[-1]:
456            parts.append((stack[-1], string[:mo.start()]))
457        string = string[mo.end():]
458
459        orderno = mo.group(1)
460        if orderno is not None:
461            stack.append(int(orderno))
462        else:
463            stack.pop()
464        if not stack:
465            break
466
467    if string:
468        parts.append((stack[-1], string))
469
470    return parts
471
472
473def extract_from_code(code, gettext_functions):
474    """Extract strings from Python bytecode.
475   
476    >>> from genshi.template.eval import Expression
477   
478    >>> expr = Expression('_("Hello")')
479    >>> list(extract_from_code(expr, Translator.GETTEXT_FUNCTIONS))
480    [('_', u'Hello')]
481
482    >>> expr = Expression('ngettext("You have %(num)s item", '
483    ...                            '"You have %(num)s items", num)')
484    >>> list(extract_from_code(expr, Translator.GETTEXT_FUNCTIONS))
485    [('ngettext', (u'You have %(num)s item', u'You have %(num)s items', None))]
486   
487    :param code: the `Code` object
488    :type code: `genshi.template.eval.Code`
489    :param gettext_functions: a sequence of function names
490    :since: version 0.5
491    """
492    def _walk(node):
493        if isinstance(node, ast.CallFunc) and isinstance(node.node, ast.Name) \
494                and node.node.name in gettext_functions:
495            strings = []
496            def _add(arg):
497                if isinstance(arg, ast.Const) \
498                        and isinstance(arg.value, basestring):
499                    strings.append(unicode(arg.value, 'utf-8'))
500                elif arg and not isinstance(arg, ast.Keyword):
501                    strings.append(None)
502            [_add(arg) for arg in node.args]
503            _add(node.star_args)
504            _add(node.dstar_args)
505            if len(strings) == 1:
506                strings = strings[0]
507            else:
508                strings = tuple(strings)
509            yield node.node.name, strings
510        else:
511            for child in node.getChildNodes():
512                for funcname, strings in _walk(child):
513                    yield funcname, strings
514    return _walk(code.ast)
515
516
517def extract(fileobj, keywords, comment_tags, options):
518    """Babel extraction method for Genshi templates.
519   
520    :param fileobj: the file-like object the messages should be extracted from
521    :param keywords: a list of keywords (i.e. function names) that should be
522                     recognized as translation functions
523    :param comment_tags: a list of translator tags to search for and include
524                         in the results
525    :param options: a dictionary of additional options (optional)
526    :return: an iterator over ``(lineno, funcname, message, comments)`` tuples
527    :rtype: ``iterator``
528    """
529    template_class = options.get('template_class', MarkupTemplate)
530    if isinstance(template_class, basestring):
531        module, clsname = template_class.split(':', 1)
532        template_class = getattr(__import__(module, {}, {}, [clsname]), clsname)
533    encoding = options.get('encoding', None)
534
535    extract_text = options.get('extract_text', True)
536    if isinstance(extract_text, basestring):
537        extract_text = extract_text.lower() in ('1', 'on', 'yes', 'true')
538
539    ignore_tags = options.get('ignore_tags', Translator.IGNORE_TAGS)
540    if isinstance(ignore_tags, basestring):
541        ignore_tags = ignore_tags.split()
542    ignore_tags = [QName(tag) for tag in ignore_tags]
543
544    include_attrs = options.get('include_attrs', Translator.INCLUDE_ATTRS)
545    if isinstance(include_attrs, basestring):
546        include_attrs = include_attrs.split()
547    include_attrs = [QName(attr) for attr in include_attrs]
548
549    tmpl = template_class(fileobj, filename=getattr(fileobj, 'name', None),
550                          encoding=encoding)
551    translator = Translator(None, ignore_tags, include_attrs, extract_text)
552    for lineno, func, message in translator.extract(tmpl.stream,
553                                                    gettext_functions=keywords):
554        yield lineno, func, message, []
Note: See TracBrowser for help on using the repository browser.