Skip to content
3 changes: 2 additions & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[report]
exclude_lines =
NotImplemented
if TYPE_CHECKING:
pragma: no cover
raise OptionError
warnings.warn
if TYPE_CHECKING:
13 changes: 8 additions & 5 deletions babel/messages/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -587,8 +587,10 @@ def _set_mime_headers(self, headers: Iterable[tuple[str, str]]) -> None:
self.language_team = value
elif name == 'content-type':
params = parse_separated_header(value)
if 'charset' in params:
self.charset = params['charset'].lower()
charset = params.get('charset', '').lower()
# Ignore the placeholder in gettext's templates (`charset=CHARSET`).
if charset and charset != 'charset':
self.charset = charset
elif name == 'plural-forms':
params = parse_separated_header(f" ;{value}")
self._num_plurals = int(params.get('nplurals', 2))
Expand Down Expand Up @@ -805,7 +807,7 @@ def add_conflict(self, message: Message, filename: str, project: str, version: s
"""
key = self._key_for(message.id, message.context)
self._conflicts[key].append({
'message': message,
'message': message.clone(), # The catalog copy is mutable
'filename': filename,
'project': project,
'version': version,
Expand All @@ -818,8 +820,9 @@ def get_conflicts(self, id: _MessageID, context: str | None = None) -> list[Conf
:param context: optional message context (msgctxt)
:return: list of :class:`ConflictInfo` dicts, or an empty list if none
"""
key = self._key_for(id, context)
return self._conflicts.get(key, [])
# Guard against catalogs serialized prior to conflicts existing
conflicts = getattr(self, '_conflicts', None) or {}
return conflicts.get(self._key_for(id, context), [])

def add(
self,
Expand Down
136 changes: 106 additions & 30 deletions babel/messages/frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,15 +150,15 @@ def __init__(self, dist=None):
self.help = 0
self.finalized = 0

def initialize_options(self):
def initialize_options(self): # pragma: no cover
pass

def ensure_finalized(self):
if not self.finalized:
self.finalize_options()
self.finalized = 1

def finalize_options(self):
def finalize_options(self): # pragma: no cover
raise RuntimeError(
f"abstract method -- subclass {self.__class__} must override",
)
Expand Down Expand Up @@ -887,6 +887,17 @@ def run(self):
return


def _is_translated(message) -> bool:
"""Whether a message actually carries a translation.

An empty msgstr (or a plural tuple of empty msgstrs) is not a definition,
it just means the message is known but untranslated.
"""
if isinstance(message.string, (list, tuple)):
return any(message.string)
return bool(message.string)


class ConcatenateCatalog(CommandMixin):
description = 'concatenates the specified PO files into single one'
user_options = [
Expand Down Expand Up @@ -955,46 +966,88 @@ def finalize_options(self):
def _collect_message_info(self):
templates: list[tuple[str, Catalog]] = []
message_counts: Counter[_MessageID] = Counter()
message_strings: dict[_MessageID, set[str | tuple[str, ...]]] = defaultdict(set)
message_strings: dict[_MessageID, set[_MessageID]] = defaultdict(set)

for filename in self.input_files:
with open(filename) as pofile:
with open(filename, 'rb') as pofile:
template = read_po(pofile)
Comment thread
akx marked this conversation as resolved.
Resolved
for message in template:
if not message.id:
continue
message_counts[message.id] += 1
message_strings[message.id].add(
message.string if isinstance(message.string, str) else tuple(message.string),
)
key = template._key_for(message.id, message.context)
message_counts[key] += 1
if _is_translated(message):
message_strings[key].add(
message.string if isinstance(message.string, str) else tuple(message.string),
)
templates.append((filename, template))

return templates, message_counts, message_strings

def run(self):
catalog = Catalog(fuzzy=False)
templates, message_counts, message_strings = self._collect_message_info()

for path, template in templates:
if catalog.locale is None:
catalog.locale = template.locale
# Take the header from the first input preferring one that has a locale.
header_source = next(
(template for _, template in templates if template.locale is not None),
templates[0][1],
)
catalog = Catalog(fuzzy=False)
catalog.header_comment = header_source.header_comment
catalog.mime_headers = header_source.mime_headers

if len({template.charset for _, template in templates}) > 1:
# If charsets disagree, use UTF-8 as a common denominator.
catalog.charset = 'utf-8'

for path, template in templates:
for message in template:
if not message.id:
continue

count = message_counts[message.id]
key = template._key_for(message.id, message.context)
count = message_counts[key]
if count <= self.more_than or (self.less_than is not None and count >= self.less_than):
continue

if count > 1 and not self.use_first and len(message_strings[message.id]) > 1:
filename = os.path.basename(path)
catalog.add_conflict(message, filename, template.project, template.version)
message.flags |= {'fuzzy'}
translated = _is_translated(message)
conflicting = translated and not self.use_first and len(message_strings[key]) > 1

current = catalog.get(message.id, message.context)
was_translated = current is not None and _is_translated(current)
was_fuzzy = current is not None and current.fuzzy
was_singular = current is not None and not current.pluralizable
previous_string = current.string if current is not None else None
catalog[message.id] = message
stored = catalog.get(message.id, message.context)

if translated and not was_translated:
if stored.pluralizable == message.pluralizable:
stored.string = message.string
was_fuzzy = message.fuzzy
else:
# A singular translation for a plural message: keep it as
# the first form (like GNU msgcat), but mark it fuzzy.
stored.string = (message.string, *([''] * (len(stored.string) - 1)))
was_fuzzy = True
elif was_translated and was_singular and stored.pluralizable and (self.use_first or not translated):
# Upgrading to plural would drop the singular translation
# (which also wins over a later one with `--use-first`);
# keep it as the first form, but mark it fuzzy.
# (GNU msgcat keeps the entry singular here instead.)
stored.string = (previous_string, *([''] * (len(stored.string) - 1)))
was_fuzzy = True

if was_fuzzy:
stored.flags.add('fuzzy')
elif current is not None:
stored.flags.discard('fuzzy')

if conflicting:
filename = os.path.basename(path)
catalog.add_conflict(message, filename, template.project, template.version)
stored.flags.add('fuzzy')

catalog.fuzzy = any(message.fuzzy for message in catalog)

output_file = self.output_file
if not output_file or output_file == '-':
Expand Down Expand Up @@ -1083,38 +1136,61 @@ def finalize_options(self):

self._finalize_width()

if self.compendium is None:
self.compendium = []
elif isinstance(self.compendium, str):
self.compendium = [self.compendium]

def _get_messages_from_compendiums(self, compendium_paths):
for file_path in compendium_paths:
with open(file_path) as pofile:
with open(file_path, 'rb') as pofile:
catalog = read_po(pofile)
for message in catalog:
yield message, file_path

def run(self):
def_file, ref_file = self.input_files

with open(def_file) as pofile:
with open(def_file, 'rb') as pofile:
catalog = read_po(pofile)
with open(ref_file) as pofile:
with open(ref_file, 'rb') as pofile:
ref_catalog = read_po(pofile)
catalog.update(
ref_catalog,
no_fuzzy_matching=self.no_fuzzy_matching,
)

for message, compendium_path in self._get_messages_from_compendiums(self.compendium):
if (current := catalog.get(message.id)) and (not current.string or current.fuzzy or self.compendium_overwrite):
if self.compendium_overwrite and not current.fuzzy and current.string:
catalog.obsolete[message.id] = current.clone()
if not message.id or not _is_translated(message):
# Only translated compendium entries are of any use.
continue

current.string = message.string
if current.fuzzy:
current.flags.remove('fuzzy')
current = catalog.get(message.id, message.context)
if current is None:
# The compendium does not add messages missing from the template.
continue

if current.pluralizable != message.pluralizable:
continue

if _is_translated(current) and not current.fuzzy:
if not self.compendium_overwrite:
# Keep existing translations unless explicitly overwriting them.
continue

# Preserve the translation being replaced as an obsolete message.
key = catalog._key_for(current.id, current.context)
catalog.obsolete[key] = current.clone()

current.string = message.string
if message.fuzzy:
current.flags.add('fuzzy')
else:
current.flags.discard('fuzzy')

if not self.no_compendium_comment:
current.auto_comments.append(compendium_path)
if not self.no_compendium_comment:
current.auto_comments.append(compendium_path)

catalog.fuzzy = any(message.fuzzy for message in catalog)
output_path = def_file if self.update else self.output_file

if self.update and self.backup:
Expand Down
62 changes: 33 additions & 29 deletions babel/messages/pofile.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
_unescape_re = re.compile(r'\\([\\abfnrtv"])')
_unescape_map = {'a': '\a', 'b': '\b', 'f': '\f', 'n': '\n', 'r': '\r', 't': '\t', 'v': '\v'}

CONFLICT_MARKER = "#-#-#-#-#"


def unescape(string: str) -> str:
r"""Reverse `escape` the given string.
Expand Down Expand Up @@ -644,36 +646,38 @@ def _format_comment(comment, prefix=''):
for line in comment_wrapper.wrap(comment):
yield f"#{prefix} {line.strip()}\n"

def _format_conflict_comment(file, project, version, prefix=''):
comment = f"#-#-#-#-# {file} ({project} {version}) #-#-#-#-#"
yield f"{normalize(comment, prefix=prefix, width=width)}\n"

def _format_conflict(key: str | tuple[str, str], conflicts: list[ConflictInfo], prefix=''):
def _get_conflict_string(conflicts: list[ConflictInfo], plural_index: int | None = None) -> str:
parts = []
for conflict in conflicts:
message = conflict['message']
if message.context:
yield from _format_conflict_comment(conflict['filename'], conflict['project'], conflict['version'], prefix=prefix)
yield f"{prefix}msgctxt {normalize(message.context, prefix=prefix, width=width)}\n"
parts.append(
f"{CONFLICT_MARKER} "
f"{conflict['filename']} ({conflict['project']} {conflict['version']})"
f" {CONFLICT_MARKER}",
)
string = conflict['message'].string
if plural_index is not None:
if isinstance(string, (list, tuple)):
string = string[plural_index] if plural_index < len(string) else ''
elif plural_index:
# A singular translation only contributes to the singular form.
string = ''
parts.append(string)
return '\n'.join(parts)

if isinstance(key, (list, tuple)):
yield f"{prefix}msgid {normalize(key[0], prefix=prefix, width=width)}\n"
yield f"{prefix}msgid_plural {normalize(key[1], prefix=prefix, width=width)}\n"
else:
yield f"{prefix}msgid {normalize(key, prefix=prefix, width=width)}\n"
yield f"{prefix}msgstr {normalize('', prefix=prefix, width=width)}\n"
def _format_conflict(message, conflicts: list[ConflictInfo], prefix=''):
if message.context:
yield f"{prefix}msgctxt {normalize(message.context, prefix=prefix, width=width)}\n"

for conflict in conflicts:
message = conflict['message']
yield from _format_conflict_comment(conflict['filename'], conflict['project'], conflict['version'], prefix=prefix)
if isinstance(key, (list, tuple)):
for idx in range(catalog.num_plurals):
try:
string = message.string[idx]
except IndexError:
string = ''
yield f"{prefix}msgstr[{idx:d}] {normalize(string, prefix=prefix, width=width)}\n"
else:
yield f"{normalize(message.string, prefix=prefix, width=width)}\n"
if isinstance(message.id, (list, tuple)):
yield f"{prefix}msgid {normalize(message.id[0], prefix=prefix, width=width)}\n"
yield f"{prefix}msgid_plural {normalize(message.id[1], prefix=prefix, width=width)}\n"
for idx in range(catalog.num_plurals):
string = _get_conflict_string(conflicts, plural_index=idx)
yield f"{prefix}msgstr[{idx:d}] {normalize(string, prefix=prefix, width=width)}\n"
else:
yield f"{prefix}msgid {normalize(message.id, prefix=prefix, width=width)}\n"
string = _get_conflict_string(conflicts)
yield f"{prefix}msgstr {normalize(string, prefix=prefix, width=width)}\n"

def _format_message(message, prefix=''):
if isinstance(message.id, (list, tuple)):
Expand Down Expand Up @@ -745,8 +749,8 @@ def _format_message(message, prefix=''):
norm_previous_id = normalize(message.previous_id[1], width=width)
yield from _format_comment(f'msgid_plural {norm_previous_id}', prefix='|')

if len(conflicts := catalog.get_conflicts(message.id)) > 0:
yield from _format_conflict(message.id, conflicts)
if conflicts := catalog.get_conflicts(message.id, message.context):
yield from _format_conflict(message, conflicts)
else:
yield from _format_message(message)
yield '\n'
Expand Down
Loading
Loading