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)