aboutsummaryrefslogtreecommitdiff
path: root/app/backends/envs.py
blob: dd693ebda2fe8a584ccb53d7bd44058b542417b1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
"""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 json
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Dict, List, Optional, Tuple

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"

# requirements.txt lines whose comment starts with this tag are installed
# best-effort: they gate features that degrade gracefully at runtime (e.g.
# faster-whisper falls back to x-vector-only cloning), so on platforms with
# no compatible wheels (ctranslate2 has no musllinux builds) the install
# retries without them instead of failing the whole bootstrap.
OPTIONAL_TAG = "# optional:"

# Marker file recording what was last installed into the env, so
# ensure_app_env() re-installs when requirements.txt changes. Its content is
# "<requirements sha256>:<MARKER_VERSION>"; bump MARKER_VERSION whenever
# ensure_app_env gains a new post-install obligation, so envs installed by
# older tool versions are re-installed (and re-verified) once on next launch.
MARKER_PATH = ENV_DIR / ".audiobook_env_ready"
MARKER_VERSION = "2"


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 _marker_applies(marker: str) -> bool:
    """Best-effort evaluation of a requirements.txt environment marker.

    Only the ``sys_platform == "win32"`` gate is interpreted (the one form
    this project uses): it applies everywhere except non-Windows hosts,
    where the entry must not be installed *or* probed. Any other marker is
    assumed to apply.
    """
    if not marker.strip():
        return True
    return not ("win32" in marker and not _is_windows())


def requirement_specs() -> List[Tuple[str, bool]]:
    """Parse requirements.txt into ``(spec, is_optional)`` pairs.

    SPEC is the pip requirement (e.g. ``faster-whisper>=1.0.0``), with
    comments stripped and environment markers evaluated best-effort by
    _marker_applies (entries excluded by their marker are left out here so
    neither the install nor the import probes see them). A line is
    optional when its comment starts with OPTIONAL_TAG. Option/flag lines
    (``-r``, ``--index-url``, ...) are ignored — this file holds plain
    requirement lines only.
    """
    try:
        lines = REQUIREMENTS_PATH.read_text(encoding="utf-8").splitlines()
    except OSError:
        return []
    specs: List[Tuple[str, bool]] = []
    for line in lines:
        req, _, comment = line.partition("#")
        optional = comment.strip().lower().startswith(OPTIONAL_TAG[2:])
        req, _, marker = req.partition(";")
        if not _marker_applies(marker):
            continue
        req = req.strip().rstrip("\\").strip()
        if not req or req.startswith(("-", "--")):
            continue
        specs.append((req, optional))
    return specs


def _base_name(spec: str) -> str:
    """The distribution name portion of a pip requirement spec."""
    return re.split(r"[<>=!~;\[ ]", spec, maxsplit=1)[0].strip()


def install_requirements(skip_optional: bool = False) -> int:
    """Install requirements.txt into the venv. Returns pip's exit code.

    With SKIP_OPTIONAL the lines tagged OPTIONAL_TAG are left out — the
    fallback for platforms where an optional dependency cannot resolve
    (see ensure_app_env, which retries core-only before giving up).
    """
    specs = requirement_specs()
    installable = [spec for spec, optional in specs
                   if not (skip_optional and optional)]
    if not installable:
        print(f"[ERROR] no installable requirements found in {REQUIREMENTS_PATH}")
        return 1
    skipped = [spec for spec, optional in specs if optional]
    label = str(REQUIREMENTS_PATH) if not skip_optional else \
        f"{REQUIREMENTS_PATH} (without optional: {', '.join(_base_name(s) for s in skipped)})"
    print(f"[INFO] pip install {label} into {ENV_DIR}...")
    return common.run_console_subprocess(
        [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.

    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.
    """
    if not env_exists() and create_env() != 0:
        return 1
    print(f"[INFO] pip install {' '.join(packages)} into {ENV_DIR}...")
    argv = [str(env_python()), "-m", "pip", "install"]
    if emit is not None:
        argv.append("--progress-bar")
        argv.append("off")
    argv.extend(packages)
    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.

    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.
    """
    if not env_exists():
        return 0
    print(f"[INFO] pip uninstall {' '.join(packages)} from {ENV_DIR}...")
    return common.run_console_subprocess(
        [str(env_python()), "-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).

    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
    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


# Import names that differ from their requirements.txt distribution name.
_IMPORT_NAMES = {
    "beautifulsoup4": "bs4",
    "faster-whisper": "faster_whisper",
}


def _imports_ok(import_names: List[str], *, python: Optional[Path] = None) -> bool:
    """True when every name in IMPORT_NAMES imports inside the target env.

    One combined probe subprocess: a healthy env costs a single interpreter
    start-up; only failures are isolated per-name afterwards.
    """
    python = python or env_python()
    try:
        result = subprocess.run(
            [str(python), "-c", f"import {', '.join(import_names)}"],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
            timeout=300, check=False)
    except (OSError, subprocess.TimeoutExpired):
        return False
    return result.returncode == 0


# Runs INSIDE the target env: collect one candidate import name per installed
# distribution (its top_level.txt, falling back to the normalized project
# name) and actually import each, reporting the failures. Two subtleties the
# wrong-platform-wheel case demands: find_spec gates the heuristic fallback
# names (a distribution like protobuf exposes no top-level "protobuf" module,
# and guessing must not turn that into a false positive), and distributions
# shipping compiled extensions get their extension submodules probed too —
# lxml's pure-Python __init__ imports fine while every binary submodule is
# missing.
_ENV_SCAN_CODE = """
import importlib.metadata as md
import importlib.util as iu
import json
import os

names = set()
for dist in md.distributions():
    top = dist.read_text("top_level.txt")
    if top:
        names.update(part.strip() for part in top.split())
    else:
        name = (dist.metadata.get("Name") or "").strip()
        if name:
            names.add(name.lower().replace("-", "_"))


def compiled_submodules(top):
    \"\"\"top.* extension modules worth probing (only for .so-shipping tops).\"\"\"
    try:
        spec = iu.find_spec(top)
        if spec is None or not spec.submodule_search_locations:
            return []
        mods = []
        for location in spec.submodule_search_locations:
            mods.extend(
                top + "." + entry.split(".")[0]
                for entry in os.listdir(location)
                if entry.endswith(".so")
                and entry.split(".")[0].isidentifier()
                and not entry.split(".")[0].startswith("_"))
        return mods
    except Exception:
        return []


failed = []
for name in sorted(names):
    if not name.isidentifier() or name.startswith("_"):
        continue
    try:
        if iu.find_spec(name) is None:
            continue
        __import__(name)
        probes = compiled_submodules(name)
    except Exception:
        failed.append(name)
        continue
    for module in probes:
        try:
            __import__(module)
        except Exception:
            failed.append(name)
            break
print(json.dumps(failed))
"""


def scanned_broken_imports(*, python: Optional[Path] = None) -> Optional[List[str]]:
    """Every installed distribution whose top-level import fails in the env.

    Unlike broken_imports this sees transitive dependencies too (ebooklib's
    lxml, faster-whisper's ctranslate2), where wrong-platform wheels do
    their silent damage. Returns None when the scan itself could not run.
    """
    python = python or env_python()
    try:
        proc = subprocess.run([str(python), "-c", _ENV_SCAN_CODE],
                              capture_output=True, text=True,
                              timeout=600, check=False)
        return json.loads(proc.stdout.strip().splitlines()[-1])
    except (OSError, subprocess.TimeoutExpired, ValueError, IndexError):
        return None


def broken_imports(import_names: List[str],
                   *, python: Optional[Path] = None) -> List[str]:
    """The subset of IMPORT_NAMES that fails to import inside the env.

    Targeted form of scanned_broken_imports: names are probed individually,
    so callers get exactly which of the given names are broken.
    """
    python = python or env_python()
    if not import_names:
        return []
    if _imports_ok(import_names, python=python):
        return []
    return [name for name in import_names
            if not _imports_ok([name], python=python)]


def _venv_tags(python: Optional[Path] = None) -> Optional[dict]:
    """Platform facts of the target env's interpreter, or None on any failure.

    Returns ``{"musl": bool, "pyver": "3.14", "impl": "cp", "abi": "cp314",
    "arch": "x86_64"}`` — everything pip's ``--platform`` repair needs —
    derived from EXT_SUFFIX (``.cpython-314-x86_64-linux-musl.so``) rather
    than sysconfig.get_platform(), which misreports ``linux-x86_64`` for
    portable musl builds and is what lures pip into glibc wheels.
    """
    python = python or env_python()
    code = ("import json, sys, sysconfig; suffix = "
            "sysconfig.get_config_var('EXT_SUFFIX') or ''; "
            "print(json.dumps({'suffix': suffix, "
            "'vi': list(sys.version_info[:2])}))")
    try:
        proc = subprocess.run([str(python), "-c", code],
                              capture_output=True, text=True,
                              timeout=60, check=False)
        data = json.loads(proc.stdout.strip().splitlines()[-1])
    except (OSError, subprocess.TimeoutExpired, ValueError, IndexError):
        return None
    match = re.match(r"\.([a-z]+)-(\d+)-([^-]+)-", data["suffix"])
    if match is None:
        return None
    impl, version, arch = match.groups()
    major, minor = data["vi"]
    return {"musl": "-musl" in data["suffix"],
            "pyver": f"{major}.{minor}",
            "impl": impl[:2],
            "abi": f"{impl[:2]}{version}",
            "arch": arch}


def _installed_specs(python: Optional[Path] = None) -> Dict[str, str]:
    """Map canonical distribution name -> pinned spec (``name==version``).

    Reads ``pip list --format=json`` from the target env so repairs pin the
    exact installed version instead of re-resolving (and never confuse an
    import name like ``bs4`` with a same-named-but-different PyPI project).
    """
    python = python or env_python()
    try:
        proc = subprocess.run(
            [str(python), "-m", "pip", "list", "--format=json",
             "--disable-pip-version-check"],
            capture_output=True, text=True, timeout=120, check=False)
        entries = json.loads(proc.stdout)
    except (OSError, subprocess.TimeoutExpired, ValueError):
        return {}
    specs: Dict[str, str] = {}
    for entry in entries:
        name = entry.get("name")
        version = entry.get("version")
        if name and version:
            canonical = re.sub(r"[-_.]+", "-", name).lower()
            specs.setdefault(canonical, f"{name}=={version}")
    return specs


def _spec_for_import(name: str, installed: Dict[str, str]) -> Optional[str]:
    """Pinned spec for the dist behind import NAME, or None when unknown."""
    for candidate in (name,
                      next((k for k, v in _IMPORT_NAMES.items()
                            if v == name), None)):
        if candidate and re.sub(r"[-_.]+", "-", candidate).lower() in installed:
            return installed[re.sub(r"[-_.]+", "-", candidate).lower()]
    return None


def _site_packages(python: Path) -> Optional[Path]:
    """The env interpreter's pure-Python site-packages directory."""
    try:
        proc = subprocess.run(
            [str(python), "-c",
             "import sysconfig; print(sysconfig.get_paths()['purelib'])"],
            capture_output=True, text=True, timeout=60, check=False)
        return Path(proc.stdout.strip().splitlines()[-1])
    except (OSError, subprocess.TimeoutExpired, IndexError):
        return None


_MUSLLINUX_PLATFORMS = ("musllinux_1_2", "musllinux_1_1")


def repair_imports(broken: List[str], *, python: Optional[Path] = None,
                   emit=None) -> List[str]:
    """Reinstall BROKEN packages from musllinux wheels where possible.

    One targeted reinstall per package: pip uninstalls the mismatched
    distribution and reinstalls the exact same version with the platform
    override flags plus ``--target`` into the env's site-packages, so the
    resolver can only pick musllinux wheels (matching this env's
    interpreter) instead of the glibc wheels it guessed before. Returns
    the names still broken after the attempt — typically packages with no
    musllinux builds at all, which the caller should report as unavailable.
    """
    python = python or env_python()
    tags = _venv_tags(python)
    if tags is None or not tags["musl"] or not broken:
        return list(broken)
    site_packages = _site_packages(python)
    if site_packages is None:
        return list(broken)
    installed = _installed_specs(python)
    still_broken = []
    for name in broken:
        spec = _spec_for_import(name, installed)
        if spec is None:
            print(f"[WARN] {name}: cannot determine the installed package; "
                  "not repaired")
            still_broken.append(name)
            continue
        common.run_console_subprocess(
            [str(python), "-m", "pip", "uninstall", "-y",
             _base_name(spec)], emit=emit)
        argv = [str(python), "-m", "pip", "install", "--no-deps",
                "--upgrade", "--only-binary=:all:",
                "--target", str(site_packages),
                "--python-version", tags["pyver"],
                "--implementation", tags["impl"],
                "--abi", tags["abi"]]
        for platform_base in _MUSLLINUX_PLATFORMS:
            argv += ["--platform", f"{platform_base}_{tags['arch']}"]
        argv.append(spec)
        print(f"[INFO] reinstalling from musllinux wheels: {spec}")
        rc = common.run_console_subprocess(argv, emit=emit)
        if rc != 0:
            print(f"[WARN] {name}: reinstalling {spec} from musllinux "
                  f"wheels failed (pip exit {rc})")
            still_broken.append(name)
    # Verify with the deep scan: a top-level import can succeed while the
    # compiled submodules underneath it still cannot load (lxml's
    # pure-Python __init__ hides exactly this).
    attempted = [name for name in broken if name not in still_broken]
    deep = scanned_broken_imports(python=python)
    if deep is None:
        return list(broken)
    return sorted(set(still_broken)
                  | {name for name in attempted if name in deep})


def _requirements_sha() -> str:
    try:
        data = REQUIREMENTS_PATH.read_bytes()
    except OSError:
        return ""
    return hashlib.sha256(data).hexdigest()


def _marker_valid() -> bool:
    """True when the marker matches both the requirements hash and MARKER_VERSION.

    A bare-hash marker (written by tool versions before MARKER_VERSION
    existed) is treated as invalid, so envs installed before a new
    post-install obligation was added get one re-install + verification.
    """
    try:
        expected = f"{_requirements_sha()}:{MARKER_VERSION}"
        return MARKER_PATH.read_text(encoding="utf-8").strip() == expected
    except OSError:
        return False


def _write_marker() -> None:
    try:
        content = f"{_requirements_sha()}:{MARKER_VERSION}\n"
        MARKER_PATH.write_text(content, encoding="utf-8")
    except OSError:
        pass


def ensure_importable(*, emit=None) -> List[str]:
    """Verify every installed distribution actually imports inside the venv.

    Wheels built for the wrong platform can install "successfully" (pip
    exits 0) while their compiled modules fail to import — glibc wheels
    under a musl interpreter, or a pip wheel cache primed on another
    machine. Broken packages are reinstalled from musllinux wheels when
    possible; whatever cannot be repaired (no compatible build exists) is
    returned so callers can warn about the features it takes down. EMIT
    streams pip's output to an in-TUI task view when given.
    """
    failed = scanned_broken_imports()
    if failed is None:
        print("[WARN] could not scan the managed environment's imports")
        return []
    if not failed:
        return []
    print(f"[WARN] these installed packages fail to import inside "
          f"{ENV_DIR}: {', '.join(failed)}")
    remaining = repair_imports(failed, emit=emit)
    if remaining:
        print(f"[WARN] could not repair: {', '.join(remaining)}. The "
              "related features will be unavailable until compatible "
              "builds exist for this platform.")
    else:
        print("[OK] repaired all previously broken imports")
    return remaining


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 +
    version marker). If the full install cannot resolve — platforms whose
    wheels don't cover some OPTIONAL_TAG dependency — it retries without the
    optional lines rather than failing the bootstrap. Afterwards the imports
    are verified and wrong-platform wheels repaired (ensure_importable).
    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:
            skipped = [spec for spec, optional in requirement_specs()
                       if optional]
            if not skipped:
                raise RuntimeError("pip install -r requirements.txt failed")
            print(f"[WARN] retrying without optional requirements: "
                  f"{', '.join(_base_name(spec) for spec in skipped)}")
            if install_requirements(skip_optional=True) != 0:
                raise RuntimeError("pip install -r requirements.txt failed")
        ensure_importable()
        _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:]])