"""Tests for :mod:`iso_scalpel._deps`. The tests are hermetic: they never execute a real ``pip install`` or ``pacman -S``. The runner and ``input`` functions are injected so the install flow can be exercised end-to-end without touching the network or the filesystem. """ from __future__ import annotations import sys from unittest import mock import pytest from iso_scalpel import _deps from iso_scalpel._deps import ( Dependency, DistroInfo, MissingDependency, _check_in_venv, _distro_pkg_manager, _distro_pkg_name, build_strategies, check_dependencies, check_dependency, detect_distro, ensure_dependencies, find_pipx_venv, format_missing_report, offer_to_install, ) # ========================================================================== # Fixtures # ========================================================================== @pytest.fixture def fake_dep() -> Dependency: return Dependency( import_name="fake_pkg_xyz", pip_name="fake-pkg-xyz", min_version="1.0", purpose="A made-up package for tests.", distro_packages={ "arch": "python-fake-pkg-xyz", "debian": "python3-fake-pkg-xyz", "ubuntu": "python3-fake-pkg-xyz", "fedora": "python3-fake-pkg-xyz", }, ) @pytest.fixture def restore_imports(): """Snapshot & restore sys.modules so we can fake-remove a package.""" snapshot = dict(sys.modules) yield for k in list(sys.modules.keys()): if k not in snapshot: del sys.modules[k] sys.modules.update(snapshot) # ========================================================================== # Dependency.matches # ========================================================================== class TestVersionMatching: def test_no_min_version_always_matches(self): assert Dependency("x", "x").matches("0.0.1") assert Dependency("x", "x").matches("99.99") def test_exact_match(self): assert Dependency("x", "x", min_version="1.0").matches("1.0") def test_higher_patch_matches(self): d = Dependency("x", "x", min_version="1.0") assert d.matches("1.0.5") assert d.matches("1.0.99") def test_higher_minor_matches(self): d = Dependency("x", "x", min_version="1.0") assert d.matches("1.13.0") assert d.matches("2.0") def test_lower_minor_does_not_match(self): d = Dependency("x", "x", min_version="6.6") assert not d.matches("6.5.9") assert not d.matches("5.99") def test_pre_release_suffix_handled(self): d = Dependency("x", "x", min_version="1.13") assert d.matches("1.13rc1") assert d.matches("1.13.0") # ========================================================================== # DistroInfo # ========================================================================== class TestDistroInfo: def test_matches_id(self): d = DistroInfo(id="arch", id_like=(), version="", name="Arch Linux") assert d.matches("arch") def test_matches_id_like(self): # Linux Mint: ID=mint, ID_LIKE=ubuntu debian d = DistroInfo(id="linuxmint", id_like=("ubuntu", "debian"), version="21", name="Linux Mint 21") assert d.matches("ubuntu") # via id_like assert d.matches("debian") # via id_like assert d.matches("fedora") is False def test_detect_distro_does_not_raise(self): # Whatever the test host is, detect_distro must return something. d = detect_distro() assert isinstance(d, DistroInfo) assert d.id # always non-empty def test_parse_os_release_arch_sample(self, tmp_path): sample = ( 'NAME="Arch Linux"\n' 'PRETTY_NAME="Arch Linux"\n' 'ID=arch\n' 'BUILD_ID=rolling\n' 'ID_LIKE=arch\n' 'VERSION_ID=""\n' ) p = tmp_path / "os-release" p.write_text(sample) result = _deps._parse_os_release(str(p)) assert result["ID"] == "arch" assert result["PRETTY_NAME"] == "Arch Linux" assert result["VERSION_ID"] == "" def test_parse_os_release_ubuntu_sample(self, tmp_path): sample = ( 'PRETTY_NAME="Ubuntu 22.04.3 LTS"\n' 'NAME="Ubuntu"\n' 'VERSION_ID="22.04"\n' 'ID=ubuntu\n' 'ID_LIKE=debian\n' ) p = tmp_path / "os-release" p.write_text(sample) result = _deps._parse_os_release(str(p)) assert result["ID"] == "ubuntu" assert result["ID_LIKE"] == "debian" assert result["VERSION_ID"] == "22.04" # ========================================================================== # Distro package manager / package name resolution # ========================================================================== class TestDistroPackages: def test_arch_uses_pacman(self): d = DistroInfo(id="arch", id_like=(), version="", name="Arch Linux") pm = _distro_pkg_manager(d) assert pm is not None pm_id, prefix, sudo = pm assert pm_id == "pacman" assert prefix == ["pacman", "-S", "--noconfirm"] assert sudo is True def test_ubuntu_uses_apt_via_id_like(self): # linuxmint ID_LIKE=ubuntu debian -- should resolve to apt d = DistroInfo(id="linuxmint", id_like=("ubuntu", "debian"), version="21", name="Linux Mint") pm = _distro_pkg_manager(d) assert pm is not None assert pm[0] == "apt" def test_fedora_uses_dnf(self): d = DistroInfo(id="fedora", id_like=(), version="40", name="Fedora") pm = _distro_pkg_manager(d) assert pm is not None assert pm[0] == "dnf" assert pm[2] is True # needs sudo def test_unknown_distro_returns_none(self): d = DistroInfo(id="unknown", id_like=(), version="", name="Unknown") assert _distro_pkg_manager(d) is None def test_distro_pkg_name_uses_id_first(self, fake_dep): d = DistroInfo(id="arch", id_like=(), version="", name="Arch") assert _distro_pkg_name(fake_dep, d) == "python-fake-pkg-xyz" def test_distro_pkg_name_falls_back_to_id_like(self, fake_dep): # Manjaro: ID=manjaro, ID_LIKE=arch d = DistroInfo(id="manjaro", id_like=("arch",), version="", name="Manjaro") assert _distro_pkg_name(fake_dep, d) == "python-fake-pkg-xyz" def test_distro_pkg_name_returns_none_when_no_mapping(self, fake_dep): d = DistroInfo(id="gentoo", id_like=(), version="", name="Gentoo") assert _distro_pkg_name(fake_dep, d) is None # ========================================================================== # check_dependency / check_dependencies # ========================================================================== class TestCheckDependency: def test_missing_package_returns_not_installed(self, fake_dep, restore_imports): sys.modules.pop(fake_dep.import_name, None) result = check_dependency(fake_dep) assert result is not None assert result.reason == "not_installed" assert result.dep is fake_dep def test_satisfied_package_returns_none(self, restore_imports): dep = Dependency("sys", "sys", min_version=None, purpose="stdlib") assert check_dependency(dep) is None def test_version_too_low(self, restore_imports): fake_mod = mock.MagicMock() fake_mod.__version__ = "0.9" with mock.patch.dict(sys.modules, {"fake_low_v": fake_mod}): dep = Dependency("fake_low_v", "fake-low-v", min_version="1.0") result = check_dependency(dep) assert result is not None assert result.reason == "version_too_low" assert result.found_version == "0.9" def test_import_error_inside_package(self, fake_dep, restore_imports): def boom(name, *args, **kwargs): raise ImportError("No module named 'some_other_dep'") with mock.patch("importlib.import_module", side_effect=boom): result = check_dependency(fake_dep) assert result is not None assert result.reason == "import_error" class TestCheckDependencies: def test_all_present(self, restore_imports): assert check_dependencies([Dependency("sys", "sys")]) == [] def test_mixed(self, fake_dep, restore_imports): deps = [Dependency("sys", "sys"), fake_dep] missing = check_dependencies(deps) assert len(missing) == 1 assert missing[0].dep is fake_dep # ========================================================================== # build_strategies # ========================================================================== class TestBuildStrategies: def test_arch_strategy_order(self, fake_dep, tmp_path): """On Arch the strategies should be: venv, pacman, pip-break-system.""" missing = [MissingDependency(dep=fake_dep, reason="not_installed")] d = DistroInfo(id="arch", id_like=(), version="", name="Arch Linux") strats = build_strategies(missing, d, project_root=tmp_path) # tmp_path has no .venv -> venv bootstrap strategy is offered. assert any("venv" in s.description for s in strats) # pacman strategy must be present with the python-* package name. pacman_strats = [s for s in strats if "pacman" in " ".join(s.command)] assert len(pacman_strats) == 1 pm_cmd = pacman_strats[0].command assert pm_cmd[0] == "sudo" assert "pacman" in pm_cmd assert "python-fake-pkg-xyz" in pm_cmd # pip --break-system-packages is the last-resort fallback. last = strats[-1] assert "--break-system-packages" in last.command # No strategy without sudo should accidentally contain sudo. for s in strats: if not s.requires_sudo: assert "sudo" not in s.command def test_ubuntu_strategy_uses_apt(self, fake_dep, tmp_path): missing = [MissingDependency(dep=fake_dep, reason="not_installed")] d = DistroInfo(id="ubuntu", id_like=("debian",), version="22.04", name="Ubuntu 22.04") strats = build_strategies(missing, d, project_root=tmp_path) apt_strats = [s for s in strats if "apt-get" in " ".join(s.command)] assert len(apt_strats) == 1 assert "python3-fake-pkg-xyz" in apt_strats[0].command def test_unknown_distro_skips_distro_manager(self, fake_dep, tmp_path): missing = [MissingDependency(dep=fake_dep, reason="not_installed")] d = DistroInfo(id="unknown", id_like=(), version="", name="Unknown") strats = build_strategies(missing, d, project_root=tmp_path) # venv + pip --break-system-packages, but NO pacman/apt/dnf. assert any("venv" in s.description for s in strats) assert any("--break-system-packages" in s.command for s in strats) for s in strats: for forbidden in ("pacman", "apt-get", "dnf", "zypper"): assert forbidden not in " ".join(s.command) def test_existing_venv_strategy(self, fake_dep, tmp_path): # Create a fake .venv/bin/python so the "existing venv" path triggers. venv_bin = tmp_path / ".venv" / "bin" venv_bin.mkdir(parents=True) (venv_bin / "python").write_text("#!/bin/sh\nexec python3\n") (venv_bin / "python").chmod(0o755) missing = [MissingDependency(dep=fake_dep, reason="not_installed")] d = DistroInfo(id="arch", id_like=(), version="", name="Arch") strats = build_strategies(missing, d, project_root=tmp_path) venv_strat = next(s for s in strats if "venv" in s.description) assert str(tmp_path / ".venv" / "bin" / "python") in venv_strat.command def test_strategy_includes_version_pin_for_missing(self, fake_dep, tmp_path): missing = [MissingDependency(dep=fake_dep, reason="not_installed")] d = DistroInfo(id="arch", id_like=(), version="", name="Arch") strats = build_strategies(missing, d, project_root=tmp_path) # The venv strategy's command must pin the minimum version. venv_strat = next(s for s in strats if "venv" in s.description) # The pip spec appears in the bash -c string. bash_cmd = " ".join(venv_strat.command) assert "fake-pkg-xyz>=1.0" in bash_cmd def test_strategy_omits_pin_for_version_upgrade(self, fake_dep, tmp_path): # When the dep is present but too old, we want --upgrade, not a pin. missing = [MissingDependency(dep=fake_dep, reason="version_too_low", found_version="0.5")] d = DistroInfo(id="arch", id_like=(), version="", name="Arch") strats = build_strategies(missing, d, project_root=tmp_path) venv_strat = next(s for s in strats if "venv" in s.description) bash_cmd = " ".join(venv_strat.command) assert "fake-pkg-xyz>=1.0" not in bash_cmd assert "fake-pkg-xyz" in bash_cmd # ========================================================================== # pipx detection # ========================================================================== class TestPipxDetection: def test_find_pipx_venv_missing(self, tmp_path, monkeypatch): # Point PIPX_HOME at a temp dir with no venvs. monkeypatch.setenv("PIPX_HOME", str(tmp_path)) assert find_pipx_venv("pycdlib") is None def test_find_pipx_venv_present(self, tmp_path, monkeypatch): venv = tmp_path / "venvs" / "pycdlib" (venv / "lib").mkdir(parents=True) monkeypatch.setenv("PIPX_HOME", str(tmp_path)) assert find_pipx_venv("pycdlib") == venv def test_pipx_misinstall_surfaces_in_report(self, fake_dep, tmp_path, monkeypatch): # Pretend pycdlib (well, fake-pkg-xyz) is in a pipx venv. venv = tmp_path / "venvs" / fake_dep.pip_name (venv / "lib").mkdir(parents=True) monkeypatch.setenv("PIPX_HOME", str(tmp_path)) m = MissingDependency(dep=fake_dep, reason="not_installed") report = format_missing_report( [m], DistroInfo(id="arch", id_like=(), version="", name="Arch Linux"), ) assert "pipx venv" in report assert fake_dep.pip_name in report # ========================================================================== # format_missing_report # ========================================================================== class TestFormatReport: def test_empty_missing_returns_empty_string(self): assert format_missing_report([]) == "" def test_not_installed_message_includes_distro_hint(self, fake_dep): m = MissingDependency(dep=fake_dep, reason="not_installed") report = format_missing_report( [m], DistroInfo(id="arch", id_like=(), version="", name="Arch Linux"), ) assert "fake-pkg-xyz" in report assert "not installed" in report # Arch hint should appear with the python-* package name. assert "pacman -S" in report assert "python-fake-pkg-xyz" in report # And the venv alternative. assert "venv" in report def test_version_too_low_message(self, fake_dep): m = MissingDependency(dep=fake_dep, reason="version_too_low", found_version="0.5") report = format_missing_report([m]) assert "found version 0.5" in report assert "pip install --upgrade" in report def test_import_error_message(self, fake_dep): m = MissingDependency(dep=fake_dep, reason="import_error", import_error="boom!") report = format_missing_report([m]) assert "failed to import" in report assert "boom!" in report def test_unknown_distro_skips_native_hint(self, fake_dep): m = MissingDependency(dep=fake_dep, reason="not_installed") report = format_missing_report( [m], DistroInfo(id="unknown", id_like=(), version="", name="Unknown"), ) # No pacman/apt hint when the distro is unknown. assert "pacman" not in report assert "apt" not in report # But the generic pip hint is still there. assert "pip install" in report # ========================================================================== # offer_to_install # ========================================================================== class TestOfferToInstall: def _arch(self): return DistroInfo(id="arch", id_like=(), version="", name="Arch Linux") def test_empty_missing_returns_true(self): assert offer_to_install([], interactive=True) is True def test_non_interactive_returns_false(self, fake_dep): m = MissingDependency(dep=fake_dep, reason="not_installed") assert offer_to_install([m], interactive=False, distro=self._arch()) is False def test_user_declines_first_strategy(self, fake_dep, capsys, tmp_path): m = MissingDependency(dep=fake_dep, reason="not_installed") # Answer "no" to every prompt -- should exhaust all strategies. answers = iter(["n"] * 10) result = offer_to_install( [m], interactive=True, distro=self._arch(), input_fn=lambda _q: next(answers), runner=lambda cmd: 0, project_root=tmp_path, ) assert result is False out = capsys.readouterr().out assert "No strategy succeeded" in out def test_user_accepts_first_strategy_success(self, fake_dep, tmp_path, capsys): m = MissingDependency(dep=fake_dep, reason="not_installed") with mock.patch.object(_deps, "check_dependencies", return_value=[]): result = offer_to_install( [m], interactive=True, distro=self._arch(), input_fn=lambda _q: "y", runner=lambda cmd: 0, project_root=tmp_path, ) assert result is True out = capsys.readouterr().out assert "venv" in out # first strategy offered was the venv one def test_first_fails_then_succeeds_with_pacman(self, fake_dep, tmp_path, capsys): """User declines venv, then accepts pacman -- pacman succeeds.""" m = MissingDependency(dep=fake_dep, reason="not_installed") answers = iter(["n", "y"]) # decline venv, accept pacman call_count = {"n": 0} def runner(cmd): call_count["n"] += 1 # First runner call is pacman (venv was declined). return 0 if call_count["n"] == 1 else 1 with mock.patch.object(_deps, "check_dependencies", return_value=[]): result = offer_to_install( [m], interactive=True, distro=self._arch(), input_fn=lambda _q: next(answers), runner=runner, project_root=tmp_path, ) assert result is True out = capsys.readouterr().out # The user should have seen the pacman command with sudo prefix. assert "sudo" in out assert "pacman" in out assert "python-fake-pkg-xyz" in out def test_pipx_misinstall_warning_shown(self, fake_dep, tmp_path, monkeypatch, capsys): # Simulate pycdlib (fake-pkg-xyz) being in a pipx venv. venv = tmp_path / "venvs" / fake_dep.pip_name (venv / "lib").mkdir(parents=True) monkeypatch.setenv("PIPX_HOME", str(tmp_path)) m = MissingDependency(dep=fake_dep, reason="not_installed") answers = iter(["n"] * 10) offer_to_install( [m], interactive=True, distro=self._arch(), input_fn=lambda _q: next(answers), runner=lambda cmd: 0, project_root=tmp_path / "project", ) out = capsys.readouterr().out assert "pipx venv" in out assert "isolated" in out # ========================================================================== # ensure_dependencies # ========================================================================== class TestEnsureDependencies: def test_all_satisfied_returns_silently(self): ensure_dependencies(deps=[Dependency("sys", "sys")]) def test_missing_in_non_interactive_raises_and_prints( self, fake_dep, restore_imports, capsys ): with pytest.raises(SystemExit) as exc: ensure_dependencies( deps=[fake_dep], interactive=False, auto_install=True, ) assert exc.value.code == 1 err = capsys.readouterr().err assert "fake-pkg-xyz" in err # Non-interactive hint must mention venv on PEP 668 distros. assert "venv" in err def test_no_auto_install_just_reports(self, fake_dep, restore_imports, capsys): with pytest.raises(SystemExit) as exc: ensure_dependencies( deps=[fake_dep], interactive=True, auto_install=False, ) assert exc.value.code == 1 def test_auto_install_user_declines_raises(self, fake_dep, restore_imports, capsys): with mock.patch.object(_deps, "offer_to_install", return_value=False), \ pytest.raises(SystemExit) as exc: ensure_dependencies( deps=[fake_dep], interactive=True, auto_install=True, ) assert exc.value.code == 1 # ========================================================================== # CLI flag parsing (main.py) # ========================================================================== class TestCheckDepsCLI: def test_parse_dep_flags(self): import main as main_mod rest, flags = main_mod._parse_flags( ["main.py", "--check-deps", "file.iso"] ) assert flags["check_only"] is True assert flags["no_install"] is False assert rest == ["main.py", "file.iso"] def test_parse_no_install_flag(self): import main as main_mod rest, flags = main_mod._parse_flags( ["main.py", "--no-install-deps"] ) assert flags["no_install"] is True assert flags["check_only"] is False assert rest == ["main.py"] def test_parse_reset_layout_flag(self): import main as main_mod rest, flags = main_mod._parse_flags(["main.py", "--reset-layout"]) assert flags["reset_layout"] is True assert rest == ["main.py"] def test_parse_debug_layout_flag(self): import main as main_mod rest, flags = main_mod._parse_flags(["main.py", "--debug-layout"]) assert flags["debug_layout"] is True assert rest == ["main.py"] # ========================================================================== # Regression: shell quoting of `>=` in venv bootstrap command # ========================================================================== class TestShellQuoting: """Regression: ``pycdlib>=1.13`` was being parsed by bash as an output redirection to a file named ``=1.13``, silently swallowing the version pin and the install output. The spec must now be single-quoted. """ def test_venv_bootstrap_command_quotes_version_pin(self, fake_dep, tmp_path): missing = [MissingDependency(dep=fake_dep, reason="not_installed")] d = DistroInfo(id="arch", id_like=(), version="", name="Arch Linux") strats = build_strategies(missing, d, project_root=tmp_path) venv_strat = next(s for s in strats if "venv" in s.description) # The bash -c string must contain a single-quoted 'fake-pkg-xyz>=1.0'. bash_cmd = " ".join(venv_strat.command) assert "'fake-pkg-xyz>=1.0'" in bash_cmd, ( "version pin must be shell-quoted to avoid >= redirection" ) def test_venv_bootstrap_command_does_not_have_unquoted_redirect(self, fake_dep, tmp_path): """The unquoted form ``pycdlib>=1.13`` must NOT appear anywhere in the bash -c command (it would be parsed as output redirection). """ missing = [MissingDependency(dep=fake_dep, reason="not_installed")] d = DistroInfo(id="arch", id_like=(), version="", name="Arch Linux") strats = build_strategies(missing, d, project_root=tmp_path) venv_strat = next(s for s in strats if "venv" in s.description) # Reconstruct the bash -c payload (last element of the argv list). bash_cmd = venv_strat.command[-1] # The unquoted form would be: install pycdlib>=1.13 (no quotes around >=) assert " install fake-pkg-xyz>=1.0 " not in bash_cmd assert " install fake-pkg-xyz>=1.0$" not in bash_cmd # ========================================================================== # Regression: importlib cache invalidation after install # ========================================================================== class TestImportlibCacheInvalidation: """Regression: after the first failed import, importlib caches the negative result. A subsequent successful ``pip install`` was still reported as ``not_installed`` because the cached failure was returned. """ def test_invalidate_caches_called_after_install(self, fake_dep, tmp_path): m = MissingDependency(dep=fake_dep, reason="not_installed") arch = DistroInfo(id="arch", id_like=(), version="", name="Arch") with mock.patch.object(_deps, "check_dependencies", return_value=[]), \ mock.patch("importlib.invalidate_caches") as invalidate: offer_to_install( [m], interactive=True, distro=arch, input_fn=lambda _q: "y", runner=lambda cmd: 0, project_root=tmp_path, ) invalidate.assert_called() def test_sys_modules_entries_cleared_after_install(self, fake_dep, tmp_path, restore_imports): # Stash a sentinel in sys.modules to simulate a cached failure. sys.modules["fake_pkg_xyz"] = None # None means "known to not exist" m = MissingDependency(dep=fake_dep, reason="not_installed") arch = DistroInfo(id="arch", id_like=(), version="", name="Arch") with mock.patch.object(_deps, "check_dependencies", return_value=[]): offer_to_install( [m], interactive=True, distro=arch, input_fn=lambda _q: "y", runner=lambda cmd: 0, project_root=tmp_path, ) # The cached negative entry must have been removed. assert "fake_pkg_xyz" not in sys.modules # ========================================================================== # Regression: venv-targeted install re-checks via venv python # ========================================================================== class TestVenvRecheckAndReExec: """Regression: after a venv-targeted install, the current interpreter cannot see the new packages (they live in ./.venv). The flow must detect this, re-check using the venv python, and offer to re-exec main.py from the venv. """ def _arch(self): return DistroInfo(id="arch", id_like=(), version="", name="Arch Linux") def test_check_in_venv_true_when_imports_succeed(self, tmp_path): # Create a fake "venv python" shell script that exits 0 on any -c. venv_bin = tmp_path / ".venv" / "bin" venv_bin.mkdir(parents=True) venv_python = venv_bin / "python" venv_python.write_text("#!/bin/sh\nexit 0\n") venv_python.chmod(0o755) dep = Dependency("pycdlib", "pycdlib") assert _check_in_venv(venv_python, [dep]) is True def test_check_in_venv_false_when_imports_fail(self, tmp_path): venv_bin = tmp_path / ".venv" / "bin" venv_bin.mkdir(parents=True) venv_python = venv_bin / "python" venv_python.write_text("#!/bin/sh\nexit 1\n") venv_python.chmod(0o755) dep = Dependency("pycdlib", "pycdlib") assert _check_in_venv(venv_python, [dep]) is False def test_venv_install_offers_re_exec_when_user_accepts( self, fake_dep, tmp_path, capsys, monkeypatch ): """When venv install succeeds and venv python can import the dep, offer to re-exec; if user says yes, call os.execv.""" # Set up a fake venv python that exits 0 (imports succeed). venv_bin = tmp_path / ".venv" / "bin" venv_bin.mkdir(parents=True) venv_python = venv_bin / "python" venv_python.write_text("#!/bin/sh\nexit 0\n") venv_python.chmod(0o755) m = MissingDependency(dep=fake_dep, reason="not_installed") arch = self._arch() # check_dependencies (current interpreter) still reports missing. with mock.patch.object(_deps, "check_dependencies", return_value=[m]): # Track whether os.execv was called. execv_calls = [] monkeypatch.setattr( "os.execv", lambda path, argv: execv_calls.append((path, argv)), ) offer_to_install( [m], interactive=True, distro=arch, input_fn=lambda _q: "y", # accept install, then accept re-exec runner=lambda cmd: 0, project_root=tmp_path, ) assert len(execv_calls) == 1 path, argv = execv_calls[0] assert path == str(venv_python) assert argv[0] == str(venv_python) def test_venv_install_user_declines_re_exec( self, fake_dep, tmp_path, capsys, monkeypatch ): """When venv install succeeds but user declines re-exec, return False (not success) and don't call os.execv.""" venv_bin = tmp_path / ".venv" / "bin" venv_bin.mkdir(parents=True) venv_python = venv_bin / "python" venv_python.write_text("#!/bin/sh\nexit 0\n") venv_python.chmod(0o755) m = MissingDependency(dep=fake_dep, reason="not_installed") arch = self._arch() # Two prompts: "y" to install, "n" to re-exec. answers = iter(["y", "n"]) execv_calls = [] monkeypatch.setattr( "os.execv", lambda path, argv: execv_calls.append((path, argv)) ) with mock.patch.object(_deps, "check_dependencies", return_value=[m]): result = offer_to_install( [m], interactive=True, distro=arch, input_fn=lambda _q: next(answers), runner=lambda cmd: 0, project_root=tmp_path, ) assert result is False assert execv_calls == [] out = capsys.readouterr().out assert "not restarting" in out.lower() def test_venv_install_with_missing_venv_python_falls_through( self, fake_dep, tmp_path, capsys ): """If the venv somehow didn't get created (install rc=0 but no venv python on disk), don't crash -- just fall through to the normal 'still missing' path.""" m = MissingDependency(dep=fake_dep, reason="not_installed") arch = self._arch() # No .venv on disk. check_dependencies still reports missing. with mock.patch.object(_deps, "check_dependencies", return_value=[m]): result = offer_to_install( [m], interactive=True, distro=arch, input_fn=lambda _q: "y", runner=lambda cmd: 0, project_root=tmp_path, ) # Should fall through to next strategy and eventually fail. assert result is False