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