158 lines
5.7 KiB
Python
158 lines
5.7 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||
|
|
"""Poser un paquet, une seule fois pour tout le CLI.
|
||
|
|
|
||
|
|
Quatre familles de gestionnaires, et trois choses qui changent de l'une à
|
||
|
|
l'autre : la COMMANDE, le NOM du paquet, et le BINAIRE qu'il pose. Chaque
|
||
|
|
appelant qui refaisait ce travail chez lui en couvrait un sous-ensemble
|
||
|
|
différent — d'où un CLI où l'on pouvait installer virt-viewer sous openSUSE
|
||
|
|
mais pas un navigateur ni lm-sensors.
|
||
|
|
|
||
|
|
Deux règles que la composante tient à la place des appelants :
|
||
|
|
|
||
|
|
- l'ID de /etc/os-release décide AVANT le PATH. Une machine peut porter
|
||
|
|
plusieurs gestionnaires — un poste Debian avec dnf installé pour construire
|
||
|
|
un paquet — et c'est l'ID qui dit lequel gouverne le système ;
|
||
|
|
- la commande s'affiche AVANT la question. On approuve ce qu'on a lu, pas un
|
||
|
|
« (y/N) » posé à l'aveugle.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import shlex
|
||
|
|
import shutil
|
||
|
|
|
||
|
|
from script.todo.todo_i18n import t
|
||
|
|
|
||
|
|
# Les familles, dans l'ordre où le PATH est interrogé quand l'ID de la
|
||
|
|
# distribution est inconnu.
|
||
|
|
FAMILIES = ("apt-get", "dnf", "pacman", "zypper")
|
||
|
|
|
||
|
|
# Le préfixe de commande de chaque famille ; les paquets s'ajoutent au bout.
|
||
|
|
# Tous non interactifs : un menu qui rend la main à un prompt apt bloque.
|
||
|
|
_INSTALL = {
|
||
|
|
"apt-get": ("sudo", "apt-get", "install", "-y"),
|
||
|
|
"dnf": ("sudo", "dnf", "install", "-y"),
|
||
|
|
"pacman": ("sudo", "pacman", "-S", "--needed", "--noconfirm"),
|
||
|
|
"zypper": ("sudo", "zypper", "--non-interactive", "install"),
|
||
|
|
}
|
||
|
|
|
||
|
|
# ID de /etc/os-release -> famille. Les dérivées sont nommées explicitement :
|
||
|
|
# ID_LIKE existe mais manque ou ment sur assez de distributions pour qu'on ne
|
||
|
|
# s'y fie pas seul, et le repli par le PATH couvre ce qui n'est pas listé.
|
||
|
|
_OS_ID_FAMILY = {
|
||
|
|
"ubuntu": "apt-get",
|
||
|
|
"debian": "apt-get",
|
||
|
|
"linuxmint": "apt-get",
|
||
|
|
"pop": "apt-get",
|
||
|
|
"raspbian": "apt-get",
|
||
|
|
"fedora": "dnf",
|
||
|
|
"rhel": "dnf",
|
||
|
|
"centos": "dnf",
|
||
|
|
"almalinux": "dnf",
|
||
|
|
"rocky": "dnf",
|
||
|
|
"arch": "pacman",
|
||
|
|
"manjaro": "pacman",
|
||
|
|
"endeavouros": "pacman",
|
||
|
|
"opensuse": "zypper",
|
||
|
|
"opensuse-leap": "zypper",
|
||
|
|
"opensuse-tumbleweed": "zypper",
|
||
|
|
"sles": "zypper",
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def os_id() -> str:
|
||
|
|
"""L'ID de la distribution hôte, ou '' si /etc/os-release est illisible."""
|
||
|
|
try:
|
||
|
|
with open("/etc/os-release", encoding="utf-8") as fh:
|
||
|
|
for line in fh:
|
||
|
|
if line.startswith("ID="):
|
||
|
|
return line.split("=", 1)[1].strip().strip('"').lower()
|
||
|
|
except OSError:
|
||
|
|
pass
|
||
|
|
return ""
|
||
|
|
|
||
|
|
|
||
|
|
def family() -> str | None:
|
||
|
|
"""La famille qui gouverne cette machine, ou None si aucune n'est connue.
|
||
|
|
|
||
|
|
L'ID de la distribution d'abord, le PATH ensuite : sur une machine qui
|
||
|
|
porte deux gestionnaires, l'ID dit lequel possède le système.
|
||
|
|
"""
|
||
|
|
connue = _OS_ID_FAMILY.get(os_id())
|
||
|
|
if connue and shutil.which(connue):
|
||
|
|
return connue
|
||
|
|
for candidate in FAMILIES:
|
||
|
|
if shutil.which(candidate):
|
||
|
|
return candidate
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def install_command(paquets, famille=None) -> list | None:
|
||
|
|
"""La commande d'installation, en liste d'arguments. None si personne.
|
||
|
|
|
||
|
|
`paquets` est une liste de noms, ou un dict {famille: liste} quand le
|
||
|
|
paquet ne porte pas le même nom partout (lm-sensors chez Debian,
|
||
|
|
lm_sensors chez Fedora et Arch).
|
||
|
|
"""
|
||
|
|
famille = famille or family()
|
||
|
|
if not famille:
|
||
|
|
return None
|
||
|
|
if isinstance(paquets, dict):
|
||
|
|
paquets = paquets.get(famille) or []
|
||
|
|
paquets = [paquets] if isinstance(paquets, str) else list(paquets)
|
||
|
|
if not paquets:
|
||
|
|
return None
|
||
|
|
return list(_INSTALL[famille]) + paquets
|
||
|
|
|
||
|
|
|
||
|
|
def resolve(binaires, commun=None, par_famille=None, famille=None):
|
||
|
|
"""(paquets, inconnus) pour les binaires demandés.
|
||
|
|
|
||
|
|
Le nom du binaire n'est presque jamais celui du paquet : `commun` donne la
|
||
|
|
correspondance quand elle vaut pour les quatre familles, `par_famille` la
|
||
|
|
corrige là où elle diverge. Les paquets sortent dédoublonnés et dans
|
||
|
|
l'ordre demandé — trois binaires d'un même paquet ne le demandent qu'une
|
||
|
|
fois. `inconnus` liste ce pour quoi aucun paquet n'est déclaré : à dire,
|
||
|
|
jamais à deviner.
|
||
|
|
"""
|
||
|
|
famille = famille or family()
|
||
|
|
surcharge = (par_famille or {}).get(famille, {})
|
||
|
|
commun = commun or {}
|
||
|
|
paquets, inconnus = [], []
|
||
|
|
for binaire in binaires:
|
||
|
|
paquet = surcharge.get(binaire) or commun.get(binaire)
|
||
|
|
if not paquet:
|
||
|
|
inconnus.append(binaire)
|
||
|
|
elif paquet not in paquets:
|
||
|
|
paquets.append(paquet)
|
||
|
|
return paquets, inconnus
|
||
|
|
|
||
|
|
|
||
|
|
def show_and_ask(cmd, question, is_yes, prefix=" ") -> bool:
|
||
|
|
"""Afficher la commande, PUIS demander. True si l'opérateur accepte.
|
||
|
|
|
||
|
|
L'ordre est le fond de l'affaire : une question posée avant la commande
|
||
|
|
fait approuver à l'aveugle. Centralisé ici pour qu'aucun appelant ne
|
||
|
|
puisse l'inverser.
|
||
|
|
"""
|
||
|
|
lisible = cmd if isinstance(cmd, str) else shlex.join(cmd)
|
||
|
|
print(f"{prefix}{t('Will execute:')} {lisible}")
|
||
|
|
return bool(is_yes(input(question)))
|
||
|
|
|
||
|
|
|
||
|
|
def ask_and_install(execute, cmd, question, is_yes, prefix=" "):
|
||
|
|
"""Proposer puis lancer. Rend le code de sortie, ou None si rien n'a été
|
||
|
|
lancé — refus de l'opérateur, ou aucune commande à proposer.
|
||
|
|
|
||
|
|
`execute` est le lanceur du CLI : exec_command_live REND le code de sortie
|
||
|
|
et ne lève rien, donc l'appelant doit tester ce qui revient.
|
||
|
|
"""
|
||
|
|
if not cmd:
|
||
|
|
print(f"{prefix}⚠ {t('no known package manager here.')}")
|
||
|
|
return None
|
||
|
|
if not show_and_ask(cmd, question, is_yes, prefix=prefix):
|
||
|
|
print(t("Nothing to do."))
|
||
|
|
return None
|
||
|
|
lisible = cmd if isinstance(cmd, str) else shlex.join(cmd)
|
||
|
|
return execute.exec_command_live(lisible, source_erplibre=False)
|