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

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

450 statements  

1""" 

2babel.messages.catalog 

3~~~~~~~~~~~~~~~~~~~~~~ 

4 

5Data structures for message catalogs. 

6 

7:copyright: (c) 2013-2026 by the Babel Team. 

8:license: BSD, see LICENSE for more details. 

9""" 

10 

11from __future__ import annotations 

12 

13import datetime 

14import re 

15from collections import defaultdict 

16from collections.abc import Iterable, Iterator 

17from copy import copy 

18from difflib import SequenceMatcher 

19from email import message_from_string 

20from heapq import nlargest 

21from string import Formatter 

22from typing import TYPE_CHECKING, TypedDict 

23 

24from babel import __version__ as VERSION 

25from babel.core import Locale, UnknownLocaleError 

26from babel.dates import format_datetime 

27from babel.messages.plurals import get_plural 

28from babel.util import LOCALTZ, _cmp 

29 

30if TYPE_CHECKING: 

31 from typing_extensions import TypeAlias 

32 

33 _MessageID: TypeAlias = str | tuple[str, ...] | list[str] 

34 

35__all__ = [ 

36 'DEFAULT_HEADER', 

37 'PYTHON_FORMAT', 

38 'Catalog', 

39 'Message', 

40 'TranslationError', 

41] 

42 

43 

44def get_close_matches(word, possibilities, n=3, cutoff=0.6): 

45 """A modified version of ``difflib.get_close_matches``. 

46 

47 It just passes ``autojunk=False`` to the ``SequenceMatcher``, to work 

48 around https://github.com/python/cpython/issues/90825. 

49 """ 

50 if not n > 0: # pragma: no cover 

51 raise ValueError(f"n must be > 0: {n!r}") 

52 if not 0.0 <= cutoff <= 1.0: # pragma: no cover 

53 raise ValueError(f"cutoff must be in [0.0, 1.0]: {cutoff!r}") 

54 result = [] 

55 s = SequenceMatcher(autojunk=False) # only line changed from difflib.py 

56 s.set_seq2(word) 

57 for x in possibilities: 

58 s.set_seq1(x) 

59 if ( 

60 s.real_quick_ratio() >= cutoff 

61 and s.quick_ratio() >= cutoff 

62 and s.ratio() >= cutoff 

63 ): 

64 result.append((s.ratio(), x)) 

65 

66 # Move the best scorers to head of list 

67 result = nlargest(n, result) 

68 # Strip scores for the best n matches 

69 return [x for score, x in result] 

70 

71 

72PYTHON_FORMAT = re.compile( 

73 r''' 

74 \% 

75 (?:\(([\w]*)\))? 

76 ( 

77 [-#0\ +]?(?:\*|[\d]+)? 

78 (?:\.(?:\*|[\d]+))? 

79 [hlL]? 

80 ) 

81 ([diouxXeEfFgGcrs%]) 

82''', 

83 re.VERBOSE, 

84) 

85 

86 

87def _has_python_brace_format(string: str) -> bool: 

88 if "{" not in string: 

89 return False 

90 fmt = Formatter() 

91 try: 

92 # `fmt.parse` returns 3-or-4-tuples of the form 

93 # `(literal_text, field_name, format_spec, conversion)`; 

94 # if `field_name` is set, this smells like brace format 

95 field_name_seen = False 

96 for t in fmt.parse(string): 

97 if t[1] is not None: 

98 field_name_seen = True 

99 # We cannot break here, as we need to consume the whole string 

100 # to ensure that it is a valid format string. 

101 except ValueError: 

102 return False 

103 return field_name_seen 

104 

105 

106def _parse_datetime_header(value: str) -> datetime.datetime: 

107 match = re.match(r'^(?P<datetime>.*?)(?P<tzoffset>[+-]\d{4})?$', value) 

108 

109 dt = datetime.datetime.strptime(match.group('datetime'), '%Y-%m-%d %H:%M') 

110 

111 # Separate the offset into a sign component, hours, and # minutes 

112 tzoffset = match.group('tzoffset') 

113 if tzoffset is not None: 

114 plus_minus_s, rest = tzoffset[0], tzoffset[1:] 

115 hours_offset_s, mins_offset_s = rest[:2], rest[2:] 

116 

117 # Make them all integers 

118 plus_minus = int(f"{plus_minus_s}1") 

119 hours_offset = int(hours_offset_s) 

120 mins_offset = int(mins_offset_s) 

121 

122 # Calculate net offset 

123 net_mins_offset = hours_offset * 60 

124 net_mins_offset += mins_offset 

125 net_mins_offset *= plus_minus 

126 

127 # Create an offset object 

128 tzoffset = datetime.timezone( 

129 offset=datetime.timedelta(minutes=net_mins_offset), 

130 name=f'Etc/GMT{net_mins_offset:+d}', 

131 ) 

132 

133 # Store the offset in a datetime object 

134 dt = dt.replace(tzinfo=tzoffset) 

135 

136 return dt 

137 

138 

139class Message: 

140 """Representation of a single message in a catalog.""" 

141 

142 def __init__( 

143 self, 

144 id: _MessageID, 

145 string: _MessageID | None = '', 

146 locations: Iterable[tuple[str, int]] = (), 

147 flags: Iterable[str] = (), 

148 auto_comments: Iterable[str] = (), 

149 user_comments: Iterable[str] = (), 

150 previous_id: _MessageID = (), 

151 lineno: int | None = None, 

152 context: str | None = None, 

153 ) -> None: 

154 """Create the message object. 

155 

156 :param id: the message ID, or a ``(singular, plural)`` tuple for 

157 pluralizable messages 

158 :param string: the translated message string, or a 

159 ``(singular, plural)`` tuple for pluralizable messages 

160 :param locations: a sequence of ``(filename, lineno)`` tuples 

161 :param flags: a set or sequence of flags 

162 :param auto_comments: a sequence of automatic comments for the message 

163 :param user_comments: a sequence of user comments for the message 

164 :param previous_id: the previous message ID, or a ``(singular, plural)`` 

165 tuple for pluralizable messages 

166 :param lineno: the line number on which the msgid line was found in the 

167 PO file, if any 

168 :param context: the message context 

169 """ 

170 self.id = id 

171 if not string and self.pluralizable: 

172 string = ('', '') 

173 self.string = string 

174 self.locations = list(dict.fromkeys(locations)) if locations else [] 

175 self.flags = set(flags) 

176 if id and self.python_format: 

177 self.flags.add('python-format') 

178 else: 

179 self.flags.discard('python-format') 

180 if id and self.python_brace_format: 

181 self.flags.add('python-brace-format') 

182 else: 

183 self.flags.discard('python-brace-format') 

184 self.auto_comments = list(dict.fromkeys(auto_comments)) if auto_comments else [] 

185 self.user_comments = list(dict.fromkeys(user_comments)) if user_comments else [] 

186 if previous_id: 

187 if isinstance(previous_id, str): 

188 self.previous_id = [previous_id] 

189 else: 

190 self.previous_id = list(previous_id) 

191 else: 

192 self.previous_id = [] 

193 self.lineno = lineno 

194 self.context = context 

195 

196 def __repr__(self) -> str: 

197 return f"<{type(self).__name__} {self.id!r} (flags: {list(self.flags)!r})>" 

198 

199 def __cmp__(self, other: object) -> int: 

200 """Compare Messages, taking into account plural ids""" 

201 

202 def values_to_compare(obj): 

203 if isinstance(obj, Message) and obj.pluralizable: 

204 return obj.id[0], obj.context or '' 

205 return obj.id, obj.context or '' 

206 

207 return _cmp(values_to_compare(self), values_to_compare(other)) 

208 

209 def __gt__(self, other: object) -> bool: 

210 return self.__cmp__(other) > 0 

211 

212 def __lt__(self, other: object) -> bool: 

213 return self.__cmp__(other) < 0 

214 

215 def __ge__(self, other: object) -> bool: 

216 return self.__cmp__(other) >= 0 

217 

218 def __le__(self, other: object) -> bool: 

219 return self.__cmp__(other) <= 0 

220 

221 def __eq__(self, other: object) -> bool: 

222 return self.__cmp__(other) == 0 

223 

224 def __ne__(self, other: object) -> bool: 

225 return self.__cmp__(other) != 0 

226 

227 def is_identical(self, other: Message) -> bool: 

228 """Checks whether messages are identical, taking into account all 

229 properties. 

230 """ 

231 assert isinstance(other, Message) 

232 return self.__dict__ == other.__dict__ 

233 

234 def clone(self) -> Message: 

235 return Message( 

236 id=copy(self.id), 

237 string=copy(self.string), 

238 locations=copy(self.locations), 

239 flags=copy(self.flags), 

240 auto_comments=copy(self.auto_comments), 

241 user_comments=copy(self.user_comments), 

242 previous_id=copy(self.previous_id), 

243 lineno=self.lineno, # immutable (str/None) 

244 context=self.context, # immutable (str/None) 

245 ) 

246 

247 def check(self, catalog: Catalog | None = None) -> list[TranslationError]: 

248 """Run various validation checks on the message. Some validations 

249 are only performed if the catalog is provided. This method returns 

250 a sequence of `TranslationError` objects. 

251 

252 :rtype: ``iterator`` 

253 :param catalog: A catalog instance that is passed to the checkers 

254 :see: `Catalog.check` for a way to perform checks for all messages 

255 in a catalog. 

256 """ 

257 from babel.messages.checkers import checkers 

258 

259 errors: list[TranslationError] = [] 

260 for checker in checkers: 

261 try: 

262 checker(catalog, self) 

263 except TranslationError as e: 

264 errors.append(e) 

265 return errors 

266 

267 @property 

268 def fuzzy(self) -> bool: 

269 """Whether the translation is fuzzy. 

270 

271 >>> Message('foo').fuzzy 

272 False 

273 >>> msg = Message('foo', 'foo', flags=['fuzzy']) 

274 >>> msg.fuzzy 

275 True 

276 >>> msg 

277 <Message 'foo' (flags: ['fuzzy'])> 

278 """ 

279 return 'fuzzy' in self.flags 

280 

281 @property 

282 def pluralizable(self) -> bool: 

283 """Whether the message is plurizable. 

284 

285 >>> Message('foo').pluralizable 

286 False 

287 >>> Message(('foo', 'bar')).pluralizable 

288 True 

289 """ 

290 return isinstance(self.id, (list, tuple)) 

291 

292 @property 

293 def python_format(self) -> bool: 

294 """Whether the message contains Python-style parameters. 

295 

296 >>> Message('foo %(name)s bar').python_format 

297 True 

298 >>> Message(('foo %(name)s', 'foo %(name)s')).python_format 

299 True 

300 """ 

301 ids = self.id 

302 if isinstance(ids, (list, tuple)): 

303 for id in ids: # Explicit loop for performance reasons. 

304 if PYTHON_FORMAT.search(id): 

305 return True 

306 return False 

307 return bool(PYTHON_FORMAT.search(ids)) 

308 

309 @property 

310 def python_brace_format(self) -> bool: 

311 """Whether the message contains Python f-string parameters. 

312 

313 >>> Message('Hello, {name}!').python_brace_format 

314 True 

315 >>> Message(('One apple', '{count} apples')).python_brace_format 

316 True 

317 """ 

318 ids = self.id 

319 if isinstance(ids, (list, tuple)): 

320 for id in ids: # Explicit loop for performance reasons. 

321 if _has_python_brace_format(id): 

322 return True 

323 return False 

324 return _has_python_brace_format(ids) 

325 

326 

327class TranslationError(Exception): 

328 """Exception thrown by translation checkers when invalid message 

329 translations are encountered.""" 

330 

331 

332DEFAULT_HEADER = """\ 

333# Translations template for PROJECT. 

334# Copyright (C) YEAR ORGANIZATION 

335# This file is distributed under the same license as the PROJECT project. 

336# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. 

337#""" 

338 

339 

340def parse_separated_header(value: str) -> dict[str, str]: 

341 # Adapted from https://peps.python.org/pep-0594/#cgi 

342 from email.message import Message 

343 

344 m = Message() 

345 m['content-type'] = value 

346 return dict(m.get_params()) 

347 

348 

349def _force_text(s: str | bytes, encoding: str = 'utf-8', errors: str = 'strict') -> str: 

350 if isinstance(s, str): 

351 return s 

352 if isinstance(s, bytes): 

353 return s.decode(encoding, errors) 

354 return str(s) 

355 

356 

357class ConflictInfo(TypedDict): 

358 message: Message 

359 filename: str 

360 project: str 

361 version: str 

362 

363 

364class Catalog: 

365 """Representation of a message catalog.""" 

366 

367 def __init__( 

368 self, 

369 locale: Locale | str | None = None, 

370 domain: str | None = None, 

371 header_comment: str | None = DEFAULT_HEADER, 

372 project: str | None = None, 

373 version: str | None = None, 

374 copyright_holder: str | None = None, 

375 msgid_bugs_address: str | None = None, 

376 creation_date: datetime.datetime | str | None = None, 

377 revision_date: datetime.datetime | datetime.time | float | str | None = None, 

378 last_translator: str | None = None, 

379 language_team: str | None = None, 

380 charset: str | None = None, 

381 fuzzy: bool = True, 

382 ) -> None: 

383 """Initialize the catalog object. 

384 

385 :param locale: the locale identifier or `Locale` object, or `None` 

386 if the catalog is not bound to a locale (which basically 

387 means it's a template) 

388 :param domain: the message domain 

389 :param header_comment: the header comment as string, or `None` for the 

390 default header 

391 :param project: the project's name 

392 :param version: the project's version 

393 :param copyright_holder: the copyright holder of the catalog 

394 :param msgid_bugs_address: the email address or URL to submit bug 

395 reports to 

396 :param creation_date: the date the catalog was created 

397 :param revision_date: the date the catalog was revised 

398 :param last_translator: the name and email of the last translator 

399 :param language_team: the name and email of the language team 

400 :param charset: the encoding to use in the output (defaults to utf-8) 

401 :param fuzzy: the fuzzy bit on the catalog header 

402 """ 

403 self.domain = domain 

404 self.locale = locale 

405 self._header_comment = header_comment 

406 self._messages: dict[str | tuple[str, str], Message] = {} 

407 self._conflicts: dict[str | tuple[str, str], list[ConflictInfo]] = defaultdict(list) 

408 

409 self.project = project or 'PROJECT' 

410 self.version = version or 'VERSION' 

411 self.copyright_holder = copyright_holder or 'ORGANIZATION' 

412 self.msgid_bugs_address = msgid_bugs_address or 'EMAIL@ADDRESS' 

413 

414 self.last_translator = last_translator or 'FULL NAME <EMAIL@ADDRESS>' 

415 """Name and email address of the last translator.""" 

416 self.language_team = language_team or 'LANGUAGE <LL@li.org>' 

417 """Name and email address of the language team.""" 

418 

419 self.charset = charset or 'utf-8' 

420 

421 if creation_date is None: 

422 creation_date = datetime.datetime.now(LOCALTZ) 

423 elif isinstance(creation_date, datetime.datetime) and not creation_date.tzinfo: 

424 creation_date = creation_date.replace(tzinfo=LOCALTZ) 

425 self.creation_date = creation_date 

426 if revision_date is None: 

427 revision_date = 'YEAR-MO-DA HO:MI+ZONE' 

428 elif isinstance(revision_date, datetime.datetime) and not revision_date.tzinfo: 

429 revision_date = revision_date.replace(tzinfo=LOCALTZ) 

430 self.revision_date = revision_date 

431 self.fuzzy = fuzzy 

432 

433 # Dictionary of obsolete messages 

434 self.obsolete: dict[str | tuple[str, str], Message] = {} 

435 self._num_plurals = None 

436 self._plural_expr = None 

437 

438 def _set_locale(self, locale: Locale | str | None) -> None: 

439 if locale is None: 

440 self._locale_identifier = None 

441 self._locale = None 

442 return 

443 

444 if isinstance(locale, Locale): 

445 self._locale_identifier = str(locale) 

446 self._locale = locale 

447 return 

448 

449 if isinstance(locale, str): 

450 self._locale_identifier = str(locale) 

451 try: 

452 self._locale = Locale.parse(locale) 

453 except UnknownLocaleError: 

454 self._locale = None 

455 return 

456 

457 raise TypeError( 

458 f"`locale` must be a Locale, a locale identifier string, or None; got {locale!r}", 

459 ) 

460 

461 @property 

462 def locale(self) -> Locale | None: 

463 return self._locale 

464 

465 @locale.setter 

466 def locale(self, locale: Locale | str | None) -> None: 

467 self._set_locale(locale) 

468 

469 @property 

470 def locale_identifier(self) -> str | None: 

471 return self._locale_identifier 

472 

473 def _get_header_comment(self) -> str: 

474 comment = self._header_comment 

475 year = datetime.datetime.now(LOCALTZ).strftime('%Y') 

476 if hasattr(self.revision_date, 'strftime'): 

477 year = self.revision_date.strftime('%Y') 

478 comment = ( 

479 comment.replace('PROJECT', self.project) 

480 .replace('VERSION', self.version) 

481 .replace('YEAR', year) 

482 .replace('ORGANIZATION', self.copyright_holder) 

483 ) 

484 locale_name = self.locale.english_name if self.locale else self.locale_identifier 

485 if locale_name: 

486 comment = comment.replace("Translations template", f"{locale_name} translations") 

487 return comment 

488 

489 def _set_header_comment(self, string: str | None) -> None: 

490 self._header_comment = string 

491 

492 @property 

493 def header_comment(self) -> str: 

494 """ 

495 The header comment for the catalog. 

496 

497 >>> catalog = Catalog(project='Foobar', version='1.0', 

498 ... copyright_holder='Foo Company') 

499 >>> print(catalog.header_comment) #doctest: +ELLIPSIS 

500 # Translations template for Foobar. 

501 # Copyright (C) ... Foo Company 

502 # This file is distributed under the same license as the Foobar project. 

503 # FIRST AUTHOR <EMAIL@ADDRESS>, .... 

504 # 

505 

506 The header can also be set from a string. Any known upper-case variables 

507 will be replaced when the header is retrieved again: 

508 

509 >>> catalog = Catalog(project='Foobar', version='1.0', 

510 ... copyright_holder='Foo Company') 

511 >>> catalog.header_comment = '''\\ 

512 ... # The POT for my really cool PROJECT project. 

513 ... # Copyright (C) 1990-2003 ORGANIZATION 

514 ... # This file is distributed under the same license as the PROJECT 

515 ... # project. 

516 ... #''' 

517 >>> print(catalog.header_comment) 

518 # The POT for my really cool Foobar project. 

519 # Copyright (C) 1990-2003 Foo Company 

520 # This file is distributed under the same license as the Foobar 

521 # project. 

522 # 

523 """ 

524 return self._get_header_comment() 

525 

526 @header_comment.setter 

527 def header_comment(self, value: str) -> None: 

528 self._set_header_comment(value) 

529 

530 def _get_mime_headers(self) -> list[tuple[str, str]]: 

531 if isinstance(self.revision_date, (datetime.datetime, datetime.time, int, float)): 

532 revision_date = format_datetime( 

533 self.revision_date, 

534 'yyyy-MM-dd HH:mmZ', 

535 locale='en', 

536 ) 

537 else: 

538 revision_date = self.revision_date 

539 

540 language_team = self.language_team 

541 if self.locale_identifier and 'LANGUAGE' in language_team: 

542 language_team = language_team.replace('LANGUAGE', str(self.locale_identifier)) 

543 

544 headers: list[tuple[str, str]] = [ 

545 ("Project-Id-Version", f"{self.project} {self.version}"), 

546 ('Report-Msgid-Bugs-To', self.msgid_bugs_address), 

547 ('POT-Creation-Date', format_datetime(self.creation_date, 'yyyy-MM-dd HH:mmZ', locale='en')), 

548 ('PO-Revision-Date', revision_date), 

549 ('Last-Translator', self.last_translator), 

550 ] # fmt: skip 

551 if self.locale_identifier: 

552 headers.append(('Language', str(self.locale_identifier))) 

553 headers.append(('Language-Team', language_team)) 

554 if self.locale is not None: 

555 headers.append(('Plural-Forms', self.plural_forms)) 

556 headers += [ 

557 ('MIME-Version', '1.0'), 

558 ("Content-Type", f"text/plain; charset={self.charset}"), 

559 ('Content-Transfer-Encoding', '8bit'), 

560 ("Generated-By", f"Babel {VERSION}\n"), 

561 ] 

562 return headers 

563 

564 def _set_mime_headers(self, headers: Iterable[tuple[str, str]]) -> None: 

565 for name, value in headers: 

566 name = _force_text(name.lower(), encoding=self.charset) 

567 value = _force_text(value, encoding=self.charset) 

568 if name == 'project-id-version': 

569 parts = value.split(' ') 

570 self.project = ' '.join(parts[:-1]) 

571 self.version = parts[-1] 

572 elif name == 'report-msgid-bugs-to': 

573 self.msgid_bugs_address = value 

574 elif name == 'last-translator': 

575 self.last_translator = value 

576 elif name == 'language': 

577 value = value.replace('-', '_') 

578 # The `or None` makes sure that the locale is set to None 

579 # if the header's value is an empty string, which is what 

580 # some tools generate (instead of eliding the empty Language 

581 # header altogether). 

582 self._set_locale(value or None) 

583 elif name == 'language-team': 

584 self.language_team = value 

585 elif name == 'content-type': 

586 params = parse_separated_header(value) 

587 if 'charset' in params: 

588 self.charset = params['charset'].lower() 

589 elif name == 'plural-forms': 

590 params = parse_separated_header(f" ;{value}") 

591 self._num_plurals = int(params.get('nplurals', 2)) 

592 self._plural_expr = params.get('plural', '(n != 1)') 

593 elif name == 'pot-creation-date': 

594 self.creation_date = _parse_datetime_header(value) 

595 elif name == 'po-revision-date': 

596 # Keep the value if it's not the default one 

597 if 'YEAR' not in value: 

598 self.revision_date = _parse_datetime_header(value) 

599 

600 @property 

601 def mime_headers(self) -> list[tuple[str, str]]: 

602 """ 

603 The MIME headers of the catalog, used for the special ``msgid ""`` entry. 

604 

605 The behavior of this property changes slightly depending on whether a locale 

606 is set or not, the latter indicating that the catalog is actually a template 

607 for actual translations. 

608 

609 Here's an example of the output for such a catalog template: 

610 

611 >>> from babel.dates import UTC 

612 >>> from datetime import datetime 

613 >>> created = datetime(1990, 4, 1, 15, 30, tzinfo=UTC) 

614 >>> catalog = Catalog(project='Foobar', version='1.0', 

615 ... creation_date=created) 

616 >>> for name, value in catalog.mime_headers: 

617 ... print('%s: %s' % (name, value)) 

618 Project-Id-Version: Foobar 1.0 

619 Report-Msgid-Bugs-To: EMAIL@ADDRESS 

620 POT-Creation-Date: 1990-04-01 15:30+0000 

621 PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE 

622 Last-Translator: FULL NAME <EMAIL@ADDRESS> 

623 Language-Team: LANGUAGE <LL@li.org> 

624 MIME-Version: 1.0 

625 Content-Type: text/plain; charset=utf-8 

626 Content-Transfer-Encoding: 8bit 

627 Generated-By: Babel ... 

628 

629 And here's an example of the output when the locale is set: 

630 

631 >>> revised = datetime(1990, 8, 3, 12, 0, tzinfo=UTC) 

632 >>> catalog = Catalog(locale='de_DE', project='Foobar', version='1.0', 

633 ... creation_date=created, revision_date=revised, 

634 ... last_translator='John Doe <jd@example.com>', 

635 ... language_team='de_DE <de@example.com>') 

636 >>> for name, value in catalog.mime_headers: 

637 ... print('%s: %s' % (name, value)) 

638 Project-Id-Version: Foobar 1.0 

639 Report-Msgid-Bugs-To: EMAIL@ADDRESS 

640 POT-Creation-Date: 1990-04-01 15:30+0000 

641 PO-Revision-Date: 1990-08-03 12:00+0000 

642 Last-Translator: John Doe <jd@example.com> 

643 Language: de_DE 

644 Language-Team: de_DE <de@example.com> 

645 Plural-Forms: nplurals=2; plural=(n != 1); 

646 MIME-Version: 1.0 

647 Content-Type: text/plain; charset=utf-8 

648 Content-Transfer-Encoding: 8bit 

649 Generated-By: Babel ... 

650 """ 

651 return self._get_mime_headers() 

652 

653 @mime_headers.setter 

654 def mime_headers(self, value: Iterable[tuple[str, str]]) -> None: 

655 self._set_mime_headers(value) 

656 

657 @property 

658 def num_plurals(self) -> int: 

659 """The number of plurals used by the catalog or locale. 

660 

661 >>> Catalog(locale='en').num_plurals 

662 2 

663 >>> Catalog(locale='ga').num_plurals 

664 5 

665 """ 

666 if self._num_plurals is None: 

667 num = 2 

668 if self.locale: 

669 num = get_plural(self.locale)[0] 

670 self._num_plurals = num 

671 return self._num_plurals 

672 

673 @property 

674 def plural_expr(self) -> str: 

675 """The plural expression used by the catalog or locale. 

676 

677 >>> Catalog(locale='en').plural_expr 

678 '(n != 1)' 

679 >>> Catalog(locale='ga').plural_expr 

680 '(n == 1 ? 0 : n == 2 ? 1 : n >= 3 && n <= 6 ? 2 : n >= 7 && n <= 10 ? 3 : 4)' 

681 >>> Catalog(locale='ding').plural_expr # unknown locale 

682 '(n != 1)' 

683 """ 

684 if self._plural_expr is None: 

685 expr = '(n != 1)' 

686 if self.locale: 

687 expr = get_plural(self.locale)[1] 

688 self._plural_expr = expr 

689 return self._plural_expr 

690 

691 @property 

692 def plural_forms(self) -> str: 

693 """Return the plural forms declaration for the locale. 

694 

695 >>> Catalog(locale='en').plural_forms 

696 'nplurals=2; plural=(n != 1);' 

697 >>> Catalog(locale='pt_BR').plural_forms 

698 'nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;' 

699 """ 

700 return f"nplurals={self.num_plurals}; plural={self.plural_expr};" 

701 

702 def __contains__(self, id: _MessageID) -> bool: 

703 """Return whether the catalog has a message with the specified ID.""" 

704 return self._key_for(id) in self._messages 

705 

706 def __len__(self) -> int: 

707 """The number of messages in the catalog. 

708 

709 This does not include the special ``msgid ""`` entry.""" 

710 return len(self._messages) 

711 

712 def __iter__(self) -> Iterator[Message]: 

713 """Iterates through all the entries in the catalog, in the order they 

714 were added, yielding a `Message` object for every entry. 

715 

716 :rtype: ``iterator``""" 

717 buf = [] 

718 for name, value in self.mime_headers: 

719 buf.append(f"{name}: {value}") 

720 flags = set() 

721 if self.fuzzy: 

722 flags |= {'fuzzy'} 

723 yield Message('', '\n'.join(buf), flags=flags) 

724 for key in self._messages: 

725 yield self._messages[key] 

726 

727 def __repr__(self) -> str: 

728 locale = '' 

729 if self.locale: 

730 locale = f" {self.locale}" 

731 return f"<{type(self).__name__} {self.domain!r}{locale}>" 

732 

733 def __delitem__(self, id: _MessageID) -> None: 

734 """Delete the message with the specified ID.""" 

735 self.delete(id) 

736 

737 def __getitem__(self, id: _MessageID) -> Message: 

738 """Return the message with the specified ID. 

739 

740 :param id: the message ID 

741 """ 

742 return self.get(id) 

743 

744 def __setitem__(self, id: _MessageID, message: Message) -> None: 

745 """Add or update the message with the specified ID. 

746 

747 >>> catalog = Catalog() 

748 >>> catalog['foo'] = Message('foo') 

749 >>> catalog['foo'] 

750 <Message 'foo' (flags: [])> 

751 

752 If a message with that ID is already in the catalog, it is updated 

753 to include the locations and flags of the new message. 

754 

755 >>> catalog = Catalog() 

756 >>> catalog['foo'] = Message('foo', locations=[('main.py', 1)]) 

757 >>> catalog['foo'].locations 

758 [('main.py', 1)] 

759 >>> catalog['foo'] = Message('foo', locations=[('utils.py', 5)]) 

760 >>> catalog['foo'].locations 

761 [('main.py', 1), ('utils.py', 5)] 

762 

763 :param id: the message ID 

764 :param message: the `Message` object 

765 """ 

766 assert isinstance(message, Message), 'expected a Message object' 

767 key = self._key_for(id, message.context) 

768 current = self._messages.get(key) 

769 if current: 

770 if message.pluralizable and not current.pluralizable: 

771 # The new message adds pluralization 

772 current.id = message.id 

773 current.string = message.string 

774 current.locations = list(dict.fromkeys([*current.locations, *message.locations])) 

775 current.auto_comments = list(dict.fromkeys([*current.auto_comments, *message.auto_comments])) # fmt:skip 

776 current.user_comments = list(dict.fromkeys([*current.user_comments, *message.user_comments])) # fmt:skip 

777 current.flags |= message.flags 

778 elif id == '': 

779 # special treatment for the header message 

780 self.mime_headers = message_from_string(message.string).items() 

781 self.header_comment = "\n".join(f"# {c}".rstrip() for c in message.user_comments) 

782 self.fuzzy = message.fuzzy 

783 else: 

784 if isinstance(id, (list, tuple)): 

785 assert isinstance(message.string, (list, tuple)), ( 

786 f"Expected sequence but got {type(message.string)}" 

787 ) 

788 self._messages[key] = message 

789 

790 def add_conflict(self, message: Message, filename: str, project: str, version: str) -> None: 

791 """Record a conflicting translation for a message. 

792 

793 When the same message ID has different translations across input files, 

794 the conflicting entry is stored and the message is marked as fuzzy in 

795 the output catalog. 

796 

797 :param message: the conflicting :class:`Message` object 

798 :param filename: the basename of the file where the conflict originates 

799 :param project: the project name of the conflicting file 

800 :param version: the project version of the conflicting file 

801 """ 

802 key = self._key_for(message.id, message.context) 

803 self._conflicts[key].append({ 

804 'message': message, 

805 'filename': filename, 

806 'project': project, 

807 'version': version, 

808 }) 

809 

810 def get_conflicts(self, id: _MessageID, context: str | None = None) -> list[ConflictInfo]: 

811 """Return all recorded conflicts for a message ID. 

812 

813 :param id: the message ID to look up conflicts for 

814 :param context: optional message context (msgctxt) 

815 :return: list of :class:`ConflictInfo` dicts, or an empty list if none 

816 """ 

817 key = self._key_for(id, context) 

818 return self._conflicts.get(key, []) 

819 

820 def add( 

821 self, 

822 id: _MessageID, 

823 string: _MessageID | None = None, 

824 locations: Iterable[tuple[str, int]] = (), 

825 flags: Iterable[str] = (), 

826 auto_comments: Iterable[str] = (), 

827 user_comments: Iterable[str] = (), 

828 previous_id: _MessageID = (), 

829 lineno: int | None = None, 

830 context: str | None = None, 

831 ) -> Message: 

832 """Add or update the message with the specified ID. 

833 

834 >>> catalog = Catalog() 

835 >>> catalog.add('foo') 

836 <Message ...> 

837 >>> catalog['foo'] 

838 <Message 'foo' (flags: [])> 

839 

840 This method simply constructs a `Message` object with the given 

841 arguments and invokes `__setitem__` with that object. 

842 

843 :param id: the message ID, or a ``(singular, plural)`` tuple for 

844 pluralizable messages 

845 :param string: the translated message string, or a 

846 ``(singular, plural)`` tuple for pluralizable messages 

847 :param locations: a sequence of ``(filename, lineno)`` tuples 

848 :param flags: a set or sequence of flags 

849 :param auto_comments: a sequence of automatic comments 

850 :param user_comments: a sequence of user comments 

851 :param previous_id: the previous message ID, or a ``(singular, plural)`` 

852 tuple for pluralizable messages 

853 :param lineno: the line number on which the msgid line was found in the 

854 PO file, if any 

855 :param context: the message context 

856 """ 

857 message = Message( 

858 id, 

859 string, 

860 list(locations), 

861 flags, 

862 auto_comments, 

863 user_comments, 

864 previous_id, 

865 lineno=lineno, 

866 context=context, 

867 ) 

868 self[id] = message 

869 return message 

870 

871 def check(self) -> Iterable[tuple[Message, list[TranslationError]]]: 

872 """Run various validation checks on the translations in the catalog. 

873 

874 For every message which fails validation, this method yield a 

875 ``(message, errors)`` tuple, where ``message`` is the `Message` object 

876 and ``errors`` is a sequence of `TranslationError` objects. 

877 

878 :rtype: ``generator`` of ``(message, errors)`` 

879 """ 

880 for message in self._messages.values(): 

881 errors = message.check(catalog=self) 

882 if errors: 

883 yield message, errors 

884 

885 def get(self, id: _MessageID, context: str | None = None) -> Message | None: 

886 """Return the message with the specified ID and context. 

887 

888 :param id: the message ID 

889 :param context: the message context, or ``None`` for no context 

890 """ 

891 return self._messages.get(self._key_for(id, context)) 

892 

893 def delete(self, id: _MessageID, context: str | None = None) -> None: 

894 """Delete the message with the specified ID and context. 

895 

896 :param id: the message ID 

897 :param context: the message context, or ``None`` for no context 

898 """ 

899 key = self._key_for(id, context) 

900 if key in self._messages: 

901 del self._messages[key] 

902 

903 def update( 

904 self, 

905 template: Catalog, 

906 no_fuzzy_matching: bool = False, 

907 update_header_comment: bool = False, 

908 keep_user_comments: bool = True, 

909 update_creation_date: bool = True, 

910 ) -> None: 

911 """Update the catalog based on the given template catalog. 

912 

913 >>> from babel.messages import Catalog 

914 >>> template = Catalog() 

915 >>> template.add('green', locations=[('main.py', 99)]) 

916 <Message ...> 

917 >>> template.add('blue', locations=[('main.py', 100)]) 

918 <Message ...> 

919 >>> template.add(('salad', 'salads'), locations=[('util.py', 42)]) 

920 <Message ...> 

921 >>> catalog = Catalog(locale='de_DE') 

922 >>> catalog.add('blue', 'blau', locations=[('main.py', 98)]) 

923 <Message ...> 

924 >>> catalog.add('head', 'Kopf', locations=[('util.py', 33)]) 

925 <Message ...> 

926 >>> catalog.add(('salad', 'salads'), ('Salat', 'Salate'), 

927 ... locations=[('util.py', 38)]) 

928 <Message ...> 

929 

930 >>> catalog.update(template) 

931 >>> len(catalog) 

932 3 

933 

934 >>> msg1 = catalog['green'] 

935 >>> msg1.string 

936 >>> msg1.locations 

937 [('main.py', 99)] 

938 

939 >>> msg2 = catalog['blue'] 

940 >>> msg2.string 

941 'blau' 

942 >>> msg2.locations 

943 [('main.py', 100)] 

944 

945 >>> msg3 = catalog['salad'] 

946 >>> msg3.string 

947 ('Salat', 'Salate') 

948 >>> msg3.locations 

949 [('util.py', 42)] 

950 

951 Messages that are in the catalog but not in the template are removed 

952 from the main collection, but can still be accessed via the `obsolete` 

953 member: 

954 

955 >>> 'head' in catalog 

956 False 

957 >>> list(catalog.obsolete.values()) 

958 [<Message 'head' (flags: [])>] 

959 

960 :param template: the reference catalog, usually read from a POT file 

961 :param no_fuzzy_matching: whether to use fuzzy matching of message IDs 

962 :param update_header_comment: whether to copy the header comment from the template 

963 :param keep_user_comments: whether to keep user comments from the old catalog 

964 :param update_creation_date: whether to copy the creation date from the template 

965 """ 

966 messages = self._messages 

967 remaining = messages.copy() 

968 self._messages = {} 

969 

970 # Prepare for fuzzy matching 

971 fuzzy_candidates = {} 

972 if not no_fuzzy_matching: 

973 for msgid in messages: 

974 if msgid and messages[msgid].string: 

975 key = self._key_for(msgid) 

976 ctxt = messages[msgid].context 

977 fuzzy_candidates[self._to_fuzzy_match_key(key)] = (key, ctxt) 

978 fuzzy_matches = set() 

979 

980 def _merge( 

981 message: Message, 

982 oldkey: tuple[str, str] | str, 

983 newkey: tuple[str, str] | str, 

984 ) -> None: 

985 message = message.clone() 

986 fuzzy = False 

987 if oldkey != newkey: 

988 fuzzy = True 

989 fuzzy_matches.add(oldkey) 

990 oldmsg = messages.get(oldkey) 

991 assert oldmsg is not None 

992 if isinstance(oldmsg.id, str): 

993 message.previous_id = [oldmsg.id] 

994 else: 

995 message.previous_id = list(oldmsg.id) 

996 else: 

997 oldmsg = remaining.pop(oldkey, None) 

998 assert oldmsg is not None 

999 message.string = oldmsg.string 

1000 

1001 if keep_user_comments and oldmsg.user_comments: 

1002 message.user_comments = list(dict.fromkeys(oldmsg.user_comments)) 

1003 

1004 if isinstance(message.id, (list, tuple)): 

1005 if not isinstance(message.string, (list, tuple)): 

1006 fuzzy = True 

1007 message.string = tuple( 

1008 [message.string] + ([''] * (len(message.id) - 1)), 

1009 ) 

1010 elif len(message.string) != self.num_plurals: 

1011 fuzzy = True 

1012 message.string = tuple(message.string[: len(oldmsg.string)]) 

1013 elif isinstance(message.string, (list, tuple)): 

1014 fuzzy = True 

1015 message.string = message.string[0] 

1016 message.flags |= oldmsg.flags 

1017 if fuzzy: 

1018 message.flags |= {'fuzzy'} 

1019 self[message.id] = message 

1020 

1021 for message in template: 

1022 if message.id: 

1023 key = self._key_for(message.id, message.context) 

1024 if key in messages: 

1025 _merge(message, key, key) 

1026 else: 

1027 if not no_fuzzy_matching: 

1028 # do some fuzzy matching with difflib 

1029 matches = get_close_matches( 

1030 self._to_fuzzy_match_key(key), 

1031 fuzzy_candidates.keys(), 

1032 1, 

1033 ) 

1034 if matches: 

1035 modified_key = matches[0] 

1036 newkey, newctxt = fuzzy_candidates[modified_key] 

1037 if newctxt is not None: 

1038 newkey = newkey, newctxt 

1039 _merge(message, newkey, key) 

1040 continue 

1041 

1042 self[message.id] = message 

1043 

1044 for msgid in remaining: 

1045 if no_fuzzy_matching or msgid not in fuzzy_matches: 

1046 self.obsolete[msgid] = remaining[msgid] 

1047 

1048 if update_header_comment: 

1049 # Allow the updated catalog's header to be rewritten based on the 

1050 # template's header 

1051 self.header_comment = template.header_comment 

1052 

1053 # Make updated catalog's POT-Creation-Date equal to the template 

1054 # used to update the catalog 

1055 if update_creation_date: 

1056 self.creation_date = template.creation_date 

1057 

1058 def _to_fuzzy_match_key(self, key: tuple[str, str] | str) -> str: 

1059 """Converts a message key to a string suitable for fuzzy matching.""" 

1060 if isinstance(key, tuple): 

1061 matchkey = key[0] # just the msgid, no context 

1062 else: 

1063 matchkey = key 

1064 return matchkey.lower().strip() 

1065 

1066 def _key_for( 

1067 self, 

1068 id: _MessageID, 

1069 context: str | None = None, 

1070 ) -> tuple[str, str] | str: 

1071 """The key for a message is just the singular ID even for pluralizable 

1072 messages, but is a ``(msgid, msgctxt)`` tuple for context-specific 

1073 messages. 

1074 """ 

1075 key = id 

1076 if isinstance(key, (list, tuple)): 

1077 key = id[0] 

1078 if context is not None: 

1079 key = (key, context) 

1080 return key 

1081 

1082 def is_identical(self, other: Catalog) -> bool: 

1083 """Checks if catalogs are identical, taking into account messages and 

1084 headers. 

1085 """ 

1086 assert isinstance(other, Catalog) 

1087 for key in self._messages.keys() | other._messages.keys(): 

1088 message_1 = self.get(key) 

1089 message_2 = other.get(key) 

1090 if message_1 is None or message_2 is None or not message_1.is_identical(message_2): 

1091 return False 

1092 return dict(self.mime_headers) == dict(other.mime_headers)