mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
One string that every engine refused or mangled raised out of the batch route's gather, so the gateway answered the whole batch with a 422 and the client lost the other nineteen translations. Gateway.translate_each now owns the bounded fan-out; with partial=true it answers (None, None) for a string that ran into a GatewayError and keeps the rest. Non-gateway exceptions still raise, and a NoBackendError on every string still fails the batch as the outage it is. The i18n client asks for partial batches and counts a null answer as a refused entry with its reason, so the re-ask and damage checks continue per string. A caller that does not send the flag gets the old behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
265 lines
10 KiB
Python
265 lines
10 KiB
Python
import os
|
|
import subprocess
|
|
import unittest
|
|
import unittest.mock as mock
|
|
import urllib.error
|
|
from pathlib import Path
|
|
from tempfile import TemporaryDirectory
|
|
|
|
from utils.i18n.libretranslate import (
|
|
LibreTranslate,
|
|
accelerated,
|
|
deploying,
|
|
server,
|
|
)
|
|
|
|
|
|
class TestParallelDeployIsRefused(unittest.TestCase):
|
|
def test_a_running_deploy_stops_the_translation(self) -> None:
|
|
with (
|
|
mock.patch("utils.i18n.libretranslate.deploying", return_value=True),
|
|
mock.patch("utils.i18n.libretranslate.deploy") as started,
|
|
self.assertRaises(RuntimeError),
|
|
server(Path("/repo")),
|
|
):
|
|
pass
|
|
started.assert_not_called()
|
|
|
|
def test_a_requested_redeploy_replaces_the_gateway_that_answers(self) -> None:
|
|
"""``make i18n-translate redeploy=true`` reaches here as a keyword.
|
|
|
|
The parameter went missing once while the caller kept passing it, so
|
|
every run raised TypeError before translating a line, and the only
|
|
symptom was an empty log.
|
|
"""
|
|
with (
|
|
mock.patch("utils.i18n.libretranslate.deploying", return_value=False),
|
|
mock.patch("utils.i18n.libretranslate.deployed") as probed,
|
|
mock.patch(
|
|
"utils.i18n.libretranslate.deploy", return_value="http://rebuilt"
|
|
) as started,
|
|
server(Path("/repo"), redeploy=True) as url,
|
|
):
|
|
self.assertEqual(url, "http://rebuilt")
|
|
probed.assert_not_called()
|
|
started.assert_called_once()
|
|
|
|
def test_a_live_pid_that_is_not_the_router_reports_no_deploy(self) -> None:
|
|
"""This process is alive and is not a deploy.
|
|
|
|
The guard used to answer yes to any live pid. Every sandboxed command
|
|
runs in its own pid namespace where the low numbers belong to that
|
|
sandbox, so a stale file naming pid 8 blocked every translation run.
|
|
"""
|
|
self.assertFalse(deploying(self._root_holding(os.getpid())))
|
|
|
|
def test_a_live_router_pid_reports_a_deploy(self) -> None:
|
|
root = Path(self._tmp.name)
|
|
router = root / "scripts" / "deploy" / "main.sh"
|
|
router.parent.mkdir(parents=True, exist_ok=True)
|
|
router.write_text("echo started\nread -r _\n", encoding="utf-8")
|
|
held = subprocess.Popen(
|
|
["bash", str(router)],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
)
|
|
self.addCleanup(held.kill)
|
|
self.addCleanup(held.stdout.close)
|
|
self.assertEqual(
|
|
held.stdout.readline(),
|
|
b"started\n",
|
|
"the router has to reach its first line before its command line is read; "
|
|
"/proc/<pid>/cmdline is empty until the child execs, which it is for "
|
|
"376 of 400 reads taken right after Popen returns",
|
|
)
|
|
|
|
self.assertTrue(deploying(self._root_holding(held.pid)))
|
|
|
|
def test_a_stale_pid_file_reports_no_deploy(self) -> None:
|
|
root = self._root_holding(4242)
|
|
with mock.patch("os.kill", side_effect=ProcessLookupError):
|
|
self.assertFalse(deploying(root))
|
|
|
|
def test_a_missing_pid_file_reports_no_deploy(self) -> None:
|
|
self.assertFalse(deploying(Path(self._tmp.name)))
|
|
|
|
def _root_holding(self, pid: int) -> Path:
|
|
root = Path(self._tmp.name)
|
|
(root / "build").mkdir(exist_ok=True)
|
|
(root / "build" / "deploy.pid").write_text(str(pid))
|
|
return root
|
|
|
|
def setUp(self) -> None:
|
|
self._tmp = TemporaryDirectory()
|
|
self.addCleanup(self._tmp.cleanup)
|
|
|
|
|
|
class TestTranslateFailures(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.client = LibreTranslate("http://libretranslate", 1)
|
|
|
|
def test_an_unreachable_server_stops_the_run(self) -> None:
|
|
refused = urllib.error.URLError(ConnectionRefusedError())
|
|
with (
|
|
mock.patch.object(self.client, "_call", side_effect=refused),
|
|
self.assertRaises(OSError),
|
|
):
|
|
self.client.translate(["Hello", "World"], "de")
|
|
|
|
def test_a_rejected_request_discards_only_its_texts(self) -> None:
|
|
rejected = urllib.error.HTTPError(
|
|
"http://libretranslate", 500, "boom", {}, None
|
|
)
|
|
with (
|
|
mock.patch("utils.i18n.libretranslate.time.sleep"),
|
|
mock.patch.object(self.client, "_call", side_effect=rejected),
|
|
):
|
|
self.assertEqual(
|
|
self.client.translate(["Hello", "World"], "de").values, [None, None]
|
|
)
|
|
|
|
|
|
class TestPartialBatches(unittest.TestCase):
|
|
"""The gateway answers null for one string it refused and keeps the rest."""
|
|
|
|
def setUp(self) -> None:
|
|
self.client = LibreTranslate("http://libretranslate", 1)
|
|
|
|
def test_a_null_answer_discards_only_its_own_string(self) -> None:
|
|
answer = {"translatedText": ["Hallo", None], "engine": ["alpha", None]}
|
|
with mock.patch.object(self.client, "_call", return_value=answer) as call:
|
|
outcome = self.client.translate(["Hello", "Tree house"], "de")
|
|
|
|
self.assertEqual(outcome.values, ["Hallo", None])
|
|
self.assertEqual(outcome.refused, 1)
|
|
self.assertEqual(outcome.engines, ("alpha", ""))
|
|
self.assertIn("null", outcome.refusal)
|
|
self.assertTrue(call.call_args.args[1]["partial"])
|
|
|
|
|
|
class TestRefusalsAreRetried(unittest.TestCase):
|
|
"""A server that buckles under the lanes must not cost entries for good."""
|
|
|
|
def setUp(self) -> None:
|
|
self.client = LibreTranslate("http://libretranslate", 1)
|
|
self.refusal = urllib.error.HTTPError(
|
|
"http://libretranslate", 500, "boom", {}, None
|
|
)
|
|
|
|
def test_a_transient_refusal_is_retried_until_it_succeeds(self) -> None:
|
|
answers = [self.refusal, self.refusal, {"translatedText": ["Hallo"]}]
|
|
with (
|
|
mock.patch("utils.i18n.libretranslate.time.sleep"),
|
|
mock.patch.object(self.client, "_call", side_effect=answers),
|
|
):
|
|
outcome = self.client.translate(["Hello"], "de")
|
|
|
|
self.assertEqual(outcome.values, ["Hallo"])
|
|
self.assertEqual(outcome.refused, 2)
|
|
self.assertEqual(outcome.damaged, 0)
|
|
|
|
def test_a_permanent_refusal_is_counted_and_named(self) -> None:
|
|
with (
|
|
mock.patch("utils.i18n.libretranslate.time.sleep"),
|
|
mock.patch.object(self.client, "_call", side_effect=self.refusal),
|
|
):
|
|
outcome = self.client.translate(["Hello"], "de")
|
|
|
|
self.assertEqual(outcome.values, [None])
|
|
self.assertTrue(outcome.refused)
|
|
self.assertIn("HTTPError", outcome.refusal)
|
|
|
|
def test_a_catalog_never_inherits_another_lane_s_counts(self) -> None:
|
|
answers = [
|
|
self.refusal,
|
|
{"translatedText": ["Hallo"]},
|
|
{"translatedText": ["Hi"]},
|
|
]
|
|
with (
|
|
mock.patch("utils.i18n.libretranslate.time.sleep"),
|
|
mock.patch.object(self.client, "_call", side_effect=answers),
|
|
):
|
|
first = self.client.translate(["Hello"], "de")
|
|
second = self.client.translate(["Hello"], "de")
|
|
|
|
self.assertEqual(first.refused, 1)
|
|
self.assertEqual(second.refused, 0)
|
|
|
|
|
|
class TestScriptedTargetCodes(unittest.TestCase):
|
|
"""LibreTranslate offers Chinese as zh-Hans/zh-Hant, never as bare zh."""
|
|
|
|
def setUp(self) -> None:
|
|
self.client = LibreTranslate("http://libretranslate", 1)
|
|
self.served = [{"code": "en", "targets": ["de", "zh-Hans", "zh-Hant"]}]
|
|
|
|
def test_the_catalog_code_is_sent_as_the_code_the_server_serves(self) -> None:
|
|
with mock.patch.object(
|
|
self.client, "_call", return_value={"translatedText": ["你好"]}
|
|
) as call:
|
|
self.client.translate(["Hello"], "zh")
|
|
|
|
self.assertEqual(call.call_args.args[1]["target"], "zh-Hans")
|
|
|
|
def test_waiting_accepts_a_server_that_only_names_the_script(self) -> None:
|
|
with mock.patch.object(self.client, "_call", return_value=self.served):
|
|
self.client.wait(["zh", "de"], timeout=0)
|
|
|
|
def test_waiting_still_reports_a_target_nobody_serves(self) -> None:
|
|
with (
|
|
mock.patch.object(self.client, "_call", return_value=self.served),
|
|
self.assertRaises(TimeoutError) as raised,
|
|
):
|
|
self.client.wait(["fr"], timeout=0)
|
|
|
|
self.assertIn("fr", str(raised.exception))
|
|
|
|
|
|
class TestAccelerated(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
patched = mock.patch.dict(os.environ, {}, clear=False)
|
|
patched.start()
|
|
os.environ.pop("INFINITO_GPU_COUNT", None)
|
|
self.addCleanup(patched.stop)
|
|
|
|
def _runtimes(self, stdout: str) -> mock.Mock:
|
|
return mock.Mock(spec=subprocess.CompletedProcess, stdout=stdout)
|
|
|
|
def test_the_reserved_count_overrides_the_daemon(self) -> None:
|
|
listed = self._runtimes('{"nvidia":{"path":"nvidia-container-runtime"}}')
|
|
os.environ["INFINITO_GPU_COUNT"] = "0"
|
|
|
|
with mock.patch("subprocess.run", return_value=listed):
|
|
self.assertFalse(accelerated())
|
|
|
|
def test_a_reserved_count_enables_the_gpu_without_asking_the_daemon(self) -> None:
|
|
os.environ["INFINITO_GPU_COUNT"] = "all"
|
|
|
|
with mock.patch("subprocess.run", side_effect=AssertionError("asked docker")):
|
|
self.assertTrue(accelerated())
|
|
|
|
def test_an_empty_count_still_asks_the_daemon(self) -> None:
|
|
listed = self._runtimes('{"nvidia":{"path":"nvidia-container-runtime"}}')
|
|
os.environ["INFINITO_GPU_COUNT"] = " "
|
|
|
|
with mock.patch("subprocess.run", return_value=listed):
|
|
self.assertTrue(accelerated())
|
|
|
|
def test_a_registered_nvidia_runtime_enables_the_gpu(self) -> None:
|
|
listed = self._runtimes('{"nvidia":{"path":"nvidia-container-runtime"}}')
|
|
with mock.patch("subprocess.run", return_value=listed):
|
|
self.assertTrue(accelerated())
|
|
|
|
def test_a_host_without_the_toolkit_falls_back_to_the_cpu(self) -> None:
|
|
listed = self._runtimes('{"runc":{"path":"runc"}}')
|
|
with mock.patch("subprocess.run", return_value=listed):
|
|
self.assertFalse(accelerated())
|
|
|
|
def test_an_unavailable_docker_falls_back_to_the_cpu(self) -> None:
|
|
with mock.patch("subprocess.run", return_value=self._runtimes("")):
|
|
self.assertFalse(accelerated())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|