extract.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645
  1. # -*- coding: utf-8 -*-
  2. """
  3. babel.messages.extract
  4. ~~~~~~~~~~~~~~~~~~~~~~
  5. Basic infrastructure for extracting localizable messages from source files.
  6. This module defines an extensible system for collecting localizable message
  7. strings from a variety of sources. A native extractor for Python source
  8. files is builtin, extractors for other sources can be added using very
  9. simple plugins.
  10. The main entry points into the extraction functionality are the functions
  11. `extract_from_dir` and `extract_from_file`.
  12. :copyright: (c) 2013-2021 by the Babel Team.
  13. :license: BSD, see LICENSE for more details.
  14. """
  15. import os
  16. from os.path import relpath
  17. import sys
  18. from tokenize import generate_tokens, COMMENT, NAME, OP, STRING
  19. from babel.util import parse_encoding, parse_future_flags, pathmatch
  20. from babel._compat import PY2, text_type
  21. from textwrap import dedent
  22. GROUP_NAME = 'babel.extractors'
  23. DEFAULT_KEYWORDS = {
  24. '_': None,
  25. 'gettext': None,
  26. 'ngettext': (1, 2),
  27. 'ugettext': None,
  28. 'ungettext': (1, 2),
  29. 'dgettext': (2,),
  30. 'dngettext': (2, 3),
  31. 'N_': None,
  32. 'pgettext': ((1, 'c'), 2),
  33. 'npgettext': ((1, 'c'), 2, 3)
  34. }
  35. DEFAULT_MAPPING = [('**.py', 'python')]
  36. empty_msgid_warning = (
  37. '%s: warning: Empty msgid. It is reserved by GNU gettext: gettext("") '
  38. 'returns the header entry with meta information, not the empty string.')
  39. def _strip_comment_tags(comments, tags):
  40. """Helper function for `extract` that strips comment tags from strings
  41. in a list of comment lines. This functions operates in-place.
  42. """
  43. def _strip(line):
  44. for tag in tags:
  45. if line.startswith(tag):
  46. return line[len(tag):].strip()
  47. return line
  48. comments[:] = map(_strip, comments)
  49. def extract_from_dir(dirname=None, method_map=DEFAULT_MAPPING,
  50. options_map=None, keywords=DEFAULT_KEYWORDS,
  51. comment_tags=(), callback=None, strip_comment_tags=False):
  52. """Extract messages from any source files found in the given directory.
  53. This function generates tuples of the form ``(filename, lineno, message,
  54. comments, context)``.
  55. Which extraction method is used per file is determined by the `method_map`
  56. parameter, which maps extended glob patterns to extraction method names.
  57. For example, the following is the default mapping:
  58. >>> method_map = [
  59. ... ('**.py', 'python')
  60. ... ]
  61. This basically says that files with the filename extension ".py" at any
  62. level inside the directory should be processed by the "python" extraction
  63. method. Files that don't match any of the mapping patterns are ignored. See
  64. the documentation of the `pathmatch` function for details on the pattern
  65. syntax.
  66. The following extended mapping would also use the "genshi" extraction
  67. method on any file in "templates" subdirectory:
  68. >>> method_map = [
  69. ... ('**/templates/**.*', 'genshi'),
  70. ... ('**.py', 'python')
  71. ... ]
  72. The dictionary provided by the optional `options_map` parameter augments
  73. these mappings. It uses extended glob patterns as keys, and the values are
  74. dictionaries mapping options names to option values (both strings).
  75. The glob patterns of the `options_map` do not necessarily need to be the
  76. same as those used in the method mapping. For example, while all files in
  77. the ``templates`` folders in an application may be Genshi applications, the
  78. options for those files may differ based on extension:
  79. >>> options_map = {
  80. ... '**/templates/**.txt': {
  81. ... 'template_class': 'genshi.template:TextTemplate',
  82. ... 'encoding': 'latin-1'
  83. ... },
  84. ... '**/templates/**.html': {
  85. ... 'include_attrs': ''
  86. ... }
  87. ... }
  88. :param dirname: the path to the directory to extract messages from. If
  89. not given the current working directory is used.
  90. :param method_map: a list of ``(pattern, method)`` tuples that maps of
  91. extraction method names to extended glob patterns
  92. :param options_map: a dictionary of additional options (optional)
  93. :param keywords: a dictionary mapping keywords (i.e. names of functions
  94. that should be recognized as translation functions) to
  95. tuples that specify which of their arguments contain
  96. localizable strings
  97. :param comment_tags: a list of tags of translator comments to search for
  98. and include in the results
  99. :param callback: a function that is called for every file that message are
  100. extracted from, just before the extraction itself is
  101. performed; the function is passed the filename, the name
  102. of the extraction method and and the options dictionary as
  103. positional arguments, in that order
  104. :param strip_comment_tags: a flag that if set to `True` causes all comment
  105. tags to be removed from the collected comments.
  106. :see: `pathmatch`
  107. """
  108. if dirname is None:
  109. dirname = os.getcwd()
  110. if options_map is None:
  111. options_map = {}
  112. absname = os.path.abspath(dirname)
  113. for root, dirnames, filenames in os.walk(absname):
  114. dirnames[:] = [
  115. subdir for subdir in dirnames
  116. if not (subdir.startswith('.') or subdir.startswith('_'))
  117. ]
  118. dirnames.sort()
  119. filenames.sort()
  120. for filename in filenames:
  121. filepath = os.path.join(root, filename).replace(os.sep, '/')
  122. for message_tuple in check_and_call_extract_file(
  123. filepath,
  124. method_map,
  125. options_map,
  126. callback,
  127. keywords,
  128. comment_tags,
  129. strip_comment_tags,
  130. dirpath=absname,
  131. ):
  132. yield message_tuple
  133. def check_and_call_extract_file(filepath, method_map, options_map,
  134. callback, keywords, comment_tags,
  135. strip_comment_tags, dirpath=None):
  136. """Checks if the given file matches an extraction method mapping, and if so, calls extract_from_file.
  137. Note that the extraction method mappings are based relative to dirpath.
  138. So, given an absolute path to a file `filepath`, we want to check using
  139. just the relative path from `dirpath` to `filepath`.
  140. Yields 5-tuples (filename, lineno, messages, comments, context).
  141. :param filepath: An absolute path to a file that exists.
  142. :param method_map: a list of ``(pattern, method)`` tuples that maps of
  143. extraction method names to extended glob patterns
  144. :param options_map: a dictionary of additional options (optional)
  145. :param callback: a function that is called for every file that message are
  146. extracted from, just before the extraction itself is
  147. performed; the function is passed the filename, the name
  148. of the extraction method and and the options dictionary as
  149. positional arguments, in that order
  150. :param keywords: a dictionary mapping keywords (i.e. names of functions
  151. that should be recognized as translation functions) to
  152. tuples that specify which of their arguments contain
  153. localizable strings
  154. :param comment_tags: a list of tags of translator comments to search for
  155. and include in the results
  156. :param strip_comment_tags: a flag that if set to `True` causes all comment
  157. tags to be removed from the collected comments.
  158. :param dirpath: the path to the directory to extract messages from.
  159. :return: iterable of 5-tuples (filename, lineno, messages, comments, context)
  160. :rtype: Iterable[tuple[str, int, str|tuple[str], list[str], str|None]
  161. """
  162. # filename is the relative path from dirpath to the actual file
  163. filename = relpath(filepath, dirpath)
  164. for pattern, method in method_map:
  165. if not pathmatch(pattern, filename):
  166. continue
  167. options = {}
  168. for opattern, odict in options_map.items():
  169. if pathmatch(opattern, filename):
  170. options = odict
  171. if callback:
  172. callback(filename, method, options)
  173. for message_tuple in extract_from_file(
  174. method, filepath,
  175. keywords=keywords,
  176. comment_tags=comment_tags,
  177. options=options,
  178. strip_comment_tags=strip_comment_tags
  179. ):
  180. yield (filename, ) + message_tuple
  181. break
  182. def extract_from_file(method, filename, keywords=DEFAULT_KEYWORDS,
  183. comment_tags=(), options=None, strip_comment_tags=False):
  184. """Extract messages from a specific file.
  185. This function returns a list of tuples of the form ``(lineno, message, comments, context)``.
  186. :param filename: the path to the file to extract messages from
  187. :param method: a string specifying the extraction method (.e.g. "python")
  188. :param keywords: a dictionary mapping keywords (i.e. names of functions
  189. that should be recognized as translation functions) to
  190. tuples that specify which of their arguments contain
  191. localizable strings
  192. :param comment_tags: a list of translator tags to search for and include
  193. in the results
  194. :param strip_comment_tags: a flag that if set to `True` causes all comment
  195. tags to be removed from the collected comments.
  196. :param options: a dictionary of additional options (optional)
  197. :returns: list of tuples of the form ``(lineno, message, comments, context)``
  198. :rtype: list[tuple[int, str|tuple[str], list[str], str|None]
  199. """
  200. if method == 'ignore':
  201. return []
  202. with open(filename, 'rb') as fileobj:
  203. return list(extract(method, fileobj, keywords, comment_tags,
  204. options, strip_comment_tags))
  205. def extract(method, fileobj, keywords=DEFAULT_KEYWORDS, comment_tags=(),
  206. options=None, strip_comment_tags=False):
  207. """Extract messages from the given file-like object using the specified
  208. extraction method.
  209. This function returns tuples of the form ``(lineno, message, comments, context)``.
  210. The implementation dispatches the actual extraction to plugins, based on the
  211. value of the ``method`` parameter.
  212. >>> source = b'''# foo module
  213. ... def run(argv):
  214. ... print(_('Hello, world!'))
  215. ... '''
  216. >>> from babel._compat import BytesIO
  217. >>> for message in extract('python', BytesIO(source)):
  218. ... print(message)
  219. (3, u'Hello, world!', [], None)
  220. :param method: an extraction method (a callable), or
  221. a string specifying the extraction method (.e.g. "python");
  222. if this is a simple name, the extraction function will be
  223. looked up by entry point; if it is an explicit reference
  224. to a function (of the form ``package.module:funcname`` or
  225. ``package.module.funcname``), the corresponding function
  226. will be imported and used
  227. :param fileobj: the file-like object the messages should be extracted from
  228. :param keywords: a dictionary mapping keywords (i.e. names of functions
  229. that should be recognized as translation functions) to
  230. tuples that specify which of their arguments contain
  231. localizable strings
  232. :param comment_tags: a list of translator tags to search for and include
  233. in the results
  234. :param options: a dictionary of additional options (optional)
  235. :param strip_comment_tags: a flag that if set to `True` causes all comment
  236. tags to be removed from the collected comments.
  237. :raise ValueError: if the extraction method is not registered
  238. :returns: iterable of tuples of the form ``(lineno, message, comments, context)``
  239. :rtype: Iterable[tuple[int, str|tuple[str], list[str], str|None]
  240. """
  241. func = None
  242. if callable(method):
  243. func = method
  244. elif ':' in method or '.' in method:
  245. if ':' not in method:
  246. lastdot = method.rfind('.')
  247. module, attrname = method[:lastdot], method[lastdot + 1:]
  248. else:
  249. module, attrname = method.split(':', 1)
  250. func = getattr(__import__(module, {}, {}, [attrname]), attrname)
  251. else:
  252. try:
  253. from pkg_resources import working_set
  254. except ImportError:
  255. pass
  256. else:
  257. for entry_point in working_set.iter_entry_points(GROUP_NAME,
  258. method):
  259. func = entry_point.load(require=True)
  260. break
  261. if func is None:
  262. # if pkg_resources is not available or no usable egg-info was found
  263. # (see #230), we resort to looking up the builtin extractors
  264. # directly
  265. builtin = {
  266. 'ignore': extract_nothing,
  267. 'python': extract_python,
  268. 'javascript': extract_javascript
  269. }
  270. func = builtin.get(method)
  271. if func is None:
  272. raise ValueError('Unknown extraction method %r' % method)
  273. results = func(fileobj, keywords.keys(), comment_tags,
  274. options=options or {})
  275. for lineno, funcname, messages, comments in results:
  276. if funcname:
  277. spec = keywords[funcname] or (1,)
  278. else:
  279. spec = (1,)
  280. if not isinstance(messages, (list, tuple)):
  281. messages = [messages]
  282. if not messages:
  283. continue
  284. # Validate the messages against the keyword's specification
  285. context = None
  286. msgs = []
  287. invalid = False
  288. # last_index is 1 based like the keyword spec
  289. last_index = len(messages)
  290. for index in spec:
  291. if isinstance(index, tuple):
  292. context = messages[index[0] - 1]
  293. continue
  294. if last_index < index:
  295. # Not enough arguments
  296. invalid = True
  297. break
  298. message = messages[index - 1]
  299. if message is None:
  300. invalid = True
  301. break
  302. msgs.append(message)
  303. if invalid:
  304. continue
  305. # keyword spec indexes are 1 based, therefore '-1'
  306. if isinstance(spec[0], tuple):
  307. # context-aware *gettext method
  308. first_msg_index = spec[1] - 1
  309. else:
  310. first_msg_index = spec[0] - 1
  311. if not messages[first_msg_index]:
  312. # An empty string msgid isn't valid, emit a warning
  313. where = '%s:%i' % (hasattr(fileobj, 'name') and
  314. fileobj.name or '(unknown)', lineno)
  315. sys.stderr.write((empty_msgid_warning % where) + '\n')
  316. continue
  317. messages = tuple(msgs)
  318. if len(messages) == 1:
  319. messages = messages[0]
  320. if strip_comment_tags:
  321. _strip_comment_tags(comments, comment_tags)
  322. yield lineno, messages, comments, context
  323. def extract_nothing(fileobj, keywords, comment_tags, options):
  324. """Pseudo extractor that does not actually extract anything, but simply
  325. returns an empty list.
  326. """
  327. return []
  328. def extract_python(fileobj, keywords, comment_tags, options):
  329. """Extract messages from Python source code.
  330. It returns an iterator yielding tuples in the following form ``(lineno,
  331. funcname, message, comments)``.
  332. :param fileobj: the seekable, file-like object the messages should be
  333. extracted from
  334. :param keywords: a list of keywords (i.e. function names) that should be
  335. recognized as translation functions
  336. :param comment_tags: a list of translator tags to search for and include
  337. in the results
  338. :param options: a dictionary of additional options (optional)
  339. :rtype: ``iterator``
  340. """
  341. funcname = lineno = message_lineno = None
  342. call_stack = -1
  343. buf = []
  344. messages = []
  345. translator_comments = []
  346. in_def = in_translator_comments = False
  347. comment_tag = None
  348. encoding = parse_encoding(fileobj) or options.get('encoding', 'UTF-8')
  349. future_flags = parse_future_flags(fileobj, encoding)
  350. if PY2:
  351. next_line = fileobj.readline
  352. else:
  353. next_line = lambda: fileobj.readline().decode(encoding)
  354. tokens = generate_tokens(next_line)
  355. for tok, value, (lineno, _), _, _ in tokens:
  356. if call_stack == -1 and tok == NAME and value in ('def', 'class'):
  357. in_def = True
  358. elif tok == OP and value == '(':
  359. if in_def:
  360. # Avoid false positives for declarations such as:
  361. # def gettext(arg='message'):
  362. in_def = False
  363. continue
  364. if funcname:
  365. message_lineno = lineno
  366. call_stack += 1
  367. elif in_def and tok == OP and value == ':':
  368. # End of a class definition without parens
  369. in_def = False
  370. continue
  371. elif call_stack == -1 and tok == COMMENT:
  372. # Strip the comment token from the line
  373. if PY2:
  374. value = value.decode(encoding)
  375. value = value[1:].strip()
  376. if in_translator_comments and \
  377. translator_comments[-1][0] == lineno - 1:
  378. # We're already inside a translator comment, continue appending
  379. translator_comments.append((lineno, value))
  380. continue
  381. # If execution reaches this point, let's see if comment line
  382. # starts with one of the comment tags
  383. for comment_tag in comment_tags:
  384. if value.startswith(comment_tag):
  385. in_translator_comments = True
  386. translator_comments.append((lineno, value))
  387. break
  388. elif funcname and call_stack == 0:
  389. nested = (tok == NAME and value in keywords)
  390. if (tok == OP and value == ')') or nested:
  391. if buf:
  392. messages.append(''.join(buf))
  393. del buf[:]
  394. else:
  395. messages.append(None)
  396. if len(messages) > 1:
  397. messages = tuple(messages)
  398. else:
  399. messages = messages[0]
  400. # Comments don't apply unless they immediately preceed the
  401. # message
  402. if translator_comments and \
  403. translator_comments[-1][0] < message_lineno - 1:
  404. translator_comments = []
  405. yield (message_lineno, funcname, messages,
  406. [comment[1] for comment in translator_comments])
  407. funcname = lineno = message_lineno = None
  408. call_stack = -1
  409. messages = []
  410. translator_comments = []
  411. in_translator_comments = False
  412. if nested:
  413. funcname = value
  414. elif tok == STRING:
  415. # Unwrap quotes in a safe manner, maintaining the string's
  416. # encoding
  417. # https://sourceforge.net/tracker/?func=detail&atid=355470&
  418. # aid=617979&group_id=5470
  419. code = compile('# coding=%s\n%s' % (str(encoding), value),
  420. '<string>', 'eval', future_flags)
  421. value = eval(code, {'__builtins__': {}}, {})
  422. if PY2 and not isinstance(value, text_type):
  423. value = value.decode(encoding)
  424. buf.append(value)
  425. elif tok == OP and value == ',':
  426. if buf:
  427. messages.append(''.join(buf))
  428. del buf[:]
  429. else:
  430. messages.append(None)
  431. if translator_comments:
  432. # We have translator comments, and since we're on a
  433. # comma(,) user is allowed to break into a new line
  434. # Let's increase the last comment's lineno in order
  435. # for the comment to still be a valid one
  436. old_lineno, old_comment = translator_comments.pop()
  437. translator_comments.append((old_lineno + 1, old_comment))
  438. elif call_stack > 0 and tok == OP and value == ')':
  439. call_stack -= 1
  440. elif funcname and call_stack == -1:
  441. funcname = None
  442. elif tok == NAME and value in keywords:
  443. funcname = value
  444. def extract_javascript(fileobj, keywords, comment_tags, options):
  445. """Extract messages from JavaScript source code.
  446. :param fileobj: the seekable, file-like object the messages should be
  447. extracted from
  448. :param keywords: a list of keywords (i.e. function names) that should be
  449. recognized as translation functions
  450. :param comment_tags: a list of translator tags to search for and include
  451. in the results
  452. :param options: a dictionary of additional options (optional)
  453. Supported options are:
  454. * `jsx` -- set to false to disable JSX/E4X support.
  455. * `template_string` -- set to false to disable ES6
  456. template string support.
  457. """
  458. from babel.messages.jslexer import Token, tokenize, unquote_string
  459. funcname = message_lineno = None
  460. messages = []
  461. last_argument = None
  462. translator_comments = []
  463. concatenate_next = False
  464. encoding = options.get('encoding', 'utf-8')
  465. last_token = None
  466. call_stack = -1
  467. dotted = any('.' in kw for kw in keywords)
  468. for token in tokenize(
  469. fileobj.read().decode(encoding),
  470. jsx=options.get("jsx", True),
  471. template_string=options.get("template_string", True),
  472. dotted=dotted
  473. ):
  474. if ( # Turn keyword`foo` expressions into keyword("foo") calls:
  475. funcname and # have a keyword...
  476. (last_token and last_token.type == 'name') and # we've seen nothing after the keyword...
  477. token.type == 'template_string' # this is a template string
  478. ):
  479. message_lineno = token.lineno
  480. messages = [unquote_string(token.value)]
  481. call_stack = 0
  482. token = Token('operator', ')', token.lineno)
  483. if token.type == 'operator' and token.value == '(':
  484. if funcname:
  485. message_lineno = token.lineno
  486. call_stack += 1
  487. elif call_stack == -1 and token.type == 'linecomment':
  488. value = token.value[2:].strip()
  489. if translator_comments and \
  490. translator_comments[-1][0] == token.lineno - 1:
  491. translator_comments.append((token.lineno, value))
  492. continue
  493. for comment_tag in comment_tags:
  494. if value.startswith(comment_tag):
  495. translator_comments.append((token.lineno, value.strip()))
  496. break
  497. elif token.type == 'multilinecomment':
  498. # only one multi-line comment may preceed a translation
  499. translator_comments = []
  500. value = token.value[2:-2].strip()
  501. for comment_tag in comment_tags:
  502. if value.startswith(comment_tag):
  503. lines = value.splitlines()
  504. if lines:
  505. lines[0] = lines[0].strip()
  506. lines[1:] = dedent('\n'.join(lines[1:])).splitlines()
  507. for offset, line in enumerate(lines):
  508. translator_comments.append((token.lineno + offset,
  509. line))
  510. break
  511. elif funcname and call_stack == 0:
  512. if token.type == 'operator' and token.value == ')':
  513. if last_argument is not None:
  514. messages.append(last_argument)
  515. if len(messages) > 1:
  516. messages = tuple(messages)
  517. elif messages:
  518. messages = messages[0]
  519. else:
  520. messages = None
  521. # Comments don't apply unless they immediately precede the
  522. # message
  523. if translator_comments and \
  524. translator_comments[-1][0] < message_lineno - 1:
  525. translator_comments = []
  526. if messages is not None:
  527. yield (message_lineno, funcname, messages,
  528. [comment[1] for comment in translator_comments])
  529. funcname = message_lineno = last_argument = None
  530. concatenate_next = False
  531. translator_comments = []
  532. messages = []
  533. call_stack = -1
  534. elif token.type in ('string', 'template_string'):
  535. new_value = unquote_string(token.value)
  536. if concatenate_next:
  537. last_argument = (last_argument or '') + new_value
  538. concatenate_next = False
  539. else:
  540. last_argument = new_value
  541. elif token.type == 'operator':
  542. if token.value == ',':
  543. if last_argument is not None:
  544. messages.append(last_argument)
  545. last_argument = None
  546. else:
  547. messages.append(None)
  548. concatenate_next = False
  549. elif token.value == '+':
  550. concatenate_next = True
  551. elif call_stack > 0 and token.type == 'operator' \
  552. and token.value == ')':
  553. call_stack -= 1
  554. elif funcname and call_stack == -1:
  555. funcname = None
  556. elif call_stack == -1 and token.type == 'name' and \
  557. token.value in keywords and \
  558. (last_token is None or last_token.type != 'name' or
  559. last_token.value != 'function'):
  560. funcname = token.value
  561. last_token = token