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)