pofile.py 25 KB

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