Edgewall Software

source: trunk/genshi/filters/i18n.py

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

Add HTML5 input placeholder attribute to list of translatable attributes (fixes #577).

  • Property svn:eol-style set to native
File size: 49.0 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
36from genshi.compat import IS_PYTHON2
37
38__all__ = ['Translator', 'extract']
39__docformat__ = 'restructuredtext en'
40
41
42I18N_NAMESPACE = Namespace('http://genshi.edgewall.org/i18n')
43
44MSGBUF = StreamEventKind('MSGBUF')
45SUB_START = StreamEventKind('SUB_START')
46SUB_END = StreamEventKind('SUB_END')
47
48GETTEXT_FUNCTIONS = ('_', 'gettext', 'ngettext', 'dgettext', 'dngettext',
49                     'ugettext', 'ungettext')
50
51
52class I18NDirective(Directive):
53    """Simple interface for i18n directives to support messages extraction."""
54
55    def __call__(self, stream, directives, ctxt, **vars):
56        return _apply_directives(stream, directives, ctxt, vars)
57
58
59class ExtractableI18NDirective(I18NDirective):
60    """Simple interface for directives to support messages extraction."""
61
62    def extract(self, translator, stream, gettext_functions=GETTEXT_FUNCTIONS,
63                search_text=True, comment_stack=None):
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
81    def __init__(self, value, template=None, namespaces=None, lineno=-1,
82                 offset=-1):
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]!', [])]
103    >>> print(tmpl.generate().render())
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)
130    >>> print(tmpl.generate(fname='John', lname='Doe').render())
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    """
141    __slots__ = ['params', 'lineno']
142
143    def __init__(self, value, template=None, namespaces=None, lineno=-1,
144                 offset=-1):
145        Directive.__init__(self, None, template, namespaces, lineno, offset)
146        self.params = [param.strip() for param in value.split(',') if param]
147        self.lineno = lineno
148
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
156    def __call__(self, stream, directives, ctxt, **vars):
157        gettext = ctxt.get('_i18n.gettext')
158        if ctxt.get('_i18n.domain'):
159            dgettext = ctxt.get('_i18n.dgettext')
160            assert hasattr(dgettext, '__call__'), \
161                'No domain gettext function passed'
162            gettext = lambda msg: dgettext(ctxt.get('_i18n.domain'), msg)
163
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
185    def extract(self, translator, stream, gettext_functions=GETTEXT_FUNCTIONS,
186                search_text=True, comment_stack=None):
187        msgbuf = MessageBuffer(self)
188        strip = False
189
190        stream = iter(stream)
191        previous = stream.next()
192        if previous[0] is START:
193            for message in translator._extract_attrs(previous,
194                                                     gettext_functions,
195                                                     search_text=search_text):
196                yield message
197            previous = stream.next()
198            strip = True
199        for event in stream:
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
205            msgbuf.append(*previous)
206            previous = event
207        if not strip:
208            msgbuf.append(*previous)
209
210        yield self.lineno, None, msgbuf.format(), comment_stack[-1:]
211
212
213class ChooseBranchDirective(I18NDirective):
214    __slots__ = ['params']
215
216    def __call__(self, stream, directives, ctxt, **vars):
217        self.params = ctxt.get('_i18n.choose.params', [])[:]
218        msgbuf = MessageBuffer(self)
219        stream = _apply_directives(stream, directives, ctxt, vars)
220
221        previous = stream.next()
222        if previous[0] is START:
223            yield previous
224        else:
225            msgbuf.append(*previous)
226
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
232            ctxt['_i18n.choose.%s' % self.tagname] = msgbuf
233            return
234
235        for event in stream:
236            msgbuf.append(*previous)
237            previous = event
238        yield MSGBUF, (), -1 # the place holder for msgbuf output
239
240        if previous[0] is END:
241            yield previous # the outer end tag
242        else:
243            msgbuf.append(*previous)
244        ctxt['_i18n.choose.%s' % self.tagname] = msgbuf
245
246    def extract(self, translator, stream, gettext_functions=GETTEXT_FUNCTIONS,
247                search_text=True, comment_stack=None, msgbuf=None):
248        stream = iter(stream)
249        previous = stream.next()
250
251        if previous[0] is START:
252            # skip the enclosing element
253            for message in translator._extract_attrs(previous,
254                                                     gettext_functions,
255                                                     search_text=search_text):
256                yield message
257            previous = stream.next()
258
259        for event in stream:
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
265            msgbuf.append(*previous)
266            previous = event
267
268        if previous[0] is not END:
269            msgbuf.append(*previous)
270
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   
292    >>> tmpl = MarkupTemplate('''<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('''<html xmlns:i18n="http://genshi.edgewall.org/i18n">
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)
311    >>> print(tmpl.generate(num=1).render())
312    <html>
313      <div>
314        <p>There is 1 coin</p>
315      </div>
316    </html>
317    >>> print(tmpl.generate(num=2).render())
318    <html>
319      <div>
320        <p>There are 2 coins</p>
321      </div>
322    </html>
323
324    When used as a element and not as an attribute:
325
326    >>> tmpl = MarkupTemplate('''<html xmlns:i18n="http://genshi.edgewall.org/i18n">
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    """
337    __slots__ = ['numeral', 'params', 'lineno']
338
339    def __init__(self, value, template=None, namespaces=None, lineno=-1,
340                 offset=-1):
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 []
346        self.lineno = lineno
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,
360                   '_i18n.choose.singular': None,
361                   '_i18n.choose.plural': None})
362
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
369        new_stream = []
370        singular_stream = None
371        singular_msgbuf = None
372        plural_stream = None
373        plural_msgbuf = None
374
375        numeral = self.numeral.evaluate(ctxt)
376        is_plural = self._is_plural(numeral, ngettext)
377
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):
384                    singular_stream = list(_apply_directives(substream,
385                                                             subdirectives,
386                                                             ctxt, vars))
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
395            else:
396                new_stream.append(event)
397
398        if ctxt.get('_i18n.domain'):
399            ngettext = lambda s, p, n: dngettext(ctxt.get('_i18n.domain'),
400                                                 s, p, n)
401
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
406        else:
407            msgbuf, choice = singular_msgbuf, singular_stream
408            plural_msgbuf = MessageBuffer(self)
409
410        for kind, data, pos in new_stream:
411            if kind is MSGBUF:
412                for event in choice:
413                    if event[0] is MSGBUF:
414                        translation = ngettext(singular_msgbuf.format(),
415                                               plural_msgbuf.format(),
416                                               numeral)
417                        for subevent in msgbuf.translate(translation):
418                            yield subevent
419                    else:
420                        yield event
421            else:
422                yield kind, data, pos
423
424        ctxt.pop()
425
426    def extract(self, translator, stream, gettext_functions=GETTEXT_FUNCTIONS,
427                search_text=True, comment_stack=None):
428        strip = False
429        stream = iter(stream)
430        previous = stream.next()
431
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
441        singular_msgbuf = MessageBuffer(self)
442        plural_msgbuf = MessageBuffer(self)
443
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)
461            else:
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
470
471        if not strip:
472            singular_msgbuf.append(*previous)
473            plural_msgbuf.append(*previous)
474
475        yield self.lineno, 'ngettext', \
476            (singular_msgbuf.format(), plural_msgbuf.format()), \
477            comment_stack[-1:]
478
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
486
487
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
493    >>> tmpl = MarkupTemplate('''<html xmlns:i18n="http://genshi.edgewall.org/i18n">
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
510    >>> print(tmpl.generate().render())
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
524    def __init__(self, value, template=None, namespaces=None, lineno=-1,
525                 offset=-1):
526        Directive.__init__(self, None, template, namespaces, lineno, offset)
527        self.domain = value and value.strip() or '__DEFAULT__'
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):
539            yield event
540        ctxt.pop()
541
542
543class Translator(DirectiveFactory):
544    """Can extract and translate localizable strings from markup streams and
545    templates.
546   
547    For example, assume the following template:
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   
577    >>> print(tmpl.generate(username='Hans', _=pseudo_gettext))
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>
587   
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.
591    """
592
593    directives = [
594        ('domain', DomainDirective),
595        ('comment', CommentDirective),
596        ('msg', MsgDirective),
597        ('choose', ChooseDirective),
598        ('singular', SingularDirective),
599        ('plural', PluralDirective)
600    ]
601
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    ])
606    INCLUDE_ATTRS = frozenset([
607        'abbr', 'alt', 'label', 'prompt', 'standby', 'summary', 'title',
608        'placeholder',
609    ])
610    NAMESPACE = I18N_NAMESPACE
611
612    def __init__(self, translate=NullTranslations(), ignore_tags=IGNORE_TAGS,
613                 include_attrs=INCLUDE_ATTRS, extract_text=True):
614        """Initialize the translator.
615       
616        :param translate: the translation function, for example ``gettext`` or
617                          ``ugettext``.
618        :param ignore_tags: a set of tag names that should not be localized
619        :param include_attrs: a set of attribute names should be localized
620        :param extract_text: whether the content of text nodes should be
621                             extracted, or only text in explicit ``gettext``
622                             function calls
623       
624        :note: Changed in 0.6: the `translate` parameter can now be either
625               a ``gettext``-style function, or an object compatible with the
626               ``NullTransalations`` or ``GNUTranslations`` interface
627        """
628        self.translate = translate
629        self.ignore_tags = ignore_tags
630        self.include_attrs = include_attrs
631        self.extract_text = extract_text
632
633    def __call__(self, stream, ctxt=None, translate_text=True,
634                 translate_attrs=True):
635        """Translate any localizable strings in the given stream.
636       
637        This function shouldn't be called directly. Instead, an instance of
638        the `Translator` class should be registered as a filter with the
639        `Template` or the `TemplateLoader`, or applied as a regular stream
640        filter. If used as a template filter, it should be inserted in front of
641        all the default filters.
642       
643        :param stream: the markup event stream
644        :param ctxt: the template context (not used)
645        :param translate_text: whether text nodes should be translated (used
646                               internally)
647        :param translate_attrs: whether attribute values should be translated
648                                (used internally)
649        :return: the localized stream
650        """
651        ignore_tags = self.ignore_tags
652        include_attrs = self.include_attrs
653        skip = 0
654        xml_lang = XML_NAMESPACE['lang']
655        if not self.extract_text:
656            translate_text = False
657            translate_attrs = False
658
659        if type(self.translate) is FunctionType:
660            gettext = self.translate
661            if ctxt:
662                ctxt['_i18n.gettext'] = gettext
663        else:
664            if IS_PYTHON2:
665                gettext = self.translate.ugettext
666                ngettext = self.translate.ungettext
667            else:
668                gettext = self.translate.gettext
669                ngettext = self.translate.ngettext
670            try:
671                if IS_PYTHON2:
672                    dgettext = self.translate.dugettext
673                    dngettext = self.translate.dungettext
674                else:
675                    dgettext = self.translate.dgettext
676                    dngettext = self.translate.dngettext
677            except AttributeError:
678                dgettext = lambda _, y: gettext(y)
679                dngettext = lambda _, s, p, n: ngettext(s, p, n)
680            if ctxt:
681                ctxt['_i18n.gettext'] = gettext
682                ctxt['_i18n.ngettext'] = ngettext
683                ctxt['_i18n.dgettext'] = dgettext
684                ctxt['_i18n.dngettext'] = dngettext
685
686        if ctxt and ctxt.get('_i18n.domain'):
687            # TODO: This can cause infinite recursion if dgettext is defined
688            #       via the AttributeError case above!
689            gettext = lambda msg: dgettext(ctxt.get('_i18n.domain'), msg)
690
691        for kind, data, pos in stream:
692
693            # skip chunks that should not be localized
694            if skip:
695                if kind is START:
696                    skip += 1
697                elif kind is END:
698                    skip -= 1
699                yield kind, data, pos
700                continue
701
702            # handle different events that can be localized
703            if kind is START:
704                tag, attrs = data
705                if tag in self.ignore_tags or \
706                        isinstance(attrs.get(xml_lang), basestring):
707                    skip += 1
708                    yield kind, data, pos
709                    continue
710
711                new_attrs = []
712                changed = False
713
714                for name, value in attrs:
715                    newval = value
716                    if isinstance(value, basestring):
717                        if translate_attrs and name in include_attrs:
718                            newval = gettext(value)
719                    else:
720                        newval = list(
721                            self(_ensure(value), ctxt, translate_text=False)
722                        )
723                    if newval != value:
724                        value = newval
725                        changed = True
726                    new_attrs.append((name, value))
727                if changed:
728                    attrs = Attrs(new_attrs)
729
730                yield kind, (tag, attrs), pos
731
732            elif translate_text and kind is TEXT:
733                text = data.strip()
734                if text:
735                    data = data.replace(text, unicode(gettext(text)))
736                yield kind, data, pos
737
738            elif kind is SUB:
739                directives, substream = data
740                current_domain = None
741                for idx, directive in enumerate(directives):
742                    # Organize directives to make everything work
743                    # FIXME: There's got to be a better way to do this!
744                    if isinstance(directive, DomainDirective):
745                        # Grab current domain and update context
746                        current_domain = directive.domain
747                        ctxt.push({'_i18n.domain': current_domain})
748                        # Put domain directive as the first one in order to
749                        # update context before any other directives evaluation
750                        directives.insert(0, directives.pop(idx))
751
752                # If this is an i18n directive, no need to translate text
753                # nodes here
754                is_i18n_directive = any([
755                    isinstance(d, ExtractableI18NDirective)
756                    for d in directives
757                ])
758                substream = list(self(substream, ctxt,
759                                      translate_text=not is_i18n_directive,
760                                      translate_attrs=translate_attrs))
761                yield kind, (directives, substream), pos
762
763                if current_domain:
764                    ctxt.pop()
765            else:
766                yield kind, data, pos
767
768    def extract(self, stream, gettext_functions=GETTEXT_FUNCTIONS,
769                search_text=True, comment_stack=None):
770        """Extract localizable strings from the given template stream.
771       
772        For every string found, this function yields a ``(lineno, function,
773        message, comments)`` tuple, where:
774       
775        * ``lineno`` is the number of the line on which the string was found,
776        * ``function`` is the name of the ``gettext`` function used (if the
777          string was extracted from embedded Python code), and
778        *  ``message`` is the string itself (a ``unicode`` object, or a tuple
779           of ``unicode`` objects for functions with multiple string
780           arguments).
781        *  ``comments`` is a list of comments related to the message, extracted
782           from ``i18n:comment`` attributes found in the markup
783       
784        >>> tmpl = MarkupTemplate('''<html xmlns:py="http://genshi.edgewall.org/">
785        ...   <head>
786        ...     <title>Example</title>
787        ...   </head>
788        ...   <body>
789        ...     <h1>Example</h1>
790        ...     <p>${_("Hello, %(name)s") % dict(name=username)}</p>
791        ...     <p>${ngettext("You have %d item", "You have %d items", num)}</p>
792        ...   </body>
793        ... </html>''', filename='example.html')
794        >>> for line, func, msg, comments in Translator().extract(tmpl.stream):
795        ...    print('%d, %r, %r' % (line, func, msg))
796        3, None, u'Example'
797        6, None, u'Example'
798        7, '_', u'Hello, %(name)s'
799        8, 'ngettext', (u'You have %d item', u'You have %d items', None)
800       
801        :param stream: the event stream to extract strings from; can be a
802                       regular stream or a template stream
803        :param gettext_functions: a sequence of function names that should be
804                                  treated as gettext-style localization
805                                  functions
806        :param search_text: whether the content of text nodes should be
807                            extracted (used internally)
808       
809        :note: Changed in 0.4.1: For a function with multiple string arguments
810               (such as ``ngettext``), a single item with a tuple of strings is
811               yielded, instead an item for each string argument.
812        :note: Changed in 0.6: The returned tuples now include a fourth
813               element, which is a list of comments for the translator.
814        """
815        if not self.extract_text:
816            search_text = False
817        if comment_stack is None:
818            comment_stack = []
819        skip = 0
820
821        xml_lang = XML_NAMESPACE['lang']
822
823        for kind, data, pos in stream:
824            if skip:
825                if kind is START:
826                    skip += 1
827                if kind is END:
828                    skip -= 1
829
830            if kind is START and not skip:
831                tag, attrs = data
832                if tag in self.ignore_tags or \
833                        isinstance(attrs.get(xml_lang), basestring):
834                    skip += 1
835                    continue
836
837                for message in self._extract_attrs((kind, data, pos),
838                                                   gettext_functions,
839                                                   search_text=search_text):
840                    yield message
841
842            elif not skip and search_text and kind is TEXT:
843                text = data.strip()
844                if text and [ch for ch in text if ch.isalpha()]:
845                    yield pos[1], None, text, comment_stack[-1:]
846
847            elif kind is EXPR or kind is EXEC:
848                for funcname, strings in extract_from_code(data,
849                                                           gettext_functions):
850                    # XXX: Do we need to grab i18n:comment from comment_stack ???
851                    yield pos[1], funcname, strings, []
852
853            elif kind is SUB:
854                directives, substream = data
855                in_comment = False
856
857                for idx, directive in enumerate(directives):
858                    # Do a first loop to see if there's a comment directive
859                    # If there is update context and pop it from directives
860                    if isinstance(directive, CommentDirective):
861                        in_comment = True
862                        comment_stack.append(directive.comment)
863                        if len(directives) == 1:
864                            # in case we're in the presence of something like:
865                            # <p i18n:comment="foo">Foo</p>
866                            for message in self.extract(
867                                    substream, gettext_functions,
868                                    search_text=search_text and not skip,
869                                    comment_stack=comment_stack):
870                                yield message
871                        directives.pop(idx)
872                    elif not isinstance(directive, I18NDirective):
873                        # Remove all other non i18n directives from the process
874                        directives.pop(idx)
875
876                if not directives and not in_comment:
877                    # Extract content if there's no directives because
878                    # strip was pop'ed and not because comment was pop'ed.
879                    # Extraction in this case has been taken care of.
880                    for message in self.extract(
881                            substream, gettext_functions,
882                            search_text=search_text and not skip):
883                        yield message
884
885                for directive in directives:
886                    if isinstance(directive, ExtractableI18NDirective):
887                        for message in directive.extract(self,
888                                substream, gettext_functions,
889                                search_text=search_text and not skip,
890                                comment_stack=comment_stack):
891                            yield message
892                    else:
893                        for message in self.extract(
894                                substream, gettext_functions,
895                                search_text=search_text and not skip,
896                                comment_stack=comment_stack):
897                            yield message
898
899                if in_comment:
900                    comment_stack.pop()
901
902    def get_directive_index(self, dir_cls):
903        total = len(self._dir_order)
904        if dir_cls in self._dir_order:
905            return self._dir_order.index(dir_cls) - total
906        return total
907
908    def setup(self, template):
909        """Convenience function to register the `Translator` filter and the
910        related directives with the given template.
911       
912        :param template: a `Template` instance
913        """
914        template.filters.insert(0, self)
915        if hasattr(template, 'add_directives'):
916            template.add_directives(Translator.NAMESPACE, self)
917
918    def _extract_attrs(self, event, gettext_functions, search_text):
919        for name, value in event[1][1]:
920            if search_text and isinstance(value, basestring):
921                if name in self.include_attrs:
922                    text = value.strip()
923                    if text:
924                        yield event[2][1], None, text, []
925            else:
926                for message in self.extract(_ensure(value), gettext_functions,
927                                            search_text=False):
928                    yield message
929
930
931class MessageBuffer(object):
932    """Helper class for managing internationalized mixed content.
933   
934    :since: version 0.5
935    """
936
937    def __init__(self, directive=None):
938        """Initialize the message buffer.
939       
940        :param directive: the directive owning the buffer
941        :type directive: I18NDirective
942        """
943        # params list needs to be copied so that directives can be evaluated
944        # more than once
945        self.orig_params = self.params = directive.params[:]
946        self.directive = directive
947        self.string = []
948        self.events = {}
949        self.values = {}
950        self.depth = 1
951        self.order = 1
952        self._prev_order = None
953        self.stack = [0]
954        self.subdirectives = {}
955
956    def _add_event(self, order, event):
957        if order == self._prev_order:
958            self.events[order][-1].append(event)
959        else:
960            self._prev_order = order
961            self.events.setdefault(order, [])
962            self.events[order].append([event])
963
964    def append(self, kind, data, pos):
965        """Append a stream event to the buffer.
966       
967        :param kind: the stream event kind
968        :param data: the event data
969        :param pos: the position of the event in the source
970        """
971        if kind is SUB:
972            # The order needs to be +1 because a new START kind event will
973            # happen and we we need to wrap those events into our custom kind(s)
974            order = self.stack[-1] + 1
975            subdirectives, substream = data
976            # Store the directives that should be applied after translation
977            self.subdirectives.setdefault(order, []).extend(subdirectives)
978            self._add_event(order, (SUB_START, None, pos))
979            for skind, sdata, spos in substream:
980                self.append(skind, sdata, spos)
981            self._add_event(order, (SUB_END, None, pos))
982        elif kind is TEXT:
983            if '[' in data or ']' in data:
984                # Quote [ and ] if it ain't us adding it, ie, if the user is
985                # using those chars in his templates, escape them
986                data = data.replace('[', '\[').replace(']', '\]')
987            self.string.append(data)
988            self._add_event(self.stack[-1], (kind, data, pos))
989        elif kind is EXPR:
990            if self.params:
991                param = self.params.pop(0)
992            else:
993                params = ', '.join(['"%s"' % p for p in self.orig_params if p])
994                if params:
995                    params = "(%s)" % params
996                raise IndexError("%d parameters%s given to 'i18n:%s' but "
997                                 "%d or more expressions used in '%s', line %s"
998                                 % (len(self.orig_params), params, 
999                                    self.directive.tagname,
1000                                    len(self.orig_params) + 1,
1001                                    os.path.basename(pos[0] or
1002                                                     'In-memory Template'),
1003                                    pos[1]))
1004            self.string.append('%%(%s)s' % param)
1005            self._add_event(self.stack[-1], (kind, data, pos))
1006            self.values[param] = (kind, data, pos)
1007        else:
1008            if kind is START: 
1009                self.string.append('[%d:' % self.order)
1010                self.stack.append(self.order)
1011                self._add_event(self.stack[-1], (kind, data, pos))
1012                self.depth += 1
1013                self.order += 1
1014            elif kind is END:
1015                self.depth -= 1
1016                if self.depth:
1017                    self._add_event(self.stack[-1], (kind, data, pos))
1018                    self.string.append(']')
1019                    self.stack.pop()
1020
1021    def format(self):
1022        """Return a message identifier representing the content in the
1023        buffer.
1024        """
1025        return ''.join(self.string).strip()
1026
1027    def translate(self, string, regex=re.compile(r'%\((\w+)\)s')):
1028        """Interpolate the given message translation with the events in the
1029        buffer and return the translated stream.
1030       
1031        :param string: the translated message string
1032        """
1033        substream = None
1034
1035        def yield_parts(string):
1036            for idx, part in enumerate(regex.split(string)):
1037                if idx % 2:
1038                    yield self.values[part]
1039                elif part:
1040                    yield (TEXT,
1041                           part.replace('\[', '[').replace('\]', ']'),
1042                           (None, -1, -1)
1043                    )
1044
1045        parts = parse_msg(string)
1046        parts_counter = {}
1047        for order, string in parts:
1048            parts_counter.setdefault(order, []).append(None)
1049
1050        while parts:
1051            order, string = parts.pop(0)
1052            events = self.events[order]
1053            if events:
1054                events = events.pop(0)
1055            else:
1056                # create a dummy empty text event so any remaining
1057                # part of the translation can be processed.
1058                events = [(TEXT, "", (None, -1, -1))]
1059            parts_counter[order].pop()
1060
1061            for event in events:
1062                if event[0] is SUB_START:
1063                    substream = []
1064                elif event[0] is SUB_END:
1065                    # Yield a substream which might have directives to be
1066                    # applied to it (after translation events)
1067                    yield SUB, (self.subdirectives[order], substream), event[2]
1068                    substream = None
1069                elif event[0] is TEXT:
1070                    if string:
1071                        for part in yield_parts(string):
1072                            if substream is not None:
1073                                substream.append(part)
1074                            else:
1075                                yield part
1076                        # String handled, reset it
1077                        string = None
1078                elif event[0] is START:
1079                    if substream is not None:
1080                        substream.append(event)
1081                    else:
1082                        yield event
1083                    if string:
1084                        for part in yield_parts(string):
1085                            if substream is not None:
1086                                substream.append(part)
1087                            else:
1088                                yield part
1089                        # String handled, reset it
1090                        string = None
1091                elif event[0] is END:
1092                    if string:
1093                        for part in yield_parts(string):
1094                            if substream is not None:
1095                                substream.append(part)
1096                            else:
1097                                yield part
1098                        # String handled, reset it
1099                        string = None
1100                    if substream is not None:
1101                        substream.append(event)
1102                    else:
1103                        yield event
1104                elif event[0] is EXPR:
1105                    # These are handled on the strings itself
1106                    continue
1107                else:
1108                    if string:
1109                        for part in yield_parts(string):
1110                            if substream is not None:
1111                                substream.append(part)
1112                            else:
1113                                yield part
1114                        # String handled, reset it
1115                        string = None
1116                    if substream is not None:
1117                        substream.append(event)
1118                    else:
1119                        yield event
1120
1121
1122def parse_msg(string, regex=re.compile(r'(?:\[(\d+)\:)|(?<!\\)\]')):
1123    """Parse a translated message using Genshi mixed content message
1124    formatting.
1125   
1126    >>> parse_msg("See [1:Help].")
1127    [(0, 'See '), (1, 'Help'), (0, '.')]
1128   
1129    >>> parse_msg("See [1:our [2:Help] page] for details.")
1130    [(0, 'See '), (1, 'our '), (2, 'Help'), (1, ' page'), (0, ' for details.')]
1131   
1132    >>> parse_msg("[2:Details] finden Sie in [1:Hilfe].")
1133    [(2, 'Details'), (0, ' finden Sie in '), (1, 'Hilfe'), (0, '.')]
1134   
1135    >>> parse_msg("[1:] Bilder pro Seite anzeigen.")
1136    [(1, ''), (0, ' Bilder pro Seite anzeigen.')]
1137   
1138    :param string: the translated message string
1139    :return: a list of ``(order, string)`` tuples
1140    :rtype: `list`
1141    """
1142    parts = []
1143    stack = [0]
1144    while True:
1145        mo = regex.search(string)
1146        if not mo:
1147            break
1148
1149        if mo.start() or stack[-1]:
1150            parts.append((stack[-1], string[:mo.start()]))
1151        string = string[mo.end():]
1152
1153        orderno = mo.group(1)
1154        if orderno is not None:
1155            stack.append(int(orderno))
1156        else:
1157            stack.pop()
1158        if not stack:
1159            break
1160
1161    if string:
1162        parts.append((stack[-1], string))
1163
1164    return parts
1165
1166
1167def extract_from_code(code, gettext_functions):
1168    """Extract strings from Python bytecode.
1169   
1170    >>> from genshi.template.eval import Expression
1171    >>> expr = Expression('_("Hello")')
1172    >>> list(extract_from_code(expr, GETTEXT_FUNCTIONS))
1173    [('_', u'Hello')]
1174   
1175    >>> expr = Expression('ngettext("You have %(num)s item", '
1176    ...                            '"You have %(num)s items", num)')
1177    >>> list(extract_from_code(expr, GETTEXT_FUNCTIONS))
1178    [('ngettext', (u'You have %(num)s item', u'You have %(num)s items', None))]
1179   
1180    :param code: the `Code` object
1181    :type code: `genshi.template.eval.Code`
1182    :param gettext_functions: a sequence of function names
1183    :since: version 0.5
1184    """
1185    def _walk(node):
1186        if isinstance(node, _ast.Call) and isinstance(node.func, _ast.Name) \
1187                and node.func.id in gettext_functions:
1188            strings = []
1189            def _add(arg):
1190                if isinstance(arg, _ast.Str) and isinstance(arg.s, unicode):
1191                    strings.append(arg.s)
1192                elif isinstance(arg, _ast.Str):
1193                    strings.append(unicode(arg.s, 'utf-8'))
1194                elif arg:
1195                    strings.append(None)
1196            [_add(arg) for arg in node.args]
1197            _add(node.starargs)
1198            _add(node.kwargs)
1199            if len(strings) == 1:
1200                strings = strings[0]
1201            else:
1202                strings = tuple(strings)
1203            yield node.func.id, strings
1204        elif node._fields:
1205            children = []
1206            for field in node._fields:
1207                child = getattr(node, field, None)
1208                if isinstance(child, list):
1209                    for elem in child:
1210                        children.append(elem)
1211                elif isinstance(child, _ast.AST):
1212                    children.append(child)
1213            for child in children:
1214                for funcname, strings in _walk(child):
1215                    yield funcname, strings
1216    return _walk(code.ast)
1217
1218
1219def extract(fileobj, keywords, comment_tags, options):
1220    """Babel extraction method for Genshi templates.
1221   
1222    :param fileobj: the file-like object the messages should be extracted from
1223    :param keywords: a list of keywords (i.e. function names) that should be
1224                     recognized as translation functions
1225    :param comment_tags: a list of translator tags to search for and include
1226                         in the results
1227    :param options: a dictionary of additional options (optional)
1228    :return: an iterator over ``(lineno, funcname, message, comments)`` tuples
1229    :rtype: ``iterator``
1230    """
1231    template_class = options.get('template_class', MarkupTemplate)
1232    if isinstance(template_class, basestring):
1233        module, clsname = template_class.split(':', 1)
1234        template_class = getattr(__import__(module, {}, {}, [clsname]), clsname)
1235    encoding = options.get('encoding', None)
1236
1237    extract_text = options.get('extract_text', True)
1238    if isinstance(extract_text, basestring):
1239        extract_text = extract_text.lower() in ('1', 'on', 'yes', 'true')
1240
1241    ignore_tags = options.get('ignore_tags', Translator.IGNORE_TAGS)
1242    if isinstance(ignore_tags, basestring):
1243        ignore_tags = ignore_tags.split()
1244    ignore_tags = [QName(tag) for tag in ignore_tags]
1245
1246    include_attrs = options.get('include_attrs', Translator.INCLUDE_ATTRS)
1247    if isinstance(include_attrs, basestring):
1248        include_attrs = include_attrs.split()
1249    include_attrs = [QName(attr) for attr in include_attrs]
1250
1251    tmpl = template_class(fileobj, filename=getattr(fileobj, 'name', None),
1252                          encoding=encoding)
1253    tmpl.loader = None
1254
1255    translator = Translator(None, ignore_tags, include_attrs, extract_text)
1256    if hasattr(tmpl, 'add_directives'):
1257        tmpl.add_directives(Translator.NAMESPACE, translator)
1258    for message in translator.extract(tmpl.stream, gettext_functions=keywords):
1259        yield message
Note: See TracBrowser for help on using the repository browser.