fix(engines): engine installs never inherit VoiceStudio's own uv config
The backend runs inside the app's tree, and the uv processes it starts for engine installs had no working directory of their own. uv therefore discovered VoiceStudio's pyproject.toml and applied its [tool.uv] constraint-dependencies, torch==2.8.0 among them, to each engine's venv. Resolved that way, MOSS-TTS-v1.5 (torch==2.9.1+cu128) and Confucius4 (torch==2.7.0) are unsatisfiable, so their one-click installs and bootstraps could never succeed. An engine that pins no torch got the app's instead of its own, so its venv was not really independent. uv_subprocess_env, which every one-click install step and every engine bootstrap already uses, now always sets UV_NO_CONFIG=1. It used to return None in two cases and let the call inherit the environment; it now always returns a copy. Region and custom mirrors still apply, because they reach uv as UV_INDEX_URL. The app's own `uv sync` and the translation installer, which install into the app's environment on purpose, are unchanged.
This commit is contained in:
@@ -9,6 +9,7 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
## [Unreleased]
|
||||
|
||||
**Highlights**
|
||||
- One-click engine installs no longer inherit VoiceStudio's own PyTorch pin, which made MOSS-TTS-v1.5 and Confucius4 impossible to install (#NOCFGPR)
|
||||
- MOSS-TTS-v1.5, Confucius4-TTS, dots.tts, Supertonic-3 and PocketTTS install in one click, each in its own environment, so switching engines and back never breaks a working one (#2015, #2016)
|
||||
- A pronunciation entry that is stored but not applied yet says so, instead of looking like it did not match (#1949)
|
||||
- A bare 500 report now names the backend error class, so two unrelated faults stop filing the same issue (#1773)
|
||||
|
||||
@@ -549,13 +549,20 @@ def _default_uv_cache_root() -> Path:
|
||||
return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "uv"
|
||||
|
||||
|
||||
def uv_subprocess_env(cache_parent: Path) -> "dict[str, str] | None":
|
||||
def uv_subprocess_env(cache_parent: Path) -> "dict[str, str]":
|
||||
"""Environment for ``uv`` subprocesses that install into *cache_parent*'s volume.
|
||||
|
||||
Returns ``None`` (inherit the parent environment untouched) when uv's
|
||||
default cache already shares a volume with *cache_parent* or the user
|
||||
pinned both variables themselves. Otherwise returns a copy of
|
||||
``os.environ`` with the *unset* one(s) of ``UV_CACHE_DIR`` /
|
||||
Always a copy of ``os.environ`` with ``UV_NO_CONFIG=1``: an engine's
|
||||
install resolves its own requirements, never VoiceStudio's. The backend
|
||||
runs inside the app's tree, so uv would otherwise discover the app's
|
||||
``pyproject.toml`` and apply its ``[tool.uv] constraint-dependencies``
|
||||
(``torch==2.8.0``) to the engine's venv. An engine pinning another torch
|
||||
(MOSS-TTS-v1.5, Confucius4) could then never resolve, and one that pins
|
||||
none got the app's torch instead of its own. Mirrors still apply: they
|
||||
arrive as ``UV_INDEX_URL``, an environment variable, not a config file.
|
||||
|
||||
When uv's default cache is on another volume than *cache_parent*, the
|
||||
copy also places the *unset* one(s) of ``UV_CACHE_DIR`` /
|
||||
``UV_PYTHON_INSTALL_DIR`` placed inside *cache_parent*, so downloads, the
|
||||
unpacked wheel cache, managed Pythons, and the venv all stay on the
|
||||
target volume — and same-volume hardlink installs work again. The two
|
||||
@@ -568,17 +575,15 @@ def uv_subprocess_env(cache_parent: Path) -> "dict[str, str] | None":
|
||||
pass the directory that should hold the shared ``.uv-cache`` — typically
|
||||
the common parent of the engine venvs on that volume.
|
||||
"""
|
||||
if _same_volume(cache_parent, _default_uv_cache_root()):
|
||||
return None
|
||||
env = dict(os.environ)
|
||||
overrode = False
|
||||
env["UV_NO_CONFIG"] = "1"
|
||||
if _same_volume(cache_parent, _default_uv_cache_root()):
|
||||
return env
|
||||
if not env.get("UV_CACHE_DIR"): # explicit user choice always wins
|
||||
env["UV_CACHE_DIR"] = str(Path(cache_parent) / ".uv-cache")
|
||||
overrode = True
|
||||
if not env.get("UV_PYTHON_INSTALL_DIR"):
|
||||
env["UV_PYTHON_INSTALL_DIR"] = str(Path(cache_parent) / ".uv-python")
|
||||
overrode = True
|
||||
return env if overrode else None
|
||||
return env
|
||||
|
||||
|
||||
# ── Disk preflight ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -73,12 +73,13 @@ def test_same_volume_false_across_devices(tmp_path, monkeypatch):
|
||||
|
||||
# ── uv_subprocess_env ──────────────────────────────────────────────────────
|
||||
|
||||
def test_uv_env_is_none_on_the_default_cache_volume(tmp_path, monkeypatch):
|
||||
"""Same volume as uv's default cache → inherit env untouched (default
|
||||
installs stay byte-identical)."""
|
||||
def test_uv_env_moves_no_cache_on_the_default_cache_volume(tmp_path, monkeypatch):
|
||||
"""Same volume as uv's default cache → the cache stays where uv puts it."""
|
||||
monkeypatch.delenv("UV_CACHE_DIR", raising=False)
|
||||
monkeypatch.delenv("UV_PYTHON_INSTALL_DIR", raising=False)
|
||||
monkeypatch.setattr(si, "_default_uv_cache_root", lambda: tmp_path / "uv")
|
||||
assert si.uv_subprocess_env(tmp_path / "engines") is None
|
||||
env = si.uv_subprocess_env(tmp_path / "engines")
|
||||
assert "UV_CACHE_DIR" not in env and "UV_PYTHON_INSTALL_DIR" not in env
|
||||
|
||||
|
||||
def test_uv_env_colocates_cache_on_a_foreign_volume(tmp_path, monkeypatch):
|
||||
@@ -111,12 +112,14 @@ def test_uv_env_respects_user_pinned_cache_dir(tmp_path, monkeypatch):
|
||||
assert env["UV_PYTHON_INSTALL_DIR"] == str(tmp_path / "engines" / ".uv-python")
|
||||
|
||||
|
||||
def test_uv_env_is_none_when_both_vars_pinned(tmp_path, monkeypatch):
|
||||
"""Both pinned → nothing left to override → inherit env untouched."""
|
||||
def test_uv_env_keeps_both_vars_when_both_pinned(tmp_path, monkeypatch):
|
||||
"""Both pinned → the user's values stand."""
|
||||
monkeypatch.setenv("UV_CACHE_DIR", str(tmp_path / "my-cache"))
|
||||
monkeypatch.setenv("UV_PYTHON_INSTALL_DIR", str(tmp_path / "my-pythons"))
|
||||
monkeypatch.setattr(si, "_same_volume", lambda a, b: False)
|
||||
assert si.uv_subprocess_env(tmp_path / "engines") is None
|
||||
env = si.uv_subprocess_env(tmp_path / "engines")
|
||||
assert env["UV_CACHE_DIR"] == str(tmp_path / "my-cache")
|
||||
assert env["UV_PYTHON_INSTALL_DIR"] == str(tmp_path / "my-pythons")
|
||||
|
||||
|
||||
def test_uv_env_respects_user_pinned_python_dir(tmp_path, monkeypatch):
|
||||
@@ -244,3 +247,74 @@ def test_every_bootstrap_uv_call_passes_env(path):
|
||||
"subprocess calls in _bootstrap_engines_venv without env= "
|
||||
f"(cross-drive uv cache class): {offenders}"
|
||||
)
|
||||
|
||||
|
||||
# ── Engine installs never inherit the app's uv config ─────────────────────
|
||||
#
|
||||
# The backend runs inside VoiceStudio's tree, so a uv process it starts
|
||||
# discovers the app's pyproject.toml and applies its [tool.uv]
|
||||
# constraint-dependencies (torch==2.8.0). Resolved that way,
|
||||
# torch==2.9.1+cu128 (MOSS-TTS-v1.5) and torch==2.7.0 (Confucius4) are
|
||||
# unsatisfiable. Every engine install and bootstrap gets its environment from
|
||||
# uv_subprocess_env; these pin that it always opts out of config discovery.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("same_volume", [True, False])
|
||||
@pytest.mark.parametrize("pinned", [(), ("UV_CACHE_DIR",), ("UV_CACHE_DIR", "UV_PYTHON_INSTALL_DIR")])
|
||||
def test_uv_env_always_ignores_the_apps_uv_config(tmp_path, monkeypatch, same_volume, pinned):
|
||||
for var in ("UV_CACHE_DIR", "UV_PYTHON_INSTALL_DIR"):
|
||||
if var in pinned:
|
||||
monkeypatch.setenv(var, str(tmp_path / var.lower()))
|
||||
else:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
monkeypatch.setattr(si, "_same_volume", lambda a, b: same_volume)
|
||||
env = si.uv_subprocess_env(tmp_path / "engines")
|
||||
assert env["UV_NO_CONFIG"] == "1"
|
||||
|
||||
|
||||
def test_uv_env_keeps_the_mirror_setting(tmp_path, monkeypatch):
|
||||
"""Region and custom mirrors reach uv as UV_INDEX_URL, which config opt-out
|
||||
leaves alone."""
|
||||
monkeypatch.setenv("UV_INDEX_URL", "https://mirror.example/simple")
|
||||
env = si.uv_subprocess_env(tmp_path / "engines")
|
||||
assert env["UV_INDEX_URL"] == "https://mirror.example/simple"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"module",
|
||||
[
|
||||
"engines.indextts.bootstrap",
|
||||
"engines.moss_tts_v15.bootstrap",
|
||||
"engines.confucius4.bootstrap",
|
||||
"engines.dots_tts.bootstrap",
|
||||
],
|
||||
)
|
||||
def test_every_engine_bootstrap_ignores_the_apps_uv_config(module):
|
||||
import importlib
|
||||
|
||||
env = importlib.import_module(module)._uv_env()
|
||||
assert env is not None and env["UV_NO_CONFIG"] == "1", module
|
||||
|
||||
|
||||
def test_one_click_install_steps_ignore_the_apps_uv_config(tmp_path, monkeypatch):
|
||||
envs = []
|
||||
|
||||
def capture(job, argv, *, timeout, env=None):
|
||||
envs.append((argv[1], env))
|
||||
if argv[1] == "venv":
|
||||
py = si._venv_python(Path(argv[2]))
|
||||
py.parent.mkdir(parents=True, exist_ok=True)
|
||||
py.write_text("#!fake\n")
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(si, "DATA_DIR", str(tmp_path / "data"))
|
||||
monkeypatch.setattr(si, "_locate_uv", lambda: "/fake/uv")
|
||||
monkeypatch.setattr(si, "_run_logged", capture)
|
||||
spec = si.get_spec("indextts2")
|
||||
si.managed_checkout(spec).mkdir(parents=True)
|
||||
job = si._new_job(spec.engine_id)
|
||||
si._step_create_venv(spec, job)
|
||||
si._step_install_deps(spec, job)
|
||||
assert [step for step, _ in envs] == ["venv", "pip"]
|
||||
for step, env in envs:
|
||||
assert env is not None and env["UV_NO_CONFIG"] == "1", step
|
||||
|
||||
Reference in New Issue
Block a user