d190a2c07176c6aac1dca9c4c1c11f9be66ff6c4
[sdc/sdc-distribution-client.git] /
1 # -*- coding: utf-8 -*-
2 """
3     babel.messages.frontend
4     ~~~~~~~~~~~~~~~~~~~~~~~
5
6     Frontends for the message extraction functionality.
7
8     :copyright: (c) 2013 by the Babel Team.
9     :license: BSD, see LICENSE for more details.
10 """
11 from __future__ import print_function
12
13 import logging
14 import optparse
15 import os
16 import re
17 import shutil
18 import sys
19 import tempfile
20 from datetime import datetime
21 from locale import getpreferredencoding
22
23 from babel import __version__ as VERSION
24 from babel import Locale, localedata
25 from babel._compat import StringIO, string_types, text_type
26 from babel.core import UnknownLocaleError
27 from babel.messages.catalog import Catalog
28 from babel.messages.extract import DEFAULT_KEYWORDS, DEFAULT_MAPPING, check_and_call_extract_file, extract_from_dir
29 from babel.messages.mofile import write_mo
30 from babel.messages.pofile import read_po, write_po
31 from babel.util import LOCALTZ, odict
32 from distutils import log as distutils_log
33 from distutils.cmd import Command as _Command
34 from distutils.errors import DistutilsOptionError, DistutilsSetupError
35
36 try:
37     from ConfigParser import RawConfigParser
38 except ImportError:
39     from configparser import RawConfigParser
40
41
42 def listify_value(arg, split=None):
43     """
44     Make a list out of an argument.
45
46     Values from `distutils` argument parsing are always single strings;
47     values from `optparse` parsing may be lists of strings that may need
48     to be further split.
49
50     No matter the input, this function returns a flat list of whitespace-trimmed
51     strings, with `None` values filtered out.
52
53     >>> listify_value("foo bar")
54     ['foo', 'bar']
55     >>> listify_value(["foo bar"])
56     ['foo', 'bar']
57     >>> listify_value([["foo"], "bar"])
58     ['foo', 'bar']
59     >>> listify_value([["foo"], ["bar", None, "foo"]])
60     ['foo', 'bar', 'foo']
61     >>> listify_value("foo, bar, quux", ",")
62     ['foo', 'bar', 'quux']
63
64     :param arg: A string or a list of strings
65     :param split: The argument to pass to `str.split()`.
66     :return:
67     """
68     out = []
69
70     if not isinstance(arg, (list, tuple)):
71         arg = [arg]
72
73     for val in arg:
74         if val is None:
75             continue
76         if isinstance(val, (list, tuple)):
77             out.extend(listify_value(val, split=split))
78             continue
79         out.extend(s.strip() for s in text_type(val).split(split))
80     assert all(isinstance(val, string_types) for val in out)
81     return out
82
83
84 class Command(_Command):
85     # This class is a small shim between Distutils commands and
86     # optparse option parsing in the frontend command line.
87
88     #: Option name to be input as `args` on the script command line.
89     as_args = None
90
91     #: Options which allow multiple values.
92     #: This is used by the `optparse` transmogrification code.
93     multiple_value_options = ()
94
95     #: Options which are booleans.
96     #: This is used by the `optparse` transmogrification code.
97     # (This is actually used by distutils code too, but is never
98     # declared in the base class.)
99     boolean_options = ()
100
101     #: Option aliases, to retain standalone command compatibility.
102     #: Distutils does not support option aliases, but optparse does.
103     #: This maps the distutils argument name to an iterable of aliases
104     #: that are usable with optparse.
105     option_aliases = {}
106
107     #: Log object. To allow replacement in the script command line runner.
108     log = distutils_log
109
110     def __init__(self, dist=None):
111         # A less strict version of distutils' `__init__`.
112         self.distribution = dist
113         self.initialize_options()
114         self._dry_run = None
115         self.verbose = False
116         self.force = None
117         self.help = 0
118         self.finalized = 0
119
120
121 class compile_catalog(Command):
122     """Catalog compilation command for use in ``setup.py`` scripts.
123
124     If correctly installed, this command is available to Setuptools-using
125     setup scripts automatically. For projects using plain old ``distutils``,
126     the command needs to be registered explicitly in ``setup.py``::
127
128         from babel.messages.frontend import compile_catalog
129
130         setup(
131             ...
132             cmdclass = {'compile_catalog': compile_catalog}
133         )
134
135     .. versionadded:: 0.9
136     """
137
138     description = 'compile message catalogs to binary MO files'
139     user_options = [
140         ('domain=', 'D',
141          "domains of PO files (space separated list, default 'messages')"),
142         ('directory=', 'd',
143          'path to base directory containing the catalogs'),
144         ('input-file=', 'i',
145          'name of the input file'),
146         ('output-file=', 'o',
147          "name of the output file (default "
148          "'<output_dir>/<locale>/LC_MESSAGES/<domain>.mo')"),
149         ('locale=', 'l',
150          'locale of the catalog to compile'),
151         ('use-fuzzy', 'f',
152          'also include fuzzy translations'),
153         ('statistics', None,
154          'print statistics about translations')
155     ]
156     boolean_options = ['use-fuzzy', 'statistics']
157
158     def initialize_options(self):
159         self.domain = 'messages'
160         self.directory = None
161         self.input_file = None
162         self.output_file = None
163         self.locale = None
164         self.use_fuzzy = False
165         self.statistics = False
166
167     def finalize_options(self):
168         self.domain = listify_value(self.domain)
169         if not self.input_file and not self.directory:
170             raise DistutilsOptionError('you must specify either the input file '
171                                        'or the base directory')
172         if not self.output_file and not self.directory:
173             raise DistutilsOptionError('you must specify either the output file '
174                                        'or the base directory')
175
176     def run(self):
177         for domain in self.domain:
178             self._run_domain(domain)
179
180     def _run_domain(self, domain):
181         po_files = []
182         mo_files = []
183
184         if not self.input_file:
185             if self.locale:
186                 po_files.append((self.locale,
187                                  os.path.join(self.directory, self.locale,
188                                               'LC_MESSAGES',
189                                               domain + '.po')))
190                 mo_files.append(os.path.join(self.directory, self.locale,
191                                              'LC_MESSAGES',
192                                              domain + '.mo'))
193             else:
194                 for locale in os.listdir(self.directory):
195                     po_file = os.path.join(self.directory, locale,
196                                            'LC_MESSAGES', domain + '.po')
197                     if os.path.exists(po_file):
198                         po_files.append((locale, po_file))
199                         mo_files.append(os.path.join(self.directory, locale,
200                                                      'LC_MESSAGES',
201                                                      domain + '.mo'))
202         else:
203             po_files.append((self.locale, self.input_file))
204             if self.output_file:
205                 mo_files.append(self.output_file)
206             else:
207                 mo_files.append(os.path.join(self.directory, self.locale,
208                                              'LC_MESSAGES',
209                                              domain + '.mo'))
210
211         if not po_files:
212             raise DistutilsOptionError('no message catalogs found')
213
214         for idx, (locale, po_file) in enumerate(po_files):
215             mo_file = mo_files[idx]
216             infile = open(po_file, 'rb')
217             try:
218                 catalog = read_po(infile, locale)
219             finally:
220                 infile.close()
221
222             if self.statistics:
223                 translated = 0
224                 for message in list(catalog)[1:]:
225                     if message.string:
226                         translated += 1
227                 percentage = 0
228                 if len(catalog):
229                     percentage = translated * 100 // len(catalog)
230                 self.log.info(
231                     '%d of %d messages (%d%%) translated in %s',
232                     translated, len(catalog), percentage, po_file
233                 )
234
235             if catalog.fuzzy and not self.use_fuzzy:
236                 self.log.info('catalog %s is marked as fuzzy, skipping', po_file)
237                 continue
238
239             for message, errors in catalog.check():
240                 for error in errors:
241                     self.log.error(
242                         'error: %s:%d: %s', po_file, message.lineno, error
243                     )
244
245             self.log.info('compiling catalog %s to %s', po_file, mo_file)
246
247             outfile = open(mo_file, 'wb')
248             try:
249                 write_mo(outfile, catalog, use_fuzzy=self.use_fuzzy)
250             finally:
251                 outfile.close()
252
253
254 class extract_messages(Command):
255     """Message extraction command for use in ``setup.py`` scripts.
256
257     If correctly installed, this command is available to Setuptools-using
258     setup scripts automatically. For projects using plain old ``distutils``,
259     the command needs to be registered explicitly in ``setup.py``::
260
261         from babel.messages.frontend import extract_messages
262
263         setup(
264             ...
265             cmdclass = {'extract_messages': extract_messages}
266         )
267     """
268
269     description = 'extract localizable strings from the project code'
270     user_options = [
271         ('charset=', None,
272          'charset to use in the output file (default "utf-8")'),
273         ('keywords=', 'k',
274          'space-separated list of keywords to look for in addition to the '
275          'defaults (may be repeated multiple times)'),
276         ('no-default-keywords', None,
277          'do not include the default keywords'),
278         ('mapping-file=', 'F',
279          'path to the mapping configuration file'),
280         ('no-location', None,
281          'do not include location comments with filename and line number'),
282         ('omit-header', None,
283          'do not include msgid "" entry in header'),
284         ('output-file=', 'o',
285          'name of the output file'),
286         ('width=', 'w',
287          'set output line width (default 76)'),
288         ('no-wrap', None,
289          'do not break long message lines, longer than the output line width, '
290          'into several lines'),
291         ('sort-output', None,
292          'generate sorted output (default False)'),
293         ('sort-by-file', None,
294          'sort output by file location (default False)'),
295         ('msgid-bugs-address=', None,
296          'set report address for msgid'),
297         ('copyright-holder=', None,
298          'set copyright holder in output'),
299         ('project=', None,
300          'set project name in output'),
301         ('version=', None,
302          'set project version in output'),
303         ('add-comments=', 'c',
304          'place comment block with TAG (or those preceding keyword lines) in '
305          'output file. Separate multiple TAGs with commas(,)'),  # TODO: Support repetition of this argument
306         ('strip-comments', 's',
307          'strip the comment TAGs from the comments.'),
308         ('input-paths=', None,
309          'files or directories that should be scanned for messages. Separate multiple '
310          'files or directories with commas(,)'),  # TODO: Support repetition of this argument
311         ('input-dirs=', None,  # TODO (3.x): Remove me.
312          'alias for input-paths (does allow files as well as directories).'),
313     ]
314     boolean_options = [
315         'no-default-keywords', 'no-location', 'omit-header', 'no-wrap',
316         'sort-output', 'sort-by-file', 'strip-comments'
317     ]
318     as_args = 'input-paths'
319     multiple_value_options = ('add-comments', 'keywords')
320     option_aliases = {
321         'keywords': ('--keyword',),
322         'mapping-file': ('--mapping',),
323         'output-file': ('--output',),
324         'strip-comments': ('--strip-comment-tags',),
325     }
326
327     def initialize_options(self):
328         self.charset = 'utf-8'
329         self.keywords = None
330         self.no_default_keywords = False
331         self.mapping_file = None
332         self.no_location = False
333         self.omit_header = False
334         self.output_file = None
335         self.input_dirs = None
336         self.input_paths = None
337         self.width = None
338         self.no_wrap = False
339         self.sort_output = False
340         self.sort_by_file = False
341         self.msgid_bugs_address = None
342         self.copyright_holder = None
343         self.project = None
344         self.version = None
345         self.add_comments = None
346         self.strip_comments = False
347
348     def finalize_options(self):
349         if self.input_dirs:
350             if not self.input_paths:
351                 self.input_paths = self.input_dirs
352             else:
353                 raise DistutilsOptionError(
354                     'input-dirs and input-paths are mutually exclusive'
355                 )
356
357         if self.no_default_keywords:
358             keywords = {}
359         else:
360             keywords = DEFAULT_KEYWORDS.copy()
361
362         keywords.update(parse_keywords(listify_value(self.keywords)))
363
364         self.keywords = keywords
365
366         if not self.keywords:
367             raise DistutilsOptionError('you must specify new keywords if you '
368                                        'disable the default ones')
369
370         if not self.output_file:
371             raise DistutilsOptionError('no output file specified')
372         if self.no_wrap and self.width:
373             raise DistutilsOptionError("'--no-wrap' and '--width' are mutually "
374                                        "exclusive")
375         if not self.no_wrap and not self.width:
376             self.width = 76
377         elif self.width is not None:
378             self.width = int(self.width)
379
380         if self.sort_output and self.sort_by_file:
381             raise DistutilsOptionError("'--sort-output' and '--sort-by-file' "
382                                        "are mutually exclusive")
383
384         if self.input_paths:
385             if isinstance(self.input_paths, string_types):
386                 self.input_paths = re.split(',\s*', self.input_paths)
387         elif self.distribution is not None:
388             self.input_paths = dict.fromkeys([
389                 k.split('.', 1)[0]
390                 for k in (self.distribution.packages or ())
391             ]).keys()
392         else:
393             self.input_paths = []
394
395         if not self.input_paths:
396             raise DistutilsOptionError("no input files or directories specified")
397
398         for path in self.input_paths:
399             if not os.path.exists(path):
400                 raise DistutilsOptionError("Input path: %s does not exist" % path)
401
402         self.add_comments = listify_value(self.add_comments or (), ",")
403
404         if self.distribution:
405             if not self.project:
406                 self.project = self.distribution.get_name()
407             if not self.version:
408                 self.version = self.distribution.get_version()
409
410     def run(self):
411         mappings = self._get_mappings()
412         with open(self.output_file, 'wb') as outfile:
413             catalog = Catalog(project=self.project,
414                               version=self.version,
415                               msgid_bugs_address=self.msgid_bugs_address,
416                               copyright_holder=self.copyright_holder,
417                               charset=self.charset)
418
419             for path, (method_map, options_map) in mappings.items():
420                 def callback(filename, method, options):
421                     if method == 'ignore':
422                         return
423
424                     # If we explicitly provide a full filepath, just use that.
425                     # Otherwise, path will be the directory path and filename
426                     # is the relative path from that dir to the file.
427                     # So we can join those to get the full filepath.
428                     if os.path.isfile(path):
429                         filepath = path
430                     else:
431                         filepath = os.path.normpath(os.path.join(path, filename))
432
433                     optstr = ''
434                     if options:
435                         optstr = ' (%s)' % ', '.join(['%s="%s"' % (k, v) for
436                                                       k, v in options.items()])
437                     self.log.info('extracting messages from %s%s', filepath, optstr)
438
439                 if os.path.isfile(path):
440                     current_dir = os.getcwd()
441                     extracted = check_and_call_extract_file(
442                         path, method_map, options_map,
443                         callback, self.keywords, self.add_comments,
444                         self.strip_comments, current_dir
445                     )
446                 else:
447                     extracted = extract_from_dir(
448                         path, method_map, options_map,
449                         keywords=self.keywords,
450                         comment_tags=self.add_comments,
451                         callback=callback,
452                         strip_comment_tags=self.strip_comments
453                     )
454                 for filename, lineno, message, comments, context in extracted:
455                     if os.path.isfile(path):
456                         filepath = filename  # already normalized
457                     else:
458                         filepath = os.path.normpath(os.path.join(path, filename))
459
460                     catalog.add(message, None, [(filepath, lineno)],
461                                 auto_comments=comments, context=context)
462
463             self.log.info('writing PO template file to %s' % self.output_file)
464             write_po(outfile, catalog, width=self.width,
465                      no_location=self.no_location,
466                      omit_header=self.omit_header,
467                      sort_output=self.sort_output,
468                      sort_by_file=self.sort_by_file)
469
470     def _get_mappings(self):
471         mappings = {}
472
473         if self.mapping_file:
474             fileobj = open(self.mapping_file, 'U')
475             try:
476                 method_map, options_map = parse_mapping(fileobj)
477                 for path in self.input_paths:
478                     mappings[path] = method_map, options_map
479             finally:
480                 fileobj.close()
481
482         elif getattr(self.distribution, 'message_extractors', None):
483             message_extractors = self.distribution.message_extractors
484             for path, mapping in message_extractors.items():
485                 if isinstance(mapping, string_types):
486                     method_map, options_map = parse_mapping(StringIO(mapping))
487                 else:
488                     method_map, options_map = [], {}
489                     for pattern, method, options in mapping:
490                         method_map.append((pattern, method))
491                         options_map[pattern] = options or {}
492                 mappings[path] = method_map, options_map
493
494         else:
495             for path in self.input_paths:
496                 mappings[path] = DEFAULT_MAPPING, {}
497
498         return mappings
499
500
501 def check_message_extractors(dist, name, value):
502     """Validate the ``message_extractors`` keyword argument to ``setup()``.
503
504     :param dist: the distutils/setuptools ``Distribution`` object
505     :param name: the name of the keyword argument (should always be
506                  "message_extractors")
507     :param value: the value of the keyword argument
508     :raise `DistutilsSetupError`: if the value is not valid
509     """
510     assert name == 'message_extractors'
511     if not isinstance(value, dict):
512         raise DistutilsSetupError('the value of the "message_extractors" '
513                                   'parameter must be a dictionary')
514
515
516 class init_catalog(Command):
517     """New catalog initialization command for use in ``setup.py`` scripts.
518
519     If correctly installed, this command is available to Setuptools-using
520     setup scripts automatically. For projects using plain old ``distutils``,
521     the command needs to be registered explicitly in ``setup.py``::
522
523         from babel.messages.frontend import init_catalog
524
525         setup(
526             ...
527             cmdclass = {'init_catalog': init_catalog}
528         )
529     """
530
531     description = 'create a new catalog based on a POT file'
532     user_options = [
533         ('domain=', 'D',
534          "domain of PO file (default 'messages')"),
535         ('input-file=', 'i',
536          'name of the input file'),
537         ('output-dir=', 'd',
538          'path to output directory'),
539         ('output-file=', 'o',
540          "name of the output file (default "
541          "'<output_dir>/<locale>/LC_MESSAGES/<domain>.po')"),
542         ('locale=', 'l',
543          'locale for the new localized catalog'),
544         ('width=', 'w',
545          'set output line width (default 76)'),
546         ('no-wrap', None,
547          'do not break long message lines, longer than the output line width, '
548          'into several lines'),
549     ]
550     boolean_options = ['no-wrap']
551
552     def initialize_options(self):
553         self.output_dir = None
554         self.output_file = None
555         self.input_file = None
556         self.locale = None
557         self.domain = 'messages'
558         self.no_wrap = False
559         self.width = None
560
561     def finalize_options(self):
562         if not self.input_file:
563             raise DistutilsOptionError('you must specify the input file')
564
565         if not self.locale:
566             raise DistutilsOptionError('you must provide a locale for the '
567                                        'new catalog')
568         try:
569             self._locale = Locale.parse(self.locale)
570         except UnknownLocaleError as e:
571             raise DistutilsOptionError(e)
572
573         if not self.output_file and not self.output_dir:
574             raise DistutilsOptionError('you must specify the output directory')
575         if not self.output_file:
576             self.output_file = os.path.join(self.output_dir, self.locale,
577                                             'LC_MESSAGES', self.domain + '.po')
578
579         if not os.path.exists(os.path.dirname(self.output_file)):
580             os.makedirs(os.path.dirname(self.output_file))
581         if self.no_wrap and self.width:
582             raise DistutilsOptionError("'--no-wrap' and '--width' are mutually "
583                                        "exclusive")
584         if not self.no_wrap and not self.width:
585             self.width = 76
586         elif self.width is not None:
587             self.width = int(self.width)
588
589     def run(self):
590         self.log.info(
591             'creating catalog %s based on %s', self.output_file, self.input_file
592         )
593
594         infile = open(self.input_file, 'rb')
595         try:
596             # Although reading from the catalog template, read_po must be fed
597             # the locale in order to correctly calculate plurals
598             catalog = read_po(infile, locale=self.locale)
599         finally:
600             infile.close()
601
602         catalog.locale = self._locale
603         catalog.revision_date = datetime.now(LOCALTZ)
604         catalog.fuzzy = False
605
606         outfile = open(self.output_file, 'wb')
607         try:
608             write_po(outfile, catalog, width=self.width)
609         finally:
610             outfile.close()
611
612
613 class update_catalog(Command):
614     """Catalog merging command for use in ``setup.py`` scripts.
615
616     If correctly installed, this command is available to Setuptools-using
617     setup scripts automatically. For projects using plain old ``distutils``,
618     the command needs to be registered explicitly in ``setup.py``::
619
620         from babel.messages.frontend import update_catalog
621
622         setup(
623             ...
624             cmdclass = {'update_catalog': update_catalog}
625         )
626
627     .. versionadded:: 0.9
628     """
629
630     description = 'update message catalogs from a POT file'
631     user_options = [
632         ('domain=', 'D',
633          "domain of PO file (default 'messages')"),
634         ('input-file=', 'i',
635          'name of the input file'),
636         ('output-dir=', 'd',
637          'path to base directory containing the catalogs'),
638         ('output-file=', 'o',
639          "name of the output file (default "
640          "'<output_dir>/<locale>/LC_MESSAGES/<domain>.po')"),
641         ('locale=', 'l',
642          'locale of the catalog to compile'),
643         ('width=', 'w',
644          'set output line width (default 76)'),
645         ('no-wrap', None,
646          'do not break long message lines, longer than the output line width, '
647          'into several lines'),
648         ('ignore-obsolete=', None,
649          'whether to omit obsolete messages from the output'),
650         ('no-fuzzy-matching', 'N',
651          'do not use fuzzy matching'),
652         ('update-header-comment', None,
653          'update target header comment'),
654         ('previous', None,
655          'keep previous msgids of translated messages')
656     ]
657     boolean_options = ['no-wrap', 'ignore-obsolete', 'no-fuzzy-matching', 'previous', 'update-header-comment']
658
659     def initialize_options(self):
660         self.domain = 'messages'
661         self.input_file = None
662         self.output_dir = None
663         self.output_file = None
664         self.locale = None
665         self.width = None
666         self.no_wrap = False
667         self.ignore_obsolete = False
668         self.no_fuzzy_matching = False
669         self.update_header_comment = False
670         self.previous = False
671
672     def finalize_options(self):
673         if not self.input_file:
674             raise DistutilsOptionError('you must specify the input file')
675         if not self.output_file and not self.output_dir:
676             raise DistutilsOptionError('you must specify the output file or '
677                                        'directory')
678         if self.output_file and not self.locale:
679             raise DistutilsOptionError('you must specify the locale')
680         if self.no_wrap and self.width:
681             raise DistutilsOptionError("'--no-wrap' and '--width' are mutually "
682                                        "exclusive")
683         if not self.no_wrap and not self.width:
684             self.width = 76
685         elif self.width is not None:
686             self.width = int(self.width)
687         if self.no_fuzzy_matching and self.previous:
688             self.previous = False
689
690     def run(self):
691         po_files = []
692         if not self.output_file:
693             if self.locale:
694                 po_files.append((self.locale,
695                                  os.path.join(self.output_dir, self.locale,
696                                               'LC_MESSAGES',
697                                               self.domain + '.po')))
698             else:
699                 for locale in os.listdir(self.output_dir):
700                     po_file = os.path.join(self.output_dir, locale,
701                                            'LC_MESSAGES',
702                                            self.domain + '.po')
703                     if os.path.exists(po_file):
704                         po_files.append((locale, po_file))
705         else:
706             po_files.append((self.locale, self.output_file))
707
708         domain = self.domain
709         if not domain:
710             domain = os.path.splitext(os.path.basename(self.input_file))[0]
711
712         infile = open(self.input_file, 'rb')
713         try:
714             template = read_po(infile)
715         finally:
716             infile.close()
717
718         if not po_files:
719             raise DistutilsOptionError('no message catalogs found')
720
721         for locale, filename in po_files:
722             self.log.info('updating catalog %s based on %s', filename, self.input_file)
723             infile = open(filename, 'rb')
724             try:
725                 catalog = read_po(infile, locale=locale, domain=domain)
726             finally:
727                 infile.close()
728
729             catalog.update(
730                 template, self.no_fuzzy_matching,
731                 update_header_comment=self.update_header_comment
732             )
733
734             tmpname = os.path.join(os.path.dirname(filename),
735                                    tempfile.gettempprefix() +
736                                    os.path.basename(filename))
737             tmpfile = open(tmpname, 'wb')
738             try:
739                 try:
740                     write_po(tmpfile, catalog,
741                              ignore_obsolete=self.ignore_obsolete,
742                              include_previous=self.previous, width=self.width)
743                 finally:
744                     tmpfile.close()
745             except:
746                 os.remove(tmpname)
747                 raise
748
749             try:
750                 os.rename(tmpname, filename)
751             except OSError:
752                 # We're probably on Windows, which doesn't support atomic
753                 # renames, at least not through Python
754                 # If the error is in fact due to a permissions problem, that
755                 # same error is going to be raised from one of the following
756                 # operations
757                 os.remove(filename)
758                 shutil.copy(tmpname, filename)
759                 os.remove(tmpname)
760
761
762 class CommandLineInterface(object):
763     """Command-line interface.
764
765     This class provides a simple command-line interface to the message
766     extraction and PO file generation functionality.
767     """
768
769     usage = '%%prog %s [options] %s'
770     version = '%%prog %s' % VERSION
771     commands = {
772         'compile': 'compile message catalogs to MO files',
773         'extract': 'extract messages from source files and generate a POT file',
774         'init': 'create new message catalogs from a POT file',
775         'update': 'update existing message catalogs from a POT file'
776     }
777
778     command_classes = {
779         'compile': compile_catalog,
780         'extract': extract_messages,
781         'init': init_catalog,
782         'update': update_catalog,
783     }
784
785     log = None  # Replaced on instance level
786
787     def run(self, argv=None):
788         """Main entry point of the command-line interface.
789
790         :param argv: list of arguments passed on the command-line
791         """
792
793         if argv is None:
794             argv = sys.argv
795
796         self.parser = optparse.OptionParser(usage=self.usage % ('command', '[args]'),
797                                             version=self.version)
798         self.parser.disable_interspersed_args()
799         self.parser.print_help = self._help
800         self.parser.add_option('--list-locales', dest='list_locales',
801                                action='store_true',
802                                help="print all known locales and exit")
803         self.parser.add_option('-v', '--verbose', action='store_const',
804                                dest='loglevel', const=logging.DEBUG,
805                                help='print as much as possible')
806         self.parser.add_option('-q', '--quiet', action='store_const',
807                                dest='loglevel', const=logging.ERROR,
808                                help='print as little as possible')
809         self.parser.set_defaults(list_locales=False, loglevel=logging.INFO)
810
811         options, args = self.parser.parse_args(argv[1:])
812
813         self._configure_logging(options.loglevel)
814         if options.list_locales:
815             identifiers = localedata.locale_identifiers()
816             longest = max([len(identifier) for identifier in identifiers])
817             identifiers.sort()
818             format = u'%%-%ds %%s' % (longest + 1)
819             for identifier in identifiers:
820                 locale = Locale.parse(identifier)
821                 output = format % (identifier, locale.english_name)
822                 print(output.encode(sys.stdout.encoding or
823                                     getpreferredencoding() or
824                                     'ascii', 'replace'))
825             return 0
826
827         if not args:
828             self.parser.error('no valid command or option passed. '
829                               'Try the -h/--help option for more information.')
830
831         cmdname = args[0]
832         if cmdname not in self.commands:
833             self.parser.error('unknown command "%s"' % cmdname)
834
835         cmdinst = self._configure_command(cmdname, args[1:])
836         return cmdinst.run()
837
838     def _configure_logging(self, loglevel):
839         self.log = logging.getLogger('babel')
840         self.log.setLevel(loglevel)
841         # Don't add a new handler for every instance initialization (#227), this
842         # would cause duplicated output when the CommandLineInterface as an
843         # normal Python class.
844         if self.log.handlers:
845             handler = self.log.handlers[0]
846         else:
847             handler = logging.StreamHandler()
848             self.log.addHandler(handler)
849         handler.setLevel(loglevel)
850         formatter = logging.Formatter('%(message)s')
851         handler.setFormatter(formatter)
852
853     def _help(self):
854         print(self.parser.format_help())
855         print("commands:")
856         longest = max([len(command) for command in self.commands])
857         format = "  %%-%ds %%s" % max(8, longest + 1)
858         commands = sorted(self.commands.items())
859         for name, description in commands:
860             print(format % (name, description))
861
862     def _configure_command(self, cmdname, argv):
863         """
864         :type cmdname: str
865         :type argv: list[str]
866         """
867         cmdclass = self.command_classes[cmdname]
868         cmdinst = cmdclass()
869         if self.log:
870             cmdinst.log = self.log  # Use our logger, not distutils'.
871         assert isinstance(cmdinst, Command)
872         cmdinst.initialize_options()
873
874         parser = optparse.OptionParser(
875             usage=self.usage % (cmdname, ''),
876             description=self.commands[cmdname]
877         )
878         as_args = getattr(cmdclass, "as_args", ())
879         for long, short, help in cmdclass.user_options:
880             name = long.strip("=")
881             default = getattr(cmdinst, name.replace('-', '_'))
882             strs = ["--%s" % name]
883             if short:
884                 strs.append("-%s" % short)
885             strs.extend(cmdclass.option_aliases.get(name, ()))
886             if name == as_args:
887                 parser.usage += "<%s>" % name
888             elif name in cmdclass.boolean_options:
889                 parser.add_option(*strs, action="store_true", help=help)
890             elif name in cmdclass.multiple_value_options:
891                 parser.add_option(*strs, action="append", help=help)
892             else:
893                 parser.add_option(*strs, help=help, default=default)
894         options, args = parser.parse_args(argv)
895
896         if as_args:
897             setattr(options, as_args.replace('-', '_'), args)
898
899         for key, value in vars(options).items():
900             setattr(cmdinst, key, value)
901
902         try:
903             cmdinst.ensure_finalized()
904         except DistutilsOptionError as err:
905             parser.error(str(err))
906
907         return cmdinst
908
909
910 def main():
911     return CommandLineInterface().run(sys.argv)
912
913
914 def parse_mapping(fileobj, filename=None):
915     """Parse an extraction method mapping from a file-like object.
916
917     >>> buf = StringIO('''
918     ... [extractors]
919     ... custom = mypackage.module:myfunc
920     ...
921     ... # Python source files
922     ... [python: **.py]
923     ...
924     ... # Genshi templates
925     ... [genshi: **/templates/**.html]
926     ... include_attrs =
927     ... [genshi: **/templates/**.txt]
928     ... template_class = genshi.template:TextTemplate
929     ... encoding = latin-1
930     ...
931     ... # Some custom extractor
932     ... [custom: **/custom/*.*]
933     ... ''')
934
935     >>> method_map, options_map = parse_mapping(buf)
936     >>> len(method_map)
937     4
938
939     >>> method_map[0]
940     ('**.py', 'python')
941     >>> options_map['**.py']
942     {}
943     >>> method_map[1]
944     ('**/templates/**.html', 'genshi')
945     >>> options_map['**/templates/**.html']['include_attrs']
946     ''
947     >>> method_map[2]
948     ('**/templates/**.txt', 'genshi')
949     >>> options_map['**/templates/**.txt']['template_class']
950     'genshi.template:TextTemplate'
951     >>> options_map['**/templates/**.txt']['encoding']
952     'latin-1'
953
954     >>> method_map[3]
955     ('**/custom/*.*', 'mypackage.module:myfunc')
956     >>> options_map['**/custom/*.*']
957     {}
958
959     :param fileobj: a readable file-like object containing the configuration
960                     text to parse
961     :see: `extract_from_directory`
962     """
963     extractors = {}
964     method_map = []
965     options_map = {}
966
967     parser = RawConfigParser()
968     parser._sections = odict(parser._sections)  # We need ordered sections
969     parser.readfp(fileobj, filename)
970     for section in parser.sections():
971         if section == 'extractors':
972             extractors = dict(parser.items(section))
973         else:
974             method, pattern = [part.strip() for part in section.split(':', 1)]
975             method_map.append((pattern, method))
976             options_map[pattern] = dict(parser.items(section))
977
978     if extractors:
979         for idx, (pattern, method) in enumerate(method_map):
980             if method in extractors:
981                 method = extractors[method]
982             method_map[idx] = (pattern, method)
983
984     return (method_map, options_map)
985
986
987 def parse_keywords(strings=[]):
988     """Parse keywords specifications from the given list of strings.
989
990     >>> kw = sorted(parse_keywords(['_', 'dgettext:2', 'dngettext:2,3', 'pgettext:1c,2']).items())
991     >>> for keyword, indices in kw:
992     ...     print((keyword, indices))
993     ('_', None)
994     ('dgettext', (2,))
995     ('dngettext', (2, 3))
996     ('pgettext', ((1, 'c'), 2))
997     """
998     keywords = {}
999     for string in strings:
1000         if ':' in string:
1001             funcname, indices = string.split(':')
1002         else:
1003             funcname, indices = string, None
1004         if funcname not in keywords:
1005             if indices:
1006                 inds = []
1007                 for x in indices.split(','):
1008                     if x[-1] == 'c':
1009                         inds.append((int(x[:-1]), 'c'))
1010                     else:
1011                         inds.append(int(x))
1012                 indices = tuple(inds)
1013             keywords[funcname] = indices
1014     return keywords
1015
1016
1017 if __name__ == '__main__':
1018     main()