aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-26 01:18:56 -0400
committerhistoria <historiavg@proton.me>2026-08-26 01:18:56 -0400
commit104a0d65c1ba37847c15b64212b7fec8ba371ccb (patch)
tree71555cb9dfce790c0be998267c2dbe8be5549cc5
parent29aa2c8f18516e82429a9e751a74d48284f67e9c (diff)
downloadtts-audiobook-generator-104a0d65c1ba37847c15b64212b7fec8ba371ccb.tar.gz
fix: broken venv imports
-rw-r--r--app/backends/envs.py414
-rw-r--r--app/tests/test_backends_envs.py370
-rw-r--r--app/tests/test_extractors.py24
-rw-r--r--requirements.txt8
4 files changed, 794 insertions, 22 deletions
diff --git a/app/backends/envs.py b/app/backends/envs.py
index cfeeec6..dd693eb 100644
--- a/app/backends/envs.py
+++ b/app/backends/envs.py
@@ -20,10 +20,13 @@ 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 List
+from typing import Dict, List, Optional, Tuple
from backends import common
@@ -34,9 +37,20 @@ TTS_ROOT = Path(__file__).resolve().parent.parent.parent
ENV_DIR = TTS_ROOT / "app" / "envs" / "tts"
REQUIREMENTS_PATH = TTS_ROOT / "requirements.txt"
-# Marker file recording the requirements.txt hash last installed into the env,
-# so ensure_app_env() re-installs when requirements.txt changes.
+# 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:
@@ -87,11 +101,72 @@ def create_env() -> int:
return rc
-def install_requirements() -> int:
- """pip install -r requirements.txt into the venv. Returns pip's exit code."""
- print(f"[INFO] pip install -r {REQUIREMENTS_PATH} into {ENV_DIR}...")
+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", "-r", str(REQUIREMENTS_PATH)])
+ [str(env_python()), "-m", "pip", "install", *installable])
def pip_install(packages: List[str], *, emit=None, cancel=None) -> int:
@@ -139,7 +214,6 @@ def module_available(module: str) -> bool:
"""
if not env_exists():
return False
- import subprocess
try:
result = subprocess.run(
[str(env_python()), "-c", f"import {module}"],
@@ -150,6 +224,271 @@ def module_available(module: str) -> bool:
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()
@@ -159,31 +498,80 @@ def _requirements_sha() -> str:
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:
- return MARKER_PATH.read_text(encoding="utf-8").strip() == _requirements_sha()
+ 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:
- MARKER_PATH.write_text(_requirements_sha() + "\n", encoding="utf-8")
+ 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 marker).
+ 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:
- raise RuntimeError("pip install -r requirements.txt failed")
+ 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()
diff --git a/app/tests/test_backends_envs.py b/app/tests/test_backends_envs.py
index 82d903a..184a3b3 100644
--- a/app/tests/test_backends_envs.py
+++ b/app/tests/test_backends_envs.py
@@ -1,5 +1,6 @@
"""Tests for the managed Python environment (backends/envs.py)."""
+import json
import sys
import unittest
from pathlib import Path
@@ -181,14 +182,113 @@ class ModuleAvailableTests(unittest.TestCase):
self.assertFalse(envs.module_available("qwen_tts"))
+class RequirementSpecsTests(unittest.TestCase):
+ """requirements.txt parsing (specs, optional tags, markers)."""
+
+ SAMPLE = (
+ "# Core dependencies\n"
+ "gradio_client>=0.7.0\n"
+ "\n"
+ "pypdf\n"
+ "beautifulsoup4>=4.11.0 # optional: HTML cleaning (stdlib fallback)\n"
+ "faster-whisper>=1.0.0 # optional: transcription\n"
+ "windows-curses>=2.3; sys_platform == \"win32\" # TUI on Windows\n"
+ "-e ./local\n"
+ "--extra-index-url https://example.com/simple\n"
+ )
+
+ def setUp(self):
+ import tempfile
+ self._tmp = tempfile.TemporaryDirectory()
+ self.path = Path(self._tmp.name) / "requirements.txt"
+ self.addCleanup(self._tmp.cleanup)
+
+ def _patch_path(self, content):
+ self.path.write_text(content, encoding="utf-8")
+ return patch.object(envs, "REQUIREMENTS_PATH", self.path)
+
+ def test_parses_specs_and_optional_tags(self):
+ with self._patch_path(self.SAMPLE):
+ specs = envs.requirement_specs()
+ # posix host: the win32-marker line is dropped, option lines ignored,
+ # comments stripped, version specifiers kept verbatim.
+ self.assertEqual(specs, [
+ ("gradio_client>=0.7.0", False),
+ ("pypdf", False),
+ ("beautifulsoup4>=4.11.0", True),
+ ("faster-whisper>=1.0.0", True),
+ ])
+
+ def test_win32_marker_applies_on_windows(self):
+ with self._patch_path(self.SAMPLE), \
+ patch.object(envs, "_is_windows", return_value=True):
+ names = [spec for spec, _ in envs.requirement_specs()]
+ self.assertIn("windows-curses>=2.3", names)
+
+ def test_missing_file_yields_nothing(self):
+ with patch.object(envs, "REQUIREMENTS_PATH",
+ Path("/no/such/requirements.txt")):
+ self.assertEqual(envs.requirement_specs(), [])
+
+
+class MarkerTests(unittest.TestCase):
+ """The hash + version marker that gates re-installation."""
+
+ def setUp(self):
+ import tempfile
+ self._tmp = tempfile.TemporaryDirectory()
+ req = Path(self._tmp.name) / "requirements.txt"
+ req.write_bytes(b"pypdf\n")
+ marker = Path(self._tmp.name) / ".audiobook_env_ready"
+ self.addCleanup(self._tmp.cleanup)
+ self.patches = [patch.object(envs, "REQUIREMENTS_PATH", req),
+ patch.object(envs, "MARKER_PATH", marker)]
+ for p in self.patches:
+ p.start()
+ self.addCleanup(p.stop)
+ self.marker = marker
+
+ def test_valid_marker_matches_hash_and_version(self):
+ self.marker.write_text(f"{envs._requirements_sha()}:2\n",
+ encoding="utf-8")
+ with patch.object(envs, "MARKER_VERSION", "2"):
+ self.assertTrue(envs._marker_valid())
+
+ def test_version_mismatch_invalidates_marker(self):
+ self.marker.write_text(f"{envs._requirements_sha()}:1\n",
+ encoding="utf-8")
+ self.assertFalse(envs._marker_valid())
+
+ def test_legacy_bare_hash_marker_is_invalid(self):
+ # Markers written by tool versions before MARKER_VERSION existed.
+ self.marker.write_text(envs._requirements_sha() + "\n",
+ encoding="utf-8")
+ self.assertFalse(envs._marker_valid())
+
+ def test_write_marker_records_current_hash_and_version(self):
+ envs._write_marker()
+ self.assertEqual(self.marker.read_text(encoding="utf-8"),
+ f"{envs._requirements_sha()}:{envs.MARKER_VERSION}\n")
+
+ def test_missing_marker_is_invalid(self):
+ self.assertFalse(envs._marker_valid())
+
+
class EnsureAppEnvTests(unittest.TestCase):
- def test_creates_env_then_installs_when_marker_invalid(self):
+ """Bootstrap: install, optional-fallback, verification, marker."""
+
+ def test_installs_verifies_then_writes_marker(self):
with patch.object(envs, "env_exists", return_value=False), \
patch.object(envs, "create_env", return_value=0), \
patch.object(envs, "_marker_valid", return_value=False), \
- patch.object(envs, "install_requirements", return_value=0), \
+ patch.object(envs, "install_requirements",
+ return_value=0) as install, \
+ patch.object(envs, "ensure_importable",
+ return_value=[]) as verify, \
patch.object(envs, "_write_marker") as mk:
envs.ensure_app_env()
+ install.assert_called_once_with()
+ verify.assert_called_once_with()
mk.assert_called_once_with()
def test_raises_when_create_fails(self):
@@ -197,19 +297,277 @@ class EnsureAppEnvTests(unittest.TestCase):
with self.assertRaises(RuntimeError):
envs.ensure_app_env()
- def test_raises_when_install_fails(self):
+ def test_retries_without_optionals_when_install_fails(self):
+ specs = [("core-a", False), ("opt-b", True)]
+ with patch.object(envs, "env_exists", return_value=True), \
+ patch.object(envs, "_marker_valid", return_value=False), \
+ patch.object(envs, "requirement_specs",
+ return_value=specs), \
+ patch.object(envs, "install_requirements",
+ side_effect=[1, 0]) as install, \
+ patch.object(envs, "ensure_importable", return_value=[]), \
+ patch.object(envs, "_write_marker"):
+ envs.ensure_app_env()
+ self.assertEqual(install.call_args_list[0], ())
+ install.assert_any_call(skip_optional=True)
+
+ def test_raises_when_both_install_attempts_fail(self):
+ specs = [("core-a", False), ("opt-b", True)]
+ with patch.object(envs, "env_exists", return_value=True), \
+ patch.object(envs, "_marker_valid", return_value=False), \
+ patch.object(envs, "requirement_specs",
+ return_value=specs), \
+ patch.object(envs, "install_requirements", return_value=1), \
+ patch.object(envs, "ensure_importable") as verify:
+ with self.assertRaises(RuntimeError):
+ envs.ensure_app_env()
+ verify.assert_not_called()
+
+ def test_raises_on_failure_without_optionals(self):
+ specs = [("core-a", False)]
with patch.object(envs, "env_exists", return_value=True), \
patch.object(envs, "_marker_valid", return_value=False), \
+ patch.object(envs, "requirement_specs",
+ return_value=specs), \
patch.object(envs, "install_requirements", return_value=1):
with self.assertRaises(RuntimeError):
envs.ensure_app_env()
- def test_skips_install_when_marker_valid(self):
+ def test_skips_install_and_verify_when_marker_valid(self):
with patch.object(envs, "env_exists", return_value=True), \
patch.object(envs, "_marker_valid", return_value=True), \
- patch.object(envs, "install_requirements") as mk:
+ patch.object(envs, "install_requirements") as install, \
+ patch.object(envs, "ensure_importable") as verify:
envs.ensure_app_env()
- mk.assert_not_called()
+ install.assert_not_called()
+ verify.assert_not_called()
+
+
+class BrokenImportsTests(unittest.TestCase):
+ def test_empty_names_short_circuits_without_probe(self):
+ with patch.object(envs, "_imports_ok") as ok:
+ self.assertEqual(envs.broken_imports([], python=Path("/x/py")), [])
+ ok.assert_not_called()
+
+ def test_healthy_combined_probe_skips_isolation(self):
+ with patch.object(envs, "_imports_ok", return_value=True) as ok:
+ self.assertEqual(
+ envs.broken_imports(["a", "b"], python=Path("/x/py")), [])
+ self.assertEqual(ok.call_count, 1)
+
+ def test_isolates_each_broken_name(self):
+ def fake_ok(names, *, python=None):
+ # Only the good name imports cleanly; every other probe fails,
+ # including the combined short-circuit probe.
+ return names == ["good"]
+
+ with patch.object(envs, "_imports_ok", side_effect=fake_ok):
+ broken = envs.broken_imports(["good", "bad"], python=Path("/p"))
+ self.assertEqual(broken, ["bad"])
+
+
+class ScannedBrokenImportsTests(unittest.TestCase):
+ @staticmethod
+ def _proc(stdout):
+ import subprocess
+ return subprocess.CompletedProcess(args=[], returncode=0,
+ stdout=stdout, stderr="")
+
+ def test_parses_reported_failures(self):
+ with patch("subprocess.run", return_value=self._proc('["lxml"]\n')):
+ self.assertEqual(envs.scanned_broken_imports(
+ python=Path("/x/py")), ["lxml"])
+
+ def test_healthy_env_reports_no_failures(self):
+ with patch("subprocess.run", return_value=self._proc("[]\n")):
+ self.assertEqual(
+ envs.scanned_broken_imports(python=Path("/x/py")), [])
+
+ def test_unparsable_output_returns_none(self):
+ with patch("subprocess.run", return_value=self._proc("")):
+ self.assertIsNone(
+ envs.scanned_broken_imports(python=Path("/x/py")))
+
+ def test_missing_interpreter_returns_none(self):
+ with patch("subprocess.run", side_effect=OSError("nope")):
+ self.assertIsNone(
+ envs.scanned_broken_imports(python=Path("/x/py")))
+
+
+class VenvTagsTests(unittest.TestCase):
+ @staticmethod
+ def _tags_for(suffix, vi=(3, 14)):
+ import subprocess
+ payload = json.dumps({"suffix": suffix, "vi": list(vi)})
+ proc = subprocess.CompletedProcess(args=[], returncode=0,
+ stdout=payload + "\n", stderr="")
+ return patch("subprocess.run", return_value=proc)
+
+ def test_musl_suffix(self):
+ with self._tags_for(".cpython-314-x86_64-linux-musl.so"):
+ tags = envs._venv_tags(Path("/x/py"))
+ self.assertEqual(tags, {"musl": True, "pyver": "3.14", "impl": "cp",
+ "abi": "cp314", "arch": "x86_64"})
+
+ def test_glibc_suffix(self):
+ with self._tags_for(".cpython-312-x86_64-linux-gnu.so", (3, 12)):
+ tags = envs._venv_tags(Path("/x/py"))
+ self.assertFalse(tags["musl"])
+ self.assertEqual(tags["abi"], "cp312")
+
+ def test_unparsable_suffix_returns_none(self):
+ with self._tags_for("weird"):
+ self.assertIsNone(envs._venv_tags(Path("/x/py")))
+
+ def test_dead_interpreter_returns_none(self):
+ with patch("subprocess.run", side_effect=OSError("nope")):
+ self.assertIsNone(envs._venv_tags(Path("/x/py")))
+
+
+class InstalledSpecsTests(unittest.TestCase):
+ @staticmethod
+ def _proc(payload):
+ import subprocess
+ return subprocess.CompletedProcess(args=[], returncode=0,
+ stdout=payload, stderr="")
+
+ def test_pins_exact_name_and_version(self):
+ payload = json.dumps([{"name": "BeautifulSoup4", "version": "4.15.0"},
+ {"name": "lxml", "version": "6.1.2"}])
+ with patch("subprocess.run", return_value=self._proc(payload)):
+ specs = envs._installed_specs(Path("/x/py"))
+ self.assertEqual(specs.get("beautifulsoup4"),
+ "BeautifulSoup4==4.15.0")
+ self.assertEqual(specs.get("lxml"), "lxml==6.1.2")
+
+ def test_bad_output_yields_no_specs(self):
+ with patch("subprocess.run", return_value=self._proc("garbage")):
+ self.assertEqual(envs._installed_specs(Path("/x/py")), {})
+
+
+class SpecForImportTests(unittest.TestCase):
+ INSTALLED = {"beautifulsoup4": "beautifulsoup4==4.15.0",
+ "lxml": "lxml==6.1.2"}
+
+ def test_direct_distribution_hit(self):
+ self.assertEqual(envs._spec_for_import("lxml", self.INSTALLED),
+ "lxml==6.1.2")
+
+ def test_mapped_import_name(self):
+ self.assertEqual(envs._spec_for_import("bs4", self.INSTALLED),
+ "beautifulsoup4==4.15.0")
+
+ def test_unknown_import_has_no_spec(self):
+ self.assertIsNone(envs._spec_for_import("mystery", self.INSTALLED))
+
+
+class RepairImportsTests(unittest.TestCase):
+ MUSL_TAGS = {"musl": True, "pyver": "3.14", "impl": "cp",
+ "abi": "cp314", "arch": "x86_64"}
+
+ def repair(self, broken, *, rc=0, scan=None):
+ """Run repair_imports with the subprocess layer fully mocked."""
+ calls = []
+
+ def fake_run(argv, emit=None):
+ calls.append(list(argv))
+ return rc
+
+ with patch.object(envs, "_venv_tags", return_value=dict(self.MUSL_TAGS)), \
+ patch.object(envs, "_site_packages",
+ return_value=Path("/env/site-packages")), \
+ patch.object(envs, "_installed_specs",
+ return_value={"lxml": "lxml==6.1.2",
+ "ctranslate2":
+ "ctranslate2==4.8.1"}), \
+ patch.object(envs.common, "run_console_subprocess",
+ side_effect=fake_run), \
+ patch.object(envs, "scanned_broken_imports",
+ return_value=scan):
+ remaining = envs.repair_imports(broken, python=Path("/env/py"))
+ return remaining, calls
+
+ def test_installs_via_target_with_musllinux_overrides(self):
+ remaining, calls = self.repair(["lxml"], rc=0, scan=[])
+ self.assertEqual(remaining, [])
+ installs = [argv for argv in calls if "install" in argv]
+ self.assertEqual(len(installs), 1)
+ argv = installs[0]
+ self.assertIn("--target", argv)
+ self.assertIn(str(Path("/env/site-packages")), argv)
+ self.assertIn("--only-binary=:all:", argv)
+ self.assertIn("--upgrade", argv)
+ self.assertIn("--abi", argv)
+ self.assertEqual(argv[argv.index("--abi") + 1], "cp314")
+ platforms = [argv[i + 1] for i, part in enumerate(argv)
+ if part == "--platform"]
+ self.assertEqual(platforms, ["musllinux_1_2_x86_64",
+ "musllinux_1_1_x86_64"])
+ self.assertEqual(argv[-1], "lxml==6.1.2")
+
+ def test_uninstalls_before_reinstalling(self):
+ _, calls = self.repair(["lxml"], rc=0, scan=[])
+ uninstalls = [argv for argv in calls if "uninstall" in argv]
+ self.assertEqual(len(uninstalls), 1)
+ self.assertIn("-y", uninstalls[0])
+ self.assertIn("lxml", uninstalls[0])
+
+ def test_failed_pip_call_keeps_package_broken(self):
+ remaining, _ = self.repair(["lxml"], rc=1, scan=["lxml"])
+ self.assertEqual(remaining, ["lxml"])
+
+ def test_unknown_distribution_is_not_repaired(self):
+ remaining, calls = self.repair(["mystery"], rc=0, scan=["mystery"])
+ self.assertEqual(remaining, ["mystery"])
+ self.assertEqual(calls, [])
+
+ def test_deep_scan_vetoes_shallow_success(self):
+ # The reinstall exits 0 but the deep scan still flags lxml.
+ remaining, _ = self.repair(["lxml"], rc=0, scan=["lxml"])
+ self.assertEqual(remaining, ["lxml"])
+
+ def test_non_musl_env_skips_repair_entirely(self):
+ calls = []
+ with patch.object(envs, "_venv_tags",
+ return_value={"musl": False, "pyver": "3.14",
+ "impl": "cp", "abi": "cp314",
+ "arch": "x86_64"}), \
+ patch.object(envs.common, "run_console_subprocess",
+ side_effect=lambda a, emit=None:
+ calls.append(a)):
+ remaining = envs.repair_imports(["lxml"], python=Path("/env/py"))
+ self.assertEqual(remaining, ["lxml"])
+ self.assertEqual(calls, [])
+
+
+class EnsureImportableTests(unittest.TestCase):
+ def test_healthy_scan_repairs_nothing(self):
+ with patch.object(envs, "scanned_broken_imports", return_value=[]), \
+ patch.object(envs, "repair_imports") as repair:
+ self.assertEqual(envs.ensure_importable(), [])
+ repair.assert_not_called()
+
+ def test_failed_scan_warns_without_touching_pip(self):
+ with patch.object(envs, "scanned_broken_imports",
+ return_value=None), \
+ patch.object(envs, "repair_imports") as repair:
+ self.assertEqual(envs.ensure_importable(), [])
+ repair.assert_not_called()
+
+ def test_broken_packages_are_repaired(self):
+ with patch.object(envs, "scanned_broken_imports",
+ return_value=["lxml"]), \
+ patch.object(envs, "repair_imports",
+ return_value=[]) as repair:
+ self.assertEqual(envs.ensure_importable(), [])
+ repair.assert_called_once_with(["lxml"], emit=None)
+
+ def test_unrepairable_packages_are_returned(self):
+ with patch.object(envs, "scanned_broken_imports",
+ return_value=["ctranslate2"]), \
+ patch.object(envs, "repair_imports",
+ return_value=["ctranslate2"]):
+ self.assertEqual(envs.ensure_importable(), ["ctranslate2"])
class BootstrapTests(unittest.TestCase):
diff --git a/app/tests/test_extractors.py b/app/tests/test_extractors.py
index ae1794c..64666ba 100644
--- a/app/tests/test_extractors.py
+++ b/app/tests/test_extractors.py
@@ -7,6 +7,25 @@ from pathlib import Path
from converter.extractors import extract_text
+def _ebooklib_usable() -> bool:
+ """True when ebooklib's EPUB reader imports (it needs a working lxml)."""
+ try:
+ from ebooklib import epub # noqa: F401
+ except Exception:
+ return False
+ return True
+
+
+# The managed env can end up with compiled wheels that cannot load on this
+# platform (e.g. glibc lxml under a musl interpreter) — the tool repairs or
+# degrades at runtime, and these tests must degrade with it instead of
+# failing. Relaunch audiobook.py once (or delete app/envs/tts) to rebuild.
+requires_epub = unittest.skipUnless(
+ _ebooklib_usable(),
+ "ebooklib is unusable in this environment "
+ "(its compiled dependency failed to import)")
+
+
class TxtExtractionTests(unittest.TestCase):
def _extract(self, data: bytes) -> str:
with tempfile.TemporaryDirectory() as tmp:
@@ -67,6 +86,7 @@ class EpubExtractionTests(unittest.TestCase):
except ImportError:
self.skipTest("ebooklib not installed")
+ @requires_epub
def test_ebooklib_extraction(self):
# Regression test: the ebooklib path used to silently return "" due to
# isinstance(item, ebooklib.ITEM_DOCUMENT) (an int, not a class).
@@ -81,6 +101,7 @@ class EpubExtractionTests(unittest.TestCase):
self.assertIn("First chapter text.", html)
self.assertIn("Second chapter text.", html)
+ @requires_epub
def test_epub_extraction_follows_spine_order(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "book.epub"
@@ -100,6 +121,7 @@ class ExtractSectionsTests(unittest.TestCase):
except ImportError:
self.skipTest("ebooklib not installed")
+ @requires_epub
def test_epub_sections_split_on_chapters(self):
from converter.extractors import extract_sections
@@ -126,6 +148,7 @@ class ExtractSectionsTests(unittest.TestCase):
self.assertEqual(sections[0].title, "book")
self.assertEqual(sections[0].text, "Hello world.")
+ @requires_epub
def test_single_chapter_epub_keeps_chapter_title(self):
from converter.extractors import extract_sections
@@ -152,6 +175,7 @@ class ExtractBookTests(unittest.TestCase):
self.assertEqual(book.author, "")
self.assertEqual(len(book.sections), 1)
+ @requires_epub
def test_epub_metadata_harvested(self):
try:
import ebooklib # noqa: F401
diff --git a/requirements.txt b/requirements.txt
index 17e94f5..9478b64 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,9 +3,11 @@ gradio_client>=0.7.0
pypdf>=4.0.0
ebooklib>=0.18
-# Optional dependencies
-beautifulsoup4>=4.11.0 # better HTML cleaning for EPUB
-faster-whisper>=1.0.0 # reference-audio transcription for voice cloning
+# Optional dependencies — features degrade gracefully without them
+# (tagged "# optional:" so the env bootstrap can skip them on platforms
+# with no compatible wheels instead of failing the install)
+beautifulsoup4>=4.11.0 # optional: better HTML cleaning for EPUB (stdlib fallback)
+faster-whisper>=1.0.0 # optional: reference-audio transcription (falls back to x-vector-only cloning)
windows-curses>=2.3; sys_platform == "win32" # enables the TUI on Windows
# Audio processing