pofile.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744
  1. """
  2. babel.messages.pofile
  3. ~~~~~~~~~~~~~~~~~~~~~
  4. Reading and writing of files in the ``gettext`` PO (portable object)
  5. format.
  6. :copyright: (c) 2013-2025 by the Babel Team.
  7. :license: BSD, see LICENSE for more details.
  8. """
  9. from __future__ import annotations
  10. import os
  11. import re
  12. from collections.abc import Iterable
  13. from typing import TYPE_CHECKING, Literal
  14. from babel.core import Locale
  15. from babel.messages.catalog import Catalog, Message
  16. from babel.util import TextWrapper, _cmp
  17. if TYPE_CHECKING:
  18. from typing import IO, AnyStr
  19. from _typeshed import SupportsWrite
  20. def unescape(string: str) -> str:
  21. r"""Reverse `escape` the given string.
  22. >>> print(unescape('"Say:\\n \\"hello, world!\\"\\n"'))
  23. Say:
  24. "hello, world!"
  25. <BLANKLINE>
  26. :param string: the string to unescape
  27. """
  28. def replace_escapes(match):
  29. m = match.group(1)
  30. if m == 'n':
  31. return '\n'
  32. elif m == 't':
  33. return '\t'
  34. elif m == 'r':
  35. return '\r'
  36. # m is \ or "
  37. return m
  38. return re.compile(r'\\([\\trn"])').sub(replace_escapes, string[1:-1])
  39. def denormalize(string: str) -> str:
  40. r"""Reverse the normalization done by the `normalize` function.
  41. >>> print(denormalize(r'''""
  42. ... "Say:\n"
  43. ... " \"hello, world!\"\n"'''))
  44. Say:
  45. "hello, world!"
  46. <BLANKLINE>
  47. >>> print(denormalize(r'''""
  48. ... "Say:\n"
  49. ... " \"Lorem ipsum dolor sit "
  50. ... "amet, consectetur adipisicing"
  51. ... " elit, \"\n"'''))
  52. Say:
  53. "Lorem ipsum dolor sit amet, consectetur adipisicing elit, "
  54. <BLANKLINE>
  55. :param string: the string to denormalize
  56. """
  57. if '\n' in string:
  58. escaped_lines = string.splitlines()
  59. if string.startswith('""'):
  60. escaped_lines = escaped_lines[1:]
  61. lines = map(unescape, escaped_lines)
  62. return ''.join(lines)
  63. else:
  64. return unescape(string)
  65. def _extract_locations(line: str) -> list[str]:
  66. """Extract locations from location comments.
  67. Locations are extracted while properly handling First Strong
  68. Isolate (U+2068) and Pop Directional Isolate (U+2069), used by
  69. gettext to enclose filenames with spaces and tabs in their names.
  70. """
  71. if "\u2068" not in line and "\u2069" not in line:
  72. return line.lstrip().split()
  73. locations = []
  74. location = ""
  75. in_filename = False
  76. for c in line:
  77. if c == "\u2068":
  78. if in_filename:
  79. raise ValueError("location comment contains more First Strong Isolate "
  80. "characters, than Pop Directional Isolate characters")
  81. in_filename = True
  82. continue
  83. elif c == "\u2069":
  84. if not in_filename:
  85. raise ValueError("location comment contains more Pop Directional Isolate "
  86. "characters, than First Strong Isolate characters")
  87. in_filename = False
  88. continue
  89. elif c == " ":
  90. if in_filename:
  91. location += c
  92. elif location:
  93. locations.append(location)
  94. location = ""
  95. else:
  96. location += c
  97. else:
  98. if location:
  99. if in_filename:
  100. raise ValueError("location comment contains more First Strong Isolate "
  101. "characters, than Pop Directional Isolate characters")
  102. locations.append(location)
  103. return locations
  104. class PoFileError(Exception):
  105. """Exception thrown by PoParser when an invalid po file is encountered."""
  106. def __init__(self, message: str, catalog: Catalog, line: str, lineno: int) -> None:
  107. super().__init__(f'{message} on {lineno}')
  108. self.catalog = catalog
  109. self.line = line
  110. self.lineno = lineno
  111. class _NormalizedString:
  112. def __init__(self, *args: str) -> None:
  113. self._strs: list[str] = []
  114. for arg in args:
  115. self.append(arg)
  116. def append(self, s: str) -> None:
  117. self._strs.append(s.strip())
  118. def denormalize(self) -> str:
  119. return ''.join(map(unescape, self._strs))
  120. def __bool__(self) -> bool:
  121. return bool(self._strs)
  122. def __repr__(self) -> str:
  123. return os.linesep.join(self._strs)
  124. def __cmp__(self, other: object) -> int:
  125. if not other:
  126. return 1
  127. return _cmp(str(self), str(other))
  128. def __gt__(self, other: object) -> bool:
  129. return self.__cmp__(other) > 0
  130. def __lt__(self, other: object) -> bool:
  131. return self.__cmp__(other) < 0
  132. def __ge__(self, other: object) -> bool:
  133. return self.__cmp__(other) >= 0
  134. def __le__(self, other: object) -> bool:
  135. return self.__cmp__(other) <= 0
  136. def __eq__(self, other: object) -> bool:
  137. return self.__cmp__(other) == 0
  138. def __ne__(self, other: object) -> bool:
  139. return self.__cmp__(other) != 0
  140. class PoFileParser:
  141. """Support class to read messages from a ``gettext`` PO (portable object) file
  142. and add them to a `Catalog`
  143. See `read_po` for simple cases.
  144. """
  145. _keywords = [
  146. 'msgid',
  147. 'msgstr',
  148. 'msgctxt',
  149. 'msgid_plural',
  150. ]
  151. def __init__(self, catalog: Catalog, ignore_obsolete: bool = False, abort_invalid: bool = False) -> None:
  152. self.catalog = catalog
  153. self.ignore_obsolete = ignore_obsolete
  154. self.counter = 0
  155. self.offset = 0
  156. self.abort_invalid = abort_invalid
  157. self._reset_message_state()
  158. def _reset_message_state(self) -> None:
  159. self.messages = []
  160. self.translations = []
  161. self.locations = []
  162. self.flags = []
  163. self.user_comments = []
  164. self.auto_comments = []
  165. self.context = None
  166. self.obsolete = False
  167. self.in_msgid = False
  168. self.in_msgstr = False
  169. self.in_msgctxt = False
  170. def _add_message(self) -> None:
  171. """
  172. Add a message to the catalog based on the current parser state and
  173. clear the state ready to process the next message.
  174. """
  175. self.translations.sort()
  176. if len(self.messages) > 1:
  177. msgid = tuple(m.denormalize() for m in self.messages)
  178. else:
  179. msgid = self.messages[0].denormalize()
  180. if isinstance(msgid, (list, tuple)):
  181. string = ['' for _ in range(self.catalog.num_plurals)]
  182. for idx, translation in self.translations:
  183. if idx >= self.catalog.num_plurals:
  184. self._invalid_pofile("", self.offset, "msg has more translations than num_plurals of catalog")
  185. continue
  186. string[idx] = translation.denormalize()
  187. string = tuple(string)
  188. else:
  189. string = self.translations[0][1].denormalize()
  190. msgctxt = self.context.denormalize() if self.context else None
  191. message = Message(msgid, string, list(self.locations), set(self.flags),
  192. self.auto_comments, self.user_comments, lineno=self.offset + 1,
  193. context=msgctxt)
  194. if self.obsolete:
  195. if not self.ignore_obsolete:
  196. self.catalog.obsolete[self.catalog._key_for(msgid, msgctxt)] = message
  197. else:
  198. self.catalog[msgid] = message
  199. self.counter += 1
  200. self._reset_message_state()
  201. def _finish_current_message(self) -> None:
  202. if self.messages:
  203. if not self.translations:
  204. self._invalid_pofile("", self.offset, f"missing msgstr for msgid '{self.messages[0].denormalize()}'")
  205. self.translations.append([0, _NormalizedString("")])
  206. self._add_message()
  207. def _process_message_line(self, lineno, line, obsolete=False) -> None:
  208. if line.startswith('"'):
  209. self._process_string_continuation_line(line, lineno)
  210. else:
  211. self._process_keyword_line(lineno, line, obsolete)
  212. def _process_keyword_line(self, lineno, line, obsolete=False) -> None:
  213. for keyword in self._keywords:
  214. try:
  215. if line.startswith(keyword) and line[len(keyword)] in [' ', '[']:
  216. arg = line[len(keyword):]
  217. break
  218. except IndexError:
  219. self._invalid_pofile(line, lineno, "Keyword must be followed by a string")
  220. else:
  221. self._invalid_pofile(line, lineno, "Start of line didn't match any expected keyword.")
  222. return
  223. if keyword in ['msgid', 'msgctxt']:
  224. self._finish_current_message()
  225. self.obsolete = obsolete
  226. # The line that has the msgid is stored as the offset of the msg
  227. # should this be the msgctxt if it has one?
  228. if keyword == 'msgid':
  229. self.offset = lineno
  230. if keyword in ['msgid', 'msgid_plural']:
  231. self.in_msgctxt = False
  232. self.in_msgid = True
  233. self.messages.append(_NormalizedString(arg))
  234. elif keyword == 'msgstr':
  235. self.in_msgid = False
  236. self.in_msgstr = True
  237. if arg.startswith('['):
  238. idx, msg = arg[1:].split(']', 1)
  239. self.translations.append([int(idx), _NormalizedString(msg)])
  240. else:
  241. self.translations.append([0, _NormalizedString(arg)])
  242. elif keyword == 'msgctxt':
  243. self.in_msgctxt = True
  244. self.context = _NormalizedString(arg)
  245. def _process_string_continuation_line(self, line, lineno) -> None:
  246. if self.in_msgid:
  247. s = self.messages[-1]
  248. elif self.in_msgstr:
  249. s = self.translations[-1][1]
  250. elif self.in_msgctxt:
  251. s = self.context
  252. else:
  253. self._invalid_pofile(line, lineno, "Got line starting with \" but not in msgid, msgstr or msgctxt")
  254. return
  255. s.append(line)
  256. def _process_comment(self, line) -> None:
  257. self._finish_current_message()
  258. if line[1:].startswith(':'):
  259. for location in _extract_locations(line[2:]):
  260. pos = location.rfind(':')
  261. if pos >= 0:
  262. try:
  263. lineno = int(location[pos + 1:])
  264. except ValueError:
  265. continue
  266. self.locations.append((location[:pos], lineno))
  267. else:
  268. self.locations.append((location, None))
  269. elif line[1:].startswith(','):
  270. for flag in line[2:].lstrip().split(','):
  271. self.flags.append(flag.strip())
  272. elif line[1:].startswith('.'):
  273. # These are called auto-comments
  274. comment = line[2:].strip()
  275. if comment: # Just check that we're not adding empty comments
  276. self.auto_comments.append(comment)
  277. else:
  278. # These are called user comments
  279. self.user_comments.append(line[1:].strip())
  280. def parse(self, fileobj: IO[AnyStr] | Iterable[AnyStr]) -> None:
  281. """
  282. Reads from the file-like object `fileobj` and adds any po file
  283. units found in it to the `Catalog` supplied to the constructor.
  284. """
  285. for lineno, line in enumerate(fileobj):
  286. line = line.strip()
  287. if not isinstance(line, str):
  288. line = line.decode(self.catalog.charset)
  289. if not line:
  290. continue
  291. if line.startswith('#'):
  292. if line[1:].startswith('~'):
  293. self._process_message_line(lineno, line[2:].lstrip(), obsolete=True)
  294. else:
  295. try:
  296. self._process_comment(line)
  297. except ValueError as exc:
  298. self._invalid_pofile(line, lineno, str(exc))
  299. else:
  300. self._process_message_line(lineno, line)
  301. self._finish_current_message()
  302. # No actual messages found, but there was some info in comments, from which
  303. # we'll construct an empty header message
  304. if not self.counter and (self.flags or self.user_comments or self.auto_comments):
  305. self.messages.append(_NormalizedString('""'))
  306. self.translations.append([0, _NormalizedString('""')])
  307. self._add_message()
  308. def _invalid_pofile(self, line, lineno, msg) -> None:
  309. assert isinstance(line, str)
  310. if self.abort_invalid:
  311. raise PoFileError(msg, self.catalog, line, lineno)
  312. print("WARNING:", msg)
  313. print(f"WARNING: Problem on line {lineno + 1}: {line!r}")
  314. def read_po(
  315. fileobj: IO[AnyStr] | Iterable[AnyStr],
  316. locale: Locale | str | None = None,
  317. domain: str | None = None,
  318. ignore_obsolete: bool = False,
  319. charset: str | None = None,
  320. abort_invalid: bool = False,
  321. ) -> Catalog:
  322. """Read messages from a ``gettext`` PO (portable object) file from the given
  323. file-like object (or an iterable of lines) and return a `Catalog`.
  324. >>> from datetime import datetime
  325. >>> from io import StringIO
  326. >>> buf = StringIO('''
  327. ... #: main.py:1
  328. ... #, fuzzy, python-format
  329. ... msgid "foo %(name)s"
  330. ... msgstr "quux %(name)s"
  331. ...
  332. ... # A user comment
  333. ... #. An auto comment
  334. ... #: main.py:3
  335. ... msgid "bar"
  336. ... msgid_plural "baz"
  337. ... msgstr[0] "bar"
  338. ... msgstr[1] "baaz"
  339. ... ''')
  340. >>> catalog = read_po(buf)
  341. >>> catalog.revision_date = datetime(2007, 4, 1)
  342. >>> for message in catalog:
  343. ... if message.id:
  344. ... print((message.id, message.string))
  345. ... print(' ', (message.locations, sorted(list(message.flags))))
  346. ... print(' ', (message.user_comments, message.auto_comments))
  347. (u'foo %(name)s', u'quux %(name)s')
  348. ([(u'main.py', 1)], [u'fuzzy', u'python-format'])
  349. ([], [])
  350. ((u'bar', u'baz'), (u'bar', u'baaz'))
  351. ([(u'main.py', 3)], [])
  352. ([u'A user comment'], [u'An auto comment'])
  353. .. versionadded:: 1.0
  354. Added support for explicit charset argument.
  355. :param fileobj: the file-like object (or iterable of lines) to read the PO file from
  356. :param locale: the locale identifier or `Locale` object, or `None`
  357. if the catalog is not bound to a locale (which basically
  358. means it's a template)
  359. :param domain: the message domain
  360. :param ignore_obsolete: whether to ignore obsolete messages in the input
  361. :param charset: the character set of the catalog.
  362. :param abort_invalid: abort read if po file is invalid
  363. """
  364. catalog = Catalog(locale=locale, domain=domain, charset=charset)
  365. parser = PoFileParser(catalog, ignore_obsolete, abort_invalid=abort_invalid)
  366. parser.parse(fileobj)
  367. return catalog
  368. WORD_SEP = re.compile('('
  369. r'\s+|' # any whitespace
  370. r'[^\s\w]*\w+[a-zA-Z]-(?=\w+[a-zA-Z])|' # hyphenated words
  371. r'(?<=[\w\!\"\'\&\.\,\?])-{2,}(?=\w)' # em-dash
  372. ')')
  373. def escape(string: str) -> str:
  374. r"""Escape the given string so that it can be included in double-quoted
  375. strings in ``PO`` files.
  376. >>> escape('''Say:
  377. ... "hello, world!"
  378. ... ''')
  379. '"Say:\\n \\"hello, world!\\"\\n"'
  380. :param string: the string to escape
  381. """
  382. return '"%s"' % string.replace('\\', '\\\\') \
  383. .replace('\t', '\\t') \
  384. .replace('\r', '\\r') \
  385. .replace('\n', '\\n') \
  386. .replace('\"', '\\"')
  387. def normalize(string: str, prefix: str = '', width: int = 76) -> str:
  388. r"""Convert a string into a format that is appropriate for .po files.
  389. >>> print(normalize('''Say:
  390. ... "hello, world!"
  391. ... ''', width=None))
  392. ""
  393. "Say:\n"
  394. " \"hello, world!\"\n"
  395. >>> print(normalize('''Say:
  396. ... "Lorem ipsum dolor sit amet, consectetur adipisicing elit, "
  397. ... ''', width=32))
  398. ""
  399. "Say:\n"
  400. " \"Lorem ipsum dolor sit "
  401. "amet, consectetur adipisicing"
  402. " elit, \"\n"
  403. :param string: the string to normalize
  404. :param prefix: a string that should be prepended to every line
  405. :param width: the maximum line width; use `None`, 0, or a negative number
  406. to completely disable line wrapping
  407. """
  408. if width and width > 0:
  409. prefixlen = len(prefix)
  410. lines = []
  411. for line in string.splitlines(True):
  412. if len(escape(line)) + prefixlen > width:
  413. chunks = WORD_SEP.split(line)
  414. chunks.reverse()
  415. while chunks:
  416. buf = []
  417. size = 2
  418. while chunks:
  419. length = len(escape(chunks[-1])) - 2 + prefixlen
  420. if size + length < width:
  421. buf.append(chunks.pop())
  422. size += length
  423. else:
  424. if not buf:
  425. # handle long chunks by putting them on a
  426. # separate line
  427. buf.append(chunks.pop())
  428. break
  429. lines.append(''.join(buf))
  430. else:
  431. lines.append(line)
  432. else:
  433. lines = string.splitlines(True)
  434. if len(lines) <= 1:
  435. return escape(string)
  436. # Remove empty trailing line
  437. if lines and not lines[-1]:
  438. del lines[-1]
  439. lines[-1] += '\n'
  440. return '""\n' + '\n'.join([(prefix + escape(line)) for line in lines])
  441. def _enclose_filename_if_necessary(filename: str) -> str:
  442. """Enclose filenames which include white spaces or tabs.
  443. Do the same as gettext and enclose filenames which contain white
  444. spaces or tabs with First Strong Isolate (U+2068) and Pop
  445. Directional Isolate (U+2069).
  446. """
  447. if " " not in filename and "\t" not in filename:
  448. return filename
  449. if not filename.startswith("\u2068"):
  450. filename = "\u2068" + filename
  451. if not filename.endswith("\u2069"):
  452. filename += "\u2069"
  453. return filename
  454. def write_po(
  455. fileobj: SupportsWrite[bytes],
  456. catalog: Catalog,
  457. width: int = 76,
  458. no_location: bool = False,
  459. omit_header: bool = False,
  460. sort_output: bool = False,
  461. sort_by_file: bool = False,
  462. ignore_obsolete: bool = False,
  463. include_previous: bool = False,
  464. include_lineno: bool = True,
  465. ) -> None:
  466. r"""Write a ``gettext`` PO (portable object) template file for a given
  467. message catalog to the provided file-like object.
  468. >>> catalog = Catalog()
  469. >>> catalog.add(u'foo %(name)s', locations=[('main.py', 1)],
  470. ... flags=('fuzzy',))
  471. <Message...>
  472. >>> catalog.add((u'bar', u'baz'), locations=[('main.py', 3)])
  473. <Message...>
  474. >>> from io import BytesIO
  475. >>> buf = BytesIO()
  476. >>> write_po(buf, catalog, omit_header=True)
  477. >>> print(buf.getvalue().decode("utf8"))
  478. #: main.py:1
  479. #, fuzzy, python-format
  480. msgid "foo %(name)s"
  481. msgstr ""
  482. <BLANKLINE>
  483. #: main.py:3
  484. msgid "bar"
  485. msgid_plural "baz"
  486. msgstr[0] ""
  487. msgstr[1] ""
  488. <BLANKLINE>
  489. <BLANKLINE>
  490. :param fileobj: the file-like object to write to
  491. :param catalog: the `Catalog` instance
  492. :param width: the maximum line width for the generated output; use `None`,
  493. 0, or a negative number to completely disable line wrapping
  494. :param no_location: do not emit a location comment for every message
  495. :param omit_header: do not include the ``msgid ""`` entry at the top of the
  496. output
  497. :param sort_output: whether to sort the messages in the output by msgid
  498. :param sort_by_file: whether to sort the messages in the output by their
  499. locations
  500. :param ignore_obsolete: whether to ignore obsolete messages and not include
  501. them in the output; by default they are included as
  502. comments
  503. :param include_previous: include the old msgid as a comment when
  504. updating the catalog
  505. :param include_lineno: include line number in the location comment
  506. """
  507. sort_by = None
  508. if sort_output:
  509. sort_by = "message"
  510. elif sort_by_file:
  511. sort_by = "location"
  512. for line in generate_po(
  513. catalog,
  514. ignore_obsolete=ignore_obsolete,
  515. include_lineno=include_lineno,
  516. include_previous=include_previous,
  517. no_location=no_location,
  518. omit_header=omit_header,
  519. sort_by=sort_by,
  520. width=width,
  521. ):
  522. if isinstance(line, str):
  523. line = line.encode(catalog.charset, 'backslashreplace')
  524. fileobj.write(line)
  525. def generate_po(
  526. catalog: Catalog,
  527. *,
  528. ignore_obsolete: bool = False,
  529. include_lineno: bool = True,
  530. include_previous: bool = False,
  531. no_location: bool = False,
  532. omit_header: bool = False,
  533. sort_by: Literal["message", "location"] | None = None,
  534. width: int = 76,
  535. ) -> Iterable[str]:
  536. r"""Yield text strings representing a ``gettext`` PO (portable object) file.
  537. See `write_po()` for a more detailed description.
  538. """
  539. # xgettext always wraps comments even if --no-wrap is passed;
  540. # provide the same behaviour
  541. comment_width = width if width and width > 0 else 76
  542. comment_wrapper = TextWrapper(width=comment_width, break_long_words=False)
  543. header_wrapper = TextWrapper(width=width, subsequent_indent="# ", break_long_words=False)
  544. def _format_comment(comment, prefix=''):
  545. for line in comment_wrapper.wrap(comment):
  546. yield f"#{prefix} {line.strip()}\n"
  547. def _format_message(message, prefix=''):
  548. if isinstance(message.id, (list, tuple)):
  549. if message.context:
  550. yield f"{prefix}msgctxt {normalize(message.context, prefix=prefix, width=width)}\n"
  551. yield f"{prefix}msgid {normalize(message.id[0], prefix=prefix, width=width)}\n"
  552. yield f"{prefix}msgid_plural {normalize(message.id[1], prefix=prefix, width=width)}\n"
  553. for idx in range(catalog.num_plurals):
  554. try:
  555. string = message.string[idx]
  556. except IndexError:
  557. string = ''
  558. yield f"{prefix}msgstr[{idx:d}] {normalize(string, prefix=prefix, width=width)}\n"
  559. else:
  560. if message.context:
  561. yield f"{prefix}msgctxt {normalize(message.context, prefix=prefix, width=width)}\n"
  562. yield f"{prefix}msgid {normalize(message.id, prefix=prefix, width=width)}\n"
  563. yield f"{prefix}msgstr {normalize(message.string or '', prefix=prefix, width=width)}\n"
  564. for message in _sort_messages(catalog, sort_by=sort_by):
  565. if not message.id: # This is the header "message"
  566. if omit_header:
  567. continue
  568. comment_header = catalog.header_comment
  569. if width and width > 0:
  570. lines = []
  571. for line in comment_header.splitlines():
  572. lines += header_wrapper.wrap(line)
  573. comment_header = '\n'.join(lines)
  574. yield f"{comment_header}\n"
  575. for comment in message.user_comments:
  576. yield from _format_comment(comment)
  577. for comment in message.auto_comments:
  578. yield from _format_comment(comment, prefix='.')
  579. if not no_location:
  580. locs = []
  581. # sort locations by filename and lineno.
  582. # if there's no <int> as lineno, use `-1`.
  583. # if no sorting possible, leave unsorted.
  584. # (see issue #606)
  585. try:
  586. locations = sorted(message.locations,
  587. key=lambda x: (x[0], isinstance(x[1], int) and x[1] or -1))
  588. except TypeError: # e.g. "TypeError: unorderable types: NoneType() < int()"
  589. locations = message.locations
  590. for filename, lineno in locations:
  591. location = filename.replace(os.sep, '/')
  592. location = _enclose_filename_if_necessary(location)
  593. if lineno and include_lineno:
  594. location = f"{location}:{lineno:d}"
  595. if location not in locs:
  596. locs.append(location)
  597. yield from _format_comment(' '.join(locs), prefix=':')
  598. if message.flags:
  599. yield f"#{', '.join(['', *sorted(message.flags)])}\n"
  600. if message.previous_id and include_previous:
  601. yield from _format_comment(
  602. f'msgid {normalize(message.previous_id[0], width=width)}',
  603. prefix='|',
  604. )
  605. if len(message.previous_id) > 1:
  606. norm_previous_id = normalize(message.previous_id[1], width=width)
  607. yield from _format_comment(f'msgid_plural {norm_previous_id}', prefix='|')
  608. yield from _format_message(message)
  609. yield '\n'
  610. if not ignore_obsolete:
  611. for message in _sort_messages(
  612. catalog.obsolete.values(),
  613. sort_by=sort_by,
  614. ):
  615. for comment in message.user_comments:
  616. yield from _format_comment(comment)
  617. yield from _format_message(message, prefix='#~ ')
  618. yield '\n'
  619. def _sort_messages(messages: Iterable[Message], sort_by: Literal["message", "location"] | None) -> list[Message]:
  620. """
  621. Sort the given message iterable by the given criteria.
  622. Always returns a list.
  623. :param messages: An iterable of Messages.
  624. :param sort_by: Sort by which criteria? Options are `message` and `location`.
  625. :return: list[Message]
  626. """
  627. messages = list(messages)
  628. if sort_by == "message":
  629. messages.sort()
  630. elif sort_by == "location":
  631. messages.sort(key=lambda m: m.locations)
  632. return messages