Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions lib/twine/twine_file.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,14 @@ def matches_tags?(tags, include_untagged)
end

def translation_for_lang(lang)
translation = [lang].flatten.map { |l| @translations[l] }.compact.first

translation = reference.translation_for_lang(lang) if translation.nil? && reference
# Language priority also applies to inherited translations.
[lang].flatten.each do |language|
translation = @translations[language]
translation = reference.translation_for_lang(language) if translation.nil? && reference
return translation unless translation.nil?
end

return translation
nil
end

def plural_translation_for_lang(lang)
Expand Down
23 changes: 18 additions & 5 deletions python_twine/tests/test_output_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,25 @@ def test_fallback_to_developer_language(self, twine_file):

assert result.definitions_by_key["key1"].translations["de"] == "value1-fr"

def test_regional_locale_inherits_generic_reference_translation(self, twine_file):
reference = TwineDefinition("bridge")
reference.translations = {"en": "Bridge", "fr": "Pont"}
definition = TwineDefinition("pedestrian_bridge")
definition.translations["en"] = "Pedestrian Bridge"
definition.reference_key = reference.key
definition.reference = reference
twine_file.sections[0].definitions.extend([reference, definition])
twine_file.definitions_by_key.update(
{reference.key: reference, definition.key: definition}
)

result = OutputProcessor(twine_file, {"include": "all"}).process("fr-CA")

assert result.definitions_by_key["pedestrian_bridge"].translations["fr-CA"] == "Pont"


class TestPluralEdgeCases:
"""Regression tests for plural-definition handling that previously
raised KeyError / TypeError before the OutputProcessor fix."""
"""Plural definitions with partial or missing target-language forms."""

def test_include_translated_with_singular_target_does_not_raise(self):
"""A plural definition whose target language has only a non-plural
Expand All @@ -165,9 +180,7 @@ def test_include_translated_with_singular_target_does_not_raise(self):
assert "ja" not in processed.plural_translations

def test_no_plural_fallback_available_does_not_raise(self):
"""When no fallback language has plural data, the OutputProcessor
must skip the plural augmentation rather than assigning None and
TypeError-ing on `"other" not in None`."""
"""A singular translation remains usable when no plural fallback exists."""
twine_file = TwineFile()
twine_file.language_codes = ["en", "fr"]
section = TwineSection("Section")
Expand Down
34 changes: 34 additions & 0 deletions python_twine/tests/test_twine_definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,37 @@ def test_reference_translation_override(self, setup_references):
definition.translations["en"] = "value"

assert definition.translation_for_lang("en") == "value"

@pytest.mark.parametrize(
("languages", "expected"),
[
(["fr", "en"], "Pont"),
(["de", "fr", "en"], "Pont"),
(["de", "en"], "Pedestrian Bridge"),
(["de"], None),
([], None),
],
)
def test_reference_respects_language_priority(
self, setup_references, languages, expected
):
definition = setup_references
definition.translations["en"] = "Pedestrian Bridge"
definition.reference.translations["fr"] = "Pont"

assert definition.translation_for_lang(languages) == expected

def test_language_priority_through_reference_chain(self, setup_references):
definition = setup_references
definition.translations["en"] = "Pedestrian Bridge"
reference = TwineDefinition("bridge")
reference.translations["fr"] = "Pont"
definition.reference.reference = reference

assert definition.translation_for_lang(["fr", "en"]) == "Pont"

def test_empty_translation_overrides_reference(self, setup_references):
definition = setup_references
definition.translations["en"] = ""

assert definition.translation_for_lang(["en"]) == ""
44 changes: 44 additions & 0 deletions python_twine/tests/test_twine_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import pytest

from twine.output_processor import OutputProcessor
from twine.twine_file import TwineDefinition, TwineFile, TwineSection


Expand Down Expand Up @@ -221,6 +222,49 @@ def test_accent_symbol(self, fixtures_dir):
assert twine_file.definitions_by_key["value_wrapped_by_accents"].translations["en"] == '`value`'

class TestTwineFileOptimizations:
@pytest.mark.parametrize(
("content", "fallback_to_default"),
[
("[bridge]\nen = Bridge\nzh-Hans = 桥\n"
"[pedestrian]\nref = bridge\nen = Pedestrian Bridge\n"
"zh = 人行桥\nzh-CN = 人行桥\n", False),
("[bridge]\nen = Bridge\nfr = Pont\n"
"[pedestrian]\nref = bridge\nen = Pedestrian Bridge\n"
"fr-CA = Pedestrian Bridge\n", True),
("[bridge]\nen = Bridge\nfr = Pont\nfr-CA = Pont\n"
"[pedestrian]\nref = bridge\nfr = Passerelle\n", False),
("[bridge]\nfr-CA = Pont\nfr = Pont\nen = Bridge\n", True),
],
ids=["inherited-script", "inherited-generic", "alias-consumer", "regional-developer"],
)
def test_optimization_preserves_generated_translations(
self, tmp_path, content, fallback_to_default
):
path = tmp_path / "strings.txt"
path.write_text(content, encoding="utf-8")
twine_file = TwineFile(fallback_to_default=fallback_to_default)
twine_file.read(str(path))
languages = [*twine_file.language_codes, "de", "fr-CA", "zh-CN"]

def translations(source):
processor = OutputProcessor(source, {"include": "all"})
return {
language: {
key: definition.translations[language]
for key, definition in processor.process(language).definitions_by_key.items()
}
for language in languages
}

expected = translations(twine_file)
twine_file.optimize_duplicates()
assert translations(twine_file) == expected

twine_file.write(str(path))
restored = TwineFile()
restored.read(str(path))
assert translations(restored) == expected

@pytest.fixture
def fixture_twine_file(self) -> TwineFile:
twine_file = TwineFile()
Expand Down
72 changes: 38 additions & 34 deletions python_twine/twine/twine_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,30 +97,18 @@ def matches_tags(
return True

def translation_for_lang(self, lang: str | list[str]) -> str | None:
"""
Get translation for a language, checking reference if not found.

Args:
lang: Language code (can be str or list)

Returns:
Translation string or None
"""
# Handle both single lang and list of langs
"""Resolve languages in priority order, including inherited translations."""
if isinstance(lang, list):
for ln in lang:
if ln in self.translations:
return self.translations[ln]
lang_to_check = lang
else:
if lang in self.translations:
return self.translations[lang]
lang_to_check = [lang]
for language in lang:
translation = self.translation_for_lang(language)
if translation is not None:
return translation
return None

# Check reference
if lang in self.translations:
return self.translations[lang]
if self.reference:
return self.reference.translation_for_lang(lang_to_check)

return self.reference.translation_for_lang(lang)
return None

def find_plural_lang_fallback(self, fallback_langs: list[str]) -> str | None:
Expand Down Expand Up @@ -229,20 +217,36 @@ def get_developer_language_code(self) -> str | None:
return None

def optimize_duplicates(self):
# Check if translation matches value from 'ref' definition.
"""Remove translations only when their effective fallback is identical."""
referenced = {
definition.reference for definition in self.definitions_by_key.values()
if definition.reference is not None
}
developer_language = self.get_developer_language_code()
for key, definition in self.definitions_by_key.items():
if definition.reference is not None:
ref = definition.reference
definition.translations = {lang:value for lang, value in definition.translations.items()
if lang not in ref.translations or ref.translations[lang] != value}

""" Some regional languages have common items. Such as 'en-GB' and 'en'.
Deduplication: for each item and each language search the same translations
within fallback languages. Not all languages have fallbacks.
"""
for key, definition in self.definitions_by_key.items():
definition.translations = {lang:value for (lang, value) in definition.translations.items() \
if not self.match_fallback_lang(definition.translations, lang, key, value)}
# Apply deletions one at a time so subsequent comparisons see the
# translations that will actually remain available.
for lang, value in list(definition.translations.items()):
inherited = (
definition.reference.translation_for_lang(lang)
if definition.reference is not None else None
)
if inherited is not None:
if inherited == value:
del definition.translations[lang]
continue

# Aliases inherit exact-language values, without regional
# fallback. The developer language is also a terminal fallback.
if definition in referenced or lang == developer_language:
continue
fallback = definition.translation_for_lang(
self.fallback_languages(lang, self.fallback_to_default)
)
if fallback == value:
print(f"Warning: key '{key}' in lang '{lang}' matches its fallback translation")
del definition.translations[lang]

definition.plural_translations = {lang:value for (lang, value) in definition.plural_translations.items() \
if not self.match_fallback_lang(definition.plural_translations, lang, key, value)}

Expand Down
26 changes: 26 additions & 0 deletions test/test_twine_definition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -107,5 +107,31 @@ def test_reference_translation_override

assert_equal 'value', @definition.translation_for_lang('en')
end

def test_reference_respects_language_priority
@definition.translations['en'] = 'Pedestrian Bridge'
@reference.translations['fr'] = 'Pont'

assert_equal 'Pont', @definition.translation_for_lang(%w(fr en))
assert_equal 'Pont', @definition.translation_for_lang(%w(de fr en))
assert_equal 'Pedestrian Bridge', @definition.translation_for_lang(%w(de en))
assert_nil @definition.translation_for_lang(['de'])
assert_nil @definition.translation_for_lang([])
end

def test_language_priority_through_reference_chain
@definition.translations['en'] = 'Pedestrian Bridge'
reference = Twine::TwineDefinition.new 'bridge'
reference.translations['fr'] = 'Pont'
@reference.reference = reference

assert_equal 'Pont', @definition.translation_for_lang(%w(fr en))
end

def test_empty_translation_overrides_reference
@definition.translations['en'] = ''

assert_equal '', @definition.translation_for_lang(['en'])
end
end
end
Loading