[ADD] script todo : installer les outils manquants, les quatre familles

La réduction sûre listait les outils manquants et s'arrêtait là. Trois
écritures séparées savaient installer, chacune un sous-ensemble
différent : openSUSE posait virt-viewer, mais ni navigateur CLI ni
lm-sensors.

Le binaire n'est presque jamais le paquet : sgdisk vit dans « gdisk »
chez Debian et Fedora, dans « gptfdisk » chez Arch et openSUSE. Deux
règles passent aussi dans la composante : l'ID de la distribution décide
avant le PATH, et la commande s'affiche avant la question.

Vérifié : commandes identiques sur les trois familles déjà couvertes,
4139 tests verts.

--- EN ---

The safe shrink listed the missing tools and stopped there. Three
separate writings knew how to install, each covering a different
subset: openSUSE could put virt-viewer down, but neither a CLI browser
nor lm-sensors.

A binary is almost never the package: sgdisk lives in "gdisk" on Debian
and Fedora, in "gptfdisk" on Arch and openSUSE. Two rules move into the
component as well: the distribution ID decides before the PATH, and the
command is shown before the question.

Checked: commands identical on the three families already covered, and
4139 tests green.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-31 07:18:13 -04:00
parent 40a1e178f3
commit 87ad8c4439
9 changed files with 677 additions and 83 deletions

View file

@ -4,11 +4,13 @@
"""Menu QEMU/KVM : atteindre une VM \u2014 SSH, tunnels, consoles, \u00e9mulateur.\n\nTout ce qui relie l'humain \u00e0 une machine d\u00e9j\u00e0 d\u00e9ploy\u00e9e : ~/.ssh/config et ses\nProxyJump, la d\u00e9couverte des VM imbriqu\u00e9es, les tunnels de bureau distant, la\nconsole s\u00e9rie et graphique (virt-viewer), et l'\u00e9mulateur Android d'une VM\ngraphique avec son tunnel adb.\n\nS\u00e9par\u00e9 du reste parce que c'est le seul bloc qui parle de R\u00c9SEAU et de\nsessions interactives, jamais de cr\u00e9ation ni de destruction de VM."""
import os
import shlex
import shutil
import socket
import subprocess
import time
from script.todo import todo_install
from script.todo.todo_i18n import t
@ -597,34 +599,28 @@ class QemuAccessMixin:
)
print(f' {t("To close it:")} pkill -f "{port}:localhost:{port}"')
# Un paquet, quatre familles. virt-viewer porte le même nom partout, ce qui
# est rare et bienvenu : seule la commande d'installation change.
_QEMU_VIRT_VIEWER_INSTALL = (
("apt-get", "sudo apt-get install -y virt-viewer"),
("dnf", "sudo dnf install -y virt-viewer"),
("pacman", "sudo pacman -S --needed --noconfirm virt-viewer"),
("zypper", "sudo zypper --non-interactive install virt-viewer"),
)
def _qemu_ensure_virt_viewer(self):
"""virt-viewer sur CETTE machine, installé s'il manque.
Installé seulement là où il va SERVIR : sur un hyperviseur sans écran,
poser un client graphique ne rendrait service à personne. C'est
l'appelant qui a vérifié l'affichage."""
l'appelant qui a vérifié l'affichage, et c'est pourquoi celui-ci pose
sans demander — la seule installation du CLI dans ce cas.
virt-viewer porte le même nom de paquet dans les quatre familles, ce
qui est rare : seule la commande change, et todo_install la connaît."""
if shutil.which("virt-viewer"):
return True
print(f"\n {t('virt-viewer is missing here; installing it.')}")
for tool, cmd in self._QEMU_VIRT_VIEWER_INSTALL:
if shutil.which(tool):
print(f" {t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
break
else:
cmd = todo_install.install_command(["virt-viewer"])
if not cmd:
print(f" ⚠ {t('no known package manager here.')}")
return False
lisible = shlex.join(cmd)
print(f" {t('Will execute:')} {lisible}")
self.execute.exec_command_live(lisible, source_erplibre=False)
if shutil.which("virt-viewer"):
print(f" ✅ virt-viewer")
print(" ✅ virt-viewer")
return True
print(f" ⚠ {t('virt-viewer still missing after the install.')}")
return False

View file

@ -1089,17 +1089,6 @@ def cli_browser() -> str | None:
return None
def _os_id() -> str:
"""ID de la distribution hôte (/etc/os-release), ex. « ubuntu », « fedora »."""
try:
for line in open("/etc/os-release", encoding="utf-8"):
if line.startswith("ID="):
return line.split("=", 1)[1].strip().strip('"').lower()
except OSError:
pass
return ""
# Navigateurs CLI installables via apt/dnf/pacman (nom de paquet = binaire).
# browsh/carbonyl ne sont pas dans les dépôts standard -> non proposés ici.
INSTALLABLE_BROWSERS = (
@ -1111,29 +1100,17 @@ INSTALLABLE_BROWSERS = (
def browser_install_command(browser="w3m") -> list | None:
"""Commande d'installation du navigateur CLI `browser` adaptée à l'OS hôte :
apt (Ubuntu/Debian), dnf (Fedora), pacman (Arch). None si gestionnaire
inconnu."""
apt = ["sudo", "apt-get", "install", "-y", browser]
dnf = ["sudo", "dnf", "install", "-y", browser]
pac = ["sudo", "pacman", "-S", "--needed", "--noconfirm", browser]
by_id = {
"ubuntu": apt,
"debian": apt,
"linuxmint": apt,
"fedora": dnf,
"arch": pac,
}
cmd = by_id.get(_os_id())
if cmd:
return cmd
if shutil.which("apt-get"):
return apt
if shutil.which("dnf"):
return dnf
if shutil.which("pacman"):
return pac
return None
"""Commande d'installation du navigateur CLI `browser`, ou None si aucun
gestionnaire de paquets connu.
Les navigateurs proposés portent le même nom de paquet dans les quatre
familles ; todo_install choisit la commande. Cette écriture-ci ne
connaissait pas zypper, et openSUSE ne pouvait donc en installer aucun."""
# Importé ici et non en tête : l'import de todo_i18n de ce module est
# protégé pour qu'il tourne en autonome, et todo_install en dépend.
from script.todo import todo_install
return todo_install.install_command([browser])
def virsh_ip(name: str) -> str:

View file

@ -12,6 +12,7 @@ import shutil
import subprocess
import time
from script.todo import todo_install
from script.todo.todo_i18n import t
@ -1305,6 +1306,52 @@ class QemuManageMixin:
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
# Le nom du binaire n'est presque jamais celui du paquet. Ces cinq-là
# portent le même nom dans les quatre familles.
_SHRINK_PKG = {
"e2fsck": "e2fsprogs",
"resize2fs": "e2fsprogs",
"dumpe2fs": "e2fsprogs",
"partprobe": "parted",
"lsblk": "util-linux",
"blockdev": "util-linux",
}
# Les deux qui changent de famille en famille : sgdisk vit dans « gdisk »
# chez Debian et Fedora, dans « gptfdisk » chez Arch et openSUSE, et
# qemu-nbd porte quatre noms de paquet différents.
_SHRINK_PKG_FAMILY = {
"apt-get": {"sgdisk": "gdisk", "qemu-nbd": "qemu-utils"},
"dnf": {"sgdisk": "gdisk", "qemu-nbd": "qemu-img"},
"pacman": {"sgdisk": "gptfdisk", "qemu-nbd": "qemu-img"},
"zypper": {"sgdisk": "gptfdisk", "qemu-nbd": "qemu-tools"},
}
def _qemu_install_shrink_tools(self, manquants):
"""Poser les paquets qui fournissent les outils manquants.
Rend la liste de ce qui manque ENCORE, relue sur le disque : vide si
tout est là. Un refus, un gestionnaire de paquets inconnu ou une
installation en échec la rendent non vide, et l'appelant renonce.
"""
paquets, inconnus = todo_install.resolve(
manquants,
commun=self._SHRINK_PKG,
par_famille=self._SHRINK_PKG_FAMILY,
)
if inconnus:
print(
f" ⚠ {t('No package known here for:')} {', '.join(inconnus)}"
)
status = todo_install.ask_and_install(
self.execute,
todo_install.install_command(paquets),
t("Install them? (y/N): "),
self._is_yes,
)
if status:
print(f" {t('Error installing the tools: ')}{status}")
return [b for b in self._SHRINK_TOOLS if not shutil.which(b)]
def _qemu_safe_shrink(self, name, disk, new_gb):
"""Réduit le disque SANS casser l'OS, via qemu-nbd + resize2fs +
sgdisk (sans libguestfs) : on réduit le FS (ext), puis la partition,
@ -1318,6 +1365,12 @@ class QemuManageMixin:
print(
f"{t('Missing tools for safe shrink:')} {', '.join(missing)}"
)
missing = self._qemu_install_shrink_tools(missing)
if missing:
print(
f"{t('Still missing, safe shrink cancelled:')}"
f" {', '.join(missing)}"
)
return False
target = int(round(new_gb * (1 << 30)))
# Sauvegarde OPTIONNELLE (défaut OUI) : permet de restaurer en cas

View file

@ -2813,6 +2813,22 @@ TRANSLATIONS = {
"fr": "Outils manquants pour la réduction sûre :",
"en": "Missing tools for safe shrink:",
},
"Install them? (y/N): ": {
"fr": "Les installer? (y/N) : ",
"en": "Install them? (y/N): ",
},
"Error installing the tools: ": {
"fr": "Erreur lors de l'installation des outils : ",
"en": "Error installing the tools: ",
},
"Still missing, safe shrink cancelled:": {
"fr": "Manquent toujours, réduction sûre annulée :",
"en": "Still missing, safe shrink cancelled:",
},
"No package known here for:": {
"fr": "Aucun paquet connu ici pour :",
"en": "No package known here for:",
},
"Backing up the disk before shrinking…": {
"fr": "Sauvegarde du disque avant réduction…",
"en": "Backing up the disk before shrinking…",

157
script/todo/todo_install.py Normal file
View file

@ -0,0 +1,157 @@
#!/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)

View file

@ -530,37 +530,25 @@ def _command_columns(tree, paths):
# --------------------------------------------------------------------------- #
# Télémétrie SYSTÈME (vue F2)
# --------------------------------------------------------------------------- #
def _os_id() -> str:
try:
for line in open("/etc/os-release", encoding="utf-8"):
if line.startswith("ID="):
return line.split("=", 1)[1].strip().strip('"').lower()
except OSError:
pass
return ""
def sensors_install_command():
"""Commande d'installation de lm-sensors selon l'OS (nos 4 systèmes)."""
apt = ["sudo", "apt-get", "install", "-y", "lm-sensors"]
dnf = ["sudo", "dnf", "install", "-y", "lm_sensors"]
pac = ["sudo", "pacman", "-S", "--needed", "--noconfirm", "lm_sensors"]
cmd = {
"ubuntu": apt,
"debian": apt,
"linuxmint": apt,
"fedora": dnf,
"arch": pac,
}.get(_os_id())
if cmd:
return cmd
if shutil.which("apt-get"):
return apt
if shutil.which("dnf"):
return dnf
if shutil.which("pacman"):
return pac
return None
"""Commande d'installation de lm-sensors, ou None si aucun gestionnaire
de paquets connu.
Le paquet change de nom : « lm-sensors » chez Debian, « lm_sensors »
ailleurs. Cette écriture-ci ne connaissait pas zypper, et openSUSE ne
pouvait donc pas l'installer."""
# Importé ici et non en tête : l'import de todo_i18n de ce module est
# protégé pour qu'il tourne en autonome, et todo_install en dépend.
from script.todo import todo_install
return todo_install.install_command(
{
"apt-get": ["lm-sensors"],
"dnf": ["lm_sensors"],
"pacman": ["lm_sensors"],
"zypper": ["sensors"],
}
)
def _first_int(path):

View file

@ -0,0 +1,196 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les paquets qui fournissent les outils de la réduction sûre.
La réduction d'un disque de VM refuse de partir sans huit binaires. Le nom du
binaire n'est presque jamais celui du paquet, et il change de famille en
famille : sgdisk vit dans « gdisk » chez Debian et Fedora, dans « gptfdisk »
chez Arch et openSUSE. Une table pareille se démode sans bruit — un trou n'y
fait rien planter, il fait juste proposer une installation qui n'installe pas
ce qui manque.
Ce qui se vérifie ici sans VM et sans toucher au système : que chaque outil a
un paquet dans les quatre familles, que la commande construite est celle du
gestionnaire présent, que les paquets ne sont pas demandés deux fois, et qu'un
refus comme un échec laissent l'appelant renoncer plutôt que continuer sans
ses outils.
"""
import io
import sys
import unittest
from contextlib import redirect_stdout
from unittest.mock import patch
sys.argv = ["todo.py"]
from script.todo.todo import TODO # noqa: E402
from script.todo import todo_install # noqa: E402
from script.todo.todo_i18n import t # noqa: E402
FAMILIES = ("apt-get", "dnf", "pacman", "zypper")
class _Exec:
"""Le lanceur de commandes, qui retient au lieu d'exécuter."""
def __init__(self, status=0):
self.ran = []
self.status = status
def exec_command_live(self, cmd, source_erplibre=False):
self.ran.append(cmd)
return self.status
class ShrinkToolsBase(unittest.TestCase):
def setUp(self):
self.todo = TODO.__new__(TODO)
self.todo.execute = _Exec()
def _which(self, package_manager, missing, installed_after=()):
"""shutil.which vu depuis qemu_manage : un seul gestionnaire de
paquets sur le PATH, et les outils manquants qui le restent tant que
l'installation n'a pas tourné."""
done = self.todo.execute
def which(binaire):
if binaire in FAMILIES:
return (
f"/usr/bin/{binaire}"
if binaire == package_manager
else None
)
if binaire in missing:
if done.ran and binaire in installed_after:
return f"/usr/bin/{binaire}"
return None
return f"/usr/bin/{binaire}"
return which
def _run(self, package_manager, missing, answer="y", installed_after=()):
which = self._which(package_manager, missing, installed_after)
buf = io.StringIO()
# input() écrit son invite sur stdout comme le vrai : sans cela la
# question n'apparaît nulle part et l'ORDRE des deux ne se voit pas.
def demande(invite=""):
print(invite, end="")
return answer
# `shutil` est UN seul objet module partagé : patcher son « which »
# par n'importe quel importateur le patche pour todo_install aussi,
# qui est le vrai lecteur du PATH depuis le refactor.
with patch("script.todo.qemu_manage.shutil.which", which), patch(
"builtins.input", demande
), redirect_stdout(buf):
left = self.todo._qemu_install_shrink_tools(list(missing))
return self.todo.execute.ran, left, buf.getvalue()
class TestPackageTable(ShrinkToolsBase):
def test_every_tool_has_a_package_in_every_family(self):
"""Un trou ne plante pas : il propose une installation inutile."""
for family in FAMILIES:
per_family = TODO._SHRINK_PKG_FAMILY[family]
for binaire in TODO._SHRINK_TOOLS:
paquet = per_family.get(binaire) or TODO._SHRINK_PKG.get(
binaire
)
self.assertTrue(
paquet,
f"{family} : aucun paquet connu pour « {binaire} »",
)
def test_the_overrides_cover_exactly_the_known_families(self):
"""Une famille connue de todo_install sans surcharge ici proposerait
« gdisk » à un Arch, qui ne l'a pas."""
self.assertEqual(set(TODO._SHRINK_PKG_FAMILY), set(FAMILIES))
self.assertEqual(set(todo_install.FAMILIES), set(FAMILIES))
def test_sgdisk_is_the_one_that_changes_name(self):
"""Le cas qui a motivé la table, gardé explicitement."""
noms = {f: TODO._SHRINK_PKG_FAMILY[f]["sgdisk"] for f in FAMILIES}
self.assertEqual(noms["apt-get"], "gdisk")
self.assertEqual(noms["dnf"], "gdisk")
self.assertEqual(noms["pacman"], "gptfdisk")
self.assertEqual(noms["zypper"], "gptfdisk")
def test_no_family_override_repeats_the_common_table(self):
"""Un doublon entre les deux tables est une divergence en attente."""
for family in FAMILIES:
for binaire in TODO._SHRINK_PKG_FAMILY[family]:
self.assertNotIn(
binaire,
TODO._SHRINK_PKG,
f"{family} : « {binaire} » est dans les deux tables",
)
class TestInstallCommand(ShrinkToolsBase):
def test_each_family_builds_its_own_command(self):
attendu = {
"apt-get": "sudo apt-get install -y gdisk",
"dnf": "sudo dnf install -y gdisk",
"pacman": "sudo pacman -S --needed --noconfirm gptfdisk",
"zypper": "sudo zypper --non-interactive install gptfdisk",
}
for family, cmd in attendu.items():
self.setUp()
ran, left, _ = self._run(
family, ["sgdisk"], installed_after=("sgdisk",)
)
self.assertEqual(ran, [cmd])
self.assertEqual(left, [])
def test_a_package_is_asked_for_once(self):
"""e2fsck, resize2fs et dumpe2fs sortent du même paquet."""
ran, _, _ = self._run(
"apt-get",
["e2fsck", "resize2fs", "dumpe2fs", "sgdisk"],
installed_after=("e2fsck", "resize2fs", "dumpe2fs", "sgdisk"),
)
self.assertEqual(ran, ["sudo apt-get install -y e2fsprogs gdisk"])
def test_the_command_is_shown_before_the_question(self):
"""On approuve ce qu'on a lu : la commande passe AVANT la question.
L'ordre est le fond de l'affaire, pas la simple présence des deux :
une question posée avant la commande fait approuver à l'aveugle.
"""
ran, _, out = self._run("apt-get", ["sgdisk"], answer="n")
commande = out.index("sudo apt-get install -y gdisk")
question = out.index(t("Install them? (y/N): "))
self.assertLess(commande, question)
self.assertEqual(ran, [])
class TestGivingUp(ShrinkToolsBase):
def test_a_refusal_installs_nothing_and_keeps_the_list(self):
ran, left, _ = self._run("apt-get", ["sgdisk"], answer="n")
self.assertEqual(ran, [])
self.assertEqual(left, ["sgdisk"])
def test_an_unknown_package_manager_gives_up(self):
ran, left, _ = self._run("brew", ["sgdisk"])
self.assertEqual(ran, [])
self.assertEqual(left, ["sgdisk"])
def test_the_list_is_re_read_from_disk_not_assumed(self):
"""Une installation qui ne pose rien doit rester un échec."""
ran, left, _ = self._run("apt-get", ["sgdisk"], installed_after=())
self.assertEqual(len(ran), 1)
self.assertEqual(left, ["sgdisk"])
def test_a_failing_install_is_reported_and_not_swallowed(self):
"""exec_command_live REND le code de sortie, il ne lève rien."""
self.todo.execute = _Exec(status=100)
ran, left, out = self._run("apt-get", ["sgdisk"])
self.assertEqual(len(ran), 1)
self.assertEqual(left, ["sgdisk"])
self.assertIn("100", out)
if __name__ == "__main__":
unittest.main()

View file

@ -240,8 +240,19 @@ class TestEnsureVirtViewer(unittest.TestCase):
self.assertEqual(1, len(self.ran))
def test_every_family_is_covered(self):
tools = [t for t, _c in TODO._QEMU_VIRT_VIEWER_INSTALL]
self.assertEqual(["apt-get", "dnf", "pacman", "zypper"], tools)
"""La table des familles a quitté ce menu pour todo_install, qui la
partage avec les autres installations du CLI."""
from script.todo import todo_install
self.assertEqual(
["apt-get", "dnf", "pacman", "zypper"],
list(todo_install.FAMILIES),
)
for famille in todo_install.FAMILIES:
self.assertIn(
"virt-viewer",
todo_install.install_command(["virt-viewer"], famille=famille),
)
class TestTunnelMenuTargets(_MenuCase):

200
test/test_todo_install.py Normal file
View file

@ -0,0 +1,200 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""La composante d'installation partagée : familles, noms, ordre de décision.
Trois écritures séparées faisaient ce travail avant, chacune couvrant un
sous-ensemble différent : on pouvait installer virt-viewer sous openSUSE, mais
ni navigateur CLI ni lm-sensors. Ce qui se garde ici est donc surtout de la
COUVERTURE — qu'aucune famille ne retombe dans le trou — plus les deux règles
que la composante tient à la place des appelants : l'ID de la distribution
décide avant le PATH, et la commande s'affiche avant la question.
Rien n'est installé : le PATH et /etc/os-release sont simulés.
"""
import io
import sys
import unittest
from contextlib import redirect_stdout
from unittest.mock import mock_open, patch
sys.argv = ["todo.py"]
from script.todo import todo_install # noqa: E402
def _only(*present):
"""shutil.which qui ne trouve que `present`."""
return lambda b: f"/usr/bin/{b}" if b in present else None
class TestFamilyDetection(unittest.TestCase):
def test_every_family_has_a_non_interactive_command(self):
"""Un menu qui rend la main à un prompt apt reste bloqué."""
for famille in todo_install.FAMILIES:
cmd = todo_install.install_command(["p"], famille=famille)
self.assertEqual(cmd[0], "sudo")
self.assertEqual(cmd[-1], "p")
self.assertTrue(
{"-y", "--noconfirm", "--non-interactive"} & set(cmd),
f"{famille} : commande interactive {cmd}",
)
def test_the_distribution_id_decides_before_the_path(self):
"""Une machine peut porter deux gestionnaires ; l'ID dit lequel
possède le système."""
with patch(
"builtins.open", mock_open(read_data='ID="debian"\n')
), patch(
"script.todo.todo_install.shutil.which", _only("apt-get", "dnf")
):
self.assertEqual(todo_install.family(), "apt-get")
def test_the_path_decides_when_the_id_is_unknown(self):
with patch(
"builtins.open", mock_open(read_data='ID="nonesuch"\n')
), patch("script.todo.todo_install.shutil.which", _only("zypper")):
self.assertEqual(todo_install.family(), "zypper")
def test_an_id_whose_manager_is_absent_falls_back(self):
"""Un conteneur Debian minimal sans apt-get ne doit pas mener à une
commande apt-get qui n'existe pas."""
with patch(
"builtins.open", mock_open(read_data='ID="debian"\n')
), patch("script.todo.todo_install.shutil.which", _only("dnf")):
self.assertEqual(todo_install.family(), "dnf")
def test_no_manager_is_none_not_a_guess(self):
with patch("builtins.open", mock_open(read_data="")), patch(
"script.todo.todo_install.shutil.which", _only()
):
self.assertIsNone(todo_install.family())
self.assertIsNone(todo_install.install_command(["p"]))
def test_the_four_supported_families_are_all_mapped(self):
"""Les plateformes annoncées par le dépôt doivent toutes tomber sur
une famille, sans quoi l'installation leur est fermée."""
for os_id, attendu in (
("ubuntu", "apt-get"),
("linuxmint", "apt-get"),
("debian", "apt-get"),
("almalinux", "dnf"),
("rocky", "dnf"),
("opensuse-leap", "zypper"),
("opensuse-tumbleweed", "zypper"),
("arch", "pacman"),
):
with patch(
"builtins.open", mock_open(read_data=f'ID="{os_id}"\n')
), patch(
"script.todo.todo_install.shutil.which",
_only(*todo_install.FAMILIES),
):
self.assertEqual(todo_install.family(), attendu, os_id)
class TestPackageNames(unittest.TestCase):
def test_a_dict_picks_the_name_of_the_current_family(self):
"""lm-sensors chez Debian, lm_sensors ailleurs."""
noms = {
"apt-get": ["lm-sensors"],
"dnf": ["lm_sensors"],
"pacman": ["lm_sensors"],
"zypper": ["sensors"],
}
for famille, attendu in (
("apt-get", "lm-sensors"),
("dnf", "lm_sensors"),
("zypper", "sensors"),
):
cmd = todo_install.install_command(noms, famille=famille)
self.assertEqual(cmd[-1], attendu)
def test_a_family_absent_from_the_dict_installs_nothing(self):
"""Mieux vaut rien proposer qu'un paquet qui n'existe pas ici."""
self.assertIsNone(
todo_install.install_command({"apt-get": ["p"]}, famille="pacman")
)
def test_resolve_maps_binaries_and_deduplicates(self):
paquets, inconnus = todo_install.resolve(
["e2fsck", "resize2fs", "sgdisk"],
commun={"e2fsck": "e2fsprogs", "resize2fs": "e2fsprogs"},
par_famille={"pacman": {"sgdisk": "gptfdisk"}},
famille="pacman",
)
self.assertEqual(paquets, ["e2fsprogs", "gptfdisk"])
self.assertEqual(inconnus, [])
def test_resolve_says_what_it_cannot_map(self):
"""À dire, jamais à deviner : un nom inventé installerait au hasard."""
paquets, inconnus = todo_install.resolve(
["sgdisk"], commun={}, par_famille={}, famille="apt-get"
)
self.assertEqual(paquets, [])
self.assertEqual(inconnus, ["sgdisk"])
class TestAskAndInstall(unittest.TestCase):
class _Exec:
def __init__(self, status=0):
self.ran, self.status = [], status
def exec_command_live(self, cmd, source_erplibre=False):
self.ran.append(cmd)
return self.status
def _play(self, cmd, answer, status=0):
ex = self._Exec(status)
buf = io.StringIO()
def demande(invite=""):
print(invite, end="")
return answer
with patch("builtins.input", demande), redirect_stdout(buf):
got = todo_install.ask_and_install(
ex, cmd, "Installer? (y/N): ", lambda a: a.strip() == "y"
)
return ex.ran, got, buf.getvalue()
def test_the_command_is_printed_before_the_question(self):
"""Centralisé ici pour qu'aucun appelant ne puisse l'inverser."""
ran, got, out = self._play(
["sudo", "apt-get", "install", "-y", "p"], "n"
)
self.assertLess(
out.index("sudo apt-get install -y p"),
out.index("Installer? (y/N): "),
)
self.assertEqual(ran, [])
self.assertIsNone(got)
def test_accepting_runs_it_and_gives_the_exit_code_back(self):
"""exec_command_live REND le code, il ne lève rien : l'appelant doit
pouvoir le tester."""
ran, got, _ = self._play(["sudo", "dnf", "install", "-y", "p"], "y")
self.assertEqual(ran, ["sudo dnf install -y p"])
self.assertEqual(got, 0)
ran, got, _ = self._play(
["sudo", "dnf", "install", "-y", "p"], "y", status=127
)
self.assertEqual(got, 127)
def test_no_command_is_said_not_silently_skipped(self):
ran, got, out = self._play(None, "y")
self.assertEqual(ran, [])
self.assertIsNone(got)
self.assertTrue(out.strip())
def test_a_name_with_a_space_survives_the_display(self):
"""shlex.join, pour que la commande affichée soit celle qui tourne."""
ran, _, out = self._play(
["sudo", "apt-get", "install", "-y", "a b"], "y"
)
self.assertIn("'a b'", out)
self.assertIn("'a b'", ran[0])
if __name__ == "__main__":
unittest.main()