"""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 # # 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 # 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 `` -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/``. 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", ]