frontend.py 38 KB

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