Files
computer-playbook/tests/unit/python/utils/i18n/test_translate.py
T
kevinveenbirkenbachandClaude Opus 5 df557f7b80 feat(i18n): translate the catalogs through the gateway and re-ask a rejected entry
666312dfbd settled the catalog client on the dedicated LibreTranslate. One
engine cannot be compared against itself, so the direct path had no way to
improve on a language it translates badly: of the 2854 entries of the `ko`
catalog it produced 1 translated entry and 2704 `structure` rejections.

The client now asks the gateway, which fronts several engines and keeps a
history of which one survives a given signature. Protected spans are masked
as `<x id="N"></x>` before the request and declared per item, so the gateway
can reject an answer that altered them instead of passing it on. `_reask`
then retries each rejected entry with the engine that produced it in
`exclude`, which reaches a different backend rather than the same one. The
same catalog measured 17 translated entries with 2649 rejections once the
gateway checked the markup, and 331 with 796 once the retry could exclude.

The full picture a rejection needs is only available in the client: 2431 of
those 2649 structure rejections involve markup the masking hides from the
gateway, so the engine-switching decision belongs on this side of the call.

`server()` reuses a gateway that already answers and otherwise deploys the
bundle the gateway needs. It does not reintroduce the throwaway container
666312dfbd removed: there is no `docker run` here, the roles deploy through
the normal path and their models still come from the role images built
through the package cache. `redeploy=true` is offered because the reuse check
only asks whether a gateway answers, which cannot see that the gateway's own
code changed since it started. A run refuses outright while a deploy is in
progress, because both touch the same container stack, and leaves the deploy
alone.

`deployed()` additionally requires a non-empty catalogue, so a gateway that
answers while serving no pairs counts as not ready rather than as a backend
that refuses everything. `probe(url)` is split out of `deployed(root)` so one
readiness check serves a single engine and the gateway alike.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 17:13:49 +02:00

506 lines
17 KiB
Python

import json
import re
import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import ClassVar
from babel.messages.catalog import Catalog
from utils.annotations.suppress import is_suppressed_at
from utils.i18n.catalog import (
ENGINE,
MACHINE_TRANSLATION,
REFUSAL_PREFIX,
REJECTED_PREFIX,
TRANSLATION_REFUSED,
build_template,
merge,
render,
)
from utils.i18n.libretranslate import LibreTranslate
from utils.i18n.placeholders import Rejected
from utils.i18n.translate import (
URL_SUPPRESSION,
apply,
damaged,
over_shared,
pending,
retry,
taken_translations,
)
TOKEN = re.compile(r'<x id="\d+"></x>')
class FakeLibreTranslate(BaseHTTPRequestHandler):
"""German by prefix; drops every token of a message containing ``lossy``."""
requests: ClassVar[list[dict]] = []
def log_message(self, *args):
return
def _reply(self, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
self._reply([{"code": "en", "targets": ["de", "fr"]}])
def do_POST(self):
payload = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
FakeLibreTranslate.requests.append(payload)
translated = [
TOKEN.sub("", f"DE {text}") if "lossy" in text else f"DE {text}"
for text in payload["q"]
]
self._reply({"translatedText": translated})
class TestTranslate(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), FakeLibreTranslate)
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
cls.client = LibreTranslate(f"http://127.0.0.1:{cls.server.server_port}", 2)
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.server.server_close()
def setUp(self):
FakeLibreTranslate.requests.clear()
def test_waits_for_offered_languages(self):
self.client.wait(["de", "fr"], timeout=1)
with self.assertRaises(TimeoutError):
self.client.wait(["xx"], timeout=0)
def test_requests_html_from_english_into_the_target(self):
self.assertEqual(self.client.translate(["Hello"], "de").values, ["DE Hello"])
self.assertEqual(
{
key: FakeLibreTranslate.requests[0][key]
for key in ("source", "target", "format")
},
{"source": "en", "target": "de", "format": "html"},
)
def test_the_protected_tokens_travel_aligned_with_their_messages(self):
"""The gateway applies them per entry, so a flat list would mislabel."""
self.client.translate(["Run ``make setup`` now", "Plain prose"], "de")
payload = FakeLibreTranslate.requests[0]
self.assertEqual(len(payload["protected"]), len(payload["q"]))
self.assertEqual(payload["protected"], [['<x id="0"></x>'], []])
def test_rules_for_human_fuzzy_empty_and_damaged_entries(self):
catalog = merge(
build_template(
[
("human", "Kept by a person"),
("fuzzy", "Changed source"),
("empty", "New source"),
("lossy", "lossy {count} items"),
],
"core",
),
None,
"de",
)
catalog.get("Kept by a person", context="human").string = "Von Hand"
changed = catalog.get("Changed source", context="fuzzy")
changed.string = "Veraltet"
changed.flags.add("fuzzy")
todo = pending(catalog)
discarded = apply(
todo, self.client.translate([m.id for m in todo], "de").values
)
human = catalog.get("Kept by a person", context="human")
self.assertEqual((human.string, human.user_comments), ("Von Hand", []))
self.assertEqual(changed.string, "DE Changed source")
self.assertFalse(changed.fuzzy)
self.assertEqual(changed.user_comments, [MACHINE_TRANSLATION])
fresh = catalog.get("New source", context="empty")
self.assertEqual(
(fresh.string, fresh.user_comments),
("DE New source", [MACHINE_TRANSLATION]),
)
self.assertEqual(discarded, 1)
lossy = catalog.get("lossy {count} items", context="lossy")
self.assertFalse(lossy.string)
self.assertEqual(
lossy.user_comments,
[
f"{REFUSAL_PREFIX} {ENGINE} lost-token",
URL_SUPPRESSION,
f"{REJECTED_PREFIX} DE lossy items",
],
)
self.assertNotIn(lossy, pending(catalog))
class TestRefusalIsRemembered(unittest.TestCase):
"""A refusal is deterministic for an unchanged source, so it is recorded.
Without the mark every run spends a masking pass, a request and an
inference to reach the same verdict: 34 languages produced 185 requests
and no translation before this landed.
"""
def _entry(self, comments: list[str] | None = None):
catalog = Catalog(locale="de")
catalog.add("lossy {count} items", "", context="lossy")
message = catalog.get("lossy {count} items", context="lossy")
message.user_comments = list(comments or [])
return catalog, message
def test_a_refused_entry_is_not_offered_again(self):
catalog, message = self._entry([TRANSLATION_REFUSED])
self.assertNotIn(message, pending(catalog))
def test_an_entry_without_the_mark_stays_pending(self):
catalog, message = self._entry()
self.assertIn(message, pending(catalog))
def test_the_mark_is_written_once(self):
_, message = self._entry([TRANSLATION_REFUSED])
apply([message], [None])
self.assertEqual(message.user_comments, [TRANSLATION_REFUSED])
def test_a_translation_that_lands_clears_the_mark(self):
_, message = self._entry([TRANSLATION_REFUSED])
apply([message], ["verlustig {count} Einträge"])
self.assertEqual(message.user_comments, [MACHINE_TRANSLATION])
self.assertEqual(message.string, "verlustig {count} Einträge")
def test_retry_offers_the_entry_again(self):
catalog, message = self._entry([MACHINE_TRANSLATION, TRANSLATION_REFUSED])
cleared = retry(catalog)
self.assertEqual(cleared, 1)
self.assertEqual(message.user_comments, [MACHINE_TRANSLATION])
self.assertIn(message, pending(catalog))
def test_the_mark_names_the_engine_that_tried(self):
_, message = self._entry()
apply([message], [None])
self.assertEqual(
message.user_comments, [f"{REFUSAL_PREFIX} {ENGINE} damaged-span"]
)
def test_the_rejected_translation_stays_readable_beside_the_mark(self):
_, message = self._entry()
apply([message], [Rejected("verlustig Einträge", "protected-span")])
self.assertEqual(
message.user_comments,
[
(
f"{REFUSAL_PREFIX} {ENGINE} protected-span "
"lost=['{count}'] gained=[]"
),
URL_SUPPRESSION,
f"{REJECTED_PREFIX} verlustig Einträge",
],
)
self.assertFalse(message.string)
def test_the_catalog_carries_the_rejection_as_a_comment(self):
catalog, message = self._entry()
apply([message], [Rejected("verlustig Einträge", "protected-span")])
written = render(catalog).decode("utf-8")
self.assertIn(f"# {REJECTED_PREFIX} verlustig Einträge\n", written)
self.assertNotIn('verlustig Einträge"', written)
def test_a_mangled_link_in_a_rejection_is_not_probed(self):
catalog, message = self._entry()
apply(
[message],
[Rejected("Siehe [den Leitfaden]](https://gone.invalid/a)", "structure")],
)
lines = render(catalog).decode("utf-8").splitlines()
quoted = next(
number
for number, line in enumerate(lines, start=1)
if line.startswith(f"# {REJECTED_PREFIX}")
)
self.assertTrue(is_suppressed_at(lines, quoted, "url", mode="block-above"))
def test_a_rejected_translation_never_spills_over_its_comment_line(self):
_, message = self._entry()
apply([message], [Rejected("erste Zeile\nzweite Zeile", "structure")])
self.assertEqual(
message.user_comments[2],
f"{REJECTED_PREFIX} erste Zeile zweite Zeile",
)
def test_a_rejected_translation_carries_no_backslash(self):
_, message = self._entry()
apply([message], [Rejected("See [test\\_file.py](../x.py)", "structure")])
self.assertNotIn("\\", message.user_comments[2])
def test_a_second_rejection_replaces_the_first(self):
_, message = self._entry()
apply([message], [Rejected("erster Versuch", "structure")])
apply([message], [Rejected("zweiter Versuch", "stutter")])
self.assertEqual(
message.user_comments,
[
f"{REFUSAL_PREFIX} {ENGINE} stutter",
URL_SUPPRESSION,
f"{REJECTED_PREFIX} zweiter Versuch",
],
)
def test_a_translation_that_lands_clears_the_quoted_rejection(self):
_, message = self._entry()
apply([message], [Rejected("verlustig Einträge", "protected-span")])
apply([message], ["verlustig {count} Einträge"])
self.assertEqual(message.user_comments, [MACHINE_TRANSLATION])
def test_a_suppression_the_source_carries_survives_the_cleanup(self):
"""Sphinx hands a source comment to the catalog; it is not ours to drop."""
_, message = self._entry([URL_SUPPRESSION])
apply([message], [Rejected("verlustig Einträge", "protected-span")])
apply([message], ["verlustig {count} Einträge"])
self.assertEqual(message.user_comments, [URL_SUPPRESSION, MACHINE_TRANSLATION])
def test_a_refusal_from_another_engine_counts_and_clears(self):
"""The mark is matched by prefix, not by the engine this run uses."""
catalog, message = self._entry([f"{REFUSAL_PREFIX} someone-else damaged-span"])
self.assertNotIn(message, pending(catalog))
self.assertEqual(retry(catalog), 1)
self.assertIn(message, pending(catalog))
class TestDamagedMarkup(unittest.TestCase):
"""A bracket added or dropped beside a protected span leaves every span intact."""
def _catalog(self, source: str, translation: str):
catalog = Catalog(locale="de")
catalog.add(source, translation)
return catalog
def test_an_added_bracket_is_damage(self):
catalog = self._catalog(
"See [the guide](docs/guide.md) first.",
"Siehe [den Leitfaden]](docs/guide.md) zuerst.",
)
self.assertEqual(len(damaged(catalog)), 1)
def test_a_dropped_bracket_is_damage(self):
catalog = self._catalog(
"![Logo](assets/img/logo.png)",
"!Logo](assets/img/logo.png)",
)
self.assertEqual(len(damaged(catalog)), 1)
def test_an_intact_translation_is_not_damage(self):
catalog = self._catalog(
"See [the guide](docs/guide.md) first.",
"Siehe [den Leitfaden](docs/guide.md) zuerst.",
)
self.assertEqual(damaged(catalog), [])
class TestDuplicateTranslation(unittest.TestCase):
LONG_A = "Central identity provider issuing tokens for every service"
LONG_B = "Issue and project tracking platform for software teams today"
SHORT_A = "Save the file"
SHORT_B = "Open the file"
def _messages(self, *sources: str) -> list:
catalog = Catalog(locale="de")
for source in sources:
catalog.add(source, "")
return [message for message in catalog if message.id]
def test_a_second_long_source_may_not_reuse_a_translation(self) -> None:
messages = self._messages(self.LONG_A, self.LONG_B)
discarded = apply(
messages,
["Beschreibung eines Dienstes", "Beschreibung eines Dienstes"],
set(),
)
self.assertEqual(discarded, 1)
self.assertEqual(str(messages[1].string), "")
def test_short_sources_may_share_one(self) -> None:
messages = self._messages(self.SHORT_A, self.SHORT_B)
discarded = apply(messages, ["Speichern", "Speichern"], set())
self.assertEqual(discarded, 0)
self.assertEqual(str(messages[1].string), "Speichern")
def test_without_the_set_the_check_is_skipped(self) -> None:
messages = self._messages(self.LONG_A, self.LONG_B)
self.assertEqual(apply(messages, ["Gleich", "Gleich"], None), 0)
def test_what_the_catalog_already_spends_is_seeded(self) -> None:
catalog = Catalog(locale="de")
catalog.add(self.LONG_A, "Schon vergeben")
catalog.add(self.SHORT_A, "Kurz")
self.assertEqual(taken_translations(catalog), {"Schon vergeben"})
class TestOverSharedTranslation(unittest.TestCase):
LONG_A = "Central identity provider issuing tokens for every service"
LONG_B = "Issue and project tracking platform for software teams today"
LONG_C = "Reverse proxy routing and balancing traffic to the backends"
SHORT_A = "Save the file"
SHORT_B = "Open the file"
SHORT_C = "Close the file"
SHARED = "Beschreibung eines Dienstes"
def _catalog(self, sources: list, translation: str, *, machine: bool = True):
catalog = Catalog(locale="de")
for source in sources:
catalog.add(source, translation)
for message in catalog:
if message.id and machine:
message.user_comments = [MACHINE_TRANSLATION]
return catalog
def _ids(self, catalog) -> list:
return sorted(str(message.id) for message in over_shared(catalog))
def test_every_sharer_beyond_the_limit_is_returned(self) -> None:
catalog = self._catalog([self.LONG_A, self.LONG_B, self.LONG_C], self.SHARED)
self.assertEqual(
self._ids(catalog), sorted([self.LONG_A, self.LONG_B, self.LONG_C])
)
def test_two_sharers_stay(self) -> None:
catalog = self._catalog([self.LONG_A, self.LONG_B], self.SHARED)
self.assertEqual(over_shared(catalog), [])
def test_short_sources_may_share_one(self) -> None:
catalog = self._catalog([self.SHORT_A, self.SHORT_B, self.SHORT_C], "Datei")
self.assertEqual(over_shared(catalog), [])
def test_a_hand_translation_is_left_alone(self) -> None:
catalog = self._catalog(
[self.LONG_A, self.LONG_B, self.LONG_C], self.SHARED, machine=False
)
self.assertEqual(over_shared(catalog), [])
class EngineAwareGateway(BaseHTTPRequestHandler):
"""Loses a token until the engine that loses it is excluded."""
requests: ClassVar[list[dict]] = []
def log_message(self, *args):
return
def _reply(self, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
self._reply([{"code": "en", "targets": ["de"]}])
def do_POST(self):
payload = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
EngineAwareGateway.requests.append(payload)
spent = "alpha" in (payload.get("exclude") or [])
engine = "beta" if spent else "alpha"
texts = payload["q"]
self._reply(
{
"translatedText": [
f"DE {text}" if spent else TOKEN.sub("", f"DE {text}")
for text in texts
],
"engine": [engine] * len(texts),
}
)
class TestAnotherEngineIsAskedAfterARejection(unittest.TestCase):
SOURCE: ClassVar[str] = "Run ``make setup`` now"
@classmethod
def setUpClass(cls):
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), EngineAwareGateway)
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
cls.client = LibreTranslate(f"http://127.0.0.1:{cls.server.server_port}", 1)
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.server.server_close()
def setUp(self):
EngineAwareGateway.requests.clear()
def test_the_rejected_answer_is_replaced_by_the_next_engines(self) -> None:
outcome = self.client.translate([self.SOURCE], "de")
self.assertEqual(outcome.values, ["DE Run ``make setup`` now"])
self.assertEqual(outcome.damaged, 0)
def test_the_engine_that_failed_is_named_in_the_second_request(self) -> None:
self.client.translate([self.SOURCE], "de")
self.assertEqual(
[request["exclude"] for request in EngineAwareGateway.requests],
[[], ["alpha"]],
)
if __name__ == "__main__":
unittest.main()