Edgewall Software

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

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

Merge r1141 from py3k:

add support for python 3 to genshi.filters:

  • minor changes to track encoding=None API change in core genshi modules.
  • renamed genshi/filters/tests/html.py to test_html.py to avoid clashes with Python 3 top-level html module when running tests subset.
  • did not rename genshi/filters/html.py.
  • i18n filters:
    • ugettext and friends are gone in Python 3 (and only gettext and friends exist and they now handle unicode)
    • Some \ line continuations inside doctests confused 2to3 and so were removed them.
    • Testing picked up a problem (already present in trunk) where Translator.call could end up defining gettext as an endlessly recursive function. Noted with a TODO.
  • Property svn:eol-style set to native
File size: 48.6 KB
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    ])
609    NAMESPACE = I18N_NAMESPACE
610
611    def __init__(self, translate=NullTranslations(), ignore_tags=IGNORE_TAGS,
612                 include_attrs=INCLUDE_ATTRS, extract_text=True):
613        """Initialize the translator.
614       
615        :param translate: the translation function, for example ``gettext`` or
616                          ``ugettext``.
617        :param ignore_tags: a set of tag names that should not be localized
618        :param include_attrs: a set of attribute names should be localized
619        :param extract_text: whether the content of text nodes should be
620                             extracted, or only text in explicit ``gettext``
621                             function calls
622       
623        :note: Changed in 0.6: the `translate` parameter can now be either
624               a ``gettext``-style function, or an object compatible with the
625               ``NullTransalations`` or ``GNUTranslations`` interface
626        """
627        self.translate = translate
628        self.ignore_tags = ignore_tags
629        self.include_attrs = include_attrs
630        self.extract_text = extract_text
631
632    def __call__(self, stream, ctxt=None, translate_text=True,
633                 translate_attrs=True):
634        """Translate any localizable strings in the given stream.
635       
636        This function shouldn't be called directly. Instead, an instance of
637        the `Translator` class should be registered as a filter with the
638        `Template` or the `TemplateLoader`, or applied as a regular stream
639        filter. If used as a template filter, it should be inserted in front of
640        all the default filters.
641       
642        :param stream: the markup event stream
643        :param ctxt: the template context (not used)
644        :param translate_text: whether text nodes should be translated (used
645                               internally)
646        :param translate_attrs: whether attribute values should be translated
647                                (used internally)
648        :return: the localized stream
649        """
650        ignore_tags = self.ignore_tags
651        include_attrs = self.include_attrs
652        skip = 0
653        xml_lang = XML_NAMESPACE['lang']
654        if not self.extract_text:
655            translate_text = False
656            translate_attrs = False
657
658        if type(self.translate) is FunctionType:
659            gettext = self.translate
660            if ctxt:
661                ctxt['_i18n.gettext'] = gettext
662        else:
663            if IS_PYTHON2:
664                gettext = self.translate.ugettext
665                ngettext = self.translate.ungettext
666            else:
667                gettext = self.translate.gettext
668                ngettext = self.translate.ngettext
669            try:
670                if IS_PYTHON2:
671                    dgettext = self.translate.dugettext
672                    dngettext = self.translate.dungettext
673                else:
674                    dgettext = self.translate.dgettext
675                    dngettext = self.translate.dngettext
676            except AttributeError:
677                dgettext = lambda _, y: gettext(y)
678                dngettext = lambda _, s, p, n: ngettext(s, p, n)
679            if ctxt:
680                ctxt['_i18n.gettext'] = gettext
681                ctxt['_i18n.ngettext'] = ngettext
682                ctxt['_i18n.dgettext'] = dgettext
683                ctxt['_i18n.dngettext'] = dngettext
684
685        if ctxt and ctxt.get('_i18n.domain'):
686            # TODO: This can cause infinite recursion if dgettext is defined
687            #       via the AttributeError case above!
688            gettext = lambda msg: dgettext(ctxt.get('_i18n.domain'), msg)
689
690        for kind, data, pos in stream:
691
692            # skip chunks that should not be localized
693            if skip:
694                if kind is START:
695                    skip += 1
696                elif kind is END:
697                    skip -= 1
698                yield kind, data, pos
699                continue
700
701            # handle different events that can be localized
702            if kind is START:
703                tag, attrs = data
704                if tag in self.ignore_tags or \
705                        isinstance(attrs.get(xml_lang), basestring):
706                    skip += 1
707                    yield kind, data, pos
708                    continue
709
710                new_attrs = []
711                changed = False
712
713                for name, value in attrs:
714                    newval = value
715                    if isinstance(value, basestring):
716                        if translate_attrs and name in include_attrs:
717                            newval = gettext(value)
718                    else:
719                        newval = list(
720                            self(_ensure(value), ctxt, translate_text=False)
721                        )
722                    if newval != value:
723                        value = newval
724                        changed = True
725                    new_attrs.append((name, value))
726                if changed:
727                    attrs = Attrs(new_attrs)
728
729                yield kind, (tag, attrs), pos
730
731            elif translate_text and kind is TEXT:
732                text = data.strip()
733                if text:
734                    data = data.replace(text, unicode(gettext(text)))
735                yield kind, data, pos
736
737            elif kind is SUB:
738                directives, substream = data
739                current_domain = None
740                for idx, directive in enumerate(directives):
741                    # Organize directives to make everything work
742                    # FIXME: There's got to be a better way to do this!
743                    if isinstance(directive, DomainDirective):
744                        # Grab current domain and update context
745                        current_domain = directive.domain
746                        ctxt.push({'_i18n.domain': current_domain})
747                        # Put domain directive as the first one in order to
748                        # update context before any other directives evaluation
749                        directives.insert(0, directives.pop(idx))
750
751                # If this is an i18n directive, no need to translate text
752                # nodes here
753                is_i18n_directive = any([
754                    isinstance(d, ExtractableI18NDirective)
755                    for d in directives
756                ])
757                substream = list(self(substream, ctxt,
758                                      translate_text=not is_i18n_directive,
759                                      translate_attrs=translate_attrs))
760                yield kind, (directives, substream), pos
761
762                if current_domain:
763                    ctxt.pop()
764            else:
765                yield kind, data, pos
766
767    def extract(self, stream, gettext_functions=GETTEXT_FUNCTIONS,
768                search_text=True, comment_stack=None):
769        """Extract localizable strings from the given template stream.
770       
771        For every string found, this function yields a ``(lineno, function,
772        message, comments)`` tuple, where:
773       
774        * ``lineno`` is the number of the line on which the string was found,
775        * ``function`` is the name of the ``gettext`` function used (if the
776          string was extracted from embedded Python code), and
777        *  ``message`` is the string itself (a ``unicode`` object, or a tuple
778           of ``unicode`` objects for functions with multiple string
779           arguments).
780        *  ``comments`` is a list of comments related to the message, extracted
781           from ``i18n:comment`` attributes found in the markup
782       
783        >>> tmpl = MarkupTemplate('''<html xmlns:py="http://genshi.edgewall.org/">
784        ...   <head>
785        ...     <title>Example</title>
786        ...   </head>
787        ...   <body>
788        ...     <h1>Example</h1>
789        ...     <p>${_("Hello, %(name)s") % dict(name=username)}</p>
790        ...     <p>${ngettext("You have %d item", "You have %d items", num)}</p>
791        ...   </body>
792        ... </html>''', filename='example.html')
793        >>> for line, func, msg, comments in Translator().extract(tmpl.stream):
794        ...    print('%d, %r, %r' % (line, func, msg))
795        3, None, u'Example'
796        6, None, u'Example'
797        7, '_', u'Hello, %(name)s'
798        8, 'ngettext', (u'You have %d item', u'You have %d items', None)
799       
800        :param stream: the event stream to extract strings from; can be a
801                       regular stream or a template stream
802        :param gettext_functions: a sequence of function names that should be
803                                  treated as gettext-style localization
804                                  functions
805        :param search_text: whether the content of text nodes should be
806                            extracted (used internally)
807       
808        :note: Changed in 0.4.1: For a function with multiple string arguments
809               (such as ``ngettext``), a single item with a tuple of strings is
810               yielded, instead an item for each string argument.
811        :note: Changed in 0.6: The returned tuples now include a fourth
812               element, which is a list of comments for the translator.
813        """
814        if not self.extract_text:
815            search_text = False
816        if comment_stack is None:
817            comment_stack = []
818        skip = 0
819
820        xml_lang = XML_NAMESPACE['lang']
821
822        for kind, data, pos in stream:
823            if skip:
824                if kind is START:
825                    skip += 1
826                if kind is END:
827                    skip -= 1
828
829            if kind is START and not skip:
830                tag, attrs = data
831                if tag in self.ignore_tags or \
832                        isinstance(attrs.get(xml_lang), basestring):
833                    skip += 1
834                    continue
835
836                for message in self._extract_attrs((kind, data, pos),
837                                                   gettext_functions,
838                                                   search_text=search_text):
839                    yield message
840
841            elif not skip and search_text and kind is TEXT:
842                text = data.strip()
843                if text and [ch for ch in text if ch.isalpha()]:
844                    yield pos[1], None, text, comment_stack[-1:]
845
846            elif kind is EXPR or kind is EXEC:
847                for funcname, strings in extract_from_code(data,
848                                                           gettext_functions):
849                    # XXX: Do we need to grab i18n:comment from comment_stack ???
850                    yield pos[1], funcname, strings, []
851
852            elif kind is SUB:
853                directives, substream = data
854                in_comment = False
855
856                for idx, directive in enumerate(directives):
857                    # Do a first loop to see if there's a comment directive
858                    # If there is update context and pop it from directives
859                    if isinstance(directive, CommentDirective):
860                        in_comment = True
861                        comment_stack.append(directive.comment)
862                        if len(directives) == 1:
863                            # in case we're in the presence of something like:
864                            # <p i18n:comment="foo">Foo</p>
865                            for message in self.extract(
866                                    substream, gettext_functions,
867                                    search_text=search_text and not skip,
868                                    comment_stack=comment_stack):
869                                yield message
870                        directives.pop(idx)
871                    elif not isinstance(directive, I18NDirective):
872                        # Remove all other non i18n directives from the process
873                        directives.pop(idx)
874
875                if not directives and not in_comment:
876                    # Extract content if there's no directives because
877                    # strip was pop'ed and not because comment was pop'ed.
878                    # Extraction in this case has been taken care of.
879                    for message in self.extract(
880                            substream, gettext_functions,
881                            search_text=search_text and not skip):
882                        yield message
883
884                for directive in directives:
885                    if isinstance(directive, ExtractableI18NDirective):
886                        for message in directive.extract(self,
887                                substream, gettext_functions,
888                                search_text=search_text and not skip,
889                                comment_stack=comment_stack):
890                            yield message
891                    else:
892                        for message in self.extract(
893                                substream, gettext_functions,
894                                search_text=search_text and not skip,
895                                comment_stack=comment_stack):
896                            yield message
897
898                if in_comment:
899                    comment_stack.pop()
900
901    def get_directive_index(self, dir_cls):
902        total = len(self._dir_order)
903        if dir_cls in self._dir_order:
904            return self._dir_order.index(dir_cls) - total
905        return total
906
907    def setup(self, template):
908        """Convenience function to register the `Translator` filter and the
909        related directives with the given template.
910       
911        :param template: a `Template` instance
912        """
913        template.filters.insert(0, self)
914        if hasattr(template, 'add_directives'):
915            template.add_directives(Translator.NAMESPACE, self)
916
917    def _extract_attrs(self, event, gettext_functions, search_text):
918        for name, value in event[1][1]:
919            if search_text and isinstance(value, basestring):
920                if name in self.include_attrs:
921                    text = value.strip()
922                    if text:
923                        yield event[2][1], None, text, []
924            else:
925                for message in self.extract(_ensure(value), gettext_functions,
926                                            search_text=False):
927                    yield message
928
929
930class MessageBuffer(object):
931    """Helper class for managing internationalized mixed content.
932   
933    :since: version 0.5
934    """
935
936    def __init__(self, directive=None):
937        """Initialize the message buffer.
938       
939        :param directive: the directive owning the buffer
940        :type directive: I18NDirective
941        """
942        # params list needs to be copied so that directives can be evaluated
943        # more than once
944        self.orig_params = self.params = directive.params[:]
945        self.directive = directive
946        self.string = []
947        self.events = {}
948        self.values = {}
949        self.depth = 1
950        self.order = 1
951        self.stack = [0]
952        self.subdirectives = {}
953
954    def append(self, kind, data, pos):
955        """Append a stream event to the buffer.
956       
957        :param kind: the stream event kind
958        :param data: the event data
959        :param pos: the position of the event in the source
960        """
961        if kind is SUB:
962            # The order needs to be +1 because a new START kind event will
963            # happen and we we need to wrap those events into our custom kind(s)
964            order = self.stack[-1] + 1
965            subdirectives, substream = data
966            # Store the directives that should be applied after translation
967            self.subdirectives.setdefault(order, []).extend(subdirectives)
968            self.events.setdefault(order, []).append((SUB_START, None, pos))
969            for skind, sdata, spos in substream:
970                self.append(skind, sdata, spos)
971            self.events.setdefault(order, []).append((SUB_END, None, pos))
972        elif kind is TEXT:
973            if '[' in data or ']' in data:
974                # Quote [ and ] if it ain't us adding it, ie, if the user is
975                # using those chars in his templates, escape them
976                data = data.replace('[', '\[').replace(']', '\]')
977            self.string.append(data)
978            self.events.setdefault(self.stack[-1], []).append((kind, data, pos))
979        elif kind is EXPR:
980            if self.params:
981                param = self.params.pop(0)
982            else:
983                params = ', '.join(['"%s"' % p for p in self.orig_params if p])
984                if params:
985                    params = "(%s)" % params
986                raise IndexError("%d parameters%s given to 'i18n:%s' but "
987                                 "%d or more expressions used in '%s', line %s"
988                                 % (len(self.orig_params), params, 
989                                    self.directive.tagname,
990                                    len(self.orig_params) + 1,
991                                    os.path.basename(pos[0] or
992                                                     'In-memory Template'),
993                                    pos[1]))
994            self.string.append('%%(%s)s' % param)
995            self.events.setdefault(self.stack[-1], []).append((kind, data, pos))
996            self.values[param] = (kind, data, pos)
997        else:
998            if kind is START: 
999                self.string.append('[%d:' % self.order)
1000                self.stack.append(self.order)
1001                self.events.setdefault(self.stack[-1],
1002                                       []).append((kind, data, pos))
1003                self.depth += 1
1004                self.order += 1
1005            elif kind is END:
1006                self.depth -= 1
1007                if self.depth:
1008                    self.events[self.stack[-1]].append((kind, data, pos))
1009                    self.string.append(']')
1010                    self.stack.pop()
1011
1012    def format(self):
1013        """Return a message identifier representing the content in the
1014        buffer.
1015        """
1016        return ''.join(self.string).strip()
1017
1018    def translate(self, string, regex=re.compile(r'%\((\w+)\)s')):
1019        """Interpolate the given message translation with the events in the
1020        buffer and return the translated stream.
1021       
1022        :param string: the translated message string
1023        """
1024        substream = None
1025
1026        def yield_parts(string):
1027            for idx, part in enumerate(regex.split(string)):
1028                if idx % 2:
1029                    yield self.values[part]
1030                elif part:
1031                    yield (TEXT,
1032                           part.replace('\[', '[').replace('\]', ']'),
1033                           (None, -1, -1)
1034                    )
1035
1036        parts = parse_msg(string)
1037        parts_counter = {}
1038        for order, string in parts:
1039            parts_counter.setdefault(order, []).append(None)
1040
1041        while parts:
1042            order, string = parts.pop(0)
1043            if len(parts_counter[order]) == 1:
1044                events = self.events[order]
1045            else:
1046                events = [self.events[order].pop(0)]
1047            parts_counter[order].pop()
1048
1049            for event in events:
1050                if event[0] is SUB_START:
1051                    substream = []
1052                elif event[0] is SUB_END:
1053                    # Yield a substream which might have directives to be
1054                    # applied to it (after translation events)
1055                    yield SUB, (self.subdirectives[order], substream), event[2]
1056                    substream = None
1057                elif event[0] is TEXT:
1058                    if string:
1059                        for part in yield_parts(string):
1060                            if substream is not None:
1061                                substream.append(part)
1062                            else:
1063                                yield part
1064                        # String handled, reset it
1065                        string = None
1066                elif event[0] is START:
1067                    if substream is not None:
1068                        substream.append(event)
1069                    else:
1070                        yield event
1071                    if string:
1072                        for part in yield_parts(string):
1073                            if substream is not None:
1074                                substream.append(part)
1075                            else:
1076                                yield part
1077                        # String handled, reset it
1078                        string = None
1079                elif event[0] is END:
1080                    if string:
1081                        for part in yield_parts(string):
1082                            if substream is not None:
1083                                substream.append(part)
1084                            else:
1085                                yield part
1086                        # String handled, reset it
1087                        string = None
1088                    if substream is not None:
1089                        substream.append(event)
1090                    else:
1091                        yield event
1092                elif event[0] is EXPR:
1093                    # These are handled on the strings itself
1094                    continue
1095                else:
1096                    if string:
1097                        for part in yield_parts(string):
1098                            if substream is not None:
1099                                substream.append(part)
1100                            else:
1101                                yield part
1102                        # String handled, reset it
1103                        string = None
1104                    if substream is not None:
1105                        substream.append(event)
1106                    else:
1107                        yield event
1108
1109
1110def parse_msg(string, regex=re.compile(r'(?:\[(\d+)\:)|(?<!\\)\]')):
1111    """Parse a translated message using Genshi mixed content message
1112    formatting.
1113   
1114    >>> parse_msg("See [1:Help].")
1115    [(0, 'See '), (1, 'Help'), (0, '.')]
1116   
1117    >>> parse_msg("See [1:our [2:Help] page] for details.")
1118    [(0, 'See '), (1, 'our '), (2, 'Help'), (1, ' page'), (0, ' for details.')]
1119   
1120    >>> parse_msg("[2:Details] finden Sie in [1:Hilfe].")
1121    [(2, 'Details'), (0, ' finden Sie in '), (1, 'Hilfe'), (0, '.')]
1122   
1123    >>> parse_msg("[1:] Bilder pro Seite anzeigen.")
1124    [(1, ''), (0, ' Bilder pro Seite anzeigen.')]
1125   
1126    :param string: the translated message string
1127    :return: a list of ``(order, string)`` tuples
1128    :rtype: `list`
1129    """
1130    parts = []
1131    stack = [0]
1132    while True:
1133        mo = regex.search(string)
1134        if not mo:
1135            break
1136
1137        if mo.start() or stack[-1]:
1138            parts.append((stack[-1], string[:mo.start()]))
1139        string = string[mo.end():]
1140
1141        orderno = mo.group(1)
1142        if orderno is not None:
1143            stack.append(int(orderno))
1144        else:
1145            stack.pop()
1146        if not stack:
1147            break
1148
1149    if string:
1150        parts.append((stack[-1], string))
1151
1152    return parts
1153
1154
1155def extract_from_code(code, gettext_functions):
1156    """Extract strings from Python bytecode.
1157   
1158    >>> from genshi.template.eval import Expression
1159    >>> expr = Expression('_("Hello")')
1160    >>> list(extract_from_code(expr, GETTEXT_FUNCTIONS))
1161    [('_', u'Hello')]
1162   
1163    >>> expr = Expression('ngettext("You have %(num)s item", '
1164    ...                            '"You have %(num)s items", num)')
1165    >>> list(extract_from_code(expr, GETTEXT_FUNCTIONS))
1166    [('ngettext', (u'You have %(num)s item', u'You have %(num)s items', None))]
1167   
1168    :param code: the `Code` object
1169    :type code: `genshi.template.eval.Code`
1170    :param gettext_functions: a sequence of function names
1171    :since: version 0.5
1172    """
1173    def _walk(node):
1174        if isinstance(node, _ast.Call) and isinstance(node.func, _ast.Name) \
1175                and node.func.id in gettext_functions:
1176            strings = []
1177            def _add(arg):
1178                if isinstance(arg, _ast.Str) and isinstance(arg.s, unicode):
1179                    strings.append(arg.s)
1180                elif isinstance(arg, _ast.Str):
1181                    strings.append(unicode(arg.s, 'utf-8'))
1182                elif arg:
1183                    strings.append(None)
1184            [_add(arg) for arg in node.args]
1185            _add(node.starargs)
1186            _add(node.kwargs)
1187            if len(strings) == 1:
1188                strings = strings[0]
1189            else:
1190                strings = tuple(strings)
1191            yield node.func.id, strings
1192        elif node._fields:
1193            children = []
1194            for field in node._fields:
1195                child = getattr(node, field, None)
1196                if isinstance(child, list):
1197                    for elem in child:
1198                        children.append(elem)
1199                elif isinstance(child, _ast.AST):
1200                    children.append(child)
1201            for child in children:
1202                for funcname, strings in _walk(child):
1203                    yield funcname, strings
1204    return _walk(code.ast)
1205
1206
1207def extract(fileobj, keywords, comment_tags, options):
1208    """Babel extraction method for Genshi templates.
1209   
1210    :param fileobj: the file-like object the messages should be extracted from
1211    :param keywords: a list of keywords (i.e. function names) that should be
1212                     recognized as translation functions
1213    :param comment_tags: a list of translator tags to search for and include
1214                         in the results
1215    :param options: a dictionary of additional options (optional)
1216    :return: an iterator over ``(lineno, funcname, message, comments)`` tuples
1217    :rtype: ``iterator``
1218    """
1219    template_class = options.get('template_class', MarkupTemplate)
1220    if isinstance(template_class, basestring):
1221        module, clsname = template_class.split(':', 1)
1222        template_class = getattr(__import__(module, {}, {}, [clsname]), clsname)
1223    encoding = options.get('encoding', None)
1224
1225    extract_text = options.get('extract_text', True)
1226    if isinstance(extract_text, basestring):
1227        extract_text = extract_text.lower() in ('1', 'on', 'yes', 'true')
1228
1229    ignore_tags = options.get('ignore_tags', Translator.IGNORE_TAGS)
1230    if isinstance(ignore_tags, basestring):
1231        ignore_tags = ignore_tags.split()
1232    ignore_tags = [QName(tag) for tag in ignore_tags]
1233
1234    include_attrs = options.get('include_attrs', Translator.INCLUDE_ATTRS)
1235    if isinstance(include_attrs, basestring):
1236        include_attrs = include_attrs.split()
1237    include_attrs = [QName(attr) for attr in include_attrs]
1238
1239    tmpl = template_class(fileobj, filename=getattr(fileobj, 'name', None),
1240                          encoding=encoding)
1241    tmpl.loader = None
1242
1243    translator = Translator(None, ignore_tags, include_attrs, extract_text)
1244    if hasattr(tmpl, 'add_directives'):
1245        tmpl.add_directives(Translator.NAMESPACE, translator)
1246    for message in translator.extract(tmpl.stream, gettext_functions=keywords):
1247        yield message
Note: See TracBrowser for help on using the repository browser.