1041 lines
38 KiB
Python
1041 lines
38 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
babel.messages.frontend
|
|
~~~~~~~~~~~~~~~~~~~~~~~
|
|
|
|
Frontends for the message extraction functionality.
|
|
|
|
:copyright: (c) 2013-2019 by the Babel Team.
|
|
:license: BSD, see LICENSE for more details.
|
|
"""
|
|
from __future__ import print_function
|
|
|
|
import logging
|
|
import optparse
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
from collections import OrderedDict
|
|
from datetime import datetime
|
|
from locale import getpreferredencoding
|
|
|
|
from babel import __version__ as VERSION
|
|
from babel import Locale, localedata
|
|
from babel._compat import StringIO, string_types, text_type, PY2
|
|
from babel.core import UnknownLocaleError
|
|
from babel.messages.catalog import Catalog
|
|
from babel.messages.extract import DEFAULT_KEYWORDS, DEFAULT_MAPPING, check_and_call_extract_file, extract_from_dir
|
|
from babel.messages.mofile import write_mo
|
|
from babel.messages.pofile import read_po, write_po
|
|
from babel.util import LOCALTZ
|
|
from distutils import log as distutils_log
|
|
from distutils.cmd import Command as _Command
|
|
from distutils.errors import DistutilsOptionError, DistutilsSetupError
|
|
|
|
try:
|
|
from ConfigParser import RawConfigParser
|
|
except ImportError:
|
|
from configparser import RawConfigParser
|
|
|
|
|
|
po_file_read_mode = ('rU' if PY2 else 'r')
|
|
|
|
|
|
def listify_value(arg, split=None):
|
|
"""
|
|
Make a list out of an argument.
|
|
|
|
Values from `distutils` argument parsing are always single strings;
|
|
values from `optparse` parsing may be lists of strings that may need
|
|
to be further split.
|
|
|
|
No matter the input, this function returns a flat list of whitespace-trimmed
|
|
strings, with `None` values filtered out.
|
|
|
|
>>> listify_value("foo bar")
|
|
['foo', 'bar']
|
|
>>> listify_value(["foo bar"])
|
|
['foo', 'bar']
|
|
>>> listify_value([["foo"], "bar"])
|
|
['foo', 'bar']
|
|
>>> listify_value([["foo"], ["bar", None, "foo"]])
|
|
['foo', 'bar', 'foo']
|
|
>>> listify_value("foo, bar, quux", ",")
|
|
['foo', 'bar', 'quux']
|
|
|
|
:param arg: A string or a list of strings
|
|
:param split: The argument to pass to `str.split()`.
|
|
:return:
|
|
"""
|
|
out = []
|
|
|
|
if not isinstance(arg, (list, tuple)):
|
|
arg = [arg]
|
|
|
|
for val in arg:
|
|
if val is None:
|
|
continue
|
|
if isinstance(val, (list, tuple)):
|
|
out.extend(listify_value(val, split=split))
|
|
continue
|
|
out.extend(s.strip() for s in text_type(val).split(split))
|
|
assert all(isinstance(val, string_types) for val in out)
|
|
return out
|
|
|
|
|
|
class Command(_Command):
|
|
# This class is a small shim between Distutils commands and
|
|
# optparse option parsing in the frontend command line.
|
|
|
|
#: Option name to be input as `args` on the script command line.
|
|
as_args = None
|
|
|
|
#: Options which allow multiple values.
|
|
#: This is used by the `optparse` transmogrification code.
|
|
multiple_value_options = ()
|
|
|
|
#: Options which are booleans.
|
|
#: This is used by the `optparse` transmogrification code.
|
|
# (This is actually used by distutils code too, but is never
|
|
# declared in the base class.)
|
|
boolean_options = ()
|
|
|
|
#: Option aliases, to retain standalone command compatibility.
|
|
#: Distutils does not support option aliases, but optparse does.
|
|
#: This maps the distutils argument name to an iterable of aliases
|
|
#: that are usable with optparse.
|
|
option_aliases = {}
|
|
|
|
#: Choices for options that needed to be restricted to specific
|
|
#: list of choices.
|
|
option_choices = {}
|
|
|
|
#: Log object. To allow replacement in the script command line runner.
|
|
log = distutils_log
|
|
|
|
def __init__(self, dist=None):
|
|
# A less strict version of distutils' `__init__`.
|
|
self.distribution = dist
|
|
self.initialize_options()
|
|
self._dry_run = None
|
|
self.verbose = False
|
|
self.force = None
|
|
self.help = 0
|
|
self.finalized = 0
|
|
|
|
|
|
class compile_catalog(Command):
|
|
"""Catalog compilation command for use in ``setup.py`` scripts.
|
|
|
|
If correctly installed, this command is available to Setuptools-using
|
|
setup scripts automatically. For projects using plain old ``distutils``,
|
|
the command needs to be registered explicitly in ``setup.py``::
|
|
|
|
from babel.messages.frontend import compile_catalog
|
|
|
|
setup(
|
|
...
|
|
cmdclass = {'compile_catalog': compile_catalog}
|
|
)
|
|
|
|
.. versionadded:: 0.9
|
|
"""
|
|
|
|
description = 'compile message catalogs to binary MO files'
|
|
user_options = [
|
|
('domain=', 'D',
|
|
"domains of PO files (space separated list, default 'messages')"),
|
|
('directory=', 'd',
|
|
'path to base directory containing the catalogs'),
|
|
('input-file=', 'i',
|
|
'name of the input file'),
|
|
('output-file=', 'o',
|
|
"name of the output file (default "
|
|
"'<output_dir>/<locale>/LC_MESSAGES/<domain>.mo')"),
|
|
('locale=', 'l',
|
|
'locale of the catalog to compile'),
|
|
('use-fuzzy', 'f',
|
|
'also include fuzzy translations'),
|
|
('statistics', None,
|
|
'print statistics about translations')
|
|
]
|
|
boolean_options = ['use-fuzzy', 'statistics']
|
|
|
|
def initialize_options(self):
|
|
self.domain = 'messages'
|
|
self.directory = None
|
|
self.input_file = None
|
|
self.output_file = None
|
|
self.locale = None
|
|
self.use_fuzzy = False
|
|
self.statistics = False
|
|
|
|
def finalize_options(self):
|
|
self.domain = listify_value(self.domain)
|
|
if not self.input_file and not self.directory:
|
|
raise DistutilsOptionError('you must specify either the input file '
|
|
'or the base directory')
|
|
if not self.output_file and not self.directory:
|
|
raise DistutilsOptionError('you must specify either the output file '
|
|
'or the base directory')
|
|
|
|
def run(self):
|
|
n_errors = 0
|
|
for domain in self.domain:
|
|
for catalog, errors in self._run_domain(domain).items():
|
|
n_errors += len(errors)
|
|
if n_errors:
|
|
self.log.error('%d errors encountered.' % n_errors)
|
|
return (1 if n_errors else 0)
|
|
|
|
def _run_domain(self, domain):
|
|
po_files = []
|
|
mo_files = []
|
|
|
|
if not self.input_file:
|
|
if self.locale:
|
|
po_files.append((self.locale,
|
|
os.path.join(self.directory, self.locale,
|
|
'LC_MESSAGES',
|
|
domain + '.po')))
|
|
mo_files.append(os.path.join(self.directory, self.locale,
|
|
'LC_MESSAGES',
|
|
domain + '.mo'))
|
|
else:
|
|
for locale in os.listdir(self.directory):
|
|
po_file = os.path.join(self.directory, locale,
|
|
'LC_MESSAGES', domain + '.po')
|
|
if os.path.exists(po_file):
|
|
po_files.append((locale, po_file))
|
|
mo_files.append(os.path.join(self.directory, locale,
|
|
'LC_MESSAGES',
|
|
domain + '.mo'))
|
|
else:
|
|
po_files.append((self.locale, self.input_file))
|
|
if self.output_file:
|
|
mo_files.append(self.output_file)
|
|
else:
|
|
mo_files.append(os.path.join(self.directory, self.locale,
|
|
'LC_MESSAGES',
|
|
domain + '.mo'))
|
|
|
|
if not po_files:
|
|
raise DistutilsOptionError('no message catalogs found')
|
|
|
|
catalogs_and_errors = {}
|
|
|
|
for idx, (locale, po_file) in enumerate(po_files):
|
|
mo_file = mo_files[idx]
|
|
with open(po_file, 'rb') as infile:
|
|
catalog = read_po(infile, locale)
|
|
|
|
if self.statistics:
|
|
translated = 0
|
|
for message in list(catalog)[1:]:
|
|
if message.string:
|
|
translated += 1
|
|
percentage = 0
|
|
if len(catalog):
|
|
percentage = translated * 100 // len(catalog)
|
|
self.log.info(
|
|
'%d of %d messages (%d%%) translated in %s',
|
|
translated, len(catalog), percentage, po_file
|
|
)
|
|
|
|
if catalog.fuzzy and not self.use_fuzzy:
|
|
self.log.info('catalog %s is marked as fuzzy, skipping', po_file)
|
|
continue
|
|
|
|
catalogs_and_errors[catalog] = catalog_errors = list(catalog.check())
|
|
for message, errors in catalog_errors:
|
|
for error in errors:
|
|
self.log.error(
|
|
'error: %s:%d: %s', po_file, message.lineno, error
|
|
)
|
|
|
|
self.log.info('compiling catalog %s to %s', po_file, mo_file)
|
|
|
|
with open(mo_file, 'wb') as outfile:
|
|
write_mo(outfile, catalog, use_fuzzy=self.use_fuzzy)
|
|
|
|
return catalogs_and_errors
|
|
|
|
|
|
class extract_messages(Command):
|
|
"""Message extraction command for use in ``setup.py`` scripts.
|
|
|
|
If correctly installed, this command is available to Setuptools-using
|
|
setup scripts automatically. For projects using plain old ``distutils``,
|
|
the command needs to be registered explicitly in ``setup.py``::
|
|
|
|
from babel.messages.frontend import extract_messages
|
|
|
|
setup(
|
|
...
|
|
cmdclass = {'extract_messages': extract_messages}
|
|
)
|
|
"""
|
|
|
|
description = 'extract localizable strings from the project code'
|
|
user_options = [
|
|
('charset=', None,
|
|
'charset to use in the output file (default "utf-8")'),
|
|
('keywords=', 'k',
|
|
'space-separated list of keywords to look for in addition to the '
|
|
'defaults (may be repeated multiple times)'),
|
|
('no-default-keywords', None,
|
|
'do not include the default keywords'),
|
|
('mapping-file=', 'F',
|
|
'path to the mapping configuration file'),
|
|
('no-location', None,
|
|
'do not include location comments with filename and line number'),
|
|
('add-location=', None,
|
|
'location lines format. If it is not given or "full", it generates '
|
|
'the lines with both file name and line number. If it is "file", '
|
|
'the line number part is omitted. If it is "never", it completely '
|
|
'suppresses the lines (same as --no-location).'),
|
|
('omit-header', None,
|
|
'do not include msgid "" entry in header'),
|
|
('output-file=', 'o',
|
|
'name of the output file'),
|
|
('width=', 'w',
|
|
'set output line width (default 76)'),
|
|
('no-wrap', None,
|
|
'do not break long message lines, longer than the output line width, '
|
|
'into several lines'),
|
|
('sort-output', None,
|
|
'generate sorted output (default False)'),
|
|
('sort-by-file', None,
|
|
'sort output by file location (default False)'),
|
|
('msgid-bugs-address=', None,
|
|
'set report address for msgid'),
|
|
('copyright-holder=', None,
|
|
'set copyright holder in output'),
|
|
('project=', None,
|
|
'set project name in output'),
|
|
('version=', None,
|
|
'set project version in output'),
|
|
('add-comments=', 'c',
|
|
'place comment block with TAG (or those preceding keyword lines) in '
|
|
'output file. Separate multiple TAGs with commas(,)'), # TODO: Support repetition of this argument
|
|
('strip-comments', 's',
|
|
'strip the comment TAGs from the comments.'),
|
|
('input-paths=', None,
|
|
'files or directories that should be scanned for messages. Separate multiple '
|
|
'files or directories with commas(,)'), # TODO: Support repetition of this argument
|
|
('input-dirs=', None, # TODO (3.x): Remove me.
|
|
'alias for input-paths (does allow files as well as directories).'),
|
|
]
|
|
boolean_options = [
|
|
'no-default-keywords', 'no-location', 'omit-header', 'no-wrap',
|
|
'sort-output', 'sort-by-file', 'strip-comments'
|
|
]
|
|
as_args = 'input-paths'
|
|
multiple_value_options = ('add-comments', 'keywords')
|
|
option_aliases = {
|
|
'keywords': ('--keyword',),
|
|
'mapping-file': ('--mapping',),
|
|
'output-file': ('--output',),
|
|
'strip-comments': ('--strip-comment-tags',),
|
|
}
|
|
option_choices = {
|
|
'add-location': ('full', 'file', 'never',),
|
|
}
|
|
|
|
def initialize_options(self):
|
|
self.charset = 'utf-8'
|
|
self.keywords = None
|
|
self.no_default_keywords = False
|
|
self.mapping_file = None
|
|
self.no_location = False
|
|
self.add_location = None
|
|
self.omit_header = False
|
|
self.output_file = None
|
|
self.input_dirs = None
|
|
self.input_paths = None
|
|
self.width = None
|
|
self.no_wrap = False
|
|
self.sort_output = False
|
|
self.sort_by_file = False
|
|
self.msgid_bugs_address = None
|
|
self.copyright_holder = None
|
|
self.project = None
|
|
self.version = None
|
|
self.add_comments = None
|
|
self.strip_comments = False
|
|
self.include_lineno = True
|
|
|
|
def finalize_options(self):
|
|
if self.input_dirs:
|
|
if not self.input_paths:
|
|
self.input_paths = self.input_dirs
|
|
else:
|
|
raise DistutilsOptionError(
|
|
'input-dirs and input-paths are mutually exclusive'
|
|
)
|
|
|
|
if self.no_default_keywords:
|
|
keywords = {}
|
|
else:
|
|
keywords = DEFAULT_KEYWORDS.copy()
|
|
|
|
keywords.update(parse_keywords(listify_value(self.keywords)))
|
|
|
|
self.keywords = keywords
|
|
|
|
if not self.keywords:
|
|
raise DistutilsOptionError('you must specify new keywords if you '
|
|
'disable the default ones')
|
|
|
|
if not self.output_file:
|
|
raise DistutilsOptionError('no output file specified')
|
|
if self.no_wrap and self.width:
|
|
raise DistutilsOptionError("'--no-wrap' and '--width' are mutually "
|
|
"exclusive")
|
|
if not self.no_wrap and not self.width:
|
|
self.width = 76
|
|
elif self.width is not None:
|
|
self.width = int(self.width)
|
|
|
|
if self.sort_output and self.sort_by_file:
|
|
raise DistutilsOptionError("'--sort-output' and '--sort-by-file' "
|
|
"are mutually exclusive")
|
|
|
|
if self.input_paths:
|
|
if isinstance(self.input_paths, string_types):
|
|
self.input_paths = re.split(r',\s*', self.input_paths)
|
|
elif self.distribution is not None:
|
|
self.input_paths = dict.fromkeys([
|
|
k.split('.', 1)[0]
|
|
for k in (self.distribution.packages or ())
|
|
]).keys()
|
|
else:
|
|
self.input_paths = []
|
|
|
|
if not self.input_paths:
|
|
raise DistutilsOptionError("no input files or directories specified")
|
|
|
|
for path in self.input_paths:
|
|
if not os.path.exists(path):
|
|
raise DistutilsOptionError("Input path: %s does not exist" % path)
|
|
|
|
self.add_comments = listify_value(self.add_comments or (), ",")
|
|
|
|
if self.distribution:
|
|
if not self.project:
|
|
self.project = self.distribution.get_name()
|
|
if not self.version:
|
|
self.version = self.distribution.get_version()
|
|
|
|
if self.add_location == 'never':
|
|
self.no_location = True
|
|
elif self.add_location == 'file':
|
|
self.include_lineno = False
|
|
|
|
def run(self):
|
|
mappings = self._get_mappings()
|
|
with open(self.output_file, 'wb') as outfile:
|
|
catalog = Catalog(project=self.project,
|
|
version=self.version,
|
|
msgid_bugs_address=self.msgid_bugs_address,
|
|
copyright_holder=self.copyright_holder,
|
|
charset=self.charset)
|
|
|
|
for path, method_map, options_map in mappings:
|
|
def callback(filename, method, options):
|
|
if method == 'ignore':
|
|
return
|
|
|
|
# If we explicitly provide a full filepath, just use that.
|
|
# Otherwise, path will be the directory path and filename
|
|
# is the relative path from that dir to the file.
|
|
# So we can join those to get the full filepath.
|
|
if os.path.isfile(path):
|
|
filepath = path
|
|
else:
|
|
filepath = os.path.normpath(os.path.join(path, filename))
|
|
|
|
optstr = ''
|
|
if options:
|
|
optstr = ' (%s)' % ', '.join(['%s="%s"' % (k, v) for
|
|
k, v in options.items()])
|
|
self.log.info('extracting messages from %s%s', filepath, optstr)
|
|
|
|
if os.path.isfile(path):
|
|
current_dir = os.getcwd()
|
|
extracted = check_and_call_extract_file(
|
|
path, method_map, options_map,
|
|
callback, self.keywords, self.add_comments,
|
|
self.strip_comments, current_dir
|
|
)
|
|
else:
|
|
extracted = extract_from_dir(
|
|
path, method_map, options_map,
|
|
keywords=self.keywords,
|
|
comment_tags=self.add_comments,
|
|
callback=callback,
|
|
strip_comment_tags=self.strip_comments
|
|
)
|
|
for filename, lineno, message, comments, context in extracted:
|
|
if os.path.isfile(path):
|
|
filepath = filename # already normalized
|
|
else:
|
|
filepath = os.path.normpath(os.path.join(path, filename))
|
|
|
|
catalog.add(message, None, [(filepath, lineno)],
|
|
auto_comments=comments, context=context)
|
|
|
|
self.log.info('writing PO template file to %s', self.output_file)
|
|
write_po(outfile, catalog, width=self.width,
|
|
no_location=self.no_location,
|
|
omit_header=self.omit_header,
|
|
sort_output=self.sort_output,
|
|
sort_by_file=self.sort_by_file,
|
|
include_lineno=self.include_lineno)
|
|
|
|
def _get_mappings(self):
|
|
mappings = []
|
|
|
|
if self.mapping_file:
|
|
with open(self.mapping_file, po_file_read_mode) as fileobj:
|
|
method_map, options_map = parse_mapping(fileobj)
|
|
for path in self.input_paths:
|
|
mappings.append((path, method_map, options_map))
|
|
|
|
elif getattr(self.distribution, 'message_extractors', None):
|
|
message_extractors = self.distribution.message_extractors
|
|
for path, mapping in message_extractors.items():
|
|
if isinstance(mapping, string_types):
|
|
method_map, options_map = parse_mapping(StringIO(mapping))
|
|
else:
|
|
method_map, options_map = [], {}
|
|
for pattern, method, options in mapping:
|
|
method_map.append((pattern, method))
|
|
options_map[pattern] = options or {}
|
|
mappings.append((path, method_map, options_map))
|
|
|
|
else:
|
|
for path in self.input_paths:
|
|
mappings.append((path, DEFAULT_MAPPING, {}))
|
|
|
|
return mappings
|
|
|
|
|
|
def check_message_extractors(dist, name, value):
|
|
"""Validate the ``message_extractors`` keyword argument to ``setup()``.
|
|
|
|
:param dist: the distutils/setuptools ``Distribution`` object
|
|
:param name: the name of the keyword argument (should always be
|
|
"message_extractors")
|
|
:param value: the value of the keyword argument
|
|
:raise `DistutilsSetupError`: if the value is not valid
|
|
"""
|
|
assert name == 'message_extractors'
|
|
if not isinstance(value, dict):
|
|
raise DistutilsSetupError('the value of the "message_extractors" '
|
|
'parameter must be a dictionary')
|
|
|
|
|
|
class init_catalog(Command):
|
|
"""New catalog initialization command for use in ``setup.py`` scripts.
|
|
|
|
If correctly installed, this command is available to Setuptools-using
|
|
setup scripts automatically. For projects using plain old ``distutils``,
|
|
the command needs to be registered explicitly in ``setup.py``::
|
|
|
|
from babel.messages.frontend import init_catalog
|
|
|
|
setup(
|
|
...
|
|
cmdclass = {'init_catalog': init_catalog}
|
|
)
|
|
"""
|
|
|
|
description = 'create a new catalog based on a POT file'
|
|
user_options = [
|
|
('domain=', 'D',
|
|
"domain of PO file (default 'messages')"),
|
|
('input-file=', 'i',
|
|
'name of the input file'),
|
|
('output-dir=', 'd',
|
|
'path to output directory'),
|
|
('output-file=', 'o',
|
|
"name of the output file (default "
|
|
"'<output_dir>/<locale>/LC_MESSAGES/<domain>.po')"),
|
|
('locale=', 'l',
|
|
'locale for the new localized catalog'),
|
|
('width=', 'w',
|
|
'set output line width (default 76)'),
|
|
('no-wrap', None,
|
|
'do not break long message lines, longer than the output line width, '
|
|
'into several lines'),
|
|
]
|
|
boolean_options = ['no-wrap']
|
|
|
|
def initialize_options(self):
|
|
self.output_dir = None
|
|
self.output_file = None
|
|
self.input_file = None
|
|
self.locale = None
|
|
self.domain = 'messages'
|
|
self.no_wrap = False
|
|
self.width = None
|
|
|
|
def finalize_options(self):
|
|
if not self.input_file:
|
|
raise DistutilsOptionError('you must specify the input file')
|
|
|
|
if not self.locale:
|
|
raise DistutilsOptionError('you must provide a locale for the '
|
|
'new catalog')
|
|
try:
|
|
self._locale = Locale.parse(self.locale)
|
|
except UnknownLocaleError as e:
|
|
raise DistutilsOptionError(e)
|
|
|
|
if not self.output_file and not self.output_dir:
|
|
raise DistutilsOptionError('you must specify the output directory')
|
|
if not self.output_file:
|
|
self.output_file = os.path.join(self.output_dir, self.locale,
|
|
'LC_MESSAGES', self.domain + '.po')
|
|
|
|
if not os.path.exists(os.path.dirname(self.output_file)):
|
|
os.makedirs(os.path.dirname(self.output_file))
|
|
if self.no_wrap and self.width:
|
|
raise DistutilsOptionError("'--no-wrap' and '--width' are mutually "
|
|
"exclusive")
|
|
if not self.no_wrap and not self.width:
|
|
self.width = 76
|
|
elif self.width is not None:
|
|
self.width = int(self.width)
|
|
|
|
def run(self):
|
|
self.log.info(
|
|
'creating catalog %s based on %s', self.output_file, self.input_file
|
|
)
|
|
|
|
with open(self.input_file, 'rb') as infile:
|
|
# Although reading from the catalog template, read_po must be fed
|
|
# the locale in order to correctly calculate plurals
|
|
catalog = read_po(infile, locale=self.locale)
|
|
|
|
catalog.locale = self._locale
|
|
catalog.revision_date = datetime.now(LOCALTZ)
|
|
catalog.fuzzy = False
|
|
|
|
with open(self.output_file, 'wb') as outfile:
|
|
write_po(outfile, catalog, width=self.width)
|
|
|
|
|
|
class update_catalog(Command):
|
|
"""Catalog merging command for use in ``setup.py`` scripts.
|
|
|
|
If correctly installed, this command is available to Setuptools-using
|
|
setup scripts automatically. For projects using plain old ``distutils``,
|
|
the command needs to be registered explicitly in ``setup.py``::
|
|
|
|
from babel.messages.frontend import update_catalog
|
|
|
|
setup(
|
|
...
|
|
cmdclass = {'update_catalog': update_catalog}
|
|
)
|
|
|
|
.. versionadded:: 0.9
|
|
"""
|
|
|
|
description = 'update message catalogs from a POT file'
|
|
user_options = [
|
|
('domain=', 'D',
|
|
"domain of PO file (default 'messages')"),
|
|
('input-file=', 'i',
|
|
'name of the input file'),
|
|
('output-dir=', 'd',
|
|
'path to base directory containing the catalogs'),
|
|
('output-file=', 'o',
|
|
"name of the output file (default "
|
|
"'<output_dir>/<locale>/LC_MESSAGES/<domain>.po')"),
|
|
('omit-header', None,
|
|
"do not include msgid "" entry in header"),
|
|
('locale=', 'l',
|
|
'locale of the catalog to compile'),
|
|
('width=', 'w',
|
|
'set output line width (default 76)'),
|
|
('no-wrap', None,
|
|
'do not break long message lines, longer than the output line width, '
|
|
'into several lines'),
|
|
('ignore-obsolete=', None,
|
|
'whether to omit obsolete messages from the output'),
|
|
('no-fuzzy-matching', 'N',
|
|
'do not use fuzzy matching'),
|
|
('update-header-comment', None,
|
|
'update target header comment'),
|
|
('previous', None,
|
|
'keep previous msgids of translated messages'),
|
|
]
|
|
boolean_options = [
|
|
'omit-header', 'no-wrap', 'ignore-obsolete', 'no-fuzzy-matching',
|
|
'previous', 'update-header-comment',
|
|
]
|
|
|
|
def initialize_options(self):
|
|
self.domain = 'messages'
|
|
self.input_file = None
|
|
self.output_dir = None
|
|
self.output_file = None
|
|
self.omit_header = False
|
|
self.locale = None
|
|
self.width = None
|
|
self.no_wrap = False
|
|
self.ignore_obsolete = False
|
|
self.no_fuzzy_matching = False
|
|
self.update_header_comment = False
|
|
self.previous = False
|
|
|
|
def finalize_options(self):
|
|
if not self.input_file:
|
|
raise DistutilsOptionError('you must specify the input file')
|
|
if not self.output_file and not self.output_dir:
|
|
raise DistutilsOptionError('you must specify the output file or '
|
|
'directory')
|
|
if self.output_file and not self.locale:
|
|
raise DistutilsOptionError('you must specify the locale')
|
|
if self.no_wrap and self.width:
|
|
raise DistutilsOptionError("'--no-wrap' and '--width' are mutually "
|
|
"exclusive")
|
|
if not self.no_wrap and not self.width:
|
|
self.width = 76
|
|
elif self.width is not None:
|
|
self.width = int(self.width)
|
|
if self.no_fuzzy_matching and self.previous:
|
|
self.previous = False
|
|
|
|
def run(self):
|
|
po_files = []
|
|
if not self.output_file:
|
|
if self.locale:
|
|
po_files.append((self.locale,
|
|
os.path.join(self.output_dir, self.locale,
|
|
'LC_MESSAGES',
|
|
self.domain + '.po')))
|
|
else:
|
|
for locale in os.listdir(self.output_dir):
|
|
po_file = os.path.join(self.output_dir, locale,
|
|
'LC_MESSAGES',
|
|
self.domain + '.po')
|
|
if os.path.exists(po_file):
|
|
po_files.append((locale, po_file))
|
|
else:
|
|
po_files.append((self.locale, self.output_file))
|
|
|
|
if not po_files:
|
|
raise DistutilsOptionError('no message catalogs found')
|
|
|
|
domain = self.domain
|
|
if not domain:
|
|
domain = os.path.splitext(os.path.basename(self.input_file))[0]
|
|
|
|
with open(self.input_file, 'rb') as infile:
|
|
template = read_po(infile)
|
|
|
|
for locale, filename in po_files:
|
|
self.log.info('updating catalog %s based on %s', filename, self.input_file)
|
|
with open(filename, 'rb') as infile:
|
|
catalog = read_po(infile, locale=locale, domain=domain)
|
|
|
|
catalog.update(
|
|
template, self.no_fuzzy_matching,
|
|
update_header_comment=self.update_header_comment
|
|
)
|
|
|
|
tmpname = os.path.join(os.path.dirname(filename),
|
|
tempfile.gettempprefix() +
|
|
os.path.basename(filename))
|
|
try:
|
|
with open(tmpname, 'wb') as tmpfile:
|
|
write_po(tmpfile, catalog,
|
|
omit_header=self.omit_header,
|
|
ignore_obsolete=self.ignore_obsolete,
|
|
include_previous=self.previous, width=self.width)
|
|
except:
|
|
os.remove(tmpname)
|
|
raise
|
|
|
|
try:
|
|
os.rename(tmpname, filename)
|
|
except OSError:
|
|
# We're probably on Windows, which doesn't support atomic
|
|
# renames, at least not through Python
|
|
# If the error is in fact due to a permissions problem, that
|
|
# same error is going to be raised from one of the following
|
|
# operations
|
|
os.remove(filename)
|
|
shutil.copy(tmpname, filename)
|
|
os.remove(tmpname)
|
|
|
|
|
|
class CommandLineInterface(object):
|
|
"""Command-line interface.
|
|
|
|
This class provides a simple command-line interface to the message
|
|
extraction and PO file generation functionality.
|
|
"""
|
|
|
|
usage = '%%prog %s [options] %s'
|
|
version = '%%prog %s' % VERSION
|
|
commands = {
|
|
'compile': 'compile message catalogs to MO files',
|
|
'extract': 'extract messages from source files and generate a POT file',
|
|
'init': 'create new message catalogs from a POT file',
|
|
'update': 'update existing message catalogs from a POT file'
|
|
}
|
|
|
|
command_classes = {
|
|
'compile': compile_catalog,
|
|
'extract': extract_messages,
|
|
'init': init_catalog,
|
|
'update': update_catalog,
|
|
}
|
|
|
|
log = None # Replaced on instance level
|
|
|
|
def run(self, argv=None):
|
|
"""Main entry point of the command-line interface.
|
|
|
|
:param argv: list of arguments passed on the command-line
|
|
"""
|
|
|
|
if argv is None:
|
|
argv = sys.argv
|
|
|
|
self.parser = optparse.OptionParser(usage=self.usage % ('command', '[args]'),
|
|
version=self.version)
|
|
self.parser.disable_interspersed_args()
|
|
self.parser.print_help = self._help
|
|
self.parser.add_option('--list-locales', dest='list_locales',
|
|
action='store_true',
|
|
help="print all known locales and exit")
|
|
self.parser.add_option('-v', '--verbose', action='store_const',
|
|
dest='loglevel', const=logging.DEBUG,
|
|
help='print as much as possible')
|
|
self.parser.add_option('-q', '--quiet', action='store_const',
|
|
dest='loglevel', const=logging.ERROR,
|
|
help='print as little as possible')
|
|
self.parser.set_defaults(list_locales=False, loglevel=logging.INFO)
|
|
|
|
options, args = self.parser.parse_args(argv[1:])
|
|
|
|
self._configure_logging(options.loglevel)
|
|
if options.list_locales:
|
|
identifiers = localedata.locale_identifiers()
|
|
longest = max([len(identifier) for identifier in identifiers])
|
|
identifiers.sort()
|
|
format = u'%%-%ds %%s' % (longest + 1)
|
|
for identifier in identifiers:
|
|
locale = Locale.parse(identifier)
|
|
output = format % (identifier, locale.english_name)
|
|
print(output.encode(sys.stdout.encoding or
|
|
getpreferredencoding() or
|
|
'ascii', 'replace'))
|
|
return 0
|
|
|
|
if not args:
|
|
self.parser.error('no valid command or option passed. '
|
|
'Try the -h/--help option for more information.')
|
|
|
|
cmdname = args[0]
|
|
if cmdname not in self.commands:
|
|
self.parser.error('unknown command "%s"' % cmdname)
|
|
|
|
cmdinst = self._configure_command(cmdname, args[1:])
|
|
return cmdinst.run()
|
|
|
|
def _configure_logging(self, loglevel):
|
|
self.log = logging.getLogger('babel')
|
|
self.log.setLevel(loglevel)
|
|
# Don't add a new handler for every instance initialization (#227), this
|
|
# would cause duplicated output when the CommandLineInterface as an
|
|
# normal Python class.
|
|
if self.log.handlers:
|
|
handler = self.log.handlers[0]
|
|
else:
|
|
handler = logging.StreamHandler()
|
|
self.log.addHandler(handler)
|
|
handler.setLevel(loglevel)
|
|
formatter = logging.Formatter('%(message)s')
|
|
handler.setFormatter(formatter)
|
|
|
|
def _help(self):
|
|
print(self.parser.format_help())
|
|
print("commands:")
|
|
longest = max([len(command) for command in self.commands])
|
|
format = " %%-%ds %%s" % max(8, longest + 1)
|
|
commands = sorted(self.commands.items())
|
|
for name, description in commands:
|
|
print(format % (name, description))
|
|
|
|
def _configure_command(self, cmdname, argv):
|
|
"""
|
|
:type cmdname: str
|
|
:type argv: list[str]
|
|
"""
|
|
cmdclass = self.command_classes[cmdname]
|
|
cmdinst = cmdclass()
|
|
if self.log:
|
|
cmdinst.log = self.log # Use our logger, not distutils'.
|
|
assert isinstance(cmdinst, Command)
|
|
cmdinst.initialize_options()
|
|
|
|
parser = optparse.OptionParser(
|
|
usage=self.usage % (cmdname, ''),
|
|
description=self.commands[cmdname]
|
|
)
|
|
as_args = getattr(cmdclass, "as_args", ())
|
|
for long, short, help in cmdclass.user_options:
|
|
name = long.strip("=")
|
|
default = getattr(cmdinst, name.replace('-', '_'))
|
|
strs = ["--%s" % name]
|
|
if short:
|
|
strs.append("-%s" % short)
|
|
strs.extend(cmdclass.option_aliases.get(name, ()))
|
|
choices = cmdclass.option_choices.get(name, None)
|
|
if name == as_args:
|
|
parser.usage += "<%s>" % name
|
|
elif name in cmdclass.boolean_options:
|
|
parser.add_option(*strs, action="store_true", help=help)
|
|
elif name in cmdclass.multiple_value_options:
|
|
parser.add_option(*strs, action="append", help=help, choices=choices)
|
|
else:
|
|
parser.add_option(*strs, help=help, default=default, choices=choices)
|
|
options, args = parser.parse_args(argv)
|
|
|
|
if as_args:
|
|
setattr(options, as_args.replace('-', '_'), args)
|
|
|
|
for key, value in vars(options).items():
|
|
setattr(cmdinst, key, value)
|
|
|
|
try:
|
|
cmdinst.ensure_finalized()
|
|
except DistutilsOptionError as err:
|
|
parser.error(str(err))
|
|
|
|
return cmdinst
|
|
|
|
|
|
def main():
|
|
return CommandLineInterface().run(sys.argv)
|
|
|
|
|
|
def parse_mapping(fileobj, filename=None):
|
|
"""Parse an extraction method mapping from a file-like object.
|
|
|
|
>>> buf = StringIO('''
|
|
... [extractors]
|
|
... custom = mypackage.module:myfunc
|
|
...
|
|
... # Python source files
|
|
... [python: **.py]
|
|
...
|
|
... # Genshi templates
|
|
... [genshi: **/templates/**.html]
|
|
... include_attrs =
|
|
... [genshi: **/templates/**.txt]
|
|
... template_class = genshi.template:TextTemplate
|
|
... encoding = latin-1
|
|
...
|
|
... # Some custom extractor
|
|
... [custom: **/custom/*.*]
|
|
... ''')
|
|
|
|
>>> method_map, options_map = parse_mapping(buf)
|
|
>>> len(method_map)
|
|
4
|
|
|
|
>>> method_map[0]
|
|
('**.py', 'python')
|
|
>>> options_map['**.py']
|
|
{}
|
|
>>> method_map[1]
|
|
('**/templates/**.html', 'genshi')
|
|
>>> options_map['**/templates/**.html']['include_attrs']
|
|
''
|
|
>>> method_map[2]
|
|
('**/templates/**.txt', 'genshi')
|
|
>>> options_map['**/templates/**.txt']['template_class']
|
|
'genshi.template:TextTemplate'
|
|
>>> options_map['**/templates/**.txt']['encoding']
|
|
'latin-1'
|
|
|
|
>>> method_map[3]
|
|
('**/custom/*.*', 'mypackage.module:myfunc')
|
|
>>> options_map['**/custom/*.*']
|
|
{}
|
|
|
|
:param fileobj: a readable file-like object containing the configuration
|
|
text to parse
|
|
:see: `extract_from_directory`
|
|
"""
|
|
extractors = {}
|
|
method_map = []
|
|
options_map = {}
|
|
|
|
parser = RawConfigParser()
|
|
parser._sections = OrderedDict(parser._sections) # We need ordered sections
|
|
|
|
if PY2:
|
|
parser.readfp(fileobj, filename)
|
|
else:
|
|
parser.read_file(fileobj, filename)
|
|
|
|
for section in parser.sections():
|
|
if section == 'extractors':
|
|
extractors = dict(parser.items(section))
|
|
else:
|
|
method, pattern = [part.strip() for part in section.split(':', 1)]
|
|
method_map.append((pattern, method))
|
|
options_map[pattern] = dict(parser.items(section))
|
|
|
|
if extractors:
|
|
for idx, (pattern, method) in enumerate(method_map):
|
|
if method in extractors:
|
|
method = extractors[method]
|
|
method_map[idx] = (pattern, method)
|
|
|
|
return method_map, options_map
|
|
|
|
|
|
def parse_keywords(strings=[]):
|
|
"""Parse keywords specifications from the given list of strings.
|
|
|
|
>>> kw = sorted(parse_keywords(['_', 'dgettext:2', 'dngettext:2,3', 'pgettext:1c,2']).items())
|
|
>>> for keyword, indices in kw:
|
|
... print((keyword, indices))
|
|
('_', None)
|
|
('dgettext', (2,))
|
|
('dngettext', (2, 3))
|
|
('pgettext', ((1, 'c'), 2))
|
|
"""
|
|
keywords = {}
|
|
for string in strings:
|
|
if ':' in string:
|
|
funcname, indices = string.split(':')
|
|
else:
|
|
funcname, indices = string, None
|
|
if funcname not in keywords:
|
|
if indices:
|
|
inds = []
|
|
for x in indices.split(','):
|
|
if x[-1] == 'c':
|
|
inds.append((int(x[:-1]), 'c'))
|
|
else:
|
|
inds.append(int(x))
|
|
indices = tuple(inds)
|
|
keywords[funcname] = indices
|
|
return keywords
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|