Skip to content

Commit 990ba53

Browse files
Patch76claude
andauthored
fix: keep hardcoded config-flow labels untranslated (#2170)
* fix: keep hardcoded config-flow labels untranslated The 2026-08-08 locale-sync run failed its content-completeness verification and pushed nothing: filling `nl` for the first time, the engine translated the on-screen option in `common.connect_local_lan` ("Local network" -> "Lokaal netwerk"), which sends the reader looking for an option that is not on the form. The prompt already asked for that literal to survive; nothing enforced it, so the only thing that caught it was the parity test after the run, which blocks the push for every locale at once. `_validate` now rejects a translation that localises a selector label the config flow hardcodes in Python. Quoting alone is not the signal: catalogs correctly translate quoted cross-references to their own option labels, and Home Assistant translates its own buttons — all six shipped catalogs translate "Add entry" and all six keep "Local network". The label set is read from `config_flow.py` at runtime rather than duplicated here, so a rename cannot strand a stale copy; the existing `test_connect_local_lan_quotes_the_bind_host_option` guards the rename against the catalogs. Authoring the `nl` string as well takes the key out of the work plan, so the next run is clean rather than merely unblocked. Its English is unchanged since the last baseline repin, so the sync will not retranslate it and no repin is needed here — repinning now would freeze the strings that are legitimately waiting for retranslation. The workflow header and the module docstring both list what gets validated, so both name the new check; the `.github/` edit is that comment only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: type the regex results the label check reads `re.findall` is typed as `list[Any]`, so returning one of its elements made `_untranslatable_label_dropped` return `Any` from a function declared `str | None` — which the repository's mypy settings reject. Annotating both call sites keeps the values typed as the strings the patterns already guarantee. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: read the quoted label through typographic quote marks too The label check pulled quoted text out of the English source with a straight double quote on both sides, so an English string that quoted the option the way two shipped strings already do — `“…”` — never reached the comparison and the label was free to be translated. Which quote mark an author reaches for should not decide whether the option on the form is protected. Widening the class leaves the measurement unchanged: still zero violations across the 3232 English/translation pairs in the shipped catalogs, and the string that failed the sync run is still rejected in both spellings. The workflow header lists what the script validates and had been one item short of the module docstring since before this change; it now names formatting-tag parity as well, so the two lists read the same. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent cbdf028 commit 990ba53

4 files changed

Lines changed: 95 additions & 5 deletions

File tree

.github/workflows/locale-sync.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ name: Locale Sync
1818
# which is what tells the bot the changed English is already covered).
1919
#
2020
# Verification is three layers. (1) Every returned string is validated
21-
# before it is written (placeholder, markup and panel-link parity, in the
22-
# script). (2) After a clean run, the content-completeness checks that PR CI
21+
# before it is written (placeholder, markup, formatting-tag and panel-link
22+
# parity, plus the config flow's hardcoded option labels, in the script).
23+
# (2) After a clean run, the content-completeness checks that PR CI
2324
# skips run here under LOCALE_COMPLETENESS_CHECKS=1; a failure means the
2425
# output may be corrupt (the engine pasted English back, parity broke) and
2526
# BLOCKS the push — machine translations are recomputable, so dropping them
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"common": {
3+
"connect_local_lan": "Lokaal/LAN (wanneer Netwerktoegang \"Local network\" is): {url}",
34
"oauth_not_serving": "Legacy OAuth levert deze nog niet — start Home Assistant opnieuw op wanneer je daarom wordt gevraagd, om ze te activeren."
45
}
56
}

scripts/translate_locales.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@
2626
run sees nothing stale and no-ops. Hand-edits always win; the machine only
2727
touches strings whose English changed.
2828
Every returned string is validated (placeholder parity, the settings UI markup
29-
allowlist, formatting-tag parity, panel-link parity) before it is written; a
30-
failure leaves that string unwritten and the run red rather than shipping a
31-
broken translation.
29+
allowlist, formatting-tag parity, panel-link parity, and the config flow's
30+
hardcoded option labels staying untranslated) before it is written; a failure
31+
leaves that string unwritten and the run red rather than shipping a broken
32+
translation.
3233
3334
Rate limits and outages: requests are paced under the free-tier rate and
3435
retry transient errors with backoff; a persistently failing batch marks its
@@ -56,6 +57,7 @@
5657
from collections import Counter
5758
from collections.abc import Container
5859
from dataclasses import dataclass, field
60+
from functools import lru_cache
5961
from pathlib import Path
6062
from typing import Any, Literal, NamedTuple
6163

@@ -415,10 +417,54 @@ def build_plan(module: Any) -> Plan:
415417
return plan
416418

417419

420+
_CONFIG_FLOW_PATH = REPO_ROOT / "custom_components" / "ha_mcp_tools" / "config_flow.py"
421+
_SELECTOR_LABEL_RE = re.compile(r'label="([^"]+)"')
422+
# English sources quote with straight or typographic marks — both already occur
423+
# in the shipped catalogs — and the label check has to see the quoted text
424+
# either way, or the spelling of a quote silently decides whether it applies.
425+
_QUOTED_RE = re.compile(r'["“”«»„]([^"“”«»„]+)["“”«»„]')
426+
427+
428+
@lru_cache(maxsize=1)
429+
def _hardcoded_option_labels() -> tuple[str, ...]:
430+
"""Config-flow selector labels, which read English on every reader's form.
431+
432+
Quoting alone does not say whether a string may be translated: catalogs
433+
correctly translate quoted cross-references to their own option labels,
434+
and Home Assistant translates its own buttons ("Add entry"). What must
435+
survive verbatim is the narrower class this returns — labels our config
436+
flow hardcodes in Python, so no catalog can localise them and a reader
437+
told to pick a translated one goes looking for an option that is not on
438+
the form. Read from the source instead of listed here so a rename cannot
439+
strand a stale copy; ``test_connect_local_lan_quotes_the_bind_host_option``
440+
guards the rename against the catalogs.
441+
"""
442+
labels: list[str] = _SELECTOR_LABEL_RE.findall(_CONFIG_FLOW_PATH.read_text("utf-8"))
443+
return tuple(labels)
444+
445+
446+
def _untranslatable_label_dropped(english: str, translated: str) -> str | None:
447+
"""The hardcoded label this translation localised away, if any."""
448+
quoted_texts: list[str] = _QUOTED_RE.findall(english)
449+
for quoted in quoted_texts:
450+
for label in _hardcoded_option_labels():
451+
if (
452+
label == quoted or label.startswith(f"{quoted} ")
453+
) and quoted not in translated:
454+
return quoted
455+
return None
456+
457+
418458
def _validate(item: WorkItem, translated: Any) -> str | None:
419459
"""The reason a translation is unusable for this item, or None."""
420460
if not isinstance(translated, str) or not translated.strip():
421461
return "empty or non-string translation"
462+
dropped = _untranslatable_label_dropped(item.english, translated)
463+
if dropped is not None:
464+
return (
465+
f"the on-screen option {dropped!r} is hardcoded in the config flow "
466+
"and has to stay untranslated"
467+
)
422468
if set(_PLACEHOLDER_RE.findall(item.english)) != set(
423469
_PLACEHOLDER_RE.findall(translated)
424470
):

tests/src/unit/test_translate_locales.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,48 @@ def test_rejects_panel_link_target_drift(self) -> None:
118118
assert _validate(_item(english=english), wrong) is not None
119119

120120

121+
class TestHardcodedOptionLabels:
122+
"""The config flow hardcodes a couple of selector labels in Python, so
123+
Home Assistant shows them in English whatever the reader's language is.
124+
A catalog that localises one sends the reader looking for an option that
125+
is not on the form — the failure that stopped a whole sync run, since the
126+
engine only had a prose rule telling it not to."""
127+
128+
def test_the_label_set_is_read_from_the_config_flow(self) -> None:
129+
# Without this the check degrades silently: an empty label set accepts
130+
# every translation and still reports a clean run.
131+
labels = translate_locales._hardcoded_option_labels()
132+
assert labels, "no selector labels found — the config flow was restructured"
133+
assert any(label.startswith("Local network") for label in labels)
134+
135+
def test_rejects_a_localised_hardcoded_label(self) -> None:
136+
english = 'Local/LAN (when Network access is "Local network"): {url}'
137+
localised = 'Lokaal/LAN (wanneer Netwerktoegang "Lokaal netwerk" is): {url}'
138+
kept = 'Lokaal/LAN (wanneer Netwerktoegang "Local network" is): {url}'
139+
assert _validate(_item(section="component", english=english), localised)
140+
assert _validate(_item(section="component", english=english), kept) is None
141+
142+
def test_reads_the_label_through_typographic_quotes(self) -> None:
143+
# Shipped English already quotes both ways, so which mark an author
144+
# reached for must not decide whether the label is protected.
145+
english = "Local/LAN (when Network access is “Local network”): {url}"
146+
localised = 'Lokaal/LAN (wanneer Netwerktoegang "Lokaal netwerk" is): {url}'
147+
assert _validate(_item(section="component", english=english), localised)
148+
149+
def test_leaves_other_quoted_text_translatable(self) -> None:
150+
# "Add entry" is Home Assistant's own button: HA translates it, so
151+
# every shipped catalog translates the quote too. Only labels the
152+
# config flow hardcodes are pinned to English.
153+
english = 'Not installed — press "Add entry" on this integration\'s page'
154+
assert (
155+
_validate(
156+
_item(section="component", english=english),
157+
'Nicht installiert — klicke auf "Eintrag hinzufügen"',
158+
)
159+
is None
160+
)
161+
162+
121163
class TestChunk:
122164
def test_splits_on_the_character_budget(self) -> None:
123165
big = "x" * (translate_locales._MAX_CHARS_PER_REQUEST - 10)

0 commit comments

Comments
 (0)