From 5b98993b13dafe9a85495e4c68ad9b42863ef2bf Mon Sep 17 00:00:00 2001 From: historia Date: Thu, 27 Aug 2026 00:04:16 -0400 Subject: feat: separate venvs for qwen-tts and faster, manage (un)installs --- app/backends/envs.py | 122 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 73 insertions(+), 49 deletions(-) (limited to 'app/backends/envs.py') 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): -- cgit v1.2.3