| """ |
| babel.messages.frontend |
| ~~~~~~~~~~~~~~~~~~~~~~~ |
| |
| Frontends for the message extraction functionality. |
| |
| :copyright: (c) 2013-2023 by the Babel Team. |
| :license: BSD, see LICENSE for more details. |
| """ |
| |
| 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'), |
| ] |
| boolean_options = [ |
| 'omit-header', 'no-wrap', 'ignore-obsolete', 'init-missing', |
| 'no-fuzzy-matching', 'previous', 'update-header-comment', |
| 'check', |
| ] |
| |
| 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 |
| |
| 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 |
| ) |
| |
| 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_keywords(strings: Iterable[str] = ()): |
| """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() |