iso-scalpel/tests/test_deps.py

742 lines
31 KiB
Python

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