diff options
| author | historia <historiavg@proton.me> | 2026-08-26 01:18:56 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-26 01:18:56 -0400 |
| commit | 104a0d65c1ba37847c15b64212b7fec8ba371ccb (patch) | |
| tree | 71555cb9dfce790c0be998267c2dbe8be5549cc5 | |
| parent | 29aa2c8f18516e82429a9e751a74d48284f67e9c (diff) | |
| download | tts-audiobook-generator-104a0d65c1ba37847c15b64212b7fec8ba371ccb.tar.gz | |
fix: broken venv imports
| -rw-r--r-- | app/backends/envs.py | 414 | ||||
| -rw-r--r-- | app/tests/test_backends_envs.py | 370 | ||||
| -rw-r--r-- | app/tests/test_extractors.py | 24 | ||||
| -rw-r--r-- | requirements.txt | 8 |
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 |
