Files
superset2/tests/unit_tests/scripts/translations/backfill_po_test.py
Evan Rusackas af6ac4d09c feat(i18n): AI-assisted translation backfill tooling + Spanish translations (#39448)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-05-22 21:07:27 -07:00

313 lines
12 KiB
Python

# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Tests for ``scripts/translations/backfill_po.py``.
The script is not installed as a package, so it is loaded via importlib from
its filesystem path. The two units exercised here — ``parse_response`` and
``_apply_translation`` — have enough edge cases (dict/list/scalar responses,
plural vs singular entries, fuzzy flag, attribution comments) to be worth
pinning against regressions.
"""
import importlib.util
import json # noqa: TID251 - testing a standalone script that uses stdlib json
from pathlib import Path
import polib # type: ignore[import-untyped]
import pytest
_SCRIPT_PATH = (
Path(__file__).resolve().parents[4] / "scripts" / "translations" / "backfill_po.py"
)
_spec = importlib.util.spec_from_file_location("backfill_po", _SCRIPT_PATH)
assert _spec is not None, f"Could not load {_SCRIPT_PATH}"
assert _spec.loader is not None, f"No loader on spec for {_SCRIPT_PATH}"
backfill_po = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(backfill_po)
def test_parse_response_singular_strings() -> None:
"""A flat object of int-keyed strings is returned as-is."""
text = '{"0": "hola", "1": "mundo"}'
assert backfill_po.parse_response(text, batch_size=2) == {
0: "hola",
1: "mundo",
}
def test_parse_response_strips_markdown_fences() -> None:
"""Models sometimes wrap JSON in ```json fences; those must be stripped."""
text = '```json\n{"0": "hola"}\n```'
assert backfill_po.parse_response(text, batch_size=1) == {0: "hola"}
def test_parse_response_preserves_plural_dict_as_json() -> None:
"""
Plural entries arrive as nested dicts and must round-trip through
json.loads downstream — str(dict) would emit Python repr (single quotes)
and break parsing in _apply_translation. The serialized form must be
valid JSON.
"""
text = '{"0": {"0": "manzana", "1": "manzanas"}}'
parsed = backfill_po.parse_response(text, batch_size=1)
assert set(parsed.keys()) == {0}
# Must be valid JSON (double-quoted), not Python repr (single-quoted).
assert json.loads(parsed[0]) == {"0": "manzana", "1": "manzanas"}
def test_parse_response_preserves_non_ascii() -> None:
"""ensure_ascii=False keeps non-ASCII characters readable in the .po file."""
text = '{"0": {"0": "日本語", "1": "日本語s"}}'
parsed = backfill_po.parse_response(text, batch_size=1)
assert "日本語" in parsed[0]
def test_parse_response_skips_non_numeric_keys() -> None:
"""Keys that are not numeric strings are silently skipped."""
text = '{"0": "ok", "comment": "ignored", "2": "kept"}'
assert backfill_po.parse_response(text, batch_size=3) == {
0: "ok",
2: "kept",
}
@pytest.mark.parametrize(
"raw",
['["hola", "mundo"]', '"just a string"', "null", "42"],
)
def test_parse_response_rejects_non_object(raw: str) -> None:
"""
Non-object JSON (list, string, null, number) must raise ValueError so
_process_batches catches it instead of crashing on AttributeError from
.items().
"""
with pytest.raises(ValueError, match="Expected a JSON object"):
backfill_po.parse_response(raw, batch_size=1)
def test_parse_response_rejects_invalid_json() -> None:
"""Garbage input surfaces as ValueError, not the underlying JSONDecodeError."""
with pytest.raises(ValueError, match="Could not parse response as JSON"):
backfill_po.parse_response("not even close to json", batch_size=1)
# ---------------------------------------------------------------------------
# _apply_translation
# ---------------------------------------------------------------------------
def _make_singular_entry(msgid: str = "Hello") -> polib.POEntry:
return polib.POEntry(msgid=msgid, msgstr="")
def _make_plural_entry(
msgid: str = "%(n)s apple",
msgid_plural: str = "%(n)s apples",
) -> polib.POEntry:
entry = polib.POEntry(msgid=msgid, msgid_plural=msgid_plural)
entry.msgstr_plural = {0: "", 1: ""}
return entry
def _item(refs: list[str] | None = None) -> dict[str, list[str]]:
return {"context_langs": refs if refs is not None else ["fr", "de"]}
def test_apply_translation_singular_writes_msgstr_and_marks_fuzzy() -> None:
entry = _make_singular_entry()
backfill_po._apply_translation(
entry, "Hola", _item(["fr", "de"]), model="claude-test", mark_fuzzy=True
)
assert entry.msgstr == "Hola"
assert "fuzzy" in entry.flags
def test_apply_translation_singular_no_fuzzy_when_disabled() -> None:
entry = _make_singular_entry()
backfill_po._apply_translation(
entry, "Hola", _item(), model="claude-test", mark_fuzzy=False
)
assert "fuzzy" not in entry.flags
def test_apply_translation_attribution_includes_refs() -> None:
entry = _make_singular_entry()
backfill_po._apply_translation(
entry, "Hola", _item(["fr", "de"]), model="claude-test", mark_fuzzy=True
)
assert "Machine-translated via backfill_po.py (claude-test)" in entry.tcomment
assert "[refs: fr, de]" in entry.tcomment
def test_apply_translation_attribution_marks_no_refs() -> None:
entry = _make_singular_entry()
backfill_po._apply_translation(
entry, "Hola", _item([]), model="claude-test", mark_fuzzy=True
)
assert "[no refs]" in entry.tcomment
def test_apply_translation_attribution_appended_not_duplicated() -> None:
"""Re-running on an already-translated entry must not duplicate attribution."""
entry = _make_singular_entry()
entry.tcomment = "Existing maintainer note"
backfill_po._apply_translation(
entry, "Hola", _item(["fr"]), model="claude-test", mark_fuzzy=True
)
# Existing comment preserved, attribution appended.
assert entry.tcomment.startswith("Existing maintainer note\n")
assert "Machine-translated via backfill_po.py" in entry.tcomment
# Apply again — attribution must not duplicate.
backfill_po._apply_translation(
entry, "Hola", _item(["fr"]), model="claude-test", mark_fuzzy=True
)
assert entry.tcomment.count("Machine-translated via backfill_po.py") == 1
def test_apply_translation_plural_dict_response() -> None:
"""A JSON-dict response writes each plural form to msgstr_plural."""
entry = _make_plural_entry()
translation = json.dumps({"0": "manzana", "1": "manzanas"})
backfill_po._apply_translation(
entry, translation, _item(), model="claude-test", mark_fuzzy=True
)
assert entry.msgstr_plural == {0: "manzana", 1: "manzanas"}
assert "fuzzy" in entry.flags
def test_apply_translation_plural_scalar_json_fills_all_forms() -> None:
"""
A JSON-scalar response (e.g. ``"hola"``) is broadcast to every plural form.
This is the documented fallback when the model returns a single string for
a plural entry.
"""
entry = _make_plural_entry()
backfill_po._apply_translation(
entry, '"manzana"', _item(), model="claude-test", mark_fuzzy=True
)
assert entry.msgstr_plural == {0: "manzana", 1: "manzana"}
def test_apply_translation_plural_invalid_json_fills_all_forms() -> None:
"""
A non-JSON string also broadcasts to every plural form (rather than
crashing). This handles older models that ignore the JSON instruction.
"""
entry = _make_plural_entry()
backfill_po._apply_translation(
entry, "manzana", _item(), model="claude-test", mark_fuzzy=True
)
assert entry.msgstr_plural == {0: "manzana", 1: "manzana"}
def test_apply_translation_plural_round_trip_from_parse_response() -> None:
"""
End-to-end guard: the JSON string produced by parse_response for a plural
entry must be consumable by _apply_translation without losing forms. This
is the regression that #39448 fixed (str(dict) → Python repr broke the
round-trip).
"""
raw = '{"0": {"0": "manzana", "1": "manzanas"}}'
parsed = backfill_po.parse_response(raw, batch_size=1)
entry = _make_plural_entry()
backfill_po._apply_translation(
entry, parsed[0], _item(), model="claude-test", mark_fuzzy=True
)
assert entry.msgstr_plural == {0: "manzana", 1: "manzanas"}
def test_apply_translation_plural_list_response() -> None:
"""
Models sometimes return a JSON array for plural forms (forms are ordered,
so a list is a valid representation). Each element must map to the
corresponding plural index. Without this branch, ``str(list)`` would emit
Python list-repr and broadcast it to every form — observed in the wild
on a fresh run for French.
"""
entry = _make_plural_entry()
translation = json.dumps(["manzana", "manzanas"])
backfill_po._apply_translation(
entry, translation, _item(), model="claude-test", mark_fuzzy=True
)
assert entry.msgstr_plural == {0: "manzana", 1: "manzanas"}
def test_apply_translation_plural_list_round_trip_from_parse_response() -> None:
"""
The list-of-forms response must also survive parse_response → _apply
round-trip. parse_response JSON-serializes lists; _apply_translation
must json.loads them back into a list and distribute across forms.
"""
raw = '{"0": ["manzana", "manzanas"]}'
parsed = backfill_po.parse_response(raw, batch_size=1)
entry = _make_plural_entry()
backfill_po._apply_translation(
entry, parsed[0], _item(), model="claude-test", mark_fuzzy=True
)
assert entry.msgstr_plural == {0: "manzana", 1: "manzanas"}
def test_apply_translation_plural_list_shorter_repeats_last_form() -> None:
"""
If the model returns fewer forms than the language requires, repeat the
last form rather than leaving slots empty (which would render as the
literal English msgid via gettext fallback).
"""
entry = polib.POEntry(msgid="apple", msgid_plural="apples")
entry.msgstr_plural = {0: "", 1: "", 2: ""}
backfill_po._apply_translation(
entry,
json.dumps(["uno", "dos"]),
_item(),
model="claude-test",
mark_fuzzy=True,
)
assert entry.msgstr_plural == {0: "uno", 1: "dos", 2: "dos"}
def test_apply_translation_plural_empty_list_falls_back_to_string_broadcast() -> None:
"""An empty JSON list isn't usable; fall back to writing the raw string."""
entry = _make_plural_entry()
backfill_po._apply_translation(
entry, "[]", _item(), model="claude-test", mark_fuzzy=True
)
# "[]" parses cleanly to an empty list, so the JSON branch matches but the
# list-handling fork sees a falsy value and falls through to scalar
# broadcast — the raw "[]" string ends up filling every plural slot.
assert entry.msgstr_plural == {0: "[]", 1: "[]"}
def test_build_prompt_includes_plural_note_when_plural_is_not_first() -> None:
"""
Regression: batches mix singular and plural entries in .po file order. If
the plural-form guidance only fires when the first entry is plural, any
batch where the plural lives after a singular would lose the guidance and
the model would silently produce malformed plural responses.
"""
batch = [
{"msgid": "Save", "msgstr": "", "index_key": "Save"},
{
"msgid": "%(num)d row",
"msgid_plural": "%(num)d rows",
"msgstr_plural": {0: "", 1: ""},
"index_key": "%(num)d row\x00%(num)d rows",
},
]
prompt = backfill_po.build_prompt("fr", batch, index={})
assert "provide ALL plural forms" in prompt