* 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>
95 lines
3.6 KiB
Python
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
|