From 8be255de408cd7beafa05b64d4460f353f8bf24e Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Wed, 1 Jul 2026 16:28:19 -0700 Subject: [PATCH] =?UTF-8?q?chore(i18n):=20harden=20backfill=5Fpo=20?= =?UTF-8?q?=E2=80=94=20full=20language-name=20map=20+=20resilient=20batch?= =?UTF-8?q?=20translation=20(#41644)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Amin Ghadersohi Co-authored-by: Claude Opus 4.8 --- scripts/translations/backfill_po.py | 99 ++++++++++++++++++- .../scripts/translations/backfill_po_test.py | 93 +++++++++++++++++ 2 files changed, 191 insertions(+), 1 deletion(-) diff --git a/scripts/translations/backfill_po.py b/scripts/translations/backfill_po.py index 284af4a99af..25eed9ed444 100644 --- a/scripts/translations/backfill_po.py +++ b/scripts/translations/backfill_po.py @@ -91,22 +91,28 @@ _ASF_LICENSE_HEADER = """\ LANGUAGE_NAMES: dict[str, str] = { "ar": "Arabic", "ca": "Catalan", + "cs": "Czech", "de": "German", "es": "Spanish", "fa": "Persian (Farsi)", + "fi": "Finnish", "fr": "French", "it": "Italian", "ja": "Japanese", "ko": "Korean", + "lv": "Latvian", "mi": "Māori", "nl": "Dutch", "pl": "Polish", "pt": "Portuguese", "pt_BR": "Brazilian Portuguese", + "ro": "Romanian", "ru": "Russian", "sk": "Slovak", "sl": "Slovenian", "sr": "Serbian", + "sr_Latn": "Serbian (Latin script)", + "th": "Thai", "tr": "Turkish", "uk": "Ukrainian", "zh": "Chinese (Simplified)", @@ -346,6 +352,97 @@ def translate_batch( return parse_response(result.stdout.strip(), len(batch)) +def _translate_single_plaintext( + model: str, + target_lang: str, + item: dict[str, Any], + index: dict[str, Any], +) -> str | None: + """Translate a single entry with a plain-text prompt (no JSON envelope). + + Fallback for an entry whose JSON batch response cannot be parsed — typically + because the source string contains literal double-quotes that the model + echoes back unescaped, corrupting the surrounding JSON. Asking for a bare + string sidesteps the JSON contract entirely. Returns the translation text, + or None if the CLI call fails. + """ + claude_bin = shutil.which("claude") + if not claude_bin: + raise RuntimeError( + "claude CLI not found. Install Claude Code or add it to PATH." + ) + lines = [ + "You are a professional translator specializing in software UI strings.", + f"Translate the following English string into {_lang_name(target_lang)} " + f"({target_lang}).", + "Return ONLY the translation as plain text — no surrounding quotes, no " + "JSON, no markdown fences, no explanation.", + "Preserve all format placeholders exactly (%(name)s, {name}, %s, %d), any " + "HTML tags, and any inner quotation marks.", + "", + f"English: {item['msgid']}", + ] + if item.get("msgid_plural"): + lines.append(f"English plural: {item['msgid_plural']}") + refs = index.get(item["index_key"], {}) + ref_lines = [ + f"{_lang_name(lang)}: {val}" + for lang, val in sorted(refs.items()) + if lang != target_lang and isinstance(val, str) and val + ] + if ref_lines: + lines.append("") + lines.append("Reference translations in other languages:") + lines.extend(ref_lines) + prompt = "\n".join(lines) + # claude_bin is resolved via shutil.which — not user-controlled input + result = subprocess.run( # noqa: S603 + [claude_bin, "--model", model, "-p"], + input=prompt, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return None + text = result.stdout.strip() + # Strip accidental markdown fences or wrapping quotes the model may add. + text = re.sub(r"^```[^\n]*\n?", "", text) + text = re.sub(r"\n?```$", "", text).strip() + if len(text) >= 2 and text[0] == '"' and text[-1] == '"': + text = text[1:-1] + return text or None + + +def _resilient_translate( + model: str, + target_lang: str, + batch: list[dict[str, Any]], + index: dict[str, Any], +) -> dict[int, str]: + """Translate a batch, isolating entries that break the JSON response contract. + + ``translate_batch`` sends the whole batch in one request and parses a single + JSON object back. A source string containing literal double-quotes can make + the model emit unescaped quotes, so ``json.loads`` fails and the ENTIRE batch + would be lost. To salvage the rest, on a parse failure (ValueError) we bisect + the batch and recurse; a lone entry that still fails falls back to a + plain-text prompt via ``_translate_single_plaintext``. Returned keys are + positions within ``batch``. RuntimeError (CLI failure) is left to propagate + to the caller, preserving the existing per-batch failure handling. + """ + try: + return translate_batch(model, target_lang, batch, index) + except ValueError: + if len(batch) == 1: + text = _translate_single_plaintext(model, target_lang, batch[0], index) + return {0: text} if text else {} + mid = len(batch) // 2 + left = _resilient_translate(model, target_lang, batch[:mid], index) + right = _resilient_translate(model, target_lang, batch[mid:], index) + return {**left, **{k + mid: v for k, v in right.items()}} + + def _apply_plural_translation(entry: polib.POEntry, translation: str) -> None: """Distribute a model response across the entry's plural forms. @@ -462,7 +559,7 @@ def _process_batches( file=sys.stderr, ) try: - translations = translate_batch(model, lang, batch_items, index) + translations = _resilient_translate(model, lang, batch_items, index) except (ValueError, RuntimeError) as exc: print(f" ERROR in batch starting at {batch_start}: {exc}", file=sys.stderr) failed_count += len(batch_entries) diff --git a/tests/unit_tests/scripts/translations/backfill_po_test.py b/tests/unit_tests/scripts/translations/backfill_po_test.py index e0805450229..6734362b0ae 100644 --- a/tests/unit_tests/scripts/translations/backfill_po_test.py +++ b/tests/unit_tests/scripts/translations/backfill_po_test.py @@ -343,3 +343,96 @@ def test_ensure_license_header_dry_run_does_not_write(tmp_path: Path) -> None: po.write_text(original, encoding="utf-8") backfill_po._ensure_license_header(po, dry_run=True) assert po.read_text(encoding="utf-8") == original + + +# --- _resilient_translate: batch bisection + plain-text fallback --------------- +# +# A source string containing a literal double-quote can make the model emit +# unescaped quotes, so the batch's JSON response fails to parse and the whole +# batch is lost. _resilient_translate isolates such entries by bisecting the +# batch and falls back to a plain-text prompt for a lone offender. The stub +# below simulates that failure mode: any batch containing a quoted msgid raises +# ValueError (as parse_response would), everything else maps positionally. + + +def _qitem(msgid: str) -> dict[str, str]: + return {"msgid": msgid, "index_key": msgid} + + +def _fake_translate_batch( + model: str, + target_lang: str, + batch: list[dict[str, str]], + index: dict[str, object], +) -> dict[int, str]: + if any('"' in it["msgid"] for it in batch): + raise ValueError("simulated unparseable JSON") + return {i: f"T:{it['msgid']}" for i, it in enumerate(batch)} + + +def test_resilient_translate_passthrough_when_batch_parses( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A cleanly-parsing batch is returned as-is, without bisection.""" + monkeypatch.setattr(backfill_po, "translate_batch", _fake_translate_batch) + result = backfill_po._resilient_translate( + "m", "fr", [_qitem("Alpha"), _qitem("Beta")], {} + ) + assert result == {0: "T:Alpha", 1: "T:Beta"} + + +def test_resilient_translate_bisects_and_falls_back_on_poison( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The quote-bearing entry is isolated and filled via plain-text fallback, + while every other entry keeps its original batch position.""" + monkeypatch.setattr(backfill_po, "translate_batch", _fake_translate_batch) + monkeypatch.setattr( + backfill_po, + "_translate_single_plaintext", + lambda model, lang, item, index: f"PT:{item['msgid']}", + ) + batch = [_qitem("Alpha"), _qitem("Beta"), _qitem('Has "quote"'), _qitem("Delta")] + result = backfill_po._resilient_translate("m", "fr", batch, {}) + assert result == { + 0: "T:Alpha", + 1: "T:Beta", + 2: 'PT:Has "quote"', + 3: "T:Delta", + } + + +def test_resilient_translate_drops_entry_when_fallback_returns_none( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A lone entry that even the plain-text fallback can't render is dropped + (absent key) rather than sinking the surviving entries.""" + monkeypatch.setattr(backfill_po, "translate_batch", _fake_translate_batch) + monkeypatch.setattr( + backfill_po, + "_translate_single_plaintext", + lambda model, lang, item, index: None, + ) + result = backfill_po._resilient_translate( + "m", "fr", [_qitem("Alpha"), _qitem('Bad "one"')], {} + ) + assert result == {0: "T:Alpha"} + + +def test_resilient_translate_propagates_runtime_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A CLI failure (RuntimeError) is not a content problem, so it propagates + to the caller's per-batch handler instead of triggering a bisect.""" + + def _boom( + model: str, + target_lang: str, + batch: list[dict[str, str]], + index: dict[str, object], + ) -> dict[int, str]: + raise RuntimeError("claude CLI exploded") + + monkeypatch.setattr(backfill_po, "translate_batch", _boom) + with pytest.raises(RuntimeError): + backfill_po._resilient_translate("m", "fr", [_qitem("Alpha")], {})