erplibre/test/test_qemu_shrink_tools.py
Mathieu Benoit cfbdd9406e [REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.

--- EN ---

This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.

Assisted-by: Claude Opus 5
2026-09-24 14:40:38 -04:00

279 lines
11 KiB
Python

#!/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 os
import sys
import unittest
from contextlib import redirect_stdout
from unittest.mock import patch
sys.argv = ["todo.py"]
from script.todo import todo_install # noqa: E402
from script.todo.todo import TODO # 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)
class TestBackupSpace(unittest.TestCase):
"""La sauvegarde avant réduction, et la place qu'elle demande.
Elle doublait l'occupation sans rien annoncer : sur un disque presque
plein la copie s'arrête à mi-course et laisse un .bak tronqué, sur un
système de fichiers désormais saturé. Les deux chiffres passent donc
avant la question, et le défaut bascule quand la place manque — une
entrée distraite ne doit pas remplir le disque.
"""
GIB = 1 << 30
def setUp(self):
self.todo = TODO.__new__(TODO)
def test_the_need_is_the_allocated_size_not_the_apparent_one(self):
"""« cp --sparse=always » ne recopie pas les trous d'un qcow2 : un
disque de 60 Go apparents mais 8 Go alloués ne demande que 8 Go."""
faux = os.stat_result((0o644, 0, 0, 1, 0, 0, 60 * self.GIB, 0, 0, 0))
# st_blocks n'est pas dans le tuple : on le pose à part.
with (
patch("script.todo.qemu_manage.os.stat") as stat,
patch("script.todo.qemu_manage.shutil.disk_usage") as du,
):
stat.return_value = type(
"S", (), {"st_blocks": 8 * self.GIB // 512}
)()
du.return_value = type("U", (), {"free": 99 * self.GIB})()
besoin, libre = TODO._qemu_backup_need_and_free("/x/d.qcow2")
self.assertEqual(besoin, 8 * self.GIB)
self.assertEqual(libre, 99 * self.GIB)
self.assertNotEqual(besoin, faux.st_size)
def _decision(self, besoin, libre, answer):
"""(question posée, sauvegarde retenue) — par le VRAI code.
Ce helper appelle _qemu_ask_backup et ne réimplémente rien : une
copie de la logique dans le test aurait laissé passer un défaut
remis à OUI sans place, ce qui est précisément le défaut à garder.
"""
vu = []
def demande(invite=""):
vu.append(invite)
return answer
with (
patch.object(
TODO,
"_qemu_backup_need_and_free",
staticmethod(lambda d: (besoin, libre)),
),
patch("builtins.input", demande),
redirect_stdout(io.StringIO()),
):
retenu = self.todo._qemu_ask_backup("/x/d.qcow2")
return vu[-1], retenu
def test_with_room_the_default_stays_yes(self):
question, retenu = self._decision(12 * self.GIB, 40 * self.GIB, "")
self.assertIn("(O/n", question)
self.assertTrue(retenu)
def test_without_room_the_default_flips_to_no(self):
"""Le cœur du correctif : entrée vide ne doit PAS remplir le disque."""
question, retenu = self._decision(12 * self.GIB, 3 * self.GIB, "")
self.assertIn("(y/N", question)
self.assertFalse(retenu)
def test_without_room_insisting_still_works(self):
"""On informe, on ne décide pas à la place de l'opérateur."""
_, retenu = self._decision(12 * self.GIB, 3 * self.GIB, "y")
self.assertTrue(retenu)
def test_a_margin_guards_the_exactly_equal_case(self):
"""Une place égale au besoin n'en laisse aucune : refusé."""
_, retenu = self._decision(12 * self.GIB, 12 * self.GIB, "")
self.assertFalse(retenu)
if __name__ == "__main__":
unittest.main()