diff --git a/lib/twine/twine_file.rb b/lib/twine/twine_file.rb index dc524ae..7a15d8f 100644 --- a/lib/twine/twine_file.rb +++ b/lib/twine/twine_file.rb @@ -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) diff --git a/python_twine/tests/test_output_processor.py b/python_twine/tests/test_output_processor.py index a9831bd..f1c77d9 100644 --- a/python_twine/tests/test_output_processor.py +++ b/python_twine/tests/test_output_processor.py @@ -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 @@ -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") diff --git a/python_twine/tests/test_twine_definition.py b/python_twine/tests/test_twine_definition.py index 8bcd0c8..f44b50c 100644 --- a/python_twine/tests/test_twine_definition.py +++ b/python_twine/tests/test_twine_definition.py @@ -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"]) == "" diff --git a/python_twine/tests/test_twine_file.py b/python_twine/tests/test_twine_file.py index 0af16b6..0073c04 100644 --- a/python_twine/tests/test_twine_file.py +++ b/python_twine/tests/test_twine_file.py @@ -7,6 +7,7 @@ import pytest +from twine.output_processor import OutputProcessor from twine.twine_file import TwineDefinition, TwineFile, TwineSection @@ -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() diff --git a/python_twine/twine/twine_file.py b/python_twine/twine/twine_file.py index 5308110..df223eb 100644 --- a/python_twine/twine/twine_file.py +++ b/python_twine/twine/twine_file.py @@ -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: @@ -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)} diff --git a/test/test_twine_definition.rb b/test/test_twine_definition.rb index ccfbe38..1b3adb7 100644 --- a/test/test_twine_definition.rb +++ b/test/test_twine_definition.rb @@ -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