Files
VoiceStudio/tests/test_omnivoice_path.py
6e3e4bcfdd fix(bootstrap): gate venv on omnivoice import + source fallback (#564) (#603)
* fix(bootstrap): gate venv on omnivoice import + source fallback (#564)

`No module named 'omnivoice'` is a venv that starts uvicorn but can't import the
project's OWN package: an interrupted/offline `uv sync` installed deps yet never
laid the editable record (`_editable_impl_omnivoice.pth`), or antivirus removed
it. The bootstrap health gate only checked `import uvicorn` + `import
pkg_resources`, so it handed back the broken venv and the app failed only at the
first model call (the dub/generate SSE error in #564). #573's source fallback in
main.py wasn't enough on its own because the editable record, not the source
tree, was the missing piece.

Fix the root cause at the gate and harden the runtime:
- bootstrap.rs: add an `omnivoice` import check beside the uvicorn/pkg_resources
  gates, using `importlib.util.find_spec` (resolves without importing, so no
  torch load). When it fails, fall through to the repair `uv sync`, which
  re-lays the editable install. Mirrors the #248 pkg_resources pattern exactly.
- core/omnivoice_path.py (new): `ensure_omnivoice_importable()` — a tested
  helper that no-ops when the install resolves and otherwise appends the sibling
  source root to sys.path, with a precise diagnostic when neither is found.
- main.py: replace the inline #573 block with the helper.
- model_manager._lazy_omnivoice: self-heal on ModuleNotFoundError at the actual
  import site so the model-load path recovers and logs the searched roots.

Regression tests cover the path-resolution logic (env override, append-not-
insert precedence, no-source-found). cargo check passes for the Rust change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(omnivoice-path): patch via live module object to survive core reloads (#564)

The #603 CI flake: other suites importlib.reload(core.*), leaving the
top-level-imported ensure_omnivoice_importable closed over a stale module whose
_already_importable a string-form monkeypatch didn't touch, so it returned None.
Resolve the function + the patch target from sys.modules together.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 05:07:07 +05:30

95 lines
3.6 KiB
Python

"""Regression: the backend must resolve its own `omnivoice` package from source
when the venv's editable install is missing (#564).
`No module named 'omnivoice'` is a venv that starts uvicorn but never laid (or
lost) the editable record. The bootstrap now gates on it, and this source
fallback is the runtime safety net. These tests cover the pure path-resolution
logic without disturbing the real (installed) omnivoice.
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "backend"))
from core.omnivoice_path import ( # noqa: E402
find_omnivoice_source_root,
ensure_omnivoice_importable,
_candidate_roots,
)
def _make_source_root(tmp_path):
root = tmp_path / "project"
(root / "omnivoice").mkdir(parents=True)
(root / "omnivoice" / "__init__.py").write_text("")
return root
def test_find_source_root_picks_dir_with_package(tmp_path):
root = _make_source_root(tmp_path)
other = tmp_path / "empty"
other.mkdir()
assert find_omnivoice_source_root([str(other), str(root)]) == str(root)
def test_find_source_root_none_when_absent(tmp_path):
assert find_omnivoice_source_root([str(tmp_path), None, str(tmp_path / "nope")]) is None
def test_candidate_roots_prefers_env_then_backend_parent(tmp_path, monkeypatch):
monkeypatch.setenv("OMNIVOICE_PROJECT_ROOT", "/explicit/root")
backend_dir = str(tmp_path / "project" / "backend")
roots = _candidate_roots(backend_dir)
assert roots[0] == "/explicit/root"
assert roots[1] == os.path.join(str(tmp_path), "project") # parent of backend/
def test_candidate_roots_without_env(tmp_path, monkeypatch):
monkeypatch.delenv("OMNIVOICE_PROJECT_ROOT", raising=False)
backend_dir = str(tmp_path / "project" / "backend")
assert _candidate_roots(backend_dir) == [os.path.join(str(tmp_path), "project")]
# NB: patch + call through the LIVE module object (`op`), never the names bound
# at this file's import time. Other suites `importlib.reload(core.*)`, which can
# leave the top-level-imported `ensure_omnivoice_importable` closed over a stale
# module whose `_already_importable` a string-form monkeypatch wouldn't touch —
# the #603 CI flake. Resolving both from sys.modules keeps them consistent.
def _live():
import importlib
return importlib.import_module("core.omnivoice_path")
def test_ensure_noop_when_already_importable():
op = _live()
# omnivoice IS installed in the test venv → find_spec resolves → no fallback.
before = list(sys.path)
assert op.ensure_omnivoice_importable("/anywhere") is None
assert sys.path == before
def test_ensure_appends_source_root_when_not_importable(tmp_path, monkeypatch):
op = _live()
root = _make_source_root(tmp_path)
backend_dir = str(root / "backend")
# Simulate the missing editable install (patch on the live module object).
monkeypatch.setattr(op, "_already_importable", lambda: False)
monkeypatch.delenv("OMNIVOICE_PROJECT_ROOT", raising=False)
monkeypatch.setattr(sys, "path", list(sys.path)) # isolate mutation
added = op.ensure_omnivoice_importable(backend_dir)
assert added == str(root)
assert str(root) in sys.path
# Appended (not inserted) so a real install keeps precedence.
assert sys.path[-1] == str(root)
def test_ensure_returns_none_when_no_source_found(tmp_path, monkeypatch):
op = _live()
monkeypatch.setattr(op, "_already_importable", lambda: False)
monkeypatch.delenv("OMNIVOICE_PROJECT_ROOT", raising=False)
backend_dir = str(tmp_path / "no-sibling" / "backend")
assert op.ensure_omnivoice_importable(backend_dir) is None