742 lines
31 KiB
Python
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
|