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