Skip to content

Commit 2105747

Browse files
authored
fix: reject blank locale catalog values instead of rendering them (#2163)
* fix: reject blank locale catalog values instead of rendering them English is the per-key fallback only for a key that is ABSENT. `t()` and `tHtml()` resolve with `hasOwnProperty` rather than truthiness, so a present-but-empty string wins over the English source and renders as nothing at all. Omitting a key and emptying it therefore look nearly identical in the JSON and behave oppositely on screen — a translator clearing a value to mean "not translated yet" silently blanks that piece of UI. Nothing objected to it. `_validate_string_map` and `_validate_tools` checked the type and stopped, and the parity ceilings count a key untranslated only when it equals the English or is missing (`_untranslated_keys`), so `""` read there as translated. Placeholder parity caught the subset whose English carries a placeholder, which is why this stayed invisible: the loud half of the class was already covered. Both validators now reject a blank value the way `meta.native_name` already does, and the error names the fix — omit the key. Measured before changing it: 0 blank values across 4,985 shipped ones (messages, tool_groups, tools and the component catalogs), so this rejects nothing that exists today. Each guard is pinned separately: breaking the `messages`/`tool_groups` condition fails those six parametrizations while the two tool-field cases stay green, and breaking the tool-field condition inverts that exactly. The locale README gains the rule where it describes omission, since omitting is now the only way to express "not translated yet". * docs: state the blank rule on tool_groups without promising omission `_validate_string_map` guards `messages` and `tool_groups` alike, but the catalog reference stated the blank rule only under `messages`, so a translator reading the `tool_groups` bullet had no reason to expect a load error. Omission is not the way out on that section: the key set is exact, and `test_settings_catalog_keys_name_real_groups_and_tools` — gated behind the post-merge `LOCALE_COMPLETENESS_CHECKS=1` run — fails a catalog that is missing a group. An untranslated heading therefore keeps the English tag as its value. For the same reason the shared blank-value error names the mechanism, that English renders only for an absent key, rather than prescribing "omit the key": that advice holds for `messages` and points a `tool_groups` translator at a catalog which then fails the completeness check. The per-tool blank test now covers the same three blank spellings as its `messages`/`tool_groups` sibling instead of two spaces alone. `_style_sample_keys` claimed a hand-committed blank was "only type-checked" by `_validate_string_map`, which this branch made false. Its defensive skip is still worth keeping, so the docstring now names the reason that actually holds: the script reads catalogs with `json.loads` instead of going through `load_catalogs`, so a blank reaches it out of a tree the app never loaded. * docs: name why the sampler still meets a blank the loader now rejects `_style_sample_keys` and the test that pins it both justified the blank-skip with `_validate_string_map` doing no more than a type check. The previous commit makes that false. The skip is still worth keeping, so both now name the reason that does hold: `scripts/translate_locales.py` reads catalogs with `json.loads` rather than through `load_catalogs`, so a blank reaches the sampler out of a working tree the app never loaded.
1 parent 767c248 commit 2105747

5 files changed

Lines changed: 93 additions & 10 deletions

File tree

scripts/translate_locales.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -465,10 +465,13 @@ def _style_sample_keys(
465465
right-hand side into the prompt, so the pair it contributes shows the model
466466
nothing to imitate while still counting against the sample budget — and on
467467
a catalog whose register rests on a single key, that is the whole signal.
468-
Nothing upstream stops it reaching here: an engine answer this shape is
469-
rejected (`_validate`), but a hand-committed one is only type-checked
470-
(`_validate_string_map`), and the parity ceilings count a key untranslated
471-
only when it equals the English or is absent, so `""` reads as translated.
468+
An engine answer this shape is rejected (`_validate`), and a hand-committed
469+
one no longer survives a catalog load (`_validate_string_map`) — but this
470+
script reads the catalogs with `json.loads` rather than through
471+
`load_catalogs`, so one still reaches here out of a working tree the app has
472+
not loaded. The parity ceilings would not object either: they count a key
473+
untranslated only when it equals the English or is absent, so `""` reads
474+
there as translated.
472475
"""
473476
return sorted(
474477
(

src/ha_mcp/settings_ui/_i18n.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,18 @@
2323

2424

2525
def _validate_string_map(value: Any, *, context: str) -> dict[str, str]:
26-
"""Return a validated ``str -> str`` catalog section."""
26+
"""Return a validated ``str -> str`` catalog section.
27+
28+
A blank value is rejected rather than stored. English is the per-key
29+
fallback, but only for a key that is ABSENT: ``t()`` and ``tHtml()`` pick
30+
the catalog value with ``hasOwnProperty``, so a present-but-empty string
31+
wins over English and renders as nothing at all. Omitting a key and
32+
emptying it therefore look identical in the JSON and behave oppositely on
33+
screen, which is the kind of difference no reviewer catches by reading.
34+
Nothing else covers it either — the parity ceilings count a key
35+
untranslated only when it equals the English or is missing, so ``""``
36+
reads there as translated.
37+
"""
2738
if value is None:
2839
return {}
2940
if not isinstance(value, dict):
@@ -32,6 +43,12 @@ def _validate_string_map(value: Any, *, context: str) -> dict[str, str]:
3243
for key, text in value.items():
3344
if not isinstance(key, str) or not isinstance(text, str):
3445
raise ValueError(f"{context} must contain only string keys and values")
46+
if not text.strip():
47+
raise ValueError(
48+
f"{context}.{key} is blank — English renders only for an "
49+
"absent key, so translate it, or leave the key out where the "
50+
"section allows a missing one"
51+
)
3552
result[key] = text
3653
return result
3754

@@ -53,6 +70,13 @@ def _validate_tools(value: Any, *, context: str) -> dict[str, dict[str, str]]:
5370
continue
5471
if not isinstance(field_value, str):
5572
raise ValueError(f"{context}.{tool_name}.{field} must be a string")
73+
# Same rule as _validate_string_map: blank loses to nothing, an
74+
# omitted field falls back to the tool's own English metadata.
75+
if not field_value.strip():
76+
raise ValueError(
77+
f"{context}.{tool_name}.{field} is blank — omit the field "
78+
"instead, so the English tool metadata renders"
79+
)
5680
translated[field] = field_value
5781
unknown = set(tool_values) - {"title", "description"}
5882
if unknown:

src/ha_mcp/settings_ui/locales/README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,19 @@ or constructed — needs no pipeline change.
6161
rejected when the catalog loads.
6262
- `messages`: interface labels, help text, notices, and runtime messages. Keys
6363
may be omitted — English is the per-key fallback at runtime — but see the
64-
share limit below before leaving a catalog half-finished.
64+
share limit below before leaving a catalog half-finished. Omitting is the
65+
only way to say "not translated yet": a key that is present but blank is
66+
rejected when the catalog loads, because the runtime resolves by key
67+
presence, so an empty value would win over English and render as nothing.
6568
- `tool_groups`: one entry per renderable MCP tool tag, keyed by the English
66-
tag. Not optional, and exact: no key more and none fewer.
69+
tag. Not optional, and exact: no key more and none fewer. Blank is rejected
70+
here too, but dropping the key is not the escape hatch it is for `messages`
71+
the exact key set forbids that. A heading you have not translated yet keeps
72+
the English tag as its value.
6773
- `tools`: `title` and `description` per tool, keyed by the stable MCP tool
6874
name. The key set is not optional and exact in the same way; either field on
6975
its own may be left out, but a missing one counts as untranslated against the
70-
share limit below.
76+
share limit below. Blank is rejected here too, for the same reason.
7177

7278
Keep the keys and `{placeholders}` unchanged in every section.
7379

tests/src/unit/test_settings_ui_i18n.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,55 @@ def test_catalog_without_a_native_name_is_rejected(tmp_path: Path) -> None:
230230
load_catalogs(tmp_path)
231231

232232

233+
@pytest.mark.parametrize("blank", ["", " ", "\n\t "])
234+
@pytest.mark.parametrize("section", ["messages", "tool_groups"])
235+
def test_blank_catalog_value_is_rejected(
236+
tmp_path: Path, section: str, blank: str
237+
) -> None:
238+
"""Blank and absent look the same in JSON and behave oppositely on screen.
239+
240+
English is the per-key fallback only for a key that is ABSENT: `t()` and
241+
`tHtml()` resolve with `hasOwnProperty`, so an empty string wins over the
242+
English source and renders as nothing. A translator emptying a value to
243+
mean "not translated yet" would silently blank that piece of UI, and no
244+
other check objects — the parity ceilings count a key untranslated only
245+
when it equals the English or is missing.
246+
"""
247+
catalog = {
248+
"meta": {"native_name": "English", "dir": "ltr"},
249+
"messages": {},
250+
"tool_groups": {},
251+
"tools": {},
252+
}
253+
catalog[section] = {"a": blank}
254+
(tmp_path / "en.json").write_text(json.dumps(catalog), encoding="utf-8")
255+
256+
with pytest.raises(ValueError, match=re.escape(f"en.json.{section}.a is blank")):
257+
load_catalogs(tmp_path)
258+
259+
260+
@pytest.mark.parametrize("blank", ["", " ", "\n\t "])
261+
@pytest.mark.parametrize("field", ["title", "description"])
262+
def test_blank_tool_field_is_rejected(tmp_path: Path, field: str, blank: str) -> None:
263+
"""Same rule on the per-tool surface, which validates separately."""
264+
(tmp_path / "en.json").write_text(
265+
json.dumps(
266+
{
267+
"meta": {"native_name": "English", "dir": "ltr"},
268+
"messages": {},
269+
"tool_groups": {},
270+
"tools": {"ha_get_state": {field: blank}},
271+
}
272+
),
273+
encoding="utf-8",
274+
)
275+
276+
with pytest.raises(
277+
ValueError, match=re.escape(f"en.json.tools.ha_get_state.{field} is blank")
278+
):
279+
load_catalogs(tmp_path)
280+
281+
233282
def test_inline_payload_escapes_script_breakout() -> None:
234283
serialized = serialize_payload({"messages": {"unsafe": "</script><b>&"}})
235284

tests/src/unit/test_translate_locales.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,9 @@ def test_skips_keys_the_catalog_has_not_translated(self) -> None:
162162

163163
@pytest.mark.parametrize("blank", ["", " ", "\n\t "])
164164
def test_skips_keys_whose_translation_is_blank(self, blank: str) -> None:
165-
"""A blank value is a present key with nothing in it, and the sampler is
166-
the only thing that looks: `_validate_string_map` type-checks, and the
165+
"""A blank value is a present key with nothing in it, and this sampler
166+
is where one still turns up: `_validate_string_map` rejects it at load,
167+
but this script reads the catalogs with `json.loads` instead, and the
167168
parity ceilings count a key untranslated only when it equals the English
168169
or is missing, so `""` reads there as translated. Sampled, it spends one
169170
of three slots on a pair whose target side is empty — and a catalog

0 commit comments

Comments
 (0)