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
184 lines
7 KiB
Python
184 lines
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)
|
|
"""Tableau des infos avancées : RAM utilisée, uptime, largeur.
|
|
|
|
Deux colonnes ont changé de sens. « RAM » disait l'allocation, elle dit
|
|
maintenant l'usage — sur un hyperviseur, savoir qu'une VM de 32 Go n'en occupe
|
|
que 4,7 décide s'il reste de la place pour la suivante. Et l'uptime est apparu :
|
|
libvirt ne l'expose nulle part, mais le processus QEMU du domaine est né avec
|
|
lui.
|
|
|
|
Ce que ces tests gardent : la formule de la RAM, calibrée contre le « free » de
|
|
deux VM réelles, et la largeur de la ligne — un tableau qui déborde de 80
|
|
colonnes se replie et devient illisible.
|
|
"""
|
|
|
|
import contextlib
|
|
import io
|
|
import subprocess
|
|
import sys
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO # noqa: E402
|
|
|
|
# Sortie réelle de « virsh dommemstat » sur la VM de migration, en KiO. Le
|
|
# « free » de l'invité disait alors : total 11955, used 1216.
|
|
DOMMEMSTAT = """actual 12582912
|
|
swap_in 0
|
|
swap_out 0
|
|
major_fault 0
|
|
minor_fault 0
|
|
unused 1197056
|
|
available 12242432
|
|
usable 11027456
|
|
last_update 1787359366
|
|
disk_caches 8211456
|
|
"""
|
|
|
|
|
|
class TestUptimeFormat(unittest.TestCase):
|
|
def test_seconds_then_minutes_then_hours_then_days(self):
|
|
self.assertEqual("45s", TODO._fmt_uptime(45))
|
|
self.assertEqual("12m", TODO._fmt_uptime(12 * 60 + 30))
|
|
self.assertEqual("19h55", TODO._fmt_uptime(19 * 3600 + 55 * 60))
|
|
self.assertEqual("10j13h", TODO._fmt_uptime(10 * 86400 + 13 * 3600))
|
|
|
|
def test_it_never_exceeds_six_characters(self):
|
|
"""La colonne fait six caractères : au-delà, le tableau se décale."""
|
|
for secs in (0, 59, 60, 3599, 3600, 86399, 86400, 400 * 86400):
|
|
self.assertLessEqual(len(TODO._fmt_uptime(secs)), 6, secs)
|
|
|
|
def test_precision_drops_as_the_duration_grows(self):
|
|
"""Personne ne lit les secondes d'un uptime de dix jours."""
|
|
self.assertNotIn("s", TODO._fmt_uptime(19 * 3600))
|
|
self.assertNotIn("m", TODO._fmt_uptime(10 * 86400))
|
|
|
|
|
|
class TestMemStat(unittest.TestCase):
|
|
def _stat(self, out):
|
|
with mock.patch(
|
|
"subprocess.run",
|
|
return_value=subprocess.CompletedProcess([], 0, out, ""),
|
|
):
|
|
return TODO._qemu_dommemstat("vm")
|
|
|
|
def test_used_is_available_minus_usable(self):
|
|
"""Calibré contre le « free » de deux VM : 1186 contre 1216 Mo lus dans
|
|
l'invité, et 4831 contre 4838 sur l'autre. « available - unused »
|
|
donnait 10,8 Go pour une VM qui en occupait 1,2 — il compte le cache.
|
|
"""
|
|
used, total = self._stat(DOMMEMSTAT)
|
|
self.assertAlmostEqual(used / 1024, 1186, delta=5)
|
|
self.assertAlmostEqual(total / 1024, 11955, delta=5)
|
|
|
|
def test_a_stopped_vm_gives_zero_without_crashing(self):
|
|
used, total = self._stat("")
|
|
self.assertEqual((0, 0), (used, total))
|
|
|
|
def test_it_survives_virsh_failing(self):
|
|
with mock.patch("subprocess.run", side_effect=OSError):
|
|
self.assertEqual((0, 0), TODO._qemu_dommemstat("vm"))
|
|
|
|
def test_missing_usable_is_not_taken_for_zero_use(self):
|
|
"""Sans « usable », on ne sait pas : mieux vaut ne rien dire que
|
|
d'annoncer une VM qui n'utiliserait rien."""
|
|
used, total = self._stat("available 12242432\nunused 1197056\n")
|
|
self.assertEqual(0, used)
|
|
self.assertGreater(total, 0)
|
|
|
|
def test_it_asks_for_a_collection_period_first(self):
|
|
"""Sans période, le ballon ne rafraîchit rien : une VM qui occupait
|
|
4,8 Go en annonçait 490 Mo — vécu. « --live » ne touche pas le XML."""
|
|
calls = []
|
|
|
|
def fake_run(cmd, **kw):
|
|
calls.append(cmd)
|
|
return subprocess.CompletedProcess([], 0, DOMMEMSTAT, "")
|
|
|
|
with mock.patch("subprocess.run", side_effect=fake_run):
|
|
TODO._qemu_dommemstat("vm")
|
|
self.assertIn("--period", calls[0])
|
|
self.assertIn("--live", calls[0])
|
|
|
|
|
|
class TestDomainUptime(unittest.TestCase):
|
|
def test_it_reads_the_age_of_the_qemu_process(self):
|
|
outs = [
|
|
subprocess.CompletedProcess([], 0, "1137455\n", ""),
|
|
subprocess.CompletedProcess([], 0, " 1195\n", ""),
|
|
]
|
|
with mock.patch("subprocess.run", side_effect=outs):
|
|
self.assertEqual(1195, TODO._qemu_domain_uptime("vm"))
|
|
|
|
def test_the_pattern_ends_with_a_comma(self):
|
|
"""« guest=vm, » et non « guest=vm » : sinon « vm » matcherait aussi
|
|
« vm-2 », et l'uptime affiché serait celui d'une autre machine."""
|
|
seen = {}
|
|
|
|
def fake_run(cmd, **kw):
|
|
seen.setdefault("cmd", cmd)
|
|
return subprocess.CompletedProcess([], 0, "", "")
|
|
|
|
with mock.patch("subprocess.run", side_effect=fake_run):
|
|
TODO._qemu_domain_uptime("vm")
|
|
self.assertIn("guest=vm,", seen["cmd"])
|
|
|
|
def test_a_stopped_domain_has_no_uptime(self):
|
|
with mock.patch(
|
|
"subprocess.run",
|
|
return_value=subprocess.CompletedProcess([], 1, "", ""),
|
|
):
|
|
self.assertIsNone(TODO._qemu_domain_uptime("vm"))
|
|
|
|
|
|
class TestTheTable(unittest.TestCase):
|
|
def _render(self, uptime=1195, mem=(1214 * 1024, 11955 * 1024)):
|
|
todo = TODO.__new__(TODO)
|
|
todo._qemu_list_domains = lambda: ["erplibre-ubuntu-2604-gnome"]
|
|
todo._qemu_domstate = lambda n: "running"
|
|
todo._qemu_dominfo = staticmethod(lambda n: (8, 12 * 1024 * 1024))
|
|
todo._qemu_main_disk = lambda n: "/var/lib/libvirt/images/x.qcow2"
|
|
todo._qemu_disk_sizes = staticmethod(
|
|
lambda d: (65 * (1 << 30), 62 * (1 << 30))
|
|
)
|
|
todo._qemu_dommemstat = staticmethod(lambda n: mem)
|
|
todo._qemu_domain_uptime = staticmethod(lambda n: uptime)
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
todo._qemu_list_vms_advanced()
|
|
return buf.getvalue()
|
|
|
|
def test_every_line_fits_in_eighty_columns(self):
|
|
"""Le tableau est lu dans un terminal : au-delà de 80, il se replie."""
|
|
for line in self._render().splitlines():
|
|
if line.startswith("Stockage") or not line.strip():
|
|
continue
|
|
self.assertLessEqual(len(line), 80, line)
|
|
|
|
def test_ram_shows_use_over_allocation(self):
|
|
out = self._render()
|
|
self.assertIn("1.2G/12G", out)
|
|
|
|
def test_the_uptime_column_is_there(self):
|
|
out = self._render()
|
|
self.assertIn("Uptime", out)
|
|
self.assertIn("19m", out)
|
|
|
|
def test_a_vm_without_stats_shows_a_dash_not_a_zero(self):
|
|
"""« 0.0G/12G » ferait croire à une VM au repos ; « -/12G » dit qu'on
|
|
ne sait pas."""
|
|
out = self._render(uptime=None, mem=(0, 0))
|
|
self.assertIn("-/12G", out)
|
|
self.assertNotIn("0.0G/12G", out)
|
|
|
|
def test_the_full_vm_name_survives_when_it_fits(self):
|
|
"""C'est le nom qui distingue les machines : le tronquer trop tôt les
|
|
rend indiscernables."""
|
|
self.assertIn("erplibre-ubuntu-2604-gnome", self._render())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|