Files
VoiceStudio/tests/test_translation_uninstall_guard.py
Palash Debnath c5f32d020a fix(translation): uninstalling an engine never removes what others need
The translation uninstall route runs `pip uninstall -y <package>` on the
app's own environment. Two entries made that break something else:

- The LLM engine's package is openai, a core dependency that Settings →
  LLM Providers also uses. Unlike Argos, it was not marked builtin, so
  uninstalling that engine removed it from the app.
- Google, DeepL, Microsoft and MyMemory share deep_translator. Uninstalling
  any one removed it for all four.

The route now asks uninstall_blocker() first. It refuses (400) a package
VoiceStudio itself depends on, read from the installed package metadata so
there is no second list to keep in step, and refuses (409) a package
another engine shares, naming the engines that would stop working. The
openai entry is marked builtin as well, and a test requires every entry
backed by an app dependency to be.
2026-09-10 08:26:53 -07:00

77 lines
2.8 KiB
Python

"""Uninstalling a translation engine must never break the app or another engine (#2019).
`pip uninstall` acts on VoiceStudio's own environment. The LLM engine's package
(openai) is a core dependency of the app, and deep_translator backs four online
engines at once, so removing either through one engine broke something else.
"""
import asyncio
import pytest
from fastapi import HTTPException
def _router():
# Resolved at call time: other suites purge `services` from sys.modules, so
# the module the router holds is the one to patch.
from api.routers import engines as engines_router
return engines_router, engines_router.translation_engines
def _uninstall(monkeypatch, engine_id, *, pip=None):
engines_router, te = _router()
monkeypatch.setattr(te, "is_frozen", lambda: False)
async def no_pip(args, timeout=600.0):
pytest.fail(f"pip ran: {args}")
monkeypatch.setattr(te, "run_pip", pip or no_pip)
return asyncio.run(engines_router.uninstall_translation_engine(engine_id))
def test_every_engine_backed_by_an_app_dependency_is_builtin():
_, te = _router()
core = te._app_dependency_names()
assert "openai" in core and "argostranslate" in core # the metadata is readable here
for engine_id, entry in te.REGISTRY.items():
pkg = entry.get("pip_package")
if pkg and te._normalize(pkg) in core:
assert entry.get("builtin"), engine_id
def test_a_package_the_app_depends_on_is_never_uninstalled(monkeypatch):
# Even through an entry nobody marked builtin.
_, te = _router()
monkeypatch.setitem(te.REGISTRY, "x-llm", {"id": "x-llm", "display_name": "X", "pip_package": "openai"})
with pytest.raises(HTTPException) as err:
_uninstall(monkeypatch, "x-llm")
assert err.value.status_code == 400
assert "VoiceStudio itself" in err.value.detail
def test_a_package_other_engines_share_is_never_uninstalled(monkeypatch):
with pytest.raises(HTTPException) as err:
_uninstall(monkeypatch, "google")
assert err.value.status_code == 409
for name in ("DeepL", "Microsoft", "MyMemory"):
assert name in err.value.detail
def test_names_compare_in_normalized_form():
_, te = _router()
assert te._normalize("deep_translator") == te._normalize("Deep-Translator") == "deep-translator"
def test_an_unshared_optional_package_can_still_be_uninstalled(monkeypatch):
_, te = _router()
monkeypatch.setitem(te.REGISTRY, "solo", {"id": "solo", "display_name": "Solo", "pip_package": "solo-translator"})
ran = []
async def fake_pip(args, timeout=600.0):
ran.append(args)
return 0, "ok"
res = _uninstall(monkeypatch, "solo", pip=fake_pip)
assert res["status"] == "uninstalled"
assert ran == [["uninstall", "-y", "solo-translator"]]