Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/babel/messages/pofile.py: 32%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

370 statements  

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