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

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

364 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'\\([\\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