Spaces:
Runtime error
Runtime error
""" | |
babel.messages.frontend | |
~~~~~~~~~~~~~~~~~~~~~~~ | |
Frontends for the message extraction functionality. | |
:copyright: (c) 2013-2023 by the Babel Team. | |
:license: BSD, see LICENSE for more details. | |
""" | |
from __future__ import annotations | |
import datetime | |
import fnmatch | |
import logging | |
import optparse | |
import os | |
import re | |
import shutil | |
import sys | |
import tempfile | |
from collections import OrderedDict | |
from configparser import RawConfigParser | |
from io import StringIO | |
from typing import Iterable | |
from babel import Locale, localedata | |
from babel import __version__ as VERSION | |
from babel.core import UnknownLocaleError | |
from babel.messages.catalog import DEFAULT_HEADER, 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 | |
log = logging.getLogger('babel') | |
try: | |
# See: https://setuptools.pypa.io/en/latest/deprecated/distutils-legacy.html | |
from setuptools import Command as _Command | |
distutils_log = log # "distutils.log → (no replacement yet)" | |
try: | |
from setuptools.errors import BaseError, OptionError, SetupError | |
except ImportError: # Error aliases only added in setuptools 59 (2021-11). | |
OptionError = SetupError = BaseError = Exception | |
except ImportError: | |
from distutils import log as distutils_log | |
from distutils.cmd import Command as _Command | |
from distutils.errors import DistutilsError as BaseError | |
from distutils.errors import DistutilsOptionError as OptionError | |
from distutils.errors import DistutilsSetupError as SetupError | |
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 str(val).split(split)) | |
assert all(isinstance(val, str) 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 OptionError('you must specify either the input file or the base directory') | |
if not self.output_file and not self.directory: | |
raise OptionError('you must specify either the output file or the base directory') | |
def run(self): | |
n_errors = 0 | |
for domain in self.domain: | |
for errors in self._run_domain(domain).values(): | |
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', | |
f"{domain}.po"))) | |
mo_files.append(os.path.join(self.directory, self.locale, | |
'LC_MESSAGES', | |
f"{domain}.mo")) | |
else: | |
for locale in os.listdir(self.directory): | |
po_file = os.path.join(self.directory, locale, | |
'LC_MESSAGES', f"{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', | |
f"{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', | |
f"{domain}.mo")) | |
if not po_files: | |
raise OptionError('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 | |
def _make_directory_filter(ignore_patterns): | |
""" | |
Build a directory_filter function based on a list of ignore patterns. | |
""" | |
def cli_directory_filter(dirname): | |
basename = os.path.basename(dirname) | |
return not any( | |
fnmatch.fnmatch(basename, ignore_pattern) | |
for ignore_pattern | |
in ignore_patterns | |
) | |
return cli_directory_filter | |
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).'), | |
('ignore-dirs=', None, | |
'Patterns for directories to ignore when scanning for messages. ' | |
'Separate multiple patterns with spaces (default ".* ._")'), | |
('header-comment=', None, | |
'header comment for the catalog'), | |
] | |
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', | |
'ignore-dirs', | |
) | |
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 | |
self.ignore_dirs = None | |
self.header_comment = None | |
def finalize_options(self): | |
if self.input_dirs: | |
if not self.input_paths: | |
self.input_paths = self.input_dirs | |
else: | |
raise OptionError( | |
'input-dirs and input-paths are mutually exclusive' | |
) | |
keywords = {} if self.no_default_keywords else DEFAULT_KEYWORDS.copy() | |
keywords.update(parse_keywords(listify_value(self.keywords))) | |
self.keywords = keywords | |
if not self.keywords: | |
raise OptionError( | |
'you must specify new keywords if you disable the default ones' | |
) | |
if not self.output_file: | |
raise OptionError('no output file specified') | |
if self.no_wrap and self.width: | |
raise OptionError( | |
"'--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 OptionError( | |
"'--sort-output' and '--sort-by-file' are mutually exclusive" | |
) | |
if self.input_paths: | |
if isinstance(self.input_paths, str): | |
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 OptionError("no input files or directories specified") | |
for path in self.input_paths: | |
if not os.path.exists(path): | |
raise OptionError(f"Input path: {path} does not exist") | |
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 | |
ignore_dirs = listify_value(self.ignore_dirs) | |
if ignore_dirs: | |
self.directory_filter = _make_directory_filter(self.ignore_dirs) | |
else: | |
self.directory_filter = None | |
def _build_callback(self, path: str): | |
def callback(filename: str, method: str, options: dict): | |
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: | |
opt_values = ", ".join(f'{k}="{v}"' for k, v in options.items()) | |
optstr = f" ({opt_values})" | |
self.log.info('extracting messages from %s%s', filepath, optstr) | |
return callback | |
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, | |
header_comment=(self.header_comment or DEFAULT_HEADER)) | |
for path, method_map, options_map in mappings: | |
callback = self._build_callback(path) | |
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, | |
directory_filter=self.directory_filter, | |
) | |
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) 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, str): | |
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 SetupError( | |
'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 OptionError('you must specify the input file') | |
if not self.locale: | |
raise OptionError('you must provide a locale for the new catalog') | |
try: | |
self._locale = Locale.parse(self.locale) | |
except UnknownLocaleError as e: | |
raise OptionError(e) from e | |
if not self.output_file and not self.output_dir: | |
raise OptionError('you must specify the output directory') | |
if not self.output_file: | |
self.output_file = os.path.join(self.output_dir, self.locale, | |
'LC_MESSAGES', f"{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 OptionError("'--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.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'), | |
('init-missing=', None, | |
'if any output files are missing, initialize them first'), | |
('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'), | |
('check=', None, | |
'don\'t update the catalog, just return the status. Return code 0 ' | |
'means nothing would change. Return code 1 means that the catalog ' | |
'would be updated'), | |
('ignore-pot-creation-date=', None, | |
'ignore changes to POT-Creation-Date when updating or checking'), | |
] | |
boolean_options = [ | |
'omit-header', 'no-wrap', 'ignore-obsolete', 'init-missing', | |
'no-fuzzy-matching', 'previous', 'update-header-comment', | |
'check', 'ignore-pot-creation-date', | |
] | |
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.init_missing = False | |
self.no_fuzzy_matching = False | |
self.update_header_comment = False | |
self.previous = False | |
self.check = False | |
self.ignore_pot_creation_date = False | |
def finalize_options(self): | |
if not self.input_file: | |
raise OptionError('you must specify the input file') | |
if not self.output_file and not self.output_dir: | |
raise OptionError('you must specify the output file or directory') | |
if self.output_file and not self.locale: | |
raise OptionError('you must specify the locale') | |
if self.init_missing: | |
if not self.locale: | |
raise OptionError( | |
'you must specify the locale for ' | |
'the init-missing option to work' | |
) | |
try: | |
self._locale = Locale.parse(self.locale) | |
except UnknownLocaleError as e: | |
raise OptionError(e) from e | |
else: | |
self._locale = None | |
if self.no_wrap and self.width: | |
raise OptionError("'--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): | |
check_status = {} | |
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', | |
f"{self.domain}.po"))) | |
else: | |
for locale in os.listdir(self.output_dir): | |
po_file = os.path.join(self.output_dir, locale, | |
'LC_MESSAGES', | |
f"{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 OptionError('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: | |
if self.init_missing and not os.path.exists(filename): | |
if self.check: | |
check_status[filename] = False | |
continue | |
self.log.info( | |
'creating catalog %s based on %s', filename, 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.datetime.now(LOCALTZ) | |
catalog.fuzzy = False | |
with open(filename, 'wb') as outfile: | |
write_po(outfile, catalog) | |
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, | |
update_creation_date=not self.ignore_pot_creation_date, | |
) | |
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 Exception: | |
os.remove(tmpname) | |
raise | |
if self.check: | |
with open(filename, "rb") as origfile: | |
original_catalog = read_po(origfile) | |
with open(tmpname, "rb") as newfile: | |
updated_catalog = read_po(newfile) | |
updated_catalog.revision_date = original_catalog.revision_date | |
check_status[filename] = updated_catalog.is_identical(original_catalog) | |
os.remove(tmpname) | |
continue | |
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) | |
if self.check: | |
for filename, up_to_date in check_status.items(): | |
if up_to_date: | |
self.log.info('Catalog %s is up to date.', filename) | |
else: | |
self.log.warning('Catalog %s is out of date.', filename) | |
if not all(check_status.values()): | |
raise BaseError("Some catalogs are out of date.") | |
else: | |
self.log.info("All the catalogs are up-to-date.") | |
return | |
class CommandLineInterface: | |
"""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 = f'%prog {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() | |
id_width = max(len(identifier) for identifier in identifiers) + 1 | |
for identifier in sorted(identifiers): | |
locale = Locale.parse(identifier) | |
print(f"{identifier:<{id_width}} {locale.english_name}") | |
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(f'unknown command "{cmdname}"') | |
cmdinst = self._configure_command(cmdname, args[1:]) | |
return cmdinst.run() | |
def _configure_logging(self, loglevel): | |
self.log = log | |
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:") | |
cmd_width = max(8, max(len(command) for command in self.commands) + 1) | |
for name, description in sorted(self.commands.items()): | |
print(f" {name:<{cmd_width}} {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 = [f"--{name}"] | |
if short: | |
strs.append(f"-{short}") | |
strs.extend(cmdclass.option_aliases.get(name, ())) | |
choices = cmdclass.option_choices.get(name, None) | |
if name == as_args: | |
parser.usage += f"<{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 OptionError 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 | |
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_spec(s: str) -> tuple[int | None, tuple[int|tuple[int, str], ...]]: | |
inds = [] | |
number = None | |
for x in s.split(','): | |
if x[-1] == 't': | |
number = int(x[:-1]) | |
elif x[-1] == 'c': | |
inds.append((int(x[:-1]), 'c')) | |
else: | |
inds.append(int(x)) | |
return number, tuple(inds) | |
def parse_keywords(strings: Iterable[str] = ()): | |
"""Parse keywords specifications from the given list of strings. | |
>>> import pprint | |
>>> keywords = ['_', 'dgettext:2', 'dngettext:2,3', 'pgettext:1c,2', | |
... 'polymorphic:1', 'polymorphic:2,2t', 'polymorphic:3c,3t'] | |
>>> pprint.pprint(parse_keywords(keywords)) | |
{'_': None, | |
'dgettext': (2,), | |
'dngettext': (2, 3), | |
'pgettext': ((1, 'c'), 2), | |
'polymorphic': {None: (1,), 2: (2,), 3: ((3, 'c'),)}} | |
The input keywords are in GNU Gettext style; see :doc:`cmdline` for details. | |
The output is a dictionary mapping keyword names to a dictionary of specifications. | |
Keys in this dictionary are numbers of arguments, where ``None`` means that all numbers | |
of arguments are matched, and a number means only calls with that number of arguments | |
are matched (which happens when using the "t" specifier). However, as a special | |
case for backwards compatibility, if the dictionary of specifications would | |
be ``{None: x}``, i.e., there is only one specification and it matches all argument | |
counts, then it is collapsed into just ``x``. | |
A specification is either a tuple or None. If a tuple, each element can be either a number | |
``n``, meaning that the nth argument should be extracted as a message, or the tuple | |
``(n, 'c')``, meaning that the nth argument should be extracted as context for the | |
messages. A ``None`` specification is equivalent to ``(1,)``, extracting the first | |
argument. | |
""" | |
keywords = {} | |
for string in strings: | |
if ':' in string: | |
funcname, spec_str = string.split(':') | |
number, spec = _parse_spec(spec_str) | |
else: | |
funcname = string | |
number = None | |
spec = None | |
keywords.setdefault(funcname, {})[number] = spec | |
# For best backwards compatibility, collapse {None: x} into x. | |
for k, v in keywords.items(): | |
if set(v) == {None}: | |
keywords[k] = v[None] | |
return keywords | |
if __name__ == '__main__': | |
main() | |