diff options
Diffstat (limited to 'app/backends/envs.py')
| -rw-r--r-- | app/backends/envs.py | 151 |
1 files changed, 140 insertions, 11 deletions
diff --git a/app/backends/envs.py b/app/backends/envs.py index beef23b..98f1cc1 100644 --- a/app/backends/envs.py +++ b/app/backends/envs.py @@ -28,10 +28,11 @@ import hashlib import json import os import re +import shutil import subprocess import sys from pathlib import Path -from typing import Dict, List, Optional, Tuple +from typing import Dict, Iterable, List, Optional, Tuple from backends import common @@ -41,9 +42,18 @@ TTS_ROOT = Path(__file__).resolve().parent.parent.parent # 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). +# The sglang-omni backend additionally pins the interpreter *version* +# (sglang-omni requires Python >=3.10,<3.13), so its venv may be created +# from a different interpreter than the launching one (see +# compatible_interpreter / provision_env_with_uv). ENV_DIR = TTS_ROOT / "app" / "envs" / "tts" QWEN_ENV_DIR = TTS_ROOT / "app" / "envs" / "qwen" FASTER_ENV_DIR = TTS_ROOT / "app" / "envs" / "faster" +SGLOMNI_ENV_DIR = TTS_ROOT / "app" / "envs" / "sglomni" +# uv-managed standalone CPython installs (downloaded on demand when no +# 3.10-3.12 interpreter exists on the system) live here, inside the +# project, rather than uv's default ~/.local/share/uv/python. +PYTHON_INSTALL_DIR = TTS_ROOT / "app" / "envs" / "pythons" REQUIREMENTS_PATH = TTS_ROOT / "requirements.txt" # requirements.txt lines whose comment starts with this tag are installed @@ -103,21 +113,26 @@ def is_managed_env() -> bool: return False -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). +def create_env(env_dir: Optional[Path] = None, + interpreter: Optional[Path] = None) -> int: + """Create ENV (an env dir, defaulting to the app one). + + With INTERPRETER the venv is built from that Python (an absolute path, + e.g. a 3.12 found on PATH for the sglang-omni backend); otherwise the + launching interpreter is used, so the env 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 + launcher = Path(interpreter) if interpreter is not None else sys.executable print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") print(" Setting up your environment for the first time...") print(" This may take a minute.") print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") print(f"[INFO] creating managed environment at {target}...") rc = common.run_console_subprocess( - [sys.executable, "-m", "venv", str(target)]) + [str(launcher), "-m", "venv", str(target)]) if rc != 0: print(f"[ERROR] python -m venv failed (exit {rc}).") if _is_windows(): @@ -198,7 +213,9 @@ def install_requirements(skip_optional: bool = False) -> int: def pip_install(packages: List[str], *, emit=None, cancel=None, env_dir: Optional[Path] = None, - upgrade: bool = False) -> int: + upgrade: bool = False, + extra_args: Optional[List[str]] = None, + interpreter: Optional[Path] = None) -> int: """pip install PACKAGES into ENV (an env dir, default the app env), creating it first if needed. @@ -207,12 +224,15 @@ def pip_install(packages: List[str], *, emit=None, cancel=None, alongside each other or the app requirements. With UPGRADE the install runs with ``-U``: pip then resolves the latest version itself and reports "Requirement already satisfied" when the env already holds it — - the backend update action's cheap freshness check. 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. + the backend update action's cheap freshness check. EXTRA_ARGS are passed + through to pip verbatim (e.g. ``--pre`` for prerelease-resolution stacks + like sglang-omni, ``--no-deps`` for its model-companion packages); with + INTERPRETER a missing ENV is created from that Python instead of the + launching one. 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(env_dir) and create_env(env_dir) != 0: + if not env_exists(env_dir) and create_env(env_dir, interpreter) != 0: return 1 target = env_dir if env_dir is not None else ENV_DIR print(f"[INFO] pip install {' '.join(packages)} into {target}...") @@ -222,6 +242,7 @@ def pip_install(packages: List[str], *, emit=None, cancel=None, if emit is not None: argv.append("--progress-bar") argv.append("off") + argv.extend(extra_args or []) argv.extend(packages) return common.run_console_subprocess(argv, emit=emit, cancel=cancel) @@ -265,6 +286,114 @@ def module_available(module: str, env_dir: Optional[Path] = None) -> bool: return result.returncode == 0 +# -- Interpreter selection (backends with Python-version requirements) -------- +# +# sglang-omni requires Python >=3.10,<3.13 while the app itself is +# version-agnostic: a user launching ``python audiobook.py`` with 3.13+ +# must still be able to install the sglang-omni backend. These helpers +# resolve a usable interpreter for such a backend's venv: the app env's +# own interpreter when it fits, else a versioned python on PATH, else a +# uv-managed standalone CPython downloaded on demand (uv itself is +# pip-installed into the app env — no system package or root required). + +# Interpreter path names probed on PATH, newest acceptable version first. +_VERSIONED_PYTHON_NAMES = ("python3.12", "python3.11", "python3.10") + + +def python_version(python: Path) -> Optional[Tuple[int, int]]: + """The (major, minor) version of the interpreter at PYTHON, or None. + + A subprocess probe (sys.version_info) rather than the filename: a + ``python3.12`` on PATH may be anything, and the launching interpreter's + version is not parseable from its path at all. + """ + try: + proc = subprocess.run( + [str(python), "-c", + "import sys; print(sys.version_info.major, sys.version_info.minor)"], + capture_output=True, text=True, timeout=30, check=False) + except (OSError, subprocess.TimeoutExpired): + return None + try: + major, minor = proc.stdout.split() + return int(major), int(minor) + except ValueError: + return None + + +def compatible_interpreter(versions=((3, 12), (3, 11), (3, 10)), + env_dir: Optional[Path] = None, + extra_names: Tuple[str, ...] = ()) -> Optional[Path]: + """An existing interpreter whose version is in VERSIONS, or None. + + Checked in order: the managed env's own interpreter (ENV_DIR's — it + inherits the launching interpreter's version, and exists whenever the + app runs), then ``python3.X`` names for each VERSION on PATH (newest + first), then any EXTRA_NAMES (a backend may probe e.g. ``python3``). + The app interpreter first keeps the common case (a 3.10-3.12 host) + download-free; the PATH scan covers hosts that keep several Pythons + side by side (Arch's python312 AUR builds, Ubuntu deadsnakes, Homebrew + versioned formulae). + """ + names = [f"python{major}.{minor}" for major, minor in versions] + candidates: List[Path] = [] + if env_exists(env_dir): + candidates.append(env_python(env_dir)) + for name in [*names, *extra_names]: + found = shutil.which(name) + if found: + candidates.append(Path(found)) + for candidate in candidates: + version = python_version(candidate) + if version is not None and tuple(version) in {tuple(v) for v in versions}: + return candidate + return None + + +def uv_script(env_dir: Optional[Path] = None) -> Path: + """Absolute path to the uv console script installed in ENV.""" + return env_script("uv", env_dir) + + +def ensure_uv(emit=None, cancel=None) -> int: + """Make uv available inside the app env (pip install it when missing). + + uv is only needed to provision a Python for version-pinned backend + venvs (sglang-omni), so it is installed lazily into the *app* env — + never into backend envs — and its wheel exists for every platform the + backends run on (manylinux, musllinux, macOS). Returns pip's exit + code; pip's own freshness check makes a repeat call a fast no-op. + """ + if uv_script().is_file(): + return 0 + return pip_install(["uv"], emit=emit, cancel=cancel) + + +def provision_env_with_uv(env_dir: Path, python_spec: str = "3.12", + *, emit=None, cancel=None) -> int: + """Create ENV from a uv-managed PYTHON_SPEC (e.g. "3.12"). + + uv discovers a matching interpreter (system first) and downloads a + checksummed standalone CPython into PYTHON_INSTALL_DIR when none + exists — the no-prerequisites path for hosts whose only Python is + outside the backend's required range. ``--seed`` installs pip into + the new venv so the regular ``python -m pip`` helpers keep working. + UV_PYTHON_INSTALL_DIR keeps the downloaded interpreters inside the + project (survives uv cache cleans, uninstallable with the backend). + Returns the uv exit code. + """ + uv = uv_script() + if not uv.is_file(): + print("[ERROR] uv is not installed in the app environment") + return 1 + env = dict(os.environ) + env["UV_PYTHON_INSTALL_DIR"] = str(PYTHON_INSTALL_DIR) + print(f"[INFO] creating {env_dir} with uv-managed Python {python_spec} " + f"(downloaded into {PYTHON_INSTALL_DIR} when needed)...") + argv = [str(uv), "venv", "--seed", "--python", python_spec, str(env_dir)] + return common.run_console_subprocess(argv, env=env, emit=emit, cancel=cancel) + + # Import names that differ from their requirements.txt distribution name. _IMPORT_NAMES = { "beautifulsoup4": "bs4", |
