iso-scalpel/iso_scalpel/_deps.py

909 lines
35 KiB
Python

"""Runtime dependency checking and (optionally) interactive installation.
Production rule: never let the user see a raw ``ImportError`` traceback for
a missing third-party package. Instead, detect what is missing, explain it
in plain English, and -- only when the user explicitly agrees -- offer to
install it using the strategy that is appropriate for the host distro.
Why distro awareness matters
----------------------------
Modern Python distributions ship an "externally managed" environment
(PEP 668). On Arch Linux ``pip install --user`` is refused outright and
the user is steered towards ``pacman`` or ``pipx``. On Debian/Ubuntu the
``python3-xyz`` apt packages are the blessed route. Telling an Arch user
to ``sudo apt install`` is unhelpful, and telling them to ``pip install``
without ``--break-system-packages`` fails. This module picks the right
tool for each distro and only escalates to ``sudo`` after explicit consent.
This module is import-safe: it depends only on the Python standard
library, so it can run before any third-party dependency is available.
"""
# This file is part of ISO Scalpel.
# Copyright (C) 2025 Jeremy Anderson <info@dcos.net>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see <https://www.gnu.org/licenses/>
# or write to the Free Software Foundation, Inc., 51 Franklin Street,
# Fifth Floor, Boston, MA 02110-1301 USA.
from __future__ import annotations
import importlib
import os
import re
import shutil
import subprocess
import sys
from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass, field
from pathlib import Path
# ==========================================================================
# Dependency registry
# ==========================================================================
@dataclass(frozen=True)
class Dependency:
"""One declared third-party dependency.
Attributes
----------
import_name:
The name used in ``import`` statements, e.g. ``"PySide6"`` or
``"pycdlib"``.
pip_name:
The name used on PyPI, e.g. ``"PySide6"`` or ``"pycdlib"``.
min_version:
Optional minimum version string (e.g. ``"6.6"``). Comparison is
element-wise numeric on dot-separated parts, with the shorter
vector padded with zeros. ``"1.13"`` therefore matches
``"1.13.0"`` and ``"1.13rc1"``.
purpose:
Short human-readable description of why this package is needed.
distro_packages:
Optional mapping ``{distro_id: distro_package_name}`` overriding
the PyPI name when the distro's native package manager is used.
For example, on Arch ``PySide6`` is ``python-pyside6`` and on
Debian it is ``python3-pyside6`` (the apt prefix is added by the
strategy builder, so just ``pyside6`` here would be enough).
"""
import_name: str
pip_name: str
min_version: str | None = None
purpose: str = ""
distro_packages: dict = field(default_factory=dict)
def matches(self, version: str) -> bool:
if not self.min_version:
return True
def _vec(s: str) -> list[int]:
cleaned = ""
for ch in s:
if ch.isdigit() or ch == ".":
cleaned += ch
else:
break
return [int(p) if p.isdigit() else 0 for p in cleaned.split(".") if p]
want = _vec(self.min_version)
have = _vec(version)
n = max(len(want), len(have))
want += [0] * (n - len(want))
have += [0] * (n - len(have))
return have >= want
# Declared in requirements.txt -- the single source of truth lives here so
# we can describe them to the user without re-parsing the file.
REQUIREMENTS: Sequence[Dependency] = (
Dependency(
import_name="PySide6",
pip_name="PySide6",
min_version="6.6",
purpose="Qt6 GUI toolkit (main window, dialogs, widgets).",
distro_packages={
"arch": "python-pyside6",
"debian": "python3-pyside6",
"ubuntu": "python3-pyside6",
"fedora": "python3-pyside6",
"opensuse": "python3-pyside6",
},
),
Dependency(
import_name="pycdlib",
pip_name="pycdlib",
min_version="1.13",
purpose="Reads and writes ISO9660 / Joliet / Rock Ridge / UDF / El Torito images.",
distro_packages={
"arch": "python-pycdlib",
"debian": "python3-pycdlib",
"ubuntu": "python3-pycdlib",
"fedora": "python3-pycdlib",
"opensuse": "python3-pycdlib",
},
),
)
# ==========================================================================
# Distro detection
# ==========================================================================
@dataclass(frozen=True)
class DistroInfo:
"""A best-effort description of the host Linux distribution.
Attributes
----------
id:
Lowercase canonical id: ``"arch"``, ``"debian"``, ``"ubuntu"``,
``"fedora"``, ``"opensuse"``, ``"macos"``, ``"windows"`` or
``"unknown"``.
id_like:
List of compatibility ids from ``ID_LIKE=`` (e.g. Ubuntu reports
``["debian"]``, Linux Mint reports ``["ubuntu", "debian"]``).
version:
Version string (e.g. ``"22.04"``) or ``""`` if unknown.
name:
Human-readable pretty name (e.g. ``"Arch Linux"``).
"""
id: str
id_like: tuple[str, ...]
version: str
name: str
def matches(self, *ids: str) -> bool:
"""Return True if this distro's id or any id_like matches."""
return self.id in ids or any(x in ids for x in self.id_like)
def detect_distro() -> DistroInfo:
"""Detect the host distribution using ``/etc/os-release`` (Linux) or
platform hints on macOS / Windows.
Falls back to :data:`DistroInfo` ``id="unknown"`` when nothing
recognizable is found. Never raises.
"""
if sys.platform == "darwin":
try:
# sw_vers ships at /usr/bin/sw_vers on every macOS release.
v = subprocess.check_output(
["/usr/bin/sw_vers", "-productVersion"], text=True).strip()
except (OSError, subprocess.SubprocessError):
v = ""
return DistroInfo(id="macos", id_like=(), version=v, name="macOS")
if sys.platform == "win32":
return DistroInfo(id="windows", id_like=(), version="", name="Windows")
# Linux: parse /etc/os-release (the standard since systemd 219).
os_release = _parse_os_release()
if not os_release:
# Older fallbacks: /etc/lsb-release, /etc/arch-release
os_release = _parse_legacy_release()
if not os_release:
return DistroInfo(id="unknown", id_like=(), version="", name="Unknown")
raw_id = os_release.get("ID", "unknown").strip().lower()
raw_like = os_release.get("ID_LIKE", "").strip()
id_like = tuple(x for x in re.split(r"\s+", raw_like) if x)
return DistroInfo(
id=raw_id,
id_like=id_like,
version=os_release.get("VERSION_ID", "").strip(),
name=os_release.get("PRETTY_NAME", raw_id).strip() or raw_id,
)
def _parse_os_release(path: str = "/etc/os-release") -> dict:
"""Parse ``/etc/os-release`` into a dict. Returns ``{}`` on failure."""
try:
with open(path, encoding="utf-8") as fh:
text = fh.read()
except OSError:
# Try the symlink fallback some distros use.
try:
with open("/usr/lib/os-release", encoding="utf-8") as fh:
text = fh.read()
except OSError:
return {}
out: dict = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
# Strip surrounding quotes
v = v.strip().strip('"').strip("'")
out[k.strip()] = v
return out
def _parse_legacy_release() -> dict:
"""Fallback distro detection for systems without ``/etc/os-release``."""
# /etc/lsb-release (older Ubuntu / Mint)
try:
with open("/etc/lsb-release", encoding="utf-8") as fh:
data = _parse_kv(fh.read())
if data.get("DISTRIB_ID"):
data["ID"] = data["DISTRIB_ID"].lower()
data["PRETTY_NAME"] = data.get("DISTRIB_DESCRIPTION", data["DISTRIB_ID"])
data["VERSION_ID"] = data.get("DISTRIB_RELEASE", "")
return data
except OSError:
pass
# /etc/arch-release (just a flag file)
if os.path.exists("/etc/arch-release"):
return {"ID": "arch", "PRETTY_NAME": "Arch Linux", "VERSION_ID": ""}
# /etc/redhat-release (RHEL/CentOS/Fedora pre-os-release)
for fname, distro_id in (
("/etc/redhat-release", "fedora"),
("/etc/centos-release", "centos"),
("/etc/SuSE-release", "opensuse"),
):
try:
with open(fname, encoding="utf-8") as fh:
line = fh.readline().strip()
return {"ID": distro_id, "PRETTY_NAME": line, "VERSION_ID": ""}
except OSError:
continue
return {}
def _parse_kv(text: str) -> dict:
out: dict = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
out[k.strip()] = v.strip().strip('"').strip("'")
return out
# ==========================================================================
# Missing-dependency detection
# ==========================================================================
@dataclass
class MissingDependency:
"""A dependency that could not be imported (or was the wrong version)."""
dep: Dependency
reason: str # "not_installed" | "import_error" | "version_too_low"
found_version: str | None = None
import_error: str | None = None
def _get_version(module) -> str | None:
"""Best-effort extraction of a module's version string.
Tries the common attribute names in priority order. An attribute that
is callable (e.g. ``pkg_resources``-style ``version()``) is invoked; a
failure there falls through to the next candidate rather than aborting
the whole probe.
"""
for attr in ("__version__", "version", "VERSION"):
v = getattr(module, attr, None)
if isinstance(v, str) and v:
return v
if callable(v):
try:
return str(v())
except (TypeError, ValueError, AttributeError):
continue
return None
def check_dependency(dep: Dependency) -> MissingDependency | None:
"""Return ``None`` if ``dep`` is satisfied, else a :class:`MissingDependency`."""
try:
module = importlib.import_module(dep.import_name)
except ImportError as exc:
msg = str(exc)
head = msg.split("'")[1] if "'" in msg else msg
if head == dep.import_name or head.startswith(dep.import_name + "."):
return MissingDependency(dep=dep, reason="not_installed", import_error=msg)
return MissingDependency(dep=dep, reason="import_error", import_error=msg)
except Exception as exc: # noqa: BLE001 -- top-level error boundary
# Any other import-time failure (SyntaxError in the dep, binary
# incompatibility, segfault wrapper, ...) is reported as an import
# error rather than crashing the dependency probe.
return MissingDependency(dep=dep, reason="import_error", import_error=str(exc))
if dep.min_version:
v = _get_version(module)
if v is None:
return None
if not dep.matches(v):
return MissingDependency(
dep=dep, reason="version_too_low", found_version=v
)
return None
def check_dependencies(deps: Sequence[Dependency] = REQUIREMENTS) -> list[MissingDependency]:
"""Return the list of unsatisfied dependencies (empty if all OK)."""
return [m for m in (check_dependency(d) for d in deps) if m is not None]
# ==========================================================================
# Install strategies
# ==========================================================================
@dataclass(frozen=True)
class InstallStrategy:
"""One concrete install plan for a set of missing packages.
Attributes
----------
description:
Human-readable label for this strategy (e.g. ``"project venv"``).
command:
The argv list to execute. Empty list means "no command available
for this strategy on this distro".
requires_sudo:
True if the command needs root privileges (and will be wrapped in
``sudo -E``).
rationale:
Short explanation of *why* this strategy is being suggested, shown
to the user before they consent.
"""
description: str
command: list[str]
requires_sudo: bool
rationale: str
def _distro_pkg_name(dep: Dependency, distro: DistroInfo) -> str | None:
"""Return the distro-native package name for ``dep`` on ``distro``.
Falls back through ``id_like`` entries. Returns ``None`` if no mapping
exists.
"""
candidates = (distro.id, *distro.id_like)
for cid in candidates:
if cid in dep.distro_packages:
return dep.distro_packages[cid]
return None
# Each row: (manager_id, install_prefix, needs_sudo, matched-distro-ids).
# Drives _distro_pkg_manager() as a flat table so adding a new distro family
# is a one-line append instead of another if-branch.
_PKG_MANAGERS: tuple[tuple[str, list[str], bool, tuple[str, ...]], ...] = (
("pacman", ["pacman", "-S", "--noconfirm"], True, ("arch",)),
("apt", ["apt-get", "install", "-y"], True,
("debian", "ubuntu", "linuxmint", "raspbian")),
("dnf", ["dnf", "install", "-y"], True,
("fedora", "centos", "rhel", "rocky", "alma")),
("zypper", ["zypper", "--non-interactive", "install"], True,
("opensuse", "suse", "sles")),
)
def _distro_pkg_manager(distro: DistroInfo) -> tuple[str, list[str], bool] | None:
"""Return ``(manager_id, install_prefix, needs_sudo)`` for ``distro``.
The install prefix is the argv fragment that precedes the package name(s).
``needs_sudo`` is True for system-wide package managers.
"""
for pm_id, prefix, needs_sudo, ids in _PKG_MANAGERS:
if distro.matches(*ids):
return (pm_id, prefix, needs_sudo)
return None
def _pipx_available() -> bool:
return shutil.which("pipx") is not None
def _project_venv_python(project_root: Path | None = None) -> Path | None:
"""Return the path to a project-local ``.venv/bin/python`` if it exists."""
if project_root is None:
# Default: the directory containing the iso_scalpel package.
project_root = Path(__file__).resolve().parent.parent
candidate = project_root / ".venv" / "bin" / "python"
return candidate if candidate.exists() else None
def _check_in_venv(
venv_python: Path,
deps: Sequence[Dependency],
*,
runner: Callable[[Sequence[str]], int] | None = None,
) -> bool:
"""Verify that ``deps`` are importable from ``venv_python``.
Runs ``<venv_python> -c 'import dep1; import dep2'``. Returns True if
the subprocess exits 0 (i.e. all imports succeeded). Used after a
venv-targeted install to confirm the install actually landed -- the
*current* interpreter cannot see the venv's site-packages.
"""
if runner is None:
def runner(cmd: Sequence[str]) -> int:
return subprocess.call(list(cmd)) # noqa: S603
import_statements = "; ".join(f"import {d.import_name}" for d in deps)
cmd = [str(venv_python), "-c", import_statements]
return runner(cmd) == 0
def build_strategies(
missing: list[MissingDependency],
distro: DistroInfo,
*,
project_root: Path | None = None,
) -> list[InstallStrategy]:
"""Build the ordered list of install strategies for ``missing`` on ``distro``.
Order matters: lower-privilege, lower-blast-radius strategies come
first. The caller offers them one at a time and only escalates when
the user agrees.
Strategy order (filtered to what's actually available on this host):
1. **Project venv** -- create ``./.venv`` and install there. No sudo,
no system mutation. Works on every distro. The user must then run
``./.venv/bin/python main.py`` (or activate the venv) afterwards.
2. **pipx** -- ``pipx install`` is *only* valid for installable apps,
so this strategy is offered only for the iso_scalpel package itself
(not for libraries like pycdlib). Excluded here for libraries.
3. **Distro package manager** -- ``pacman -S python-pycdlib`` etc.
Requires sudo; shown with explicit consent.
4. **pip --break-system-packages --user** -- last resort on PEP-668
distros when nothing else is available.
"""
strategies: list[InstallStrategy] = []
# Build the pip-install spec for each missing dep, including a minimum
# version pin only when the dep is fully absent (not when upgrading).
# The spec is shell-quoted to prevent bash from interpreting ``>=`` as
# an output redirection (``pycdlib>=1.13`` would otherwise create a
# file named ``=1.13`` and silently drop the version pin).
def _pip_spec(m: MissingDependency) -> str:
if m.dep.min_version and m.reason == "not_installed":
return f"{m.dep.pip_name}>={m.dep.min_version}"
return m.dep.pip_name
def _shell_quote(spec: str) -> str:
"""Single-quote a string for safe inclusion in a bash -c command."""
return "'" + spec.replace("'", "'\"'\"'") + "'"
# 1. Project venv ------------------------------------------------------
venv_python = _project_venv_python(project_root)
if venv_python is None:
project_root_resolved = project_root or Path(__file__).resolve().parent.parent
venv_dir = project_root_resolved / ".venv"
# Quote each pip spec so >= doesn't trigger shell redirection.
specs = " ".join(_shell_quote(_pip_spec(m)) for m in missing)
chain = (
f"{sys.executable} -m venv {venv_dir} "
f"&& {venv_dir}/bin/python -m pip install --upgrade pip "
f"&& {venv_dir}/bin/python -m pip install {specs}"
)
strategies.append(InstallStrategy(
description="project venv (recommended on Arch / PEP 668 distros)",
command=["bash", "-c", chain],
requires_sudo=False,
rationale=(
"Create an isolated ./.venv for ISO Scalpel and install the "
"missing packages there. After this completes, run: "
"./.venv/bin/python main.py"
),
))
else:
# venv already exists; just install into it. No shell quoting
# needed because this is an argv list passed directly to execv.
cmd = [str(venv_python), "-m", "pip", "install"] + [_pip_spec(m) for m in missing]
strategies.append(InstallStrategy(
description=f"existing project venv ({venv_python})",
command=cmd,
requires_sudo=False,
rationale=(
"A project venv was found at ./.venv. Install the missing "
f"packages there, then run: {venv_python} main.py"
),
))
# 2. pipx (only valid for apps, not libraries) -------------------------
# pycdlib and PySide6 are libraries -- they should NOT be installed via
# ``pipx install`` (pipx is for CLI tools). We deliberately do not
# offer pipx as an install strategy for missing library imports.
# What we DO is check whether the user already installed them via pipx
# and offer to import from that venv (see _find_pipx_venv_site_packages).
# 3. Distro package manager --------------------------------------------
pm = _distro_pkg_manager(distro)
if pm:
pm_id, pm_prefix, needs_sudo = pm
pkg_names: list[str] = []
for m in missing:
n = _distro_pkg_name(m.dep, distro)
if n:
pkg_names.append(n)
if pkg_names:
cmd: list[str] = []
if needs_sudo:
cmd += ["sudo", "-E"]
cmd += pm_prefix + pkg_names
strategies.append(InstallStrategy(
description=f"{pm_id} (system-wide, requires sudo)",
command=cmd,
requires_sudo=needs_sudo,
rationale=(
f"Install the {pm_id} packages provided by {distro.name}. "
"This is the most stable route on this distro but affects "
"the whole system."
),
))
# 4. pip --break-system-packages --user (last resort) ------------------
cmd = [sys.executable, "-m", "pip", "install", "--user",
"--break-system-packages"] + [_pip_spec(m) for m in missing]
strategies.append(InstallStrategy(
description="pip --user --break-system-packages (last resort)",
command=cmd,
requires_sudo=False,
rationale=(
"Force pip to install into your user site-packages, overriding "
"PEP 668. This may conflict with the distro's package manager "
"and is offered only as a fallback."
),
))
return strategies
# ==========================================================================
# pipx venv detection
# ==========================================================================
def _pipx_home() -> Path:
"""Return the pipx base directory (default ``~/.local/share/pipx``)."""
env = os.environ.get("PIPX_HOME")
if env:
return Path(env)
return Path.home() / ".local" / "share" / "pipx"
def find_pipx_venv(package: str) -> Path | None:
"""Return the path to a pipx-managed venv for ``package``, or ``None``.
Looks under ``$PIPX_HOME/venvs/<package>``. This is useful when the
user has already run ``pipx install pycdlib`` and we want to surface
the fact that the package *is* installed -- just not in the current
interpreter's site-packages.
"""
base = _pipx_home() / "venvs" / package
return base if base.exists() and (base / "lib").is_dir() else None
def pipx_venv_site_packages(package: str, distro: DistroInfo) -> Path | None:
"""Return the site-packages dir inside a pipx venv, if present."""
venv = find_pipx_venv(package)
if venv is None:
return None
lib = venv / "lib"
if not lib.is_dir():
return None
# Find the pythonX.Y directory under lib/
for entry in sorted(lib.iterdir()):
if entry.name.startswith("python"):
sp = entry / "site-packages"
if sp.is_dir():
return sp
return None
# ==========================================================================
# Reporting
# ==========================================================================
def format_missing_report(
missing: Iterable[MissingDependency],
distro: DistroInfo | None = None,
) -> str:
"""Build a multi-line, human-readable explanation of what's missing.
When ``distro`` is provided the report includes distro-specific
install hints (e.g. ``pacman -S python-pycdlib`` on Arch).
"""
missing = list(missing)
if not missing:
return ""
lines: list[str] = []
if distro is None:
distro = detect_distro()
lines.append(
f"ISO Scalpel cannot start because the following dependencies "
f"are missing or out of date (detected distro: {distro.name}):\n"
)
for m in missing:
d = m.dep
if m.reason == "not_installed":
lines.append(f" - {d.pip_name} (>= {d.min_version or 'any'}) -- {d.purpose}")
# Distros with a native package get a native hint first.
native = _distro_pkg_name(d, distro)
pm = _distro_pkg_manager(distro)
if native and pm:
pm_cmd = " ".join(pm[1] + [native])
if pm[2]:
pm_cmd = "sudo " + pm_cmd
lines.append(f" not installed. On {distro.id}: {pm_cmd}")
lines.append(f" or use a project venv: python -m venv .venv "
f"&& .venv/bin/pip install {d.pip_name}")
else:
lines.append(f" not installed. Install with: "
f"pip install {d.pip_name}")
elif m.reason == "version_too_low":
lines.append(f" - {d.pip_name} (>= {d.min_version}) -- {d.purpose}")
lines.append(f" found version {m.found_version}; upgrade with: "
f"pip install --upgrade {d.pip_name}")
else: # import_error
lines.append(f" - {d.pip_name} -- {d.purpose}")
lines.append(f" installed but failed to import: {m.import_error}")
# Surface pipx misinstalls.
pv = find_pipx_venv(d.pip_name)
if pv:
lines.append(
f" note: a pipx venv for {d.pip_name} exists at {pv}, "
"but pipx venvs are isolated and not importable from this "
"interpreter. Install the package into a project venv "
"(`python -m venv .venv`) or use the distro package instead."
)
lines.append("")
lines.append("See requirements.txt for the canonical version pins.")
return "\n".join(lines)
# ==========================================================================
# Install execution
# ==========================================================================
def _run(cmd: Sequence[str]) -> int:
"""Run ``cmd`` and stream its output to the parent terminal.
``cmd`` is an argv list built by :func:`build_strategies` from trusted
inputs (the project's own dependency declarations and the detected
distro's package manager); it is never assembled from user-typed text.
"""
# If the command contains shell glue (e.g. ``&&``) it is already
# wrapped in ``bash -c`` by build_strategies; just execute it.
proc = subprocess.Popen(list(cmd), stdout=sys.stdout, stderr=sys.stderr) # noqa: S603
return proc.wait()
# ==========================================================================
# Interactive flow
# ==========================================================================
def _prompt(question: str, *, input_fn: Callable[[str], str] = input) -> str:
return input_fn(question)
def _is_interactive() -> bool:
return sys.stdin.isatty()
def offer_to_install(
missing: list[MissingDependency],
*,
distro: DistroInfo | None = None,
runner: Callable[[Sequence[str]], int] = _run,
input_fn: Callable[[str], str] = input,
interactive: bool | None = None,
project_root: Path | None = None,
) -> bool:
"""Walk the user through the available install strategies for ``missing``.
Returns ``True`` if all installs succeeded, ``False`` otherwise.
The flow is:
1. List the missing packages.
2. For each strategy returned by :func:`build_strategies` (lowest
privilege first), show the exact command and ask for consent.
Stop as soon as one strategy succeeds.
3. After each successful install, re-verify imports.
"""
if interactive is None:
interactive = _is_interactive()
if not missing:
return True
if not interactive:
return False
if distro is None:
distro = detect_distro()
print(f"\nDetected distro: {distro.name} (id={distro.id})")
print("The following packages are required:")
for m in missing:
print(f" - {m.dep.pip_name} ({m.reason}"
+ (f", found {m.found_version}" if m.found_version else "") + ")")
# Warn about pipx-misinstalled libraries up front.
for m in missing:
pv = find_pipx_venv(m.dep.pip_name)
if pv:
print(
f"\nNote: {m.dep.pip_name} is installed in a pipx venv at {pv}, "
"but pipx venvs are isolated -- their packages are not visible "
"to this interpreter. The strategies below will install it "
"where this Python can actually import it."
)
strategies = build_strategies(missing, distro, project_root=project_root)
for i, strat in enumerate(strategies, 1):
print(f"\n[{i}/{len(strategies)}] {strat.description}")
print("Proposed command:")
print(" " + " ".join(strat.command))
if strat.requires_sudo:
print("This command requires sudo (system-wide change).")
print(f"Rationale: {strat.rationale}")
answer = _prompt("Run this command now? [y/N] ", input_fn=input_fn).strip().lower()
if answer not in ("y", "yes"):
print("Skipping. Trying next strategy...")
continue
rc = runner(strat.command)
if rc != 0:
print(f"\nInstall failed with exit code {rc}.")
continue
print("Install reported success. Re-checking imports...")
# Critical: invalidate importlib's caches and drop any negative
# cached entries from sys.modules. After our first failed
# importlib.import_module() the failure is sticky -- without this
# step the freshly-installed package is still reported missing
# even though it's now on disk.
importlib.invalidate_caches()
for m in missing:
sys.modules.pop(m.dep.import_name, None)
# Also drop parent packages whose sub-import may have been
# cached as failing (e.g. for foo.bar we drop both foo and
# foo.bar).
if "." in m.dep.import_name:
parent = m.dep.import_name.split(".")[0]
sys.modules.pop(parent, None)
still_missing = check_dependencies([m.dep for m in missing])
# Venv-targeted strategies are special: the install lands in
# ./.venv, which the *current* interpreter cannot see. Re-check
# using the venv's python instead, and offer to re-exec main.py
# from the venv so the user doesn't have to remember to do it.
if still_missing and "venv" in strat.description:
venv_python = _project_venv_python(project_root)
if venv_python and venv_python.exists():
if _check_in_venv(venv_python, [m.dep for m in missing]):
print(f"\nPackages installed successfully into {venv_python.parent.parent}.")
print(f"The current interpreter ({sys.executable}) cannot see "
"them, but the venv python can.")
print("\nTo launch ISO Scalpel using the venv, run:")
print(f" {venv_python} main.py")
answer = _prompt(
"Restart ISO Scalpel using the venv now? [y/N] ",
input_fn=input_fn,
).strip().lower()
if answer in ("y", "yes"):
# Replace the current process with the venv python.
# os.execv does not return on success.
os.execv( # noqa: S606
str(venv_python),
[str(venv_python), *sys.argv])
print("OK -- not restarting. Re-run with the venv python "
"when ready.")
return False
else:
print("Venv install reported success but the venv python "
"still cannot import the packages. This is "
"unexpected; please report this bug.")
if not still_missing:
print("All dependencies satisfied.")
return True
print("Some packages are still missing after install:")
for m in still_missing:
print(f" - {m.dep.pip_name} ({m.reason})")
missing = still_missing # narrow the next strategy to what's left
print("\nNo strategy succeeded. Please install the packages manually.")
print("Suggested options:")
print(" 1. Create a project venv:")
print(" python -m venv .venv && .venv/bin/pip install -r requirements.txt")
print(" .venv/bin/python main.py")
print(" 2. Install the distro packages (example for Arch):")
print(" sudo pacman -S python-pyside6 python-pycdlib")
print(" 3. Install ISO Scalpel itself as a pipx app (after publishing):")
print(" pipx install iso-scalpel")
return False
# ==========================================================================
# Top-level entry point
# ==========================================================================
class DependencyError(SystemExit):
"""Raised when required dependencies cannot be satisfied."""
def ensure_dependencies(
*,
deps: Sequence[Dependency] = REQUIREMENTS,
interactive: bool | None = None,
auto_install: bool = True,
runner: Callable[[Sequence[str]], int] = _run,
input_fn: Callable[[str], str] = input,
distro: DistroInfo | None = None,
project_root: Path | None = None,
) -> None:
"""Verify that all ``deps`` are importable; offer to install if not.
Raises :class:`DependencyError` (a subclass of :class:`SystemExit`) with
exit code ``1`` when dependencies remain unsatisfied. Returns silently
when everything is OK.
"""
missing = check_dependencies(deps)
if not missing:
return
if distro is None:
distro = detect_distro()
if not auto_install:
sys.stderr.write(format_missing_report(missing, distro) + "\n")
raise DependencyError(1)
if interactive is None:
interactive = _is_interactive()
if not interactive:
sys.stderr.write(format_missing_report(missing, distro) + "\n")
sys.stderr.write(
"\nRe-run from an interactive terminal to be offered an automatic "
"install. On a PEP 668 distro (Arch, Fedora 38+, Debian 12+) the "
"recommended path is a project venv:\n"
" python -m venv .venv && .venv/bin/pip install -r requirements.txt\n"
" .venv/bin/python main.py\n"
)
raise DependencyError(1)
ok = offer_to_install(
missing, distro=distro, runner=runner, input_fn=input_fn,
interactive=True, project_root=project_root,
)
if not ok:
sys.stderr.write("\n" + format_missing_report(missing, distro) + "\n")
raise DependencyError(1)
__all__ = [
"REQUIREMENTS",
"Dependency",
"DependencyError",
"DistroInfo",
"InstallStrategy",
"MissingDependency",
"_check_in_venv",
"build_strategies",
"check_dependencies",
"check_dependency",
"detect_distro",
"ensure_dependencies",
"find_pipx_venv",
"format_missing_report",
"offer_to_install",
"pipx_venv_site_packages",
]