From 104a0d65c1ba37847c15b64212b7fec8ba371ccb Mon Sep 17 00:00:00 2001 From: historia Date: Wed, 26 Aug 2026 01:18:56 -0400 Subject: fix: broken venv imports --- app/tests/test_backends_envs.py | 370 +++++++++++++++++++++++++++++++++++++++- app/tests/test_extractors.py | 24 +++ 2 files changed, 388 insertions(+), 6 deletions(-) (limited to 'app/tests') 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 -- cgit v1.2.3