Edgewall Software

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

Last change on this file was 1265, checked in by hodgestar, 10 years ago

Merge r1263 from trunk (add 'placeholder' to list of translatable attributes).

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