* 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>
78 lines
3.1 KiB
Python
78 lines
3.1 KiB
Python
"""Resolve the project's own ``omnivoice`` package from source when the venv's
|
|
editable install is missing (#564).
|
|
|
|
``omnivoice`` is normally an editable install in the backend venv. An interrupted
|
|
or offline ``uv sync`` can install dependencies yet never lay the editable record
|
|
(``_editable_impl_omnivoice.pth``), or an antivirus quarantine can remove it —
|
|
leaving a venv that starts uvicorn but cannot ``import omnivoice``, so it boots
|
|
fine and only fails at the first model call (``No module named 'omnivoice'``).
|
|
|
|
The desktop layout always copies ``omnivoice/`` next to ``backend/``, so we fall
|
|
back to importing it from there. The bootstrap now also gates on omnivoice being
|
|
importable (re-syncing to re-lay the editable install), but this keeps the
|
|
backend resilient even when that repair hasn't run yet.
|
|
"""
|
|
import os
|
|
import sys
|
|
|
|
|
|
def find_omnivoice_source_root(candidates):
|
|
"""Return the first candidate dir holding ``omnivoice/__init__.py``, else None."""
|
|
for root in candidates:
|
|
if root and os.path.isfile(os.path.join(root, "omnivoice", "__init__.py")):
|
|
return root
|
|
return None
|
|
|
|
|
|
def _candidate_roots(backend_dir):
|
|
"""Source roots to probe, most-specific first.
|
|
|
|
``OMNIVOICE_PROJECT_ROOT`` lets the launcher point at the staged project dir
|
|
explicitly; otherwise the desktop layout puts ``omnivoice/`` beside
|
|
``backend/`` (parent of ``backend_dir``).
|
|
"""
|
|
roots = []
|
|
env = os.environ.get("OMNIVOICE_PROJECT_ROOT")
|
|
if env:
|
|
roots.append(env)
|
|
roots.append(os.path.dirname(os.path.abspath(backend_dir)))
|
|
return roots
|
|
|
|
|
|
def _already_importable():
|
|
import importlib.util
|
|
try:
|
|
return importlib.util.find_spec("omnivoice") is not None
|
|
except (ImportError, ValueError):
|
|
# A half-laid spec (e.g. a stale .pth pointing at a deleted dir) raises
|
|
# rather than returning None — treat it as "not importable" so we fall
|
|
# back to the on-disk source.
|
|
return False
|
|
|
|
|
|
def ensure_omnivoice_importable(backend_dir, logger=None):
|
|
"""Make ``import omnivoice`` work, falling back to the sibling source tree.
|
|
|
|
No-op when the editable/site-packages install already resolves it. Otherwise
|
|
appends the first source root containing ``omnivoice/`` to ``sys.path``
|
|
(appended, never inserted, so a real install keeps precedence). Returns the
|
|
root that was added, or ``None`` if none was needed or found.
|
|
"""
|
|
if _already_importable():
|
|
return None
|
|
root = find_omnivoice_source_root(_candidate_roots(backend_dir))
|
|
if root and root not in sys.path:
|
|
sys.path.append(root)
|
|
if logger:
|
|
logger.warning(
|
|
"omnivoice not importable from the venv (missing/broken editable "
|
|
"install) — resolving it from source at %s (#564)", root,
|
|
)
|
|
elif logger and root is None:
|
|
logger.error(
|
|
"omnivoice is not importable and no source tree was found next to "
|
|
"%s — the install is incomplete; relaunch to let the bootstrap "
|
|
"repair the venv (#564)", backend_dir,
|
|
)
|
|
return root
|