From 87ad8c443934361b81e20ff8cbf851b6a1f8e58d Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 31 Aug 2026 07:18:13 -0400 Subject: [PATCH] [ADD] script todo : installer les outils manquants, les quatre familles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- script/todo/qemu_access.py | 30 ++--- script/todo/qemu_install_monitor.py | 45 ++----- script/todo/qemu_manage.py | 53 ++++++++ script/todo/todo_i18n.py | 16 +++ script/todo/todo_install.py | 157 ++++++++++++++++++++++ script/todo/todo_telemetry.py | 48 +++---- test/test_qemu_shrink_tools.py | 196 +++++++++++++++++++++++++++ test/test_qemu_tunnel_menu.py | 15 ++- test/test_todo_install.py | 200 ++++++++++++++++++++++++++++ 9 files changed, 677 insertions(+), 83 deletions(-) create mode 100644 script/todo/todo_install.py create mode 100644 test/test_qemu_shrink_tools.py create mode 100644 test/test_todo_install.py diff --git a/script/todo/qemu_access.py b/script/todo/qemu_access.py index 645f38d..e0782f9 100644 --- a/script/todo/qemu_access.py +++ b/script/todo/qemu_access.py @@ -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 diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index 8c58ba5..2261f10 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -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: diff --git a/script/todo/qemu_manage.py b/script/todo/qemu_manage.py index 30ba3bd..51f77aa 100644 --- a/script/todo/qemu_manage.py +++ b/script/todo/qemu_manage.py @@ -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 diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 1790373..284cd9a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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…", diff --git a/script/todo/todo_install.py b/script/todo/todo_install.py new file mode 100644 index 0000000..1023132 --- /dev/null +++ b/script/todo/todo_install.py @@ -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) diff --git a/script/todo/todo_telemetry.py b/script/todo/todo_telemetry.py index d3f8492..940b5d3 100644 --- a/script/todo/todo_telemetry.py +++ b/script/todo/todo_telemetry.py @@ -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): diff --git a/test/test_qemu_shrink_tools.py b/test/test_qemu_shrink_tools.py new file mode 100644 index 0000000..364381e --- /dev/null +++ b/test/test_qemu_shrink_tools.py @@ -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() diff --git a/test/test_qemu_tunnel_menu.py b/test/test_qemu_tunnel_menu.py index 211a775..1677b6a 100644 --- a/test/test_qemu_tunnel_menu.py +++ b/test/test_qemu_tunnel_menu.py @@ -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): diff --git a/test/test_todo_install.py b/test/test_todo_install.py new file mode 100644 index 0000000..8ec04e4 --- /dev/null +++ b/test/test_todo_install.py @@ -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()