Skip to content

Commit 6cc27d4

Browse files
committed
fix(locales): salvage invalid runtime entries
1 parent 53e4e17 commit 6cc27d4

2 files changed

Lines changed: 178 additions & 22 deletions

File tree

src/ha_mcp/settings_ui/_i18n.py

Lines changed: 114 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,86 @@ def _validate_tools(value: Any, *, context: str) -> dict[str, dict[str, str]]:
9292
return result
9393

9494

95-
def _load_catalog_file(path: Path) -> dict[str, Any]:
95+
def _warn_best_effort_catalog(locale: str, path: Path, exc: Exception) -> None:
96+
_LOGGER.warning("Skipping best-effort locale %s from %s: %s", locale, path, exc)
97+
98+
99+
def _warn_best_effort_entry(locale: str, entry: str, exc: Exception) -> None:
100+
_LOGGER.warning(
101+
"Ignoring invalid best-effort locale %s %s; using English fallback: %s",
102+
locale,
103+
entry,
104+
exc,
105+
)
106+
107+
108+
def _validate_best_effort_string_map(
109+
value: Any,
110+
*,
111+
locale: str,
112+
context: str,
113+
entry_kind: str,
114+
) -> dict[str, str]:
115+
"""Drop invalid best-effort strings while preserving valid siblings."""
116+
if value is None:
117+
return {}
118+
if not isinstance(value, dict):
119+
raise ValueError(f"{context} must be an object")
120+
121+
result: dict[str, str] = {}
122+
for key, text in value.items():
123+
try:
124+
result.update(_validate_string_map({key: text}, context=context))
125+
except ValueError as exc:
126+
_warn_best_effort_entry(locale, f"{entry_kind} {key!r}", exc)
127+
return result
128+
129+
130+
def _validate_best_effort_tools(
131+
value: Any, *, locale: str, context: str
132+
) -> dict[str, dict[str, str]]:
133+
"""Drop invalid best-effort tool fields while preserving valid siblings."""
134+
if value is None:
135+
return {}
136+
if not isinstance(value, dict):
137+
raise ValueError(f"{context} must be an object")
138+
139+
result: dict[str, dict[str, str]] = {}
140+
for tool_name, tool_values in value.items():
141+
if not isinstance(tool_name, str) or not isinstance(tool_values, dict):
142+
exc = ValueError(f"{context} entries must be objects keyed by tool name")
143+
_warn_best_effort_entry(locale, f"tool {tool_name!r}", exc)
144+
continue
145+
146+
translated: dict[str, str] = {}
147+
for field, field_value in tool_values.items():
148+
if field not in ("title", "description"):
149+
exc = ValueError(
150+
f"{context}.{tool_name} has unsupported field: {field!r}"
151+
)
152+
_warn_best_effort_entry(
153+
locale, f"tool {tool_name!r} field {field!r}", exc
154+
)
155+
continue
156+
try:
157+
validated = _validate_tools(
158+
{tool_name: {field: field_value}}, context=context
159+
)
160+
except ValueError as exc:
161+
_warn_best_effort_entry(
162+
locale, f"tool {tool_name!r} field {field!r}", exc
163+
)
164+
continue
165+
if field in validated[tool_name]:
166+
translated[field] = validated[tool_name][field]
167+
if translated:
168+
result[tool_name] = translated
169+
return result
170+
171+
172+
def _load_catalog_file(
173+
path: Path, *, best_effort_locale: str | None = None
174+
) -> dict[str, Any]:
96175
"""Load and validate one catalog without coupling it to its siblings."""
97176
try:
98177
raw = json.loads(path.read_text(encoding="utf-8"))
@@ -118,29 +197,39 @@ def _load_catalog_file(path: Path) -> dict[str, Any]:
118197
f"{sorted(unknown_sections)}"
119198
)
120199

121-
return {
122-
"meta": {"native_name": native_name, "dir": direction},
123-
"messages": _validate_string_map(
200+
if best_effort_locale is None:
201+
messages = _validate_string_map(
124202
raw.get("messages"), context=f"{path.name}.messages"
125-
),
126-
"tool_groups": _validate_string_map(
203+
)
204+
tool_groups = _validate_string_map(
127205
raw.get("tool_groups"), context=f"{path.name}.tool_groups"
128-
),
129-
"tools": _validate_tools(raw.get("tools"), context=f"{path.name}.tools"),
130-
}
131-
132-
133-
def _warn_best_effort_catalog(locale: str, path: Path, exc: Exception) -> None:
134-
_LOGGER.warning("Skipping best-effort locale %s from %s: %s", locale, path, exc)
135-
206+
)
207+
tools = _validate_tools(raw.get("tools"), context=f"{path.name}.tools")
208+
else:
209+
messages = _validate_best_effort_string_map(
210+
raw.get("messages"),
211+
locale=best_effort_locale,
212+
context=f"{path.name}.messages",
213+
entry_kind="message",
214+
)
215+
tool_groups = _validate_best_effort_string_map(
216+
raw.get("tool_groups"),
217+
locale=best_effort_locale,
218+
context=f"{path.name}.tool_groups",
219+
entry_kind="tool group",
220+
)
221+
tools = _validate_best_effort_tools(
222+
raw.get("tools"),
223+
locale=best_effort_locale,
224+
context=f"{path.name}.tools",
225+
)
136226

137-
def _warn_best_effort_entry(locale: str, entry: str, exc: Exception) -> None:
138-
_LOGGER.warning(
139-
"Ignoring invalid best-effort locale %s %s; using English fallback: %s",
140-
locale,
141-
entry,
142-
exc,
143-
)
227+
return {
228+
"meta": {"native_name": native_name, "dir": direction},
229+
"messages": messages,
230+
"tool_groups": tool_groups,
231+
"tools": tools,
232+
}
144233

145234

146235
def _catalog_fragment(
@@ -236,7 +325,10 @@ def load_catalogs(
236325
for path in paths:
237326
locale = path.stem.lower().replace("_", "-")
238327
try:
239-
catalogs[locale] = _load_catalog_file(path)
328+
catalogs[locale] = _load_catalog_file(
329+
path,
330+
best_effort_locale=(locale if is_best_effort_locale(locale) else None),
331+
)
240332
except (ImportError, ValueError) as exc:
241333
if not is_best_effort_locale(locale):
242334
raise

tests/src/unit/test_settings_ui_i18n.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,36 @@ def test_invalid_best_effort_message_falls_back_without_dropping_locale(
234234
assert "tlh message 'saved'" in caplog.text
235235

236236

237+
@pytest.mark.parametrize("invalid", ["", 7], ids=["blank", "non-string"])
238+
def test_invalid_best_effort_message_value_preserves_valid_siblings(
239+
tmp_path: Path,
240+
caplog: pytest.LogCaptureFixture,
241+
invalid: object,
242+
) -> None:
243+
_write_catalog(
244+
tmp_path,
245+
"en",
246+
native_name="English",
247+
messages={"invalid": "English fallback", "valid": "Valid"},
248+
)
249+
_write_catalog(
250+
tmp_path,
251+
"tlh",
252+
native_name="tlhIngan Hol (Klingon)",
253+
messages={"invalid": invalid, "valid": "Qapla'"},
254+
)
255+
256+
with caplog.at_level(logging.WARNING, logger="ha_mcp.settings_ui._i18n"):
257+
catalogs = load_catalogs(tmp_path)
258+
259+
assert catalogs["tlh"]["messages"] == {"valid": "Qapla'"}
260+
assert build_payload("tlh", catalogs)["messages"] == {
261+
"invalid": "English fallback",
262+
"valid": "Qapla'",
263+
}
264+
assert "tlh message 'invalid'" in caplog.text
265+
266+
237267
def test_tool_placeholder_mismatch_is_rejected(tmp_path: Path) -> None:
238268
_write_catalog(
239269
tmp_path,
@@ -295,6 +325,40 @@ def test_invalid_best_effort_tool_field_falls_back_without_dropping_siblings(
295325
assert "tlh tool 'ha_example' field 'description'" in caplog.text
296326

297327

328+
def test_invalid_best_effort_tool_field_preserves_valid_siblings(
329+
tmp_path: Path, caplog: pytest.LogCaptureFixture
330+
) -> None:
331+
_write_catalog(
332+
tmp_path,
333+
"en",
334+
native_name="English",
335+
messages={},
336+
tools={
337+
"ha_example": {
338+
"title": "Example",
339+
"description": "English fallback",
340+
}
341+
},
342+
)
343+
_write_catalog(
344+
tmp_path,
345+
"tlh",
346+
native_name="tlhIngan Hol (Klingon)",
347+
messages={},
348+
tools={"ha_example": {"title": "ghantoH", "description": 7}},
349+
)
350+
351+
with caplog.at_level(logging.WARNING, logger="ha_mcp.settings_ui._i18n"):
352+
catalogs = load_catalogs(tmp_path)
353+
354+
assert catalogs["tlh"]["tools"] == {"ha_example": {"title": "ghantoH"}}
355+
assert build_payload("tlh", catalogs)["tools"]["ha_example"] == {
356+
"title": "ghantoH",
357+
"description": "English fallback",
358+
}
359+
assert "tlh tool 'ha_example' field 'description'" in caplog.text
360+
361+
298362
def test_unknown_text_direction_is_rejected(tmp_path: Path) -> None:
299363
"""``meta.dir`` reaches the rendered ``<html dir>`` attribute verbatim.
300364

0 commit comments

Comments
 (0)