frontend.py 37 KB

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