909 lines
35 KiB
Python
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",
|
|
]
|