aboutsummaryrefslogtreecommitdiff
path: root/app/backends/envs.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-27 00:04:16 -0400
committerhistoria <historiavg@proton.me>2026-08-27 00:04:16 -0400
commit5b98993b13dafe9a85495e4c68ad9b42863ef2bf (patch)
tree57db1566deb5351ec6bfb8c70de4126bbc5fc2da /app/backends/envs.py
parentf18f421d9180ae0e3bff9496b1fdaf53d3624a75 (diff)
downloadtts-audiobook-generator-5b98993b13dafe9a85495e4c68ad9b42863ef2bf.tar.gz
feat: separate venvs for qwen-tts and faster, manage (un)installs
Diffstat (limited to 'app/backends/envs.py')
-rw-r--r--app/backends/envs.py122
1 files changed, 73 insertions, 49 deletions
diff --git a/app/backends/envs.py b/app/backends/envs.py
index f120eb9..50154c2 100644
--- a/app/backends/envs.py
+++ b/app/backends/envs.py
@@ -1,18 +1,23 @@
-"""The managed Python environment for the audiobook generator and its backends.
+"""The managed Python environments 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
+is fine): on startup it bootstraps a single tool-managed app venv at
+``app/envs/tts`` and re-execs itself inside it. That venv holds the audiobook
+app's own ``requirements.txt`` dependencies only. The backend TTS packages the
+setup wizards pip install get dedicated venvs of their own —
+``app/envs/qwen`` for ``qwen-tts``, ``app/envs/faster`` for
+``faster-qwen3-tts[demo]`` — so their heavyweight torch/transformers stacks
+never share an environment with each other or with the app (qwen-tts and
+faster-qwen3-tts both ship a ``qwen_tts`` module with conflicting
+transformers pins, which makes a shared install break whichever distribution
+lands last). 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.
+helper here resolves an env's binaries by absolute path
+(``app/envs/tts/bin/python``, ``app/envs/qwen/bin/qwen-tts-demo``), so the hub can
+spawn servers in these envs 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
@@ -33,8 +38,12 @@ 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.
+# One app venv for the app requirements; one venv per pip-installed TTS
+# backend. The per-backend split keeps qwen-tts's transformers 4 pin away
+# from faster-qwen3-tts's transformers 5 requirement (and away from the app).
ENV_DIR = TTS_ROOT / "app" / "envs" / "tts"
+QWEN_ENV_DIR = TTS_ROOT / "app" / "envs" / "qwen"
+FASTER_ENV_DIR = TTS_ROOT / "app" / "envs" / "faster"
REQUIREMENTS_PATH = TTS_ROOT / "requirements.txt"
# requirements.txt lines whose comment starts with this tag are installed
@@ -57,44 +66,51 @@ 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_python(env_dir: Optional[Path] = None) -> Path:
+ """Absolute path to an env's python interpreter (the app env by default)."""
+ base = env_dir if env_dir is not None else ENV_DIR
+ return base / ("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)."""
+def env_script(name: str, env_dir: Optional[Path] = None) -> Path:
+ """Absolute path to a console script installed in an env (e.g. qwen-tts-demo).
+
+ ENV_DIR defaults to the app env; pass a backend's env dir (QWEN_ENV_DIR,
+ FASTER_ENV_DIR) for its scripts.
+ """
subdir = "Scripts" if _is_windows() else "bin"
suffix = ".exe" if _is_windows() else ""
- return ENV_DIR / subdir / f"{name}{suffix}"
+ return (env_dir if env_dir is not None else 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 env_exists(env_dir: Optional[Path] = None) -> bool:
+ """True when the given env's python interpreter is present on disk."""
+ return env_python(env_dir).is_file()
def is_managed_env() -> bool:
- """True when the current process is already running inside the managed venv."""
+ """True when the current process is already running inside the app 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).
+def create_env(env_dir: Optional[Path] = None) -> int:
+ """Create ENV (an env dir, defaulting to the app one) 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.
"""
+ target = env_dir if env_dir is not None else ENV_DIR
print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
print(" Setting up your environment for the first time...")
print(" This may take a minute.")
print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
- print(f"[INFO] creating managed environment at {ENV_DIR}...")
+ print(f"[INFO] creating managed environment at {target}...")
rc = common.run_console_subprocess(
- [sys.executable, "-m", "venv", str(ENV_DIR)])
+ [sys.executable, "-m", "venv", str(target)])
if rc != 0:
print(f"[ERROR] python -m venv failed (exit {rc}).")
if _is_windows():
@@ -173,18 +189,23 @@ def install_requirements(skip_optional: bool = False) -> int:
[str(env_python()), "-m", "pip", "install", *installable])
-def pip_install(packages: List[str], *, emit=None, cancel=None) -> int:
- """pip install PACKAGES into the venv, creating it first if needed.
+def pip_install(packages: List[str], *, emit=None, cancel=None,
+ env_dir: Optional[Path] = None) -> int:
+ """pip install PACKAGES into ENV (an env dir, default the app env),
+ 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. With EMIT given
- (the in-TUI task view) pip runs with ``--progress-bar off`` so its output
- is clean status lines rather than carriage-return progress spam.
+ Used by the qwen/faster setup wizards to install their TTS packages into
+ their dedicated backend venvs (QWEN_ENV_DIR / FASTER_ENV_DIR), never
+ alongside each other or the app requirements. Returns pip's exit code.
+ With EMIT given (the in-TUI task view) pip runs with ``--progress-bar
+ off`` so its output is clean status lines rather than carriage-return
+ progress spam.
"""
- if not env_exists() and create_env() != 0:
+ if not env_exists(env_dir) and create_env(env_dir) != 0:
return 1
- print(f"[INFO] pip install {' '.join(packages)} into {ENV_DIR}...")
- argv = [str(env_python()), "-m", "pip", "install"]
+ target = env_dir if env_dir is not None else ENV_DIR
+ print(f"[INFO] pip install {' '.join(packages)} into {target}...")
+ argv = [str(env_python(env_dir)), "-m", "pip", "install"]
if emit is not None:
argv.append("--progress-bar")
argv.append("off")
@@ -192,35 +213,38 @@ def pip_install(packages: List[str], *, emit=None, cancel=None) -> int:
return common.run_console_subprocess(argv, emit=emit, cancel=cancel)
-def pip_uninstall(packages: List[str], *, emit=None) -> int:
- """pip uninstall PACKAGES from the venv. Returns pip's exit code.
+def pip_uninstall(packages: List[str], *, emit=None,
+ env_dir: Optional[Path] = None) -> int:
+ """pip uninstall PACKAGES from ENV (an env dir, default the app env).
+ Returns pip's exit code.
Used by the backends' ``uninstall`` action to remove pip-installed TTS
- packages from the managed environment. A missing env is a no-op (there
- is nothing to uninstall from), reported as success. With EMIT given
- (the in-TUI task view) pip runs with its output piped and streamed to
- EMIT, so nothing writes to the terminal behind curses.
+ packages from their dedicated environments. A missing env is a no-op
+ (there is nothing to uninstall from), reported as success. With EMIT
+ given (the in-TUI task view) pip runs with its output piped and streamed
+ to EMIT, so nothing writes to the terminal behind curses.
"""
- if not env_exists():
+ if not env_exists(env_dir):
return 0
- print(f"[INFO] pip uninstall {' '.join(packages)} from {ENV_DIR}...")
+ target = env_dir if env_dir is not None else ENV_DIR
+ print(f"[INFO] pip uninstall {' '.join(packages)} from {target}...")
return common.run_console_subprocess(
- [str(env_python()), "-m", "pip", "uninstall", "-y", *packages],
+ [str(env_python(env_dir)), "-m", "pip", "uninstall", "-y", *packages],
emit=emit)
-def module_available(module: str) -> bool:
- """True when MODULE imports inside the venv (e.g. qwen_tts, faster_qwen3_tts).
+def module_available(module: str, env_dir: Optional[Path] = None) -> bool:
+ """True when MODULE imports inside ENV (e.g. qwen_tts in QWEN_ENV_DIR).
- 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``.
+ A short subprocess probe against the env's interpreter — the equivalent
+ of importlib.util.find_spec, but for a managed env rather than the
+ current one. Used by each backend's ``_is_installed``.
"""
- if not env_exists():
+ if not env_exists(env_dir):
return False
try:
result = subprocess.run(
- [str(env_python()), "-c", f"import {module}"],
+ [str(env_python(env_dir)), "-c", f"import {module}"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=30, check=False)
except (OSError, subprocess.TimeoutExpired):