aboutsummaryrefslogtreecommitdiff
path: root/app/backends/envs.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/backends/envs.py')
-rw-r--r--app/backends/envs.py151
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",