feat(engines): Supertonic-3 and PocketTTS install into their own venvs

Both engines ran with the app's interpreter, installed as optional extras
into the app's own environment (`uv sync --extra`). They now get one-click
installs like the sidecar engines: a PyPI-only spec (no source to fetch)
creates DATA_DIR/engines/<id>/.venv and installs the app's own pinned wheel
there, so nothing they install can touch the app or another engine.

Each engine prefers its own venv and falls back to the app's interpreter,
so an existing `uv sync --extra` install keeps working and is never
provisioned over: the spec counts a package found in the app environment
as installed.

PocketTTS installs from PyTorch's CPU index: it never uses a GPU, and
PyPI's Linux torch pulls ~15 NVIDIA packages. It stays unoffered on Intel
Macs, where no usable torch exists.

Supertonic's sidecar loads its constants by path when the revision env var
is absent, instead of importing the engines package, whose __init__
imports the app backend that its own venv does not have.

The Install button is hidden once only the license review stands between
the user and the engine. The installer tests' autouse fixture now removes
every spec's env var on teardown: a bare delenv of an unset var restored
nothing, and a persisted path leaked into later suites.
This commit is contained in:
Palash Debnath
2026-09-10 07:44:42 -07:00
parent faa3d39836
commit c079b721ed
13 changed files with 374 additions and 41 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ the frozen-backend fallback mirror it for their toolchains.
## [Unreleased]
**Highlights**
- MOSS-TTS-v1.5, Confucius4-TTS and dots.tts install in one click, each in its own environment, so switching engines and back never breaks a working one (#2015)
- 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)
- A rejected dubbing source language now names the code it rejected (#1960)
+13
View File
@@ -28,3 +28,16 @@ UV_PIP_CU128_ARGS: tuple[str, ...] = (
"--index-strategy",
"unsafe-best-match",
)
PYTORCH_CPU_INDEX_URL = "https://download.pytorch.org/whl/cpu"
# For an engine that runs torch only on the CPU (PocketTTS). On Linux, PyPI's
# torch is the CUDA build and pulls ~15 NVIDIA packages the engine never uses;
# this index serves `+cpu` builds for Linux and Windows and the regular build
# for macOS.
UV_PIP_CPU_ARGS: tuple[str, ...] = (
"--extra-index-url",
PYTORCH_CPU_INDEX_URL,
"--index-strategy",
"unsafe-best-match",
)
+24 -14
View File
@@ -48,6 +48,15 @@ from services.subprocess_backend import SubprocessBackend
logger = logging.getLogger("omnivoice.engines.pockettts")
_VENV_ENV_VAR = "OMNIVOICE_POCKETTTS_DIR"
def _own_venv_python() -> "Path | None":
"""The venv the one-click installer made for this engine, if any."""
from services.sidecar_install import engine_venv_python
return engine_venv_python(_VENV_ENV_VAR)
if TYPE_CHECKING:
import torch # noqa: F401
@@ -121,16 +130,17 @@ class PocketTTSBackend(SubprocessBackend):
def is_available(cls) -> tuple[bool, str]:
if platform_error := cls._platform_error():
return False, platform_error
# Optional-dep gate: the pocket-tts wheel is installed only when the user
# opted in. The interpreter is the parent's own (sys.executable), so
# there is no separate venv to validate.
try:
import pocket_tts # type: ignore[import-not-found] # noqa: F401
except Exception as e:
return False, (
f"pocket_tts package not installed or failed to import ({e}). "
f"Enable in Settings -> Engines (uv sync --extra pockettts)."
)
# Installed either into its own venv by the one-click installer, which
# verified `import pocket_tts` there before saving the path, or into the
# app's environment by `uv sync --extra pockettts`.
if _own_venv_python() is None:
try:
import pocket_tts # type: ignore[import-not-found] # noqa: F401
except Exception as e:
return False, (
f"pocket_tts package not installed or failed to import ({e}). "
"Install it from Model Catalogue → Engines."
)
# The model repository has an additional gated-access agreement and
# prohibited-use conditions beyond its CC-BY-4.0 license. Keep first
@@ -145,10 +155,10 @@ class PocketTTSBackend(SubprocessBackend):
@classmethod
def venv_python(cls) -> Path:
# Parent interpreter: pocket-tts deps (torch>=2.5, scipy, beartype) sit
# happily at the parent's pins, so this isolates for crash recovery, not
# dependency pins (same rationale as omnivoice-subprocess).
return Path(sys.executable)
# Its own venv when the one-click installer made one. Otherwise the
# parent interpreter, where `uv sync --extra pockettts` installs it
# (its deps sit happily at the parent's pins).
return _own_venv_python() or Path(sys.executable)
@classmethod
def sidecar_script(cls) -> Path:
+23 -12
View File
@@ -48,6 +48,15 @@ if TYPE_CHECKING:
logger = logging.getLogger("omnivoice.supertonic3")
_VENV_ENV_VAR = "OMNIVOICE_SUPERTONIC3_DIR"
def _own_venv_python() -> "Path | None":
"""The venv the one-click installer made for this engine, if any."""
from services.sidecar_install import engine_venv_python
return engine_venv_python(_VENV_ENV_VAR)
# Absolute path to the sidecar script ‑‑ same pattern as IndexTTS's
# ``INDEXTTS_SIDECAR_SCRIPT``. SubprocessBackend spawns it with the
@@ -80,11 +89,11 @@ class Supertonic3Backend(SubprocessBackend):
@classmethod
def venv_python(cls) -> Path:
"""Supertonic-3 lives in the main OmniVoice venv ‑‑ no dedicated
venv. ``sys.executable`` is the parent interpreter, which is the
same Python that ``uv sync --extra supertonic`` populated.
"""Its own venv when the one-click installer made one. Otherwise the
parent interpreter, the same Python ``uv sync --extra supertonic``
populated.
"""
return Path(sys.executable)
return _own_venv_python() or Path(sys.executable)
@classmethod
def sidecar_script(cls) -> Path:
@@ -96,14 +105,16 @@ class Supertonic3Backend(SubprocessBackend):
def is_available(cls) -> tuple[bool, str]:
# 1. Optional-dep gate (TTS-02). The ``supertonic`` wheel is only
# installed when the user opted in via ``--extra supertonic``.
try:
import supertonic # type: ignore[import-not-found] # noqa: F401
except ImportError:
return False, (
"supertonic package not installed. Enable in "
"Model Catalogue → Engines (installs `supertonic` via `uv add --optional "
"supertonic supertonic==1.3.1`)."
)
# Its own venv (made by the one-click installer, which verified the
# import there) or the app's environment (`uv sync --extra`).
if _own_venv_python() is None:
try:
import supertonic # type: ignore[import-not-found] # noqa: F401
except ImportError:
return False, (
"supertonic package not installed. Install it from "
"Model Catalogue → Engines."
)
# 2. License acceptance gate (TTS-05). Defence in depth: the
# settings_store helper handles the read; we just refuse
+11 -3
View File
@@ -137,9 +137,17 @@ def _resolve_pinned_sha() -> str:
# Final fallback ‑‑ relative import for when the file is invoked
# via ``python backend/engines/supertonic3/sidecar.py`` rather
# than via ``python -m backend.engines.supertonic3.sidecar``.
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from engines.supertonic3.constants import PINNED_REVISION_SHA # type: ignore[import-not-found]
return PINNED_REVISION_SHA
# Load constants.py by path. Importing it as `engines.supertonic3…`
# runs the package __init__, which imports the app's backend, and that
# is absent from the engine's own venv (one-click install).
import importlib.util
spec = importlib.util.spec_from_file_location(
"_supertonic3_constants", Path(__file__).resolve().with_name("constants.py"),
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) # type: ignore[union-attr]
return module.PINNED_REVISION_SHA
# ── model loading (lazy, on first synthesize) ─────────────────────────────
+92 -1
View File
@@ -139,6 +139,12 @@ class SidecarSpec:
# Python that proves the venv works; "{checkout}" / "{checkout_repr}"
# substituted. None means `import <probe_module>`.
probe_code: Optional[str] = None
# False for an engine that is a PyPI package, not a repository: nothing
# is fetched, and the managed root holds only the engine's own venv.
has_source: bool = True
# Add PyTorch's CPU index on every host, for an engine that only ever
# runs torch on the CPU (see core.torch_indexes).
cpu_torch_index: bool = False
# Can the one-click install work on THIS machine? (ok, reason). Consulted
# before an Install button is offered and again when an install starts, so
# a host the upstream does not support never gets a job that can only fail.
@@ -213,6 +219,28 @@ def _dots_host() -> tuple[bool, str]:
)
def _pockettts_host() -> tuple[bool, str]:
import platform
if sys.platform == "darwin" and platform.machine().lower() == "x86_64":
return False, (
"PocketTTS needs a PyTorch version that has no Intel Mac build."
)
return True, ""
def _in_app_env(module: str) -> Callable[[], bool]:
"""An install made with ``uv sync --extra`` lives in the app's own
environment. It counts as installed, so the installer never provisions a
second copy over one that works."""
def probe() -> bool:
import importlib.util
try:
return importlib.util.find_spec(module) is not None
except (ImportError, ValueError):
return False
return probe
SPECS: dict[str, SidecarSpec] = {
"indextts2": SidecarSpec(
engine_id="indextts2",
@@ -331,6 +359,45 @@ SPECS: dict[str, SidecarSpec] = {
invalidate=_dots_invalidate,
installed_probe=_dots_installed,
),
# PyPI packages rather than repositories: nothing to clone, and the managed
# root holds only the engine's own venv. The pins are the app's own
# optional extras (a test ties the two together), so the engine runs the
# same wheel whichever way it was installed.
"supertonic3": SidecarSpec(
engine_id="supertonic3",
display_name="Supertonic-3",
repo_url="",
tarball_url="",
checkout_dirname="supertonic3",
env_var="OMNIVOICE_SUPERTONIC3_DIR",
probe_module="supertonic",
has_source=False,
venv_args=("--python", "3.11"),
install_args=("supertonic==1.3.1",),
docs_path="docs/engines/supertonic3.md",
# onnxruntime + numpy + huggingface_hub, no torch. The ~400 MB of
# weights download on first synthesis into the shared HF cache.
required_bytes=1 * _GIB,
installed_probe=_in_app_env("supertonic"),
),
"pockettts": SidecarSpec(
engine_id="pockettts",
display_name="PocketTTS",
repo_url="",
tarball_url="",
checkout_dirname="pockettts",
env_var="OMNIVOICE_POCKETTTS_DIR",
probe_module="pocket_tts",
has_source=False,
venv_args=("--python", "3.11"),
install_args=("pocket-tts==2.1.0",),
cpu_torch_index=True,
docs_path="docs/engines/pockettts.md",
# CPU torch + scipy. The gated weights download on first use.
required_bytes=3 * _GIB,
installed_probe=_in_app_env("pocket_tts"),
host_supported=_pockettts_host,
),
}
@@ -386,6 +453,20 @@ def managed_checkout(spec: SidecarSpec) -> Path:
return managed_root(spec) / spec.checkout_dirname
def engine_venv_python(env_var: str) -> Optional[Path]:
"""The interpreter of the install *env_var* points at, if it has one.
For engines that can live in the app's environment or in a venv of their
own (PocketTTS, Supertonic-3): they prefer their own, and fall back to the
app's interpreter for an install made with ``uv sync --extra``.
"""
env_dir = os.environ.get(env_var)
if not env_dir:
return None
py = _venv_python(Path(env_dir) / ".venv")
return py if py.is_file() else None
def _legacy_managed_checkouts(spec: SidecarSpec) -> tuple[Path, ...]:
"""App-owned predecessor checkouts retained during in-place upgrades."""
if spec.engine_id == "indextts2":
@@ -872,6 +953,11 @@ def _step_preflight(spec: SidecarSpec, job: dict) -> None:
def _step_fetch_source(spec: SidecarSpec, job: dict) -> None:
step = _job_step(job, "fetch_source")
checkout = managed_checkout(spec)
if not spec.has_source:
checkout.mkdir(parents=True, exist_ok=True)
step["state"] = "done"
step["detail"] = "PyPI package, no source to fetch"
return
if _source_present(spec, checkout):
step["state"] = "done"
step["detail"] = "source already present"
@@ -936,6 +1022,8 @@ def _write_source_marker(spec: SidecarSpec, checkout: Path) -> None:
def _source_present(spec: SidecarSpec, checkout: Path) -> bool:
if not spec.has_source:
return checkout.is_dir()
if not _source_layout_ok(spec, checkout):
return False
if not spec.source_revision:
@@ -1053,7 +1141,10 @@ def _step_install_deps(spec: SidecarSpec, job: dict) -> None:
uv = _locate_uv()
_log(job, f"Installing {spec.display_name} into its venv (this can take several minutes) …")
target = [_expand(arg, checkout) for arg in spec.install_args]
if spec.uses_cuda_index and _host_family() == "cuda":
if spec.cpu_torch_index:
from core.torch_indexes import UV_PIP_CPU_ARGS
target += list(UV_PIP_CPU_ARGS)
elif spec.uses_cuda_index and _host_family() == "cuda":
from core.torch_indexes import UV_PIP_CU128_ARGS
target += list(UV_PIP_CU128_ARGS)
# Always `--python <this engine's venv>`: the install can only ever land in
+7 -1
View File
@@ -24,7 +24,13 @@ for this model.
uv sync --extra pockettts
```
(Or enable it from **Model Catalogue → Engines**.)
Or click **Install** in **Model Catalogue → Engines → PocketTTS**. That
installs the same pinned package into the engine's own Python environment
under VoiceStudio's data directory, with the CPU build of PyTorch, because
PocketTTS never uses a GPU. Nothing it installs touches VoiceStudio itself
or any other engine, and **Uninstall** in the same row removes only that
folder. An install made with `uv sync` keeps working as it is. The button
is not offered on Intel Macs (see Platform notes).
2. **Accept the license in-app**
([#1306](https://github.com/debpalash/VoiceStudio/issues/1306)). The code
+5 -2
View File
@@ -19,8 +19,11 @@ crashes and cold init never block the rest of VoiceStudio.
uv sync --extra supertonic
```
(Or enable it from **Model Catalogue → Engines**, which installs the
pinned `supertonic` wheel for you.)
Or click **Install** in **Model Catalogue → Engines → Supertonic-3**. That
installs the same pinned wheel into the engine's own Python environment
under VoiceStudio's data directory. Nothing it installs touches VoiceStudio
itself or any other engine, and **Uninstall** in the same row removes only
that folder. An install made with `uv sync` keeps working as it is.
2. **Accept the license in-app.** First use is gated behind an explicit
acceptance dialog: the inference SDK is MIT, but the model weights are
@@ -1315,7 +1315,9 @@ export default function EngineCompatibilityMatrix({
{/* One-click sidecar install — the guided replacement for
the four manual terminal steps. Progress renders in
the expansion panel (auto-opened on click). */}
{!b.available && b.one_click_install && (
{/* Hidden while a license review is all that is left: the
engine is installed, and Accept is the next step. */}
{!b.available && b.one_click_install && !reasonMentionsLicense(b.reason) && (
<Button
size="sm"
variant="subtle"
@@ -1920,3 +1920,43 @@ describe('EngineCompatibilityMatrix', () => {
}
});
});
describe('EngineCompatibilityMatrix one-click install', () => {
function renderWithRow(reason) {
const res = makeEnginesResponse();
res.tts.backends.push({
id: 'pockettts',
display_name: 'PocketTTS (test)',
available: false,
reason,
one_click_install: true,
install_hint: '',
last_error: null,
isolation_mode: 'subprocess',
gpu_compat: ['cpu'],
});
render(
<EngineCompatibilityMatrix
family="tts"
apiListEngines={vi.fn().mockResolvedValue(res)}
apiGetEngineHealth={vi.fn()}
apiInstallStatus={vi.fn().mockResolvedValue({ state: 'idle' })}
/>,
);
return waitFor(() => screen.getByText('PocketTTS (test)'));
}
it('offers Install for an engine that is not installed yet', async () => {
await renderWithRow(
"This engine's package isn't installed yet. Install it from Model Catalogue → Engines.",
);
expect(screen.getByTestId('install-pockettts')).toBeInTheDocument();
});
it('offers only the license review once the engine is installed', async () => {
await renderWithRow(
'License not accepted yet. Review and accept it in Model Catalogue → Engines to enable this engine.',
);
expect(screen.queryByTestId('install-pockettts')).not.toBeInTheDocument();
});
});
+22
View File
@@ -282,3 +282,25 @@ def test_license_api_accepts_pockettts_and_rejects_unknown(settings_mod, mock_se
def settings_mod():
import importlib
return importlib.import_module("api.routers.settings")
def test_prefers_the_venv_its_one_click_install_made(monkeypatch, tmp_path, mock_settings_store):
"""Its own venv when the installer made one; otherwise the app's
interpreter, where `uv sync --extra pockettts` installs it."""
from pathlib import Path
from services.sidecar_install import _venv_python
mock_settings_store["pockettts"] = True
monkeypatch.delenv("OMNIVOICE_POCKETTTS_DIR", raising=False)
assert _backend_cls().venv_python() == Path(sys.executable)
py = _venv_python(tmp_path / ".venv")
py.parent.mkdir(parents=True)
py.write_text("#!fake\n")
monkeypatch.setenv("OMNIVOICE_POCKETTTS_DIR", str(tmp_path))
assert _backend_cls().venv_python() == py
# Available without pocket_tts importable in the app's own environment.
monkeypatch.setitem(sys.modules, "pocket_tts", None)
if _backend_cls()._platform_error() is None:
assert _backend_cls().is_available() == (True, "ready (CPU-only)")
+92 -6
View File
@@ -49,6 +49,12 @@ def _clean_state(monkeypatch, tmp_path):
monkeypatch.delenv("OMNIVOICE_INDEXTTS_DIR", raising=False)
monkeypatch.delenv("OMNIVOICE_FAKE_SIDE_DIR", raising=False)
monkeypatch.delenv("OMNIVOICE_DESKTOP_CONTAINED", raising=False)
# Set-then-delete: a bare delenv of an unset var records nothing to
# restore, so a path an install test persists would leak into later
# suites (an engine would then find a venv that no longer exists).
for spec in si.SPECS.values():
monkeypatch.setenv(spec.env_var, "")
monkeypatch.delenv(spec.env_var)
yield
@@ -1080,17 +1086,23 @@ def test_verify_probe_runs_in_the_engines_venv_and_compiles(monkeypatch, engine_
@pytest.mark.parametrize(
("family", "platform", "expected"),
("family", "platform", "machine", "expected"),
[
("cuda", "linux", {"indextts2", "moss-tts-v15", "confucius4-tts", "dots-tts"}),
("cuda", "win32", {"indextts2", "moss-tts-v15", "confucius4-tts"}),
("cpu", "win32", {"indextts2", "confucius4-tts"}),
("mps", "darwin", {"indextts2", "confucius4-tts", "dots-tts"}),
("cuda", "linux", "x86_64", {"moss-tts-v15", "dots-tts", "pockettts"}),
("cuda", "win32", "AMD64", {"moss-tts-v15", "pockettts"}),
("cpu", "win32", "AMD64", {"pockettts"}),
("mps", "darwin", "arm64", {"dots-tts", "pockettts"}),
# Intel Mac: PyTorch publishes no build PocketTTS can use.
("cpu", "darwin", "x86_64", {"dots-tts"}),
],
)
def test_installable_engine_ids_follow_the_host(monkeypatch, family, platform, expected):
def test_installable_engine_ids_follow_the_host(monkeypatch, family, platform, machine, expected):
import platform as platform_mod
monkeypatch.setattr(si, "_host_family", lambda: family)
monkeypatch.setattr(si.sys, "platform", platform)
monkeypatch.setattr(platform_mod, "machine", lambda: machine)
# Offered on every host: IndexTTS 2.5, Confucius4, Supertonic-3.
expected = set(expected) | {"indextts2", "confucius4-tts", "supertonic3"}
assert si.installable_engine_ids() == frozenset(expected)
@@ -1129,3 +1141,77 @@ def test_list_backends_offers_install_only_where_it_can_work(monkeypatch):
assert rows["confucius4-tts"]["one_click_install"] is True
assert rows["moss-tts-v15"]["one_click_install"] is False
assert rows["dots-tts"]["one_click_install"] is False
# ── PyPI-package engines (Supertonic-3, PocketTTS) ─────────────────────────
@pytest.mark.parametrize(
("engine_id", "package", "env_var"),
[
("supertonic3", "supertonic==1.3.1", "OMNIVOICE_SUPERTONIC3_DIR"),
("pockettts", "pocket-tts==2.1.0", "OMNIVOICE_POCKETTTS_DIR"),
],
)
def test_pypi_engines_install_the_apps_own_pin_without_fetching_source(
monkeypatch, engine_id, package, env_var
):
import tomllib
spec = si.get_spec(engine_id)
assert spec.env_var == env_var and not spec.has_source
# The same pin as the app's optional extra, so the engine runs the same
# wheel whether it was installed here or with `uv sync --extra`.
pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
extras = tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]["optional-dependencies"]
assert package in {req.split(";")[0].strip() for reqs in extras.values() for req in reqs}
monkeypatch.delenv(env_var, raising=False)
argvs = _capture_install_argvs(monkeypatch, family="cpu")
monkeypatch.setattr(si, "disk_free_bytes", lambda p: 100 * _GIB)
monkeypatch.setattr(si.shutil, "which", lambda n: None)
_stub_verify_ok(monkeypatch)
monkeypatch.setattr("core.prefs.set_", lambda k, v: None)
job = _run(spec)
assert job["state"] == "succeeded", (job["error"], list(job["log"]))
assert not any(os.path.basename(a[0]).startswith("git") for a in argvs)
pip = next(a for a in argvs if a[1:3] == ["pip", "install"])
assert pip[5] == package
assert os.environ[env_var] == str(si.managed_checkout(spec))
assert si._healthy(spec)
@pytest.mark.parametrize("family", ["cuda", "cpu", "rocm", "mps"])
def test_pockettts_installs_cpu_torch_on_every_host(monkeypatch, family):
from core.torch_indexes import UV_PIP_CPU_ARGS
argvs = _capture_install_argvs(monkeypatch, family=family)
si._step_install_deps(si.get_spec("pockettts"), si._new_job("pockettts"))
pip = next(a for a in argvs if a[1:3] == ["pip", "install"])
i = pip.index("--extra-index-url")
assert tuple(pip[i:i + len(UV_PIP_CPU_ARGS)]) == UV_PIP_CPU_ARGS
assert pip.count("--extra-index-url") == 1
def test_an_extra_already_in_the_app_env_counts_as_installed(monkeypatch):
"""A `uv sync --extra supertonic` install keeps working and is never
provisioned over."""
import importlib.util as ilu
monkeypatch.delenv("OMNIVOICE_SUPERTONIC3_DIR", raising=False)
real = ilu.find_spec
monkeypatch.setattr(
ilu, "find_spec", lambda name, *a: object() if name == "supertonic" else real(name, *a)
)
assert si.start_install("supertonic3")["status"] == "already_installed"
assert "supertonic3" not in si._jobs
def test_engine_venv_python_needs_a_real_interpreter(monkeypatch, tmp_path):
monkeypatch.delenv("OMNIVOICE_FAKE_SIDE_DIR", raising=False)
assert si.engine_venv_python("OMNIVOICE_FAKE_SIDE_DIR") is None
monkeypatch.setenv("OMNIVOICE_FAKE_SIDE_DIR", str(tmp_path))
assert si.engine_venv_python("OMNIVOICE_FAKE_SIDE_DIR") is None # no venv yet
py = si._venv_python(tmp_path / ".venv")
py.parent.mkdir(parents=True)
py.write_text("#!fake\n")
assert si.engine_venv_python("OMNIVOICE_FAKE_SIDE_DIR") == py
+41
View File
@@ -381,3 +381,44 @@ def test_extra_env_carries_revision(mock_settings_store):
assert os.environ.get("SUPERTONIC3_REVISION") == constants.PINNED_REVISION_SHA
# And the property surfaces the same value.
assert backend._sidecar_env["SUPERTONIC3_REVISION"] == constants.PINNED_REVISION_SHA
def test_prefers_the_venv_its_one_click_install_made(monkeypatch, tmp_path, mock_settings_store):
"""Its own venv when the installer made one; otherwise the app's
interpreter, where `uv sync --extra supertonic` installs it."""
from pathlib import Path
from engines.supertonic3.backend import Supertonic3Backend
from services.sidecar_install import _venv_python
mock_settings_store["supertonic3"] = True
monkeypatch.delenv("OMNIVOICE_SUPERTONIC3_DIR", raising=False)
assert Supertonic3Backend.venv_python() == Path(sys.executable)
py = _venv_python(tmp_path / ".venv")
py.parent.mkdir(parents=True)
py.write_text("#!fake\n")
monkeypatch.setenv("OMNIVOICE_SUPERTONIC3_DIR", str(tmp_path))
assert Supertonic3Backend.venv_python() == py
# Available without supertonic importable in the app's own environment.
monkeypatch.setitem(sys.modules, "supertonic", None)
ok, msg = Supertonic3Backend.is_available()
assert ok is True, msg
def test_sidecar_resolves_its_pin_without_the_app_backend(monkeypatch):
"""In its own venv the app's backend package is absent. The fallback must
not import `engines.supertonic3`, whose __init__ imports the backend."""
import importlib.util
from pathlib import Path
from engines.supertonic3 import constants
monkeypatch.delenv("SUPERTONIC3_REVISION", raising=False)
monkeypatch.setitem(sys.modules, "engines", None)
monkeypatch.setitem(sys.modules, "backend", None)
path = Path(constants.__file__).with_name("sidecar.py")
spec = importlib.util.spec_from_file_location("_st3_sidecar_under_test", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
assert module._resolve_pinned_sha() == constants.PINNED_REVISION_SHA