aboutsummaryrefslogtreecommitdiff
path: root/app/backends/envs.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-24 02:59:26 -0400
committerhistoria <historiavg@proton.me>2026-08-24 02:59:26 -0400
commitf00249db9d1ea051d29aa1bcca869fc4b88e83eb (patch)
treea75f076fac1b63e0b4bf2eb8f54affbcc681a891 /app/backends/envs.py
parent9dd4f9595be3b1d76a3a07dc3eca90cfaf8a3f97 (diff)
downloadtts-audiobook-generator-f00249db9d1ea051d29aa1bcca869fc4b88e83eb.tar.gz
refactor: add app directory, dir structure change
Diffstat (limited to 'app/backends/envs.py')
-rw-r--r--app/backends/envs.py185
1 files changed, 185 insertions, 0 deletions
diff --git a/app/backends/envs.py b/app/backends/envs.py
new file mode 100644
index 0000000..6e5b6cc
--- /dev/null
+++ b/app/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
+``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:]])