"""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 ``app/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 (``app/envs/tts/bin/python``, ``app/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.parent # One shared venv for the app requirements and every pip-installed backend. ENV_DIR = TTS_ROOT / "app" / "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:]])