Edgewall Software

source: trunk/genshi/filters/i18n.py @ 1158

Last change on this file since 1158 was 1158, checked in by hodgestar, 13 years ago

Merge r1141 from py3k:

add support for python 3 to genshi.filters:

  • minor changes to track encoding=None API change in core genshi modules.
  • renamed genshi/filters/tests/html.py to test_html.py to avoid clashes with Python 3 top-level html module when running tests subset.
  • did not rename genshi/filters/html.py.
  • i18n filters:
    • ugettext and friends are gone in Python 3 (and only gettext and friends exist and they now handle unicode)
    • Some \ line continuations inside doctests confused 2to3 and so were removed them.
    • Testing picked up a problem (already present in trunk) where Translator.call could end up defining gettext as an endlessly recursive function. Noted with a TODO.
  • Property svn:eol-style set to native
File size: 48.6 KB
RevLine 
[637]1# -*- coding: utf-8 -*-
2#
[1120]3# Copyright (C) 2007-2010 Edgewall Software
[637]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
[1072]14"""Directives and utilities for internationalization and localization of
15templates.
[538]16
[690]17:since: version 0.4
[1072]18:note: Directives support added since version 0.6
[690]19"""
20
[1079]21try:
22    any
23except NameError:
24    from genshi.util import any
[932]25from gettext import NullTranslations
[1072]26import os
[538]27import re
[932]28from types import FunctionType
[538]29
[1118]30from genshi.core import Attrs, Namespace, QName, START, END, TEXT, \
31                        XML_NAMESPACE, _ensure, StreamEventKind
[988]32from genshi.template.eval import _ast
[954]33from genshi.template.base import DirectiveFactory, EXPR, SUB, _apply_directives
[1072]34from genshi.template.directives import Directive, StripDirective
[634]35from genshi.template.markup import MarkupTemplate, EXEC
[1158]36from genshi.compat import IS_PYTHON2
[538]37
[634]38__all__ = ['Translator', 'extract']
[605]39__docformat__ = 'restructuredtext en'
40
[1072]41
[671]42I18N_NAMESPACE = Namespace('http://genshi.edgewall.org/i18n')
[538]43
[1072]44MSGBUF = StreamEventKind('MSGBUF')
45SUB_START = StreamEventKind('SUB_START')
46SUB_END = StreamEventKind('SUB_END')
[671]47
[1115]48GETTEXT_FUNCTIONS = ('_', 'gettext', 'ngettext', 'dgettext', 'dngettext',
49                     'ugettext', 'ungettext')
[954]50
[1115]51
[1072]52class I18NDirective(Directive):
53    """Simple interface for i18n directives to support messages extraction."""
[954]54
[1072]55    def __call__(self, stream, directives, ctxt, **vars):
56        return _apply_directives(stream, directives, ctxt, vars)
[954]57
58
[1072]59class ExtractableI18NDirective(I18NDirective):
60    """Simple interface for directives to support messages extraction."""
[954]61
[1115]62    def extract(self, translator, stream, gettext_functions=GETTEXT_FUNCTIONS,
63                search_text=True, comment_stack=None):
[1072]64        raise NotImplementedError
65
66
67class CommentDirective(I18NDirective):
68    """Implementation of the ``i18n:comment`` template directive which adds
69    translation comments.
70   
71    >>> tmpl = MarkupTemplate('''<html xmlns:i18n="http://genshi.edgewall.org/i18n">
72    ...   <p i18n:comment="As in Foo Bar">Foo</p>
73    ... </html>''')
74    >>> translator = Translator()
75    >>> translator.setup(tmpl)
76    >>> list(translator.extract(tmpl.stream))
77    [(2, None, u'Foo', [u'As in Foo Bar'])]
78    """
79    __slots__ = ['comment']
80
[1115]81    def __init__(self, value, template=None, namespaces=None, lineno=-1,
82                 offset=-1):
[1072]83        Directive.__init__(self, None, template, namespaces, lineno, offset)
84        self.comment = value
85
86
87class MsgDirective(ExtractableI18NDirective):
88    r"""Implementation of the ``i18n:msg`` directive which marks inner content
89    as translatable. Consider the following examples:
90   
91    >>> tmpl = MarkupTemplate('''<html xmlns:i18n="http://genshi.edgewall.org/i18n">
92    ...   <div i18n:msg="">
93    ...     <p>Foo</p>
94    ...     <p>Bar</p>
95    ...   </div>
96    ...   <p i18n:msg="">Foo <em>bar</em>!</p>
97    ... </html>''')
98   
99    >>> translator = Translator()
100    >>> translator.setup(tmpl)
101    >>> list(translator.extract(tmpl.stream))
102    [(2, None, u'[1:Foo]\n    [2:Bar]', []), (6, None, u'Foo [1:bar]!', [])]
[1076]103    >>> print(tmpl.generate().render())
[1072]104    <html>
105      <div><p>Foo</p>
106        <p>Bar</p></div>
107      <p>Foo <em>bar</em>!</p>
108    </html>
109
110    >>> tmpl = MarkupTemplate('''<html xmlns:i18n="http://genshi.edgewall.org/i18n">
111    ...   <div i18n:msg="fname, lname">
112    ...     <p>First Name: ${fname}</p>
113    ...     <p>Last Name: ${lname}</p>
114    ...   </div>
115    ...   <p i18n:msg="">Foo <em>bar</em>!</p>
116    ... </html>''')
117    >>> translator.setup(tmpl)
118    >>> list(translator.extract(tmpl.stream)) #doctest: +NORMALIZE_WHITESPACE
119    [(2, None, u'[1:First Name: %(fname)s]\n    [2:Last Name: %(lname)s]', []),
120    (6, None, u'Foo [1:bar]!', [])]
121
122    >>> tmpl = MarkupTemplate('''<html xmlns:i18n="http://genshi.edgewall.org/i18n">
123    ...   <div i18n:msg="fname, lname">
124    ...     <p>First Name: ${fname}</p>
125    ...     <p>Last Name: ${lname}</p>
126    ...   </div>
127    ...   <p i18n:msg="">Foo <em>bar</em>!</p>
128    ... </html>''')
129    >>> translator.setup(tmpl)
[1076]130    >>> print(tmpl.generate(fname='John', lname='Doe').render())
[1072]131    <html>
132      <div><p>First Name: John</p>
133        <p>Last Name: Doe</p></div>
134      <p>Foo <em>bar</em>!</p>
135    </html>
136
137    Starting and ending white-space is stripped of to make it simpler for
138    translators. Stripping it is not that important since it's on the html
139    source, the rendered output will remain the same.
140    """
[1115]141    __slots__ = ['params', 'lineno']
[954]142
[1115]143    def __init__(self, value, template=None, namespaces=None, lineno=-1,
144                 offset=-1):
[954]145        Directive.__init__(self, None, template, namespaces, lineno, offset)
[1072]146        self.params = [param.strip() for param in value.split(',') if param]
[1115]147        self.lineno = lineno
[954]148
[1072]149    @classmethod
150    def attach(cls, template, stream, value, namespaces, pos):
151        if type(value) is dict:
152            value = value.get('params', '').strip()
153        return super(MsgDirective, cls).attach(template, stream, value.strip(),
154                                               namespaces, pos)
155
[954]156    def __call__(self, stream, directives, ctxt, **vars):
[1072]157        gettext = ctxt.get('_i18n.gettext')
158        if ctxt.get('_i18n.domain'):
[1114]159            dgettext = ctxt.get('_i18n.dgettext')
[1077]160            assert hasattr(dgettext, '__call__'), \
161                'No domain gettext function passed'
[1072]162            gettext = lambda msg: dgettext(ctxt.get('_i18n.domain'), msg)
[954]163
[1072]164        def _generate():
165            msgbuf = MessageBuffer(self)
166            previous = stream.next()
167            if previous[0] is START:
168                yield previous
169            else:
170                msgbuf.append(*previous)
171            previous = stream.next()
172            for kind, data, pos in stream:
173                msgbuf.append(*previous)
174                previous = kind, data, pos
175            if previous[0] is not END:
176                msgbuf.append(*previous)
177                previous = None
178            for event in msgbuf.translate(gettext(msgbuf.format())):
179                yield event
180            if previous:
181                yield previous
182
183        return _apply_directives(_generate(), directives, ctxt, vars)
184
[1115]185    def extract(self, translator, stream, gettext_functions=GETTEXT_FUNCTIONS,
186                search_text=True, comment_stack=None):
[1072]187        msgbuf = MessageBuffer(self)
[1115]188        strip = False
[1072]189
[954]190        stream = iter(stream)
[1072]191        previous = stream.next()
192        if previous[0] is START:
[1115]193            for message in translator._extract_attrs(previous,
194                                                     gettext_functions,
195                                                     search_text=search_text):
196                yield message
[1072]197            previous = stream.next()
[1115]198            strip = True
[1072]199        for event in stream:
[1115]200            if event[0] is START:
201                for message in translator._extract_attrs(event,
202                                                         gettext_functions,
203                                                         search_text=search_text):
204                    yield message
[1072]205            msgbuf.append(*previous)
206            previous = event
[1115]207        if not strip:
208            msgbuf.append(*previous)
[1072]209
[1115]210        yield self.lineno, None, msgbuf.format(), comment_stack[-1:]
[1072]211
212
213class ChooseBranchDirective(I18NDirective):
214    __slots__ = ['params']
[1115]215
[1072]216    def __call__(self, stream, directives, ctxt, **vars):
217        self.params = ctxt.get('_i18n.choose.params', [])[:]
218        msgbuf = MessageBuffer(self)
[1118]219        stream = _apply_directives(stream, directives, ctxt, vars)
[1072]220
[954]221        previous = stream.next()
[1094]222        if previous[0] is START:
223            yield previous
224        else:
225            msgbuf.append(*previous)
[1118]226
[1094]227        try:
228            previous = stream.next()
229        except StopIteration:
230            # For example <i18n:singular> or <i18n:plural> directives
231            yield MSGBUF, (), -1 # the place holder for msgbuf output
[1118]232            ctxt['_i18n.choose.%s' % self.tagname] = msgbuf
[1094]233            return
[1118]234
235        for event in stream:
[1072]236            msgbuf.append(*previous)
[1118]237            previous = event
[1072]238        yield MSGBUF, (), -1 # the place holder for msgbuf output
[1094]239
240        if previous[0] is END:
241            yield previous # the outer end tag
242        else:
243            msgbuf.append(*previous)
[1118]244        ctxt['_i18n.choose.%s' % self.tagname] = msgbuf
[1072]245
[1115]246    def extract(self, translator, stream, gettext_functions=GETTEXT_FUNCTIONS,
247                search_text=True, comment_stack=None, msgbuf=None):
[1072]248        stream = iter(stream)
249        previous = stream.next()
[1115]250
[1072]251        if previous[0] is START:
[1115]252            # skip the enclosing element
253            for message in translator._extract_attrs(previous,
254                                                     gettext_functions,
255                                                     search_text=search_text):
256                yield message
[1072]257            previous = stream.next()
[1115]258
[954]259        for event in stream:
[1115]260            if previous[0] is START:
261                for message in translator._extract_attrs(previous,
262                                                         gettext_functions,
263                                                         search_text=search_text):
264                    yield message
[954]265            msgbuf.append(*previous)
266            previous = event
[1115]267
[1072]268        if previous[0] is not END:
269            msgbuf.append(*previous)
[954]270
[1072]271
272class SingularDirective(ChooseBranchDirective):
273    """Implementation of the ``i18n:singular`` directive to be used with the
274    ``i18n:choose`` directive."""
275
276
277class PluralDirective(ChooseBranchDirective):
278    """Implementation of the ``i18n:plural`` directive to be used with the
279    ``i18n:choose`` directive."""
280
281
282class ChooseDirective(ExtractableI18NDirective):
283    """Implementation of the ``i18n:choose`` directive which provides plural
284    internationalisation of strings.
285   
286    This directive requires at least one parameter, the one which evaluates to
287    an integer which will allow to choose the plural/singular form. If you also
288    have expressions inside the singular and plural version of the string you
289    also need to pass a name for those parameters. Consider the following
290    examples:
291   
[1158]292    >>> tmpl = MarkupTemplate('''<html xmlns:i18n="http://genshi.edgewall.org/i18n">
[1072]293    ...   <div i18n:choose="num; num">
294    ...     <p i18n:singular="">There is $num coin</p>
295    ...     <p i18n:plural="">There are $num coins</p>
296    ...   </div>
297    ... </html>''')
298    >>> translator = Translator()
299    >>> translator.setup(tmpl)
300    >>> list(translator.extract(tmpl.stream)) #doctest: +NORMALIZE_WHITESPACE
301    [(2, 'ngettext', (u'There is %(num)s coin',
302                      u'There are %(num)s coins'), [])]
303
[1158]304    >>> tmpl = MarkupTemplate('''<html xmlns:i18n="http://genshi.edgewall.org/i18n">
[1072]305    ...   <div i18n:choose="num; num">
306    ...     <p i18n:singular="">There is $num coin</p>
307    ...     <p i18n:plural="">There are $num coins</p>
308    ...   </div>
309    ... </html>''')
310    >>> translator.setup(tmpl)
[1076]311    >>> print(tmpl.generate(num=1).render())
[1072]312    <html>
313      <div>
314        <p>There is 1 coin</p>
315      </div>
316    </html>
[1076]317    >>> print(tmpl.generate(num=2).render())
[1072]318    <html>
319      <div>
320        <p>There are 2 coins</p>
321      </div>
322    </html>
323
[1111]324    When used as a element and not as an attribute:
[1072]325
[1158]326    >>> tmpl = MarkupTemplate('''<html xmlns:i18n="http://genshi.edgewall.org/i18n">
[1072]327    ...   <i18n:choose numeral="num" params="num">
328    ...     <p i18n:singular="">There is $num coin</p>
329    ...     <p i18n:plural="">There are $num coins</p>
330    ...   </i18n:choose>
331    ... </html>''')
332    >>> translator.setup(tmpl)
333    >>> list(translator.extract(tmpl.stream)) #doctest: +NORMALIZE_WHITESPACE
334    [(2, 'ngettext', (u'There is %(num)s coin',
335                      u'There are %(num)s coins'), [])]
336    """
[1115]337    __slots__ = ['numeral', 'params', 'lineno']
[1072]338
[1115]339    def __init__(self, value, template=None, namespaces=None, lineno=-1,
340                 offset=-1):
[1072]341        Directive.__init__(self, None, template, namespaces, lineno, offset)
342        params = [v.strip() for v in value.split(';')]
343        self.numeral = self._parse_expr(params.pop(0), template, lineno, offset)
344        self.params = params and [name.strip() for name in
345                                  params[0].split(',') if name] or []
[1115]346        self.lineno = lineno
[1072]347
348    @classmethod
349    def attach(cls, template, stream, value, namespaces, pos):
350        if type(value) is dict:
351            numeral = value.get('numeral', '').strip()
352            assert numeral is not '', "at least pass the numeral param"
353            params = [v.strip() for v in value.get('params', '').split(',')]
354            value = '%s; ' % numeral + ', '.join(params)
355        return super(ChooseDirective, cls).attach(template, stream, value,
356                                                  namespaces, pos)
357
358    def __call__(self, stream, directives, ctxt, **vars):
359        ctxt.push({'_i18n.choose.params': self.params,
[1118]360                   '_i18n.choose.singular': None,
361                   '_i18n.choose.plural': None})
[1072]362
[1118]363        ngettext = ctxt.get('_i18n.ngettext')
364        assert hasattr(ngettext, '__call__'), 'No ngettext function available'
365        dngettext = ctxt.get('_i18n.dngettext')
366        if not dngettext:
367            dngettext = lambda d, s, p, n: ngettext(s, p, n)
368
[1072]369        new_stream = []
370        singular_stream = None
371        singular_msgbuf = None
372        plural_stream = None
373        plural_msgbuf = None
374
[1118]375        numeral = self.numeral.evaluate(ctxt)
376        is_plural = self._is_plural(numeral, ngettext)
[1115]377
[1118]378        for event in stream:
379            if event[0] is SUB and any(isinstance(d, ChooseBranchDirective)
380                                       for d in event[1][0]):
381                subdirectives, substream = event[1]
382
383                if isinstance(subdirectives[0], SingularDirective):
[1072]384                    singular_stream = list(_apply_directives(substream,
385                                                             subdirectives,
386                                                             ctxt, vars))
[1118]387                    new_stream.append((MSGBUF, None, (None, -1, -1)))
388
389                elif isinstance(subdirectives[0], PluralDirective):
390                    if is_plural:
391                        plural_stream = list(_apply_directives(substream,
392                                                               subdirectives,
393                                                               ctxt, vars))
394
[1072]395            else:
[1118]396                new_stream.append(event)
[1072]397
398        if ctxt.get('_i18n.domain'):
399            ngettext = lambda s, p, n: dngettext(ctxt.get('_i18n.domain'),
400                                                 s, p, n)
401
[1118]402        singular_msgbuf = ctxt.get('_i18n.choose.singular')
403        if is_plural:
404            plural_msgbuf = ctxt.get('_i18n.choose.plural')
405            msgbuf, choice = plural_msgbuf, plural_stream
[1096]406        else:
[1118]407            msgbuf, choice = singular_msgbuf, singular_stream
408            plural_msgbuf = MessageBuffer(self)
[1096]409
[1072]410        for kind, data, pos in new_stream:
411            if kind is MSGBUF:
[1118]412                for event in choice:
413                    if event[0] is MSGBUF:
[1072]414                        translation = ngettext(singular_msgbuf.format(),
415                                               plural_msgbuf.format(),
[1118]416                                               numeral)
417                        for subevent in msgbuf.translate(translation):
418                            yield subevent
[1072]419                    else:
[1118]420                        yield event
[1072]421            else:
422                yield kind, data, pos
423
424        ctxt.pop()
425
[1115]426    def extract(self, translator, stream, gettext_functions=GETTEXT_FUNCTIONS,
427                search_text=True, comment_stack=None):
428        strip = False
[1072]429        stream = iter(stream)
430        previous = stream.next()
431
[1115]432        if previous[0] is START:
433            # skip the enclosing element
434            for message in translator._extract_attrs(previous,
435                                                     gettext_functions,
436                                                     search_text=search_text):
437                yield message
438            previous = stream.next()
439            strip = True
440
[1072]441        singular_msgbuf = MessageBuffer(self)
442        plural_msgbuf = MessageBuffer(self)
443
[1115]444        for event in stream:
445            if previous[0] is SUB:
446                directives, substream = previous[1]
447                for directive in directives:
448                    if isinstance(directive, SingularDirective):
449                        for message in directive.extract(translator,
450                                substream, gettext_functions, search_text,
451                                comment_stack, msgbuf=singular_msgbuf):
452                            yield message
453                    elif isinstance(directive, PluralDirective):
454                        for message in directive.extract(translator,
455                                substream, gettext_functions, search_text,
456                                comment_stack, msgbuf=plural_msgbuf):
457                            yield message
458                    elif not isinstance(directive, StripDirective):
459                        singular_msgbuf.append(*previous)
460                        plural_msgbuf.append(*previous)
[1072]461            else:
[1115]462                if previous[0] is START:
463                    for message in translator._extract_attrs(previous,
464                                                             gettext_functions,
465                                                             search_text):
466                        yield message
467                singular_msgbuf.append(*previous)
468                plural_msgbuf.append(*previous)
469            previous = event
[1072]470
[1115]471        if not strip:
472            singular_msgbuf.append(*previous)
473            plural_msgbuf.append(*previous)
474
475        yield self.lineno, 'ngettext', \
[1072]476            (singular_msgbuf.format(), plural_msgbuf.format()), \
477            comment_stack[-1:]
478
[1118]479    def _is_plural(self, numeral, ngettext):
480        # XXX: should we test which form was chosen like this!?!?!?
481        # There should be no match in any catalogue for these singular and
482        # plural test strings
483        singular = u'O\x85\xbe\xa9\xa8az\xc3?\xe6\xa1\x02n\x84\x93'
484        plural = u'\xcc\xfb+\xd3Pn\x9d\tT\xec\x1d\xda\x1a\x88\x00'
485        return ngettext(singular, plural, numeral) == plural
[1072]486
[1118]487
[1072]488class DomainDirective(I18NDirective):
489    """Implementation of the ``i18n:domain`` directive which allows choosing
490    another i18n domain(catalog) to translate from.
491   
492    >>> from genshi.filters.tests.i18n import DummyTranslations
[1158]493    >>> tmpl = MarkupTemplate('''<html xmlns:i18n="http://genshi.edgewall.org/i18n">
[1072]494    ...   <p i18n:msg="">Bar</p>
495    ...   <div i18n:domain="foo">
496    ...     <p i18n:msg="">FooBar</p>
497    ...     <p>Bar</p>
498    ...     <p i18n:domain="bar" i18n:msg="">Bar</p>
499    ...     <p i18n:domain="">Bar</p>
500    ...   </div>
501    ...   <p>Bar</p>
502    ... </html>''')
503
504    >>> translations = DummyTranslations({'Bar': 'Voh'})
505    >>> translations.add_domain('foo', {'FooBar': 'BarFoo', 'Bar': 'foo_Bar'})
506    >>> translations.add_domain('bar', {'Bar': 'bar_Bar'})
507    >>> translator = Translator(translations)
508    >>> translator.setup(tmpl)
509
[1076]510    >>> print(tmpl.generate().render())
[1072]511    <html>
512      <p>Voh</p>
513      <div>
514        <p>BarFoo</p>
515        <p>foo_Bar</p>
516        <p>bar_Bar</p>
517        <p>Voh</p>
518      </div>
519      <p>Voh</p>
520    </html>
521    """
522    __slots__ = ['domain']
523
[1115]524    def __init__(self, value, template=None, namespaces=None, lineno=-1,
525                 offset=-1):
[1072]526        Directive.__init__(self, None, template, namespaces, lineno, offset)
[1118]527        self.domain = value and value.strip() or '__DEFAULT__'
[1072]528
529    @classmethod
530    def attach(cls, template, stream, value, namespaces, pos):
531        if type(value) is dict:
532            value = value.get('name')
533        return super(DomainDirective, cls).attach(template, stream, value,
534                                                  namespaces, pos)
535
536    def __call__(self, stream, directives, ctxt, **vars):
537        ctxt.push({'_i18n.domain': self.domain})
538        for event in _apply_directives(stream, directives, ctxt, vars):
[954]539            yield event
[1072]540        ctxt.pop()
[954]541
542
543class Translator(DirectiveFactory):
[538]544    """Can extract and translate localizable strings from markup streams and
[544]545    templates.
[538]546   
[1072]547    For example, assume the following template:
[538]548   
549    >>> tmpl = MarkupTemplate('''<html xmlns:py="http://genshi.edgewall.org/">
550    ...   <head>
551    ...     <title>Example</title>
552    ...   </head>
553    ...   <body>
554    ...     <h1>Example</h1>
555    ...     <p>${_("Hello, %(name)s") % dict(name=username)}</p>
556    ...   </body>
557    ... </html>''', filename='example.html')
558   
559    For demonstration, we define a dummy ``gettext``-style function with a
560    hard-coded translation table, and pass that to the `Translator` initializer:
561   
562    >>> def pseudo_gettext(string):
563    ...     return {
564    ...         'Example': 'Beispiel',
565    ...         'Hello, %(name)s': 'Hallo, %(name)s'
566    ...     }[string]
567    >>> translator = Translator(pseudo_gettext)
568   
569    Next, the translator needs to be prepended to any already defined filters
570    on the template:
571   
572    >>> tmpl.filters.insert(0, translator)
573   
574    When generating the template output, our hard-coded translations should be
575    applied as expected:
576   
[1076]577    >>> print(tmpl.generate(username='Hans', _=pseudo_gettext))
[538]578    <html>
579      <head>
580        <title>Beispiel</title>
581      </head>
582      <body>
583        <h1>Beispiel</h1>
584        <p>Hallo, Hans</p>
585      </body>
586    </html>
[1072]587   
[626]588    Note that elements defining ``xml:lang`` attributes that do not contain
589    variable expressions are ignored by this filter. That can be used to
590    exclude specific parts of a template from being extracted and translated.
[538]591    """
592
[954]593    directives = [
[1072]594        ('domain', DomainDirective),
[954]595        ('comment', CommentDirective),
[1072]596        ('msg', MsgDirective),
597        ('choose', ChooseDirective),
598        ('singular', SingularDirective),
599        ('plural', PluralDirective)
[954]600    ]
601
[544]602    IGNORE_TAGS = frozenset([
603        QName('script'), QName('http://www.w3.org/1999/xhtml}script'),
604        QName('style'), QName('http://www.w3.org/1999/xhtml}style')
605    ])
[1072]606    INCLUDE_ATTRS = frozenset([
607        'abbr', 'alt', 'label', 'prompt', 'standby', 'summary', 'title'
608    ])
[954]609    NAMESPACE = I18N_NAMESPACE
[538]610
[932]611    def __init__(self, translate=NullTranslations(), ignore_tags=IGNORE_TAGS,
[708]612                 include_attrs=INCLUDE_ATTRS, extract_text=True):
[538]613        """Initialize the translator.
614       
615        :param translate: the translation function, for example ``gettext`` or
616                          ``ugettext``.
617        :param ignore_tags: a set of tag names that should not be localized
618        :param include_attrs: a set of attribute names should be localized
[708]619        :param extract_text: whether the content of text nodes should be
620                             extracted, or only text in explicit ``gettext``
621                             function calls
[1072]622       
[932]623        :note: Changed in 0.6: the `translate` parameter can now be either
624               a ``gettext``-style function, or an object compatible with the
625               ``NullTransalations`` or ``GNUTranslations`` interface
[538]626        """
[544]627        self.translate = translate
[538]628        self.ignore_tags = ignore_tags
629        self.include_attrs = include_attrs
[708]630        self.extract_text = extract_text
[538]631
[1118]632    def __call__(self, stream, ctxt=None, translate_text=True,
633                 translate_attrs=True):
[540]634        """Translate any localizable strings in the given stream.
635       
636        This function shouldn't be called directly. Instead, an instance of
637        the `Translator` class should be registered as a filter with the
638        `Template` or the `TemplateLoader`, or applied as a regular stream
639        filter. If used as a template filter, it should be inserted in front of
640        all the default filters.
641       
642        :param stream: the markup event stream
643        :param ctxt: the template context (not used)
[1118]644        :param translate_text: whether text nodes should be translated (used
645                               internally)
646        :param translate_attrs: whether attribute values should be translated
647                                (used internally)
[540]648        :return: the localized stream
649        """
[544]650        ignore_tags = self.ignore_tags
651        include_attrs = self.include_attrs
[954]652        skip = 0
653        xml_lang = XML_NAMESPACE['lang']
[1118]654        if not self.extract_text:
655            translate_text = False
656            translate_attrs = False
[954]657
[932]658        if type(self.translate) is FunctionType:
659            gettext = self.translate
[1072]660            if ctxt:
661                ctxt['_i18n.gettext'] = gettext
[932]662        else:
[1158]663            if IS_PYTHON2:
664                gettext = self.translate.ugettext
665                ngettext = self.translate.ungettext
666            else:
667                gettext = self.translate.gettext
668                ngettext = self.translate.ngettext
[1072]669            try:
[1158]670                if IS_PYTHON2:
671                    dgettext = self.translate.dugettext
672                    dngettext = self.translate.dungettext
673                else:
674                    dgettext = self.translate.dgettext
675                    dngettext = self.translate.dngettext
[1072]676            except AttributeError:
[1118]677                dgettext = lambda _, y: gettext(y)
678                dngettext = lambda _, s, p, n: ngettext(s, p, n)
[1072]679            if ctxt:
680                ctxt['_i18n.gettext'] = gettext
[1118]681                ctxt['_i18n.ngettext'] = ngettext
[1072]682                ctxt['_i18n.dgettext'] = dgettext
683                ctxt['_i18n.dngettext'] = dngettext
684
685        if ctxt and ctxt.get('_i18n.domain'):
[1158]686            # TODO: This can cause infinite recursion if dgettext is defined
687            #       via the AttributeError case above!
[1072]688            gettext = lambda msg: dgettext(ctxt.get('_i18n.domain'), msg)
689
[538]690        for kind, data, pos in stream:
691
692            # skip chunks that should not be localized
693            if skip:
694                if kind is START:
[626]695                    skip += 1
[538]696                elif kind is END:
[626]697                    skip -= 1
[538]698                yield kind, data, pos
699                continue
700
701            # handle different events that can be localized
702            if kind is START:
703                tag, attrs = data
[626]704                if tag in self.ignore_tags or \
705                        isinstance(attrs.get(xml_lang), basestring):
[538]706                    skip += 1
707                    yield kind, data, pos
708                    continue
709
[595]710                new_attrs = []
[538]711                changed = False
[1072]712
[538]713                for name, value in attrs:
[583]714                    newval = value
[1118]715                    if isinstance(value, basestring):
716                        if translate_attrs and name in include_attrs:
[932]717                            newval = gettext(value)
[583]718                    else:
[1072]719                        newval = list(
[1118]720                            self(_ensure(value), ctxt, translate_text=False)
[583]721                        )
722                    if newval != value:
723                        value = newval
724                        changed = True
[538]725                    new_attrs.append((name, value))
726                if changed:
[783]727                    attrs = Attrs(new_attrs)
[538]728
729                yield kind, (tag, attrs), pos
730
[1118]731            elif translate_text and kind is TEXT:
[954]732                text = data.strip()
733                if text:
734                    data = data.replace(text, unicode(gettext(text)))
735                yield kind, data, pos
[538]736
737            elif kind is SUB:
[954]738                directives, substream = data
[1072]739                current_domain = None
740                for idx, directive in enumerate(directives):
741                    # Organize directives to make everything work
[1118]742                    # FIXME: There's got to be a better way to do this!
[1072]743                    if isinstance(directive, DomainDirective):
744                        # Grab current domain and update context
745                        current_domain = directive.domain
746                        ctxt.push({'_i18n.domain': current_domain})
747                        # Put domain directive as the first one in order to
748                        # update context before any other directives evaluation
749                        directives.insert(0, directives.pop(idx))
750
751                # If this is an i18n directive, no need to translate text
[954]752                # nodes here
[1079]753                is_i18n_directive = any([
754                    isinstance(d, ExtractableI18NDirective)
755                    for d in directives
756                ])
[954]757                substream = list(self(substream, ctxt,
[1118]758                                      translate_text=not is_i18n_directive,
759                                      translate_attrs=translate_attrs))
[954]760                yield kind, (directives, substream), pos
[538]761
[1072]762                if current_domain:
763                    ctxt.pop()
[538]764            else:
765                yield kind, data, pos
766
[585]767    def extract(self, stream, gettext_functions=GETTEXT_FUNCTIONS,
[1114]768                search_text=True, comment_stack=None):
[538]769        """Extract localizable strings from the given template stream.
770       
[544]771        For every string found, this function yields a ``(lineno, function,
[929]772        message, comments)`` tuple, where:
[538]773       
[544]774        * ``lineno`` is the number of the line on which the string was found,
775        * ``function`` is the name of the ``gettext`` function used (if the
776          string was extracted from embedded Python code), and
[569]777        *  ``message`` is the string itself (a ``unicode`` object, or a tuple
[929]778           of ``unicode`` objects for functions with multiple string
779           arguments).
780        *  ``comments`` is a list of comments related to the message, extracted
781           from ``i18n:comment`` attributes found in the markup
[538]782       
783        >>> tmpl = MarkupTemplate('''<html xmlns:py="http://genshi.edgewall.org/">
784        ...   <head>
785        ...     <title>Example</title>
786        ...   </head>
787        ...   <body>
788        ...     <h1>Example</h1>
789        ...     <p>${_("Hello, %(name)s") % dict(name=username)}</p>
[569]790        ...     <p>${ngettext("You have %d item", "You have %d items", num)}</p>
[538]791        ...   </body>
792        ... </html>''', filename='example.html')
[929]793        >>> for line, func, msg, comments in Translator().extract(tmpl.stream):
[1076]794        ...    print('%d, %r, %r' % (line, func, msg))
[544]795        3, None, u'Example'
796        6, None, u'Example'
797        7, '_', u'Hello, %(name)s'
[676]798        8, 'ngettext', (u'You have %d item', u'You have %d items', None)
[569]799       
[544]800        :param stream: the event stream to extract strings from; can be a
801                       regular stream or a template stream
802        :param gettext_functions: a sequence of function names that should be
803                                  treated as gettext-style localization
804                                  functions
[585]805        :param search_text: whether the content of text nodes should be
806                            extracted (used internally)
[569]807       
808        :note: Changed in 0.4.1: For a function with multiple string arguments
809               (such as ``ngettext``), a single item with a tuple of strings is
810               yielded, instead an item for each string argument.
[1072]811        :note: Changed in 0.6: The returned tuples now include a fourth
812               element, which is a list of comments for the translator.
[538]813        """
[708]814        if not self.extract_text:
815            search_text = False
[1072]816        if comment_stack is None:
817            comment_stack = []
[538]818        skip = 0
[1072]819
[626]820        xml_lang = XML_NAMESPACE['lang']
[538]821
822        for kind, data, pos in stream:
823            if skip:
824                if kind is START:
[626]825                    skip += 1
[538]826                if kind is END:
[626]827                    skip -= 1
[538]828
[658]829            if kind is START and not skip:
[538]830                tag, attrs = data
[626]831                if tag in self.ignore_tags or \
832                        isinstance(attrs.get(xml_lang), basestring):
[538]833                    skip += 1
834                    continue
835
[1115]836                for message in self._extract_attrs((kind, data, pos),
837                                                   gettext_functions,
838                                                   search_text=search_text):
839                    yield message
[538]840
[658]841            elif not skip and search_text and kind is TEXT:
[1114]842                text = data.strip()
843                if text and [ch for ch in text if ch.isalpha()]:
844                    yield pos[1], None, text, comment_stack[-1:]
[538]845
846            elif kind is EXPR or kind is EXEC:
[676]847                for funcname, strings in extract_from_code(data,
[672]848                                                           gettext_functions):
[1072]849                    # XXX: Do we need to grab i18n:comment from comment_stack ???
[929]850                    yield pos[1], funcname, strings, []
[538]851
852            elif kind is SUB:
[1072]853                directives, substream = data
854                in_comment = False
[634]855
[1072]856                for idx, directive in enumerate(directives):
857                    # Do a first loop to see if there's a comment directive
858                    # If there is update context and pop it from directives
859                    if isinstance(directive, CommentDirective):
860                        in_comment = True
861                        comment_stack.append(directive.comment)
862                        if len(directives) == 1:
863                            # in case we're in the presence of something like:
864                            # <p i18n:comment="foo">Foo</p>
[1115]865                            for message in self.extract(
866                                    substream, gettext_functions,
867                                    search_text=search_text and not skip,
868                                    comment_stack=comment_stack):
869                                yield message
[1072]870                        directives.pop(idx)
871                    elif not isinstance(directive, I18NDirective):
872                        # Remove all other non i18n directives from the process
873                        directives.pop(idx)
[634]874
[1072]875                if not directives and not in_comment:
876                    # Extract content if there's no directives because
877                    # strip was pop'ed and not because comment was pop'ed.
878                    # Extraction in this case has been taken care of.
[1115]879                    for message in self.extract(
880                            substream, gettext_functions,
881                            search_text=search_text and not skip):
882                        yield message
[1072]883
884                for directive in directives:
885                    if isinstance(directive, ExtractableI18NDirective):
[1115]886                        for message in directive.extract(self,
887                                substream, gettext_functions,
888                                search_text=search_text and not skip,
889                                comment_stack=comment_stack):
890                            yield message
[1072]891                    else:
[1115]892                        for message in self.extract(
893                                substream, gettext_functions,
894                                search_text=search_text and not skip,
895                                comment_stack=comment_stack):
896                            yield message
[1072]897
898                if in_comment:
899                    comment_stack.pop()
900
901    def get_directive_index(self, dir_cls):
902        total = len(self._dir_order)
903        if dir_cls in self._dir_order:
904            return self._dir_order.index(dir_cls) - total
905        return total
906
907    def setup(self, template):
908        """Convenience function to register the `Translator` filter and the
909        related directives with the given template.
910       
911        :param template: a `Template` instance
912        """
913        template.filters.insert(0, self)
914        if hasattr(template, 'add_directives'):
915            template.add_directives(Translator.NAMESPACE, self)
916
[1115]917    def _extract_attrs(self, event, gettext_functions, search_text):
918        for name, value in event[1][1]:
919            if search_text and isinstance(value, basestring):
920                if name in self.include_attrs:
921                    text = value.strip()
922                    if text:
923                        yield event[2][1], None, text, []
924            else:
925                for message in self.extract(_ensure(value), gettext_functions,
926                                            search_text=False):
927                    yield message
[1072]928
[1115]929
[671]930class MessageBuffer(object):
[862]931    """Helper class for managing internationalized mixed content.
[690]932   
933    :since: version 0.5
934    """
[671]935
[1072]936    def __init__(self, directive=None):
[862]937        """Initialize the message buffer.
938       
[1105]939        :param directive: the directive owning the buffer
940        :type directive: I18NDirective
[862]941        """
[1072]942        # params list needs to be copied so that directives can be evaluated
943        # more than once
944        self.orig_params = self.params = directive.params[:]
945        self.directive = directive
[862]946        self.string = []
[671]947        self.events = {}
[901]948        self.values = {}
[671]949        self.depth = 1
950        self.order = 1
951        self.stack = [0]
[1072]952        self.subdirectives = {}
[671]953
954    def append(self, kind, data, pos):
[862]955        """Append a stream event to the buffer.
956       
957        :param kind: the stream event kind
958        :param data: the event data
959        :param pos: the position of the event in the source
960        """
[1072]961        if kind is SUB:
962            # The order needs to be +1 because a new START kind event will
963            # happen and we we need to wrap those events into our custom kind(s)
964            order = self.stack[-1] + 1
965            subdirectives, substream = data
966            # Store the directives that should be applied after translation
967            self.subdirectives.setdefault(order, []).extend(subdirectives)
968            self.events.setdefault(order, []).append((SUB_START, None, pos))
969            for skind, sdata, spos in substream:
970                self.append(skind, sdata, spos)
971            self.events.setdefault(order, []).append((SUB_END, None, pos))
972        elif kind is TEXT:
973            if '[' in data or ']' in data:
974                # Quote [ and ] if it ain't us adding it, ie, if the user is
975                # using those chars in his templates, escape them
976                data = data.replace('[', '\[').replace(']', '\]')
[862]977            self.string.append(data)
[1072]978            self.events.setdefault(self.stack[-1], []).append((kind, data, pos))
[901]979        elif kind is EXPR:
[1072]980            if self.params:
981                param = self.params.pop(0)
982            else:
983                params = ', '.join(['"%s"' % p for p in self.orig_params if p])
984                if params:
985                    params = "(%s)" % params
986                raise IndexError("%d parameters%s given to 'i18n:%s' but "
987                                 "%d or more expressions used in '%s', line %s"
988                                 % (len(self.orig_params), params, 
989                                    self.directive.tagname,
[1115]990                                    len(self.orig_params) + 1,
[1072]991                                    os.path.basename(pos[0] or
[1095]992                                                     'In-memory Template'),
[1072]993                                    pos[1]))
[901]994            self.string.append('%%(%s)s' % param)
[1072]995            self.events.setdefault(self.stack[-1], []).append((kind, data, pos))
[901]996            self.values[param] = (kind, data, pos)
[671]997        else:
[1072]998            if kind is START: 
[1077]999                self.string.append('[%d:' % self.order)
[671]1000                self.stack.append(self.order)
[1072]1001                self.events.setdefault(self.stack[-1],
1002                                       []).append((kind, data, pos))
[671]1003                self.depth += 1
1004                self.order += 1
1005            elif kind is END:
1006                self.depth -= 1
1007                if self.depth:
1008                    self.events[self.stack[-1]].append((kind, data, pos))
[1077]1009                    self.string.append(']')
[671]1010                    self.stack.pop()
1011
1012    def format(self):
[862]1013        """Return a message identifier representing the content in the
1014        buffer.
1015        """
[1077]1016        return ''.join(self.string).strip()
[671]1017
[901]1018    def translate(self, string, regex=re.compile(r'%\((\w+)\)s')):
[862]1019        """Interpolate the given message translation with the events in the
1020        buffer and return the translated stream.
1021       
1022        :param string: the translated message string
1023        """
[1072]1024        substream = None
[1118]1025
[1072]1026        def yield_parts(string):
1027            for idx, part in enumerate(regex.split(string)):
1028                if idx % 2:
1029                    yield self.values[part]
1030                elif part:
1031                    yield (TEXT,
1032                           part.replace('\[', '[').replace('\]', ']'),
1033                           (None, -1, -1)
1034                    )
1035
[671]1036        parts = parse_msg(string)
[1072]1037        parts_counter = {}
[671]1038        for order, string in parts:
[1072]1039            parts_counter.setdefault(order, []).append(None)
1040
1041        while parts:
1042            order, string = parts.pop(0)
1043            if len(parts_counter[order]) == 1:
1044                events = self.events[order]
1045            else:
1046                events = [self.events[order].pop(0)]
1047            parts_counter[order].pop()
1048
1049            for event in events:
1050                if event[0] is SUB_START:
1051                    substream = []
1052                elif event[0] is SUB_END:
1053                    # Yield a substream which might have directives to be
1054                    # applied to it (after translation events)
1055                    yield SUB, (self.subdirectives[order], substream), event[2]
1056                    substream = None
1057                elif event[0] is TEXT:
1058                    if string:
1059                        for part in yield_parts(string):
1060                            if substream is not None:
1061                                substream.append(part)
1062                            else:
1063                                yield part
1064                        # String handled, reset it
1065                        string = None
1066                elif event[0] is START:
1067                    if substream is not None:
1068                        substream.append(event)
1069                    else:
1070                        yield event
1071                    if string:
1072                        for part in yield_parts(string):
1073                            if substream is not None:
1074                                substream.append(part)
1075                            else:
1076                                yield part
1077                        # String handled, reset it
1078                        string = None
1079                elif event[0] is END:
1080                    if string:
1081                        for part in yield_parts(string):
1082                            if substream is not None:
1083                                substream.append(part)
1084                            else:
1085                                yield part
1086                        # String handled, reset it
1087                        string = None
1088                    if substream is not None:
1089                        substream.append(event)
1090                    else:
1091                        yield event
1092                elif event[0] is EXPR:
1093                    # These are handled on the strings itself
1094                    continue
[901]1095                else:
[1072]1096                    if string:
1097                        for part in yield_parts(string):
1098                            if substream is not None:
1099                                substream.append(part)
1100                            else:
1101                                yield part
1102                        # String handled, reset it
1103                        string = None
1104                    if substream is not None:
1105                        substream.append(event)
1106                    else:
1107                        yield event
[671]1108
[1115]1109
[1072]1110def parse_msg(string, regex=re.compile(r'(?:\[(\d+)\:)|(?<!\\)\]')):
[862]1111    """Parse a translated message using Genshi mixed content message
1112    formatting.
[1072]1113   
[862]1114    >>> parse_msg("See [1:Help].")
1115    [(0, 'See '), (1, 'Help'), (0, '.')]
[1072]1116   
[862]1117    >>> parse_msg("See [1:our [2:Help] page] for details.")
1118    [(0, 'See '), (1, 'our '), (2, 'Help'), (1, ' page'), (0, ' for details.')]
[1072]1119   
[862]1120    >>> parse_msg("[2:Details] finden Sie in [1:Hilfe].")
1121    [(2, 'Details'), (0, ' finden Sie in '), (1, 'Hilfe'), (0, '.')]
[1072]1122   
[862]1123    >>> parse_msg("[1:] Bilder pro Seite anzeigen.")
1124    [(1, ''), (0, ' Bilder pro Seite anzeigen.')]
[1072]1125   
[862]1126    :param string: the translated message string
1127    :return: a list of ``(order, string)`` tuples
1128    :rtype: `list`
1129    """
1130    parts = []
1131    stack = [0]
1132    while True:
1133        mo = regex.search(string)
1134        if not mo:
1135            break
1136
1137        if mo.start() or stack[-1]:
1138            parts.append((stack[-1], string[:mo.start()]))
1139        string = string[mo.end():]
1140
1141        orderno = mo.group(1)
1142        if orderno is not None:
1143            stack.append(int(orderno))
1144        else:
1145            stack.pop()
1146        if not stack:
1147            break
1148
1149    if string:
1150        parts.append((stack[-1], string))
1151
1152    return parts
1153
[902]1154
[672]1155def extract_from_code(code, gettext_functions):
1156    """Extract strings from Python bytecode.
1157   
1158    >>> from genshi.template.eval import Expression
1159    >>> expr = Expression('_("Hello")')
[1115]1160    >>> list(extract_from_code(expr, GETTEXT_FUNCTIONS))
[672]1161    [('_', u'Hello')]
[1072]1162   
[672]1163    >>> expr = Expression('ngettext("You have %(num)s item", '
1164    ...                            '"You have %(num)s items", num)')
[1115]1165    >>> list(extract_from_code(expr, GETTEXT_FUNCTIONS))
[676]1166    [('ngettext', (u'You have %(num)s item', u'You have %(num)s items', None))]
[672]1167   
[676]1168    :param code: the `Code` object
1169    :type code: `genshi.template.eval.Code`
[672]1170    :param gettext_functions: a sequence of function names
[690]1171    :since: version 0.5
[672]1172    """
[676]1173    def _walk(node):
[988]1174        if isinstance(node, _ast.Call) and isinstance(node.func, _ast.Name) \
1175                and node.func.id in gettext_functions:
[676]1176            strings = []
[716]1177            def _add(arg):
[1158]1178                if isinstance(arg, _ast.Str) and isinstance(arg.s, unicode):
1179                    strings.append(arg.s)
1180                elif isinstance(arg, _ast.Str):
[988]1181                    strings.append(unicode(arg.s, 'utf-8'))
1182                elif arg:
[676]1183                    strings.append(None)
[716]1184            [_add(arg) for arg in node.args]
[988]1185            _add(node.starargs)
1186            _add(node.kwargs)
[676]1187            if len(strings) == 1:
1188                strings = strings[0]
[672]1189            else:
[676]1190                strings = tuple(strings)
[988]1191            yield node.func.id, strings
1192        elif node._fields:
1193            children = []
1194            for field in node._fields:
1195                child = getattr(node, field, None)
1196                if isinstance(child, list):
1197                    for elem in child:
1198                        children.append(elem)
1199                elif isinstance(child, _ast.AST):
1200                    children.append(child)
1201            for child in children:
[676]1202                for funcname, strings in _walk(child):
1203                    yield funcname, strings
1204    return _walk(code.ast)
[672]1205
[902]1206
[634]1207def extract(fileobj, keywords, comment_tags, options):
1208    """Babel extraction method for Genshi templates.
1209   
1210    :param fileobj: the file-like object the messages should be extracted from
1211    :param keywords: a list of keywords (i.e. function names) that should be
1212                     recognized as translation functions
1213    :param comment_tags: a list of translator tags to search for and include
1214                         in the results
1215    :param options: a dictionary of additional options (optional)
1216    :return: an iterator over ``(lineno, funcname, message, comments)`` tuples
1217    :rtype: ``iterator``
1218    """
1219    template_class = options.get('template_class', MarkupTemplate)
1220    if isinstance(template_class, basestring):
1221        module, clsname = template_class.split(':', 1)
1222        template_class = getattr(__import__(module, {}, {}, [clsname]), clsname)
1223    encoding = options.get('encoding', None)
1224
[710]1225    extract_text = options.get('extract_text', True)
1226    if isinstance(extract_text, basestring):
1227        extract_text = extract_text.lower() in ('1', 'on', 'yes', 'true')
1228
[634]1229    ignore_tags = options.get('ignore_tags', Translator.IGNORE_TAGS)
1230    if isinstance(ignore_tags, basestring):
1231        ignore_tags = ignore_tags.split()
1232    ignore_tags = [QName(tag) for tag in ignore_tags]
[710]1233
[634]1234    include_attrs = options.get('include_attrs', Translator.INCLUDE_ATTRS)
1235    if isinstance(include_attrs, basestring):
1236        include_attrs = include_attrs.split()
1237    include_attrs = [QName(attr) for attr in include_attrs]
1238
1239    tmpl = template_class(fileobj, filename=getattr(fileobj, 'name', None),
1240                          encoding=encoding)
[1105]1241    tmpl.loader = None
[1072]1242
[710]1243    translator = Translator(None, ignore_tags, include_attrs, extract_text)
[1072]1244    if hasattr(tmpl, 'add_directives'):
1245        tmpl.add_directives(Translator.NAMESPACE, translator)
[929]1246    for message in translator.extract(tmpl.stream, gettext_functions=keywords):
1247        yield message
Note: See TracBrowser for help on using the repository browser.