From c02d66b2d3221c0c5f5e8f2cb2ae218f1e325a0a Mon Sep 17 00:00:00 2001 From: historia Date: Mon, 24 Aug 2026 01:57:13 -0400 Subject: feat: manage venv for all backends --- backends/envs.py | 185 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 backends/envs.py (limited to 'backends/envs.py') diff --git a/backends/envs.py b/backends/envs.py new file mode 100644 index 0000000..7596db6 --- /dev/null +++ b/backends/envs.py @@ -0,0 +1,185 @@ +"""The managed Python environment for the audiobook generator and its backends. + +audiobook.py is meant to be launched from any Python (a bare system interpreter +is fine): on startup it bootstraps a single tool-managed venv at +``envs/tts`` and re-execs itself inside it. That venv holds both the +audiobook app's own ``requirements.txt`` dependencies and the backend TTS +packages (``qwen-tts``, ``faster-qwen3-tts[demo]``) the setup wizards pip +install, so nothing is ever installed into the launching interpreter's +environment. + +A parent process never needs to "activate" an environment — activation is +just a shell convenience that puts an env's ``bin`` on PATH. Instead every +helper here resolves the env's binaries by absolute path +(``envs/tts/bin/python``, ``envs/tts/bin/qwen-tts-demo``), so the hub can +spawn servers in this env from any parent environment. + +This module is imported before audiobook.py's third-party dependencies, so +it must stay stdlib-only (it may import ``backends.common``, which is also +stdlib-only, but never ``converter`` or the backend modules). +""" + +import hashlib +import os +import sys +from pathlib import Path +from typing import List + +from backends import common + +# The tts-audiobook-generator checkout root (where audiobook.py lives). +TTS_ROOT = Path(__file__).resolve().parent.parent + +# One shared venv for the app requirements and every pip-installed backend. +ENV_DIR = TTS_ROOT / "envs" / "tts" +REQUIREMENTS_PATH = TTS_ROOT / "requirements.txt" + +# Marker file recording the requirements.txt hash last installed into the env, +# so ensure_app_env() re-installs when requirements.txt changes. +MARKER_PATH = ENV_DIR / ".audiobook_env_ready" + + +def _is_windows() -> bool: + return sys.platform == "win32" + + +def env_python() -> Path: + """Absolute path to the venv's python interpreter.""" + return ENV_DIR / ("Scripts/python.exe" if _is_windows() else "bin/python") + + +def env_script(name: str) -> Path: + """Absolute path to a console script installed in the venv (e.g. qwen-tts-demo).""" + subdir = "Scripts" if _is_windows() else "bin" + suffix = ".exe" if _is_windows() else "" + return ENV_DIR / subdir / f"{name}{suffix}" + + +def env_exists() -> bool: + """True when the venv's python interpreter is present on disk.""" + return env_python().is_file() + + +def is_managed_env() -> bool: + """True when the current process is already running inside the managed venv.""" + try: + return Path(sys.executable).resolve() == env_python().resolve() + except OSError: + return False + + +def create_env() -> int: + """Create the venv with the launching interpreter (inherits its version). + + pip is bootstrapped inside the venv by ensurepip. Returns the ``python -m + venv`` exit code; a non-zero result is reported with platform remediation. + """ + print(f"[INFO] creating managed environment at {ENV_DIR}...") + rc = common.run_console_subprocess( + [sys.executable, "-m", "venv", str(ENV_DIR)]) + if rc != 0: + print(f"[ERROR] python -m venv failed (exit {rc}).") + if _is_windows(): + print(" On Windows make sure the launcher has the venv module.") + else: + print(" On Debian/Ubuntu install the venv package, e.g.:") + print(" sudo apt install python3-venv") + return rc + + +def install_requirements() -> int: + """pip install -r requirements.txt into the venv. Returns pip's exit code.""" + print(f"[INFO] pip install -r {REQUIREMENTS_PATH} into {ENV_DIR}...") + return common.run_console_subprocess( + [str(env_python()), "-m", "pip", "install", "-r", str(REQUIREMENTS_PATH)]) + + +def pip_install(packages: List[str]) -> int: + """pip install PACKAGES into the venv, creating it first if needed. + + Used by the qwen/faster setup wizards to install backend TTS packages + alongside the app requirements. Returns pip's exit code. + """ + if not env_exists() and create_env() != 0: + return 1 + print(f"[INFO] pip install {' '.join(packages)} into {ENV_DIR}...") + return common.run_console_subprocess( + [str(env_python()), "-m", "pip", "install", *packages]) + + +def module_available(module: str) -> bool: + """True when MODULE imports inside the venv (e.g. qwen_tts, faster_qwen3_tts). + + A short subprocess probe against the venv's interpreter — the equivalent of + importlib.util.find_spec, but for the managed env rather than the current + one. Used by each backend's ``_is_installed``. + """ + if not env_exists(): + return False + import subprocess + try: + result = subprocess.run( + [str(env_python()), "-c", f"import {module}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=30, check=False) + except (OSError, subprocess.TimeoutExpired): + return False + return result.returncode == 0 + + +def _requirements_sha() -> str: + try: + data = REQUIREMENTS_PATH.read_bytes() + except OSError: + return "" + return hashlib.sha256(data).hexdigest() + + +def _marker_valid() -> bool: + try: + return MARKER_PATH.read_text(encoding="utf-8").strip() == _requirements_sha() + except OSError: + return False + + +def _write_marker() -> None: + try: + MARKER_PATH.write_text(_requirements_sha() + "\n", encoding="utf-8") + except OSError: + pass + + +def ensure_app_env() -> None: + """Make sure the venv exists and has the current requirements.txt installed. + + Creates the venv when missing, and (re)installs requirements.txt when it is + missing or has changed since the last install (tracked by a hash marker). + Raises RuntimeError on any failure so the caller can abort before re-exec. + """ + if not env_exists() and create_env() != 0: + raise RuntimeError("could not create the managed environment") + if not _marker_valid(): + if install_requirements() != 0: + raise RuntimeError("pip install -r requirements.txt failed") + _write_marker() + + +def bootstrap(script_path: str) -> None: + """Run audiobook.py inside the managed venv, creating it first if needed. + + A no-op when the current process is already the venv's interpreter. Otherwise + ensures the env (and requirements) are ready, then replaces the process with + the venv's python running the same script and CLI args. Called at the top of + audiobook.py before any third-party import. + """ + if is_managed_env(): + return + try: + ensure_app_env() + except RuntimeError as exc: + print(f"[FATAL] {exc}", file=sys.stderr) + sys.exit(1) + py = str(env_python()) + target = str(Path(script_path).resolve()) + print(f"[INFO] re-launching inside managed environment: {py}") + os.execv(py, [py, target, *sys.argv[1:]]) -- cgit v1.2.3