Skip to content

Commit 65f99ad

Browse files
authored
Merge pull request #928 from arnoldwender/fix/i18n-lang-case-insensitive
fix(i18n): resolve language codes case-insensitively (#927)
2 parents 29112fa + 6caac50 commit 65f99ad

2 files changed

Lines changed: 118 additions & 8 deletions

File tree

mempalace/i18n/__init__.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import json
1717
from pathlib import Path
18+
from typing import Optional
1819

1920
_LANG_DIR = Path(__file__).parent
2021
_strings: dict = {}
@@ -24,6 +25,23 @@
2425
_entity_cache: dict = {}
2526

2627

28+
def _canonical_lang(lang: str) -> Optional[str]:
29+
"""Resolve a language code to its on-disk canonical filename stem.
30+
31+
BCP 47 tags are case-insensitive (RFC 5646 §2.1.1), and the locale
32+
files mix conventions (``pt-br.json`` vs ``zh-CN.json``). Match on
33+
lowercase so callers can pass ``PT-BR``, ``zh-cn``, ``Pt-Br``, etc.
34+
Returns ``None`` if no file matches.
35+
"""
36+
if not lang:
37+
return None
38+
target = lang.strip().lower()
39+
for path in _LANG_DIR.glob("*.json"):
40+
if path.stem.lower() == target:
41+
return path.stem
42+
return None
43+
44+
2745
def available_languages() -> list[str]:
2846
"""Return list of available language codes."""
2947
return sorted(p.stem for p in _LANG_DIR.glob("*.json"))
@@ -32,12 +50,12 @@ def available_languages() -> list[str]:
3250
def load_lang(lang: str = "en") -> dict:
3351
"""Load a language dictionary. Falls back to English if not found."""
3452
global _strings, _current_lang
35-
lang_file = _LANG_DIR / f"{lang}.json"
36-
if not lang_file.exists():
37-
lang_file = _LANG_DIR / "en.json"
38-
lang = "en"
53+
canonical = _canonical_lang(lang)
54+
if canonical is None:
55+
canonical = "en"
56+
lang_file = _LANG_DIR / f"{canonical}.json"
3957
_strings = json.loads(lang_file.read_text(encoding="utf-8"))
40-
_current_lang = lang
58+
_current_lang = canonical
4159
return _strings
4260

4361

@@ -81,9 +99,10 @@ def get_regex() -> dict:
8199

82100
def _load_entity_section(lang: str) -> dict:
83101
"""Load the raw entity section for one language. Returns {} if missing."""
84-
lang_file = _LANG_DIR / f"{lang}.json"
85-
if not lang_file.exists():
102+
canonical = _canonical_lang(lang)
103+
if canonical is None:
86104
return {}
105+
lang_file = _LANG_DIR / f"{canonical}.json"
87106
try:
88107
data = json.loads(lang_file.read_text(encoding="utf-8"))
89108
except (json.JSONDecodeError, OSError):
@@ -205,7 +224,12 @@ def get_entity_patterns(languages=("en",)) -> dict:
205224
"""
206225
if not languages:
207226
languages = ("en",)
208-
key = tuple(languages)
227+
# Normalize via canonical filename so callers using different casing
228+
# (e.g. "PT-BR" vs "pt-br") share the same cache entry and load the
229+
# same locale file. Unknown codes are kept as-is so the merge loop's
230+
# "found_any" branch fires the English fallback exactly once.
231+
languages = tuple(_canonical_lang(lang) or lang for lang in languages)
232+
key = languages
209233
if key in _entity_cache:
210234
return _entity_cache[key]
211235

tests/test_i18n_lang_case.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Regression tests for issue #927 — language code lookup must be case-insensitive.
2+
3+
The locale files use mixed case for the region subtag (``pt-br.json`` vs
4+
``zh-CN.json``). BCP 47 tags are case-insensitive (RFC 5646 §2.1.1), so
5+
``--lang PT-BR``, ``--lang zh-cn``, and ``--lang ZH-TW`` must all resolve
6+
to the canonical file rather than silently falling back to English.
7+
"""
8+
9+
import pytest
10+
11+
from mempalace import i18n
12+
from mempalace.i18n import (
13+
_canonical_lang,
14+
_load_entity_section,
15+
available_languages,
16+
get_entity_patterns,
17+
load_lang,
18+
)
19+
20+
21+
@pytest.fixture(autouse=True)
22+
def _reset_state():
23+
"""Reset the module-level entity cache between tests."""
24+
i18n._entity_cache.clear()
25+
yield
26+
i18n._entity_cache.clear()
27+
28+
29+
def test_canonical_lang_lowercase_passthrough():
30+
assert _canonical_lang("en") == "en"
31+
assert _canonical_lang("pt-br") == "pt-br"
32+
33+
34+
def test_canonical_lang_uppercase_resolves():
35+
assert _canonical_lang("PT-BR") == "pt-br"
36+
assert _canonical_lang("ZH-CN") == "zh-CN"
37+
assert _canonical_lang("zh-cn") == "zh-CN"
38+
assert _canonical_lang("Pt-Br") == "pt-br"
39+
40+
41+
def test_canonical_lang_unknown_returns_none():
42+
assert _canonical_lang("xx") is None
43+
assert _canonical_lang("") is None
44+
45+
46+
def test_load_lang_case_insensitive():
47+
"""`load_lang('PT-BR')` must load the pt-br dictionary, not English."""
48+
en = load_lang("en")
49+
pt_lower = load_lang("pt-br")
50+
pt_upper = load_lang("PT-BR")
51+
assert pt_lower == pt_upper, "case should not change the loaded dict"
52+
# If load_lang silently fell back to English, both would equal `en`.
53+
if "pt-br" in available_languages() and pt_lower != en:
54+
assert i18n.current_lang() == "pt-br"
55+
56+
57+
def test_entity_section_loads_for_uppercase_input():
58+
"""`_load_entity_section('PT-BR')` must read pt-br.json, not return {}."""
59+
pt_lower = _load_entity_section("pt-br")
60+
pt_upper = _load_entity_section("PT-BR")
61+
assert pt_lower == pt_upper
62+
63+
64+
def test_get_entity_patterns_case_insensitive():
65+
"""Entity patterns must be identical regardless of input case."""
66+
lower = get_entity_patterns(("pt-br",))
67+
upper = get_entity_patterns(("PT-BR",))
68+
assert lower == upper
69+
70+
71+
def test_get_entity_patterns_shares_cache_across_cases():
72+
"""Different casing must hit the same cache entry — not duplicate work."""
73+
get_entity_patterns(("zh-CN",))
74+
cache_keys = list(i18n._entity_cache.keys())
75+
get_entity_patterns(("ZH-CN",))
76+
get_entity_patterns(("zh-cn",))
77+
assert len(i18n._entity_cache) == len(
78+
cache_keys
79+
), "different casings of the same language must not create new cache entries"
80+
81+
82+
def test_unknown_language_still_falls_back_to_english():
83+
"""A code with no matching file must fall through to English (existing contract)."""
84+
patterns = get_entity_patterns(("xx-yy",))
85+
en = get_entity_patterns(("en",))
86+
assert patterns["candidate_patterns"] == en["candidate_patterns"]

0 commit comments

Comments
 (0)