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

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 itertools import groupby 

22from string import Formatter 

23from typing import TYPE_CHECKING, TypedDict 

24 

25from babel import __version__ as VERSION 

26from babel.core import Locale, UnknownLocaleError 

27from babel.dates import format_datetime 

28from babel.messages.plurals import get_plural 

29from babel.util import LOCALTZ, _cmp 

30 

31if TYPE_CHECKING: 

32 from typing_extensions import TypeAlias 

33 

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

35 

36__all__ = [ 

37 'DEFAULT_HEADER', 

38 'PYTHON_FORMAT', 

39 'Catalog', 

40 'Message', 

41 'TranslationError', 

42] 

43 

44 

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

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

47 

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

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

50 """ 

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

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

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

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

55 result = [] 

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

57 s.set_seq2(word) 

58 for x in possibilities: 

59 s.set_seq1(x) 

60 if ( 

61 s.real_quick_ratio() >= cutoff 

62 and s.quick_ratio() >= cutoff 

63 and s.ratio() >= cutoff 

64 ): 

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

66 

67 # Move the best scorers to head of list 

68 result = nlargest(n, result) 

69 # Strip scores for the best n matches 

70 return [x for score, x in result] 

71 

72 

73PYTHON_FORMAT = re.compile( 

74 r''' 

75 \% 

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

77 ( 

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

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

80 [hlL]? 

81 ) 

82 ([diouxXeEfFgGcrs%]) 

83''', 

84 re.VERBOSE, 

85) 

86 

87 

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

89 if "{" not in string: 

90 return False 

91 fmt = Formatter() 

92 try: 

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

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

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

96 field_name_seen = False 

97 for t in fmt.parse(string): 

98 if t[1] is not None: 

99 field_name_seen = True 

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

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

102 except ValueError: 

103 return False 

104 return field_name_seen 

105 

106 

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

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

109 

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

111 

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

113 tzoffset = match.group('tzoffset') 

114 if tzoffset is not None: 

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

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

117 

118 # Make them all integers 

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

120 hours_offset = int(hours_offset_s) 

121 mins_offset = int(mins_offset_s) 

122 

123 # Calculate net offset 

124 net_mins_offset = hours_offset * 60 

125 net_mins_offset += mins_offset 

126 net_mins_offset *= plus_minus 

127 

128 # Create an offset object 

129 tzoffset = datetime.timezone( 

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

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

132 ) 

133 

134 # Store the offset in a datetime object 

135 dt = dt.replace(tzinfo=tzoffset) 

136 

137 return dt 

138 

139 

140class Message: 

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

142 

143 def __init__( 

144 self, 

145 id: _MessageID, 

146 string: _MessageID | None = '', 

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

148 flags: Iterable[str] = (), 

149 auto_comments: Iterable[str] = (), 

150 user_comments: Iterable[str] = (), 

151 previous_id: _MessageID = (), 

152 lineno: int | None = None, 

153 context: str | None = None, 

154 ) -> None: 

155 """Create the message object. 

156 

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

158 pluralizable messages 

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

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

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

162 :param flags: a set or sequence of flags 

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

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

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

166 tuple for pluralizable messages 

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

168 PO file, if any 

169 :param context: the message context 

170 """ 

171 self.id = id 

172 if not string and self.pluralizable: 

173 string = ('', '') 

174 self.string = string 

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

176 self.flags = set(flags) 

177 if id and self.python_format: 

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

179 else: 

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

181 if id and self.python_brace_format: 

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

183 else: 

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

185 # Collapse consecutive repeats without losing repeated wrapped fragments. 

186 self.auto_comments = [comment for comment, _ in groupby(auto_comments)] if auto_comments else [] 

187 self.user_comments = [comment for comment, _ in groupby(user_comments)] if user_comments else [] 

188 if previous_id: 

189 if isinstance(previous_id, str): 

190 self.previous_id = [previous_id] 

191 else: 

192 self.previous_id = list(previous_id) 

193 else: 

194 self.previous_id = [] 

195 self.lineno = lineno 

196 self.context = context 

197 

198 def __repr__(self) -> str: 

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

200 

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

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

203 

204 def values_to_compare(obj): 

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

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

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

208 

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

210 

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

212 return self.__cmp__(other) > 0 

213 

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

215 return self.__cmp__(other) < 0 

216 

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

218 return self.__cmp__(other) >= 0 

219 

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

221 return self.__cmp__(other) <= 0 

222 

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

224 return self.__cmp__(other) == 0 

225 

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

227 return self.__cmp__(other) != 0 

228 

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

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

231 properties. 

232 """ 

233 assert isinstance(other, Message) 

234 return self.__dict__ == other.__dict__ 

235 

236 def clone(self) -> Message: 

237 return Message( 

238 id=copy(self.id), 

239 string=copy(self.string), 

240 locations=copy(self.locations), 

241 flags=copy(self.flags), 

242 auto_comments=copy(self.auto_comments), 

243 user_comments=copy(self.user_comments), 

244 previous_id=copy(self.previous_id), 

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

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

247 ) 

248 

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

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

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

252 a sequence of `TranslationError` objects. 

253 

254 :rtype: ``iterator`` 

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

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

257 in a catalog. 

258 """ 

259 from babel.messages.checkers import checkers 

260 

261 errors: list[TranslationError] = [] 

262 for checker in checkers: 

263 try: 

264 checker(catalog, self) 

265 except TranslationError as e: 

266 errors.append(e) 

267 return errors 

268 

269 @property 

270 def fuzzy(self) -> bool: 

271 """Whether the translation is fuzzy. 

272 

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

274 False 

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

276 >>> msg.fuzzy 

277 True 

278 >>> msg 

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

280 """ 

281 return 'fuzzy' in self.flags 

282 

283 @property 

284 def pluralizable(self) -> bool: 

285 """Whether the message is plurizable. 

286 

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

288 False 

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

290 True 

291 """ 

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

293 

294 @property 

295 def python_format(self) -> bool: 

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

297 

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

299 True 

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

301 True 

302 """ 

303 ids = self.id 

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

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

306 if PYTHON_FORMAT.search(id): 

307 return True 

308 return False 

309 return bool(PYTHON_FORMAT.search(ids)) 

310 

311 @property 

312 def python_brace_format(self) -> bool: 

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

314 

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

316 True 

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

318 True 

319 """ 

320 ids = self.id 

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

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

323 if _has_python_brace_format(id): 

324 return True 

325 return False 

326 return _has_python_brace_format(ids) 

327 

328 

329class TranslationError(Exception): 

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

331 translations are encountered.""" 

332 

333 

334DEFAULT_HEADER = """\ 

335# Translations template for PROJECT. 

336# Copyright (C) YEAR ORGANIZATION 

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

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

339#""" 

340 

341 

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

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

344 from email.message import Message 

345 

346 m = Message() 

347 m['content-type'] = value 

348 return dict(m.get_params()) 

349 

350 

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

352 if isinstance(s, str): 

353 return s 

354 if isinstance(s, bytes): 

355 return s.decode(encoding, errors) 

356 return str(s) 

357 

358 

359class ConflictInfo(TypedDict): 

360 message: Message 

361 filename: str 

362 project: str 

363 version: str 

364 

365 

366class Catalog: 

367 """Representation of a message catalog.""" 

368 

369 def __init__( 

370 self, 

371 locale: Locale | str | None = None, 

372 domain: str | None = None, 

373 header_comment: str | None = DEFAULT_HEADER, 

374 project: str | None = None, 

375 version: str | None = None, 

376 copyright_holder: str | None = None, 

377 msgid_bugs_address: str | None = None, 

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

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

380 last_translator: str | None = None, 

381 language_team: str | None = None, 

382 charset: str | None = None, 

383 fuzzy: bool = True, 

384 ) -> None: 

385 """Initialize the catalog object. 

386 

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

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

389 means it's a template) 

390 :param domain: the message domain 

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

392 default header 

393 :param project: the project's name 

394 :param version: the project's version 

395 :param copyright_holder: the copyright holder of the catalog 

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

397 reports to 

398 :param creation_date: the date the catalog was created 

399 :param revision_date: the date the catalog was revised 

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

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

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

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

404 """ 

405 self.domain = domain 

406 self.locale = locale 

407 self._header_comment = header_comment 

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

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

410 

411 self.project = project or 'PROJECT' 

412 self.version = version or 'VERSION' 

413 self.copyright_holder = copyright_holder or 'ORGANIZATION' 

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

415 

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

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

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

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

420 

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

422 

423 if creation_date is None: 

424 creation_date = datetime.datetime.now(LOCALTZ) 

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

426 creation_date = creation_date.replace(tzinfo=LOCALTZ) 

427 self.creation_date = creation_date 

428 if revision_date is None: 

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

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

431 revision_date = revision_date.replace(tzinfo=LOCALTZ) 

432 self.revision_date = revision_date 

433 self.fuzzy = fuzzy 

434 

435 # Dictionary of obsolete messages 

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

437 self._num_plurals = None 

438 self._plural_expr = None 

439 

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

441 if locale is None: 

442 self._locale_identifier = None 

443 self._locale = None 

444 return 

445 

446 if isinstance(locale, Locale): 

447 self._locale_identifier = str(locale) 

448 self._locale = locale 

449 return 

450 

451 if isinstance(locale, str): 

452 self._locale_identifier = str(locale) 

453 try: 

454 self._locale = Locale.parse(locale) 

455 except UnknownLocaleError: 

456 self._locale = None 

457 return 

458 

459 raise TypeError( 

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

461 ) 

462 

463 @property 

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

465 return self._locale 

466 

467 @locale.setter 

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

469 self._set_locale(locale) 

470 

471 @property 

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

473 return self._locale_identifier 

474 

475 def _get_header_comment(self) -> str: 

476 comment = self._header_comment 

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

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

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

480 comment = ( 

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

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

483 .replace('YEAR', year) 

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

485 ) 

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

487 if locale_name: 

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

489 return comment 

490 

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

492 self._header_comment = string 

493 

494 @property 

495 def header_comment(self) -> str: 

496 """ 

497 The header comment for the catalog. 

498 

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

500 ... copyright_holder='Foo Company') 

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

502 # Translations template for Foobar. 

503 # Copyright (C) ... Foo Company 

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

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

506 # 

507 

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

509 will be replaced when the header is retrieved again: 

510 

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

512 ... copyright_holder='Foo Company') 

513 >>> catalog.header_comment = '''\\ 

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

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

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

517 ... # project. 

518 ... #''' 

519 >>> print(catalog.header_comment) 

520 # The POT for my really cool Foobar project. 

521 # Copyright (C) 1990-2003 Foo Company 

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

523 # project. 

524 # 

525 """ 

526 return self._get_header_comment() 

527 

528 @header_comment.setter 

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

530 self._set_header_comment(value) 

531 

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

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

534 revision_date = format_datetime( 

535 self.revision_date, 

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

537 locale='en', 

538 ) 

539 else: 

540 revision_date = self.revision_date 

541 

542 language_team = self.language_team 

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

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

545 

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

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

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

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

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

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

552 ] # fmt: skip 

553 if self.locale_identifier: 

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

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

556 if self.locale is not None or self._num_plurals is not None: 

557 # Keep explicit plural forms even when no locale set. 

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

559 headers += [ 

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

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

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

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

564 ] 

565 return headers 

566 

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

568 for name, value in headers: 

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

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

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

572 parts = value.split(' ') 

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

574 self.version = parts[-1] 

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

576 self.msgid_bugs_address = value 

577 elif name == 'last-translator': 

578 self.last_translator = value 

579 elif name == 'language': 

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

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

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

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

584 # header altogether). 

585 self._set_locale(value or None) 

586 elif name == 'language-team': 

587 self.language_team = value 

588 elif name == 'content-type': 

589 params = parse_separated_header(value) 

590 if 'charset' in params: 

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

592 elif name == 'plural-forms': 

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

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

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

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

597 # Keep last value instead of crashing on blank values 

598 if value.strip(): 

599 self.creation_date = _parse_datetime_header(value) 

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

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

602 # and don't crash trying to parse blank values 

603 if 'YEAR' not in value and value.strip(): 

604 self.revision_date = _parse_datetime_header(value) 

605 

606 @property 

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

608 """ 

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

610 

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

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

613 for actual translations. 

614 

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

616 

617 >>> from babel.dates import UTC 

618 >>> from datetime import datetime 

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

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

621 ... creation_date=created) 

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

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

624 Project-Id-Version: Foobar 1.0 

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

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

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

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

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

630 MIME-Version: 1.0 

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

632 Content-Transfer-Encoding: 8bit 

633 Generated-By: Babel ... 

634 

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

636 

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

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

639 ... creation_date=created, revision_date=revised, 

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

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

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

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

644 Project-Id-Version: Foobar 1.0 

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

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

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

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

649 Language: de_DE 

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

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

652 MIME-Version: 1.0 

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

654 Content-Transfer-Encoding: 8bit 

655 Generated-By: Babel ... 

656 """ 

657 return self._get_mime_headers() 

658 

659 @mime_headers.setter 

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

661 self._set_mime_headers(value) 

662 

663 @property 

664 def num_plurals(self) -> int: 

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

666 

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

668 2 

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

670 5 

671 """ 

672 if self._num_plurals is not None: 

673 return self._num_plurals 

674 if self.locale: 

675 return get_plural(self.locale)[0] 

676 return 2 

677 

678 @property 

679 def plural_expr(self) -> str: 

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

681 

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

683 '(n != 1)' 

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

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

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

687 '(n != 1)' 

688 """ 

689 if self._plural_expr is not None: 

690 return self._plural_expr 

691 if self.locale: 

692 return get_plural(self.locale)[1] 

693 return '(n != 1)' 

694 

695 @property 

696 def plural_forms(self) -> str: 

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

698 

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

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

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

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

703 """ 

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

705 

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

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

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

709 

710 def __len__(self) -> int: 

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

712 

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

714 return len(self._messages) 

715 

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

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

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

719 

720 :rtype: ``iterator``""" 

721 buf = [] 

722 for name, value in self.mime_headers: 

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

724 flags = set() 

725 if self.fuzzy: 

726 flags |= {'fuzzy'} 

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

728 for key in self._messages: 

729 yield self._messages[key] 

730 

731 def __repr__(self) -> str: 

732 locale = '' 

733 if self.locale: 

734 locale = f" {self.locale}" 

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

736 

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

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

739 self.delete(id) 

740 

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

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

743 

744 :param id: the message ID 

745 """ 

746 return self.get(id) 

747 

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

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

750 

751 >>> catalog = Catalog() 

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

753 >>> catalog['foo'] 

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

755 

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

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

758 

759 >>> catalog = Catalog() 

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

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

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

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

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

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

766 

767 :param id: the message ID 

768 :param message: the `Message` object 

769 """ 

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

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

772 current = self._messages.get(key) 

773 if current: 

774 if message.pluralizable and not current.pluralizable: 

775 # The new message adds pluralization 

776 current.id = message.id 

777 current.string = message.string 

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

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

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

781 current.flags |= message.flags 

782 elif id == '': 

783 # special treatment for the header message 

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

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

786 self.fuzzy = message.fuzzy 

787 else: 

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

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

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

791 ) 

792 self._messages[key] = message 

793 

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

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

796 

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

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

799 the output catalog. 

800 

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

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

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

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

805 """ 

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

807 self._conflicts[key].append({ 

808 'message': message, 

809 'filename': filename, 

810 'project': project, 

811 'version': version, 

812 }) 

813 

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

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

816 

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

818 :param context: optional message context (msgctxt) 

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

820 """ 

821 key = self._key_for(id, context) 

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

823 

824 def add( 

825 self, 

826 id: _MessageID, 

827 string: _MessageID | None = None, 

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

829 flags: Iterable[str] = (), 

830 auto_comments: Iterable[str] = (), 

831 user_comments: Iterable[str] = (), 

832 previous_id: _MessageID = (), 

833 lineno: int | None = None, 

834 context: str | None = None, 

835 ) -> Message: 

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

837 

838 >>> catalog = Catalog() 

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

840 <Message ...> 

841 >>> catalog['foo'] 

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

843 

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

845 arguments and invokes `__setitem__` with that object. 

846 

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

848 pluralizable messages 

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

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

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

852 :param flags: a set or sequence of flags 

853 :param auto_comments: a sequence of automatic comments 

854 :param user_comments: a sequence of user comments 

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

856 tuple for pluralizable messages 

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

858 PO file, if any 

859 :param context: the message context 

860 """ 

861 message = Message( 

862 id, 

863 string, 

864 list(locations), 

865 flags, 

866 auto_comments, 

867 user_comments, 

868 previous_id, 

869 lineno=lineno, 

870 context=context, 

871 ) 

872 self[id] = message 

873 return message 

874 

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

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

877 

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

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

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

881 

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

883 """ 

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

885 errors = message.check(catalog=self) 

886 if errors: 

887 yield message, errors 

888 

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

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

891 

892 :param id: the message ID 

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

894 """ 

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

896 

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

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

899 

900 :param id: the message ID 

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

902 """ 

903 key = self._key_for(id, context) 

904 if key in self._messages: 

905 del self._messages[key] 

906 

907 def update( 

908 self, 

909 template: Catalog, 

910 no_fuzzy_matching: bool = False, 

911 update_header_comment: bool = False, 

912 keep_user_comments: bool = True, 

913 update_creation_date: bool = True, 

914 ) -> None: 

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

916 

917 >>> from babel.messages import Catalog 

918 >>> template = Catalog() 

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

920 <Message ...> 

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

922 <Message ...> 

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

924 <Message ...> 

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

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

927 <Message ...> 

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

929 <Message ...> 

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

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

932 <Message ...> 

933 

934 >>> catalog.update(template) 

935 >>> len(catalog) 

936 3 

937 

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

939 >>> msg1.string 

940 >>> msg1.locations 

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

942 

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

944 >>> msg2.string 

945 'blau' 

946 >>> msg2.locations 

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

948 

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

950 >>> msg3.string 

951 ('Salat', 'Salate') 

952 >>> msg3.locations 

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

954 

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

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

957 member: 

958 

959 >>> 'head' in catalog 

960 False 

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

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

963 

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

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

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

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

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

969 """ 

970 messages = self._messages 

971 remaining = messages.copy() 

972 self._messages = {} 

973 

974 # Prepare for fuzzy matching 

975 fuzzy_candidates = {} 

976 if not no_fuzzy_matching: 

977 for msgid in messages: 

978 if msgid and messages[msgid].string: 

979 key = self._key_for(msgid) 

980 ctxt = messages[msgid].context 

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

982 fuzzy_matches = set() 

983 

984 def _merge( 

985 message: Message, 

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

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

988 ) -> None: 

989 message = message.clone() 

990 fuzzy = False 

991 if oldkey != newkey: 

992 fuzzy = True 

993 fuzzy_matches.add(oldkey) 

994 oldmsg = messages.get(oldkey) 

995 assert oldmsg is not None 

996 if isinstance(oldmsg.id, str): 

997 message.previous_id = [oldmsg.id] 

998 else: 

999 message.previous_id = list(oldmsg.id) 

1000 else: 

1001 oldmsg = remaining.pop(oldkey, None) 

1002 assert oldmsg is not None 

1003 message.string = oldmsg.string 

1004 

1005 if keep_user_comments and oldmsg.user_comments: 

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

1007 

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

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

1010 fuzzy = True 

1011 message.string = tuple( 

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

1013 ) 

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

1015 fuzzy = True 

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

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

1018 fuzzy = True 

1019 message.string = message.string[0] 

1020 message.flags |= oldmsg.flags 

1021 if fuzzy: 

1022 message.flags |= {'fuzzy'} 

1023 self[message.id] = message 

1024 

1025 for message in template: 

1026 if message.id: 

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

1028 if key in messages: 

1029 _merge(message, key, key) 

1030 else: 

1031 if not no_fuzzy_matching: 

1032 # do some fuzzy matching with difflib 

1033 matches = get_close_matches( 

1034 self._to_fuzzy_match_key(key), 

1035 fuzzy_candidates.keys(), 

1036 1, 

1037 ) 

1038 if matches: 

1039 modified_key = matches[0] 

1040 newkey, newctxt = fuzzy_candidates[modified_key] 

1041 if newctxt is not None: 

1042 newkey = newkey, newctxt 

1043 _merge(message, newkey, key) 

1044 continue 

1045 

1046 self[message.id] = message 

1047 

1048 for msgid in remaining: 

1049 if no_fuzzy_matching or msgid not in fuzzy_matches: 

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

1051 

1052 if update_header_comment: 

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

1054 # template's header 

1055 self.header_comment = template.header_comment 

1056 

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

1058 # used to update the catalog 

1059 if update_creation_date: 

1060 self.creation_date = template.creation_date 

1061 

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

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

1064 if isinstance(key, tuple): 

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

1066 else: 

1067 matchkey = key 

1068 return matchkey.lower().strip() 

1069 

1070 def _key_for( 

1071 self, 

1072 id: _MessageID, 

1073 context: str | None = None, 

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

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

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

1077 messages. 

1078 """ 

1079 key = id 

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

1081 key = id[0] 

1082 if context is not None: 

1083 key = (key, context) 

1084 return key 

1085 

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

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

1088 headers. 

1089 """ 

1090 assert isinstance(other, Catalog) 

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

1092 message_1 = self._messages.get(key) 

1093 message_2 = other._messages.get(key) 

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

1095 return False 

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