[FIX] todo : rendre au découpage ses colonnes de télémétrie

« Il manque plein d'informations qu'il y avait avant » : l'écran de
télémétrie construit son arbre en LISANT le code — un fichier, sa première
classe. Depuis que les menus QEMU/KVM et Proxmox vivent dans des mixins,
leurs colonnes avaient disparu de cet écran ; les commandes s'exécutaient
toujours, mais on ne pouvait plus les lancer de là. L'arbre lit maintenant
aussi les mixins, trouvés dans les imports de todo.py — un mixin ajouté
demain apparaîtra sans qu'on y pense.

Le menu Proxmox n'avait pas d'étiquette : son fil d'Ariane s'arrêtait deux
niveaux plus haut, sur « Deploy ». Vérifié : 16 commandes QEMU/KVM et 18
Proxmox dans l'arbre, contre zéro et zéro.

--- EN ---

"A lot of information that used to be there is missing": the telemetry screen
builds its tree by READING the code — one file, its first class. Since the
QEMU/KVM and Proxmox menus moved into mixins, their columns had vanished from
that screen; the commands still ran, but could no longer be launched from
there. The tree now reads the mixins too, found in todo.py's own imports — a
mixin added tomorrow shows up without anyone thinking about it.

The Proxmox menu had no label: its breadcrumb stopped two levels up, at
"Deploy". Verified: 16 QEMU/KVM commands and 18 Proxmox ones in the tree,
against zero and zero.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-24 04:17:56 -04:00
parent 1a8f1525b1
commit 0cfb6f6a00
3 changed files with 129 additions and 34 deletions

View file

@ -608,6 +608,7 @@ class TODO(
"prompt_execute_deploy": "Deploy", "prompt_execute_deploy": "Deploy",
"prompt_execute_deploy_ssh": "SSH", "prompt_execute_deploy_ssh": "SSH",
"prompt_execute_qemu": "QEMU/KVM", "prompt_execute_qemu": "QEMU/KVM",
"prompt_execute_proxmox": "Proxmox VE",
"prompt_configuration": "Configuration", "prompt_configuration": "Configuration",
} }

View file

@ -17,6 +17,7 @@ import asyncio
import glob import glob
import json import json
import os import os
import re
import shutil import shutil
import subprocess import subprocess
import time import time
@ -180,8 +181,7 @@ def _dispatch(func) -> dict:
kwargs = { kwargs = {
kw.arg: kw.value.value kw.arg: kw.value.value
for kw in n.keywords for kw in n.keywords
if kw.arg if kw.arg and isinstance(kw.value, ast.Constant)
and isinstance(kw.value, ast.Constant)
} }
found = (n.func.attr, kwargs) found = (n.func.attr, kwargs)
break break
@ -311,6 +311,7 @@ def _choices_children(func, todo_dir):
liste de (label, méthode, kwargs) dans l'ordre affiché, ou None si le motif liste de (label, méthode, kwargs) dans l'ordre affiché, ou None si le motif
ne s'applique pas. Les entrées de CONFIG rejouent via ne s'applique pas. Les entrées de CONFIG rejouent via
execute_from_configuration ; les entrées APPENDÉES via leur méthode.""" execute_from_configuration ; les entrées APPENDÉES via leur méthode."""
def _dict_label(dnode): def _dict_label(dnode):
d = {} d = {}
for k, v in zip(dnode.keys, dnode.values): for k, v in zip(dnode.keys, dnode.values):
@ -361,9 +362,9 @@ def _choices_children(func, todo_dir):
dnode = ( dnode = (
arg arg
if isinstance(arg, ast.Dict) if isinstance(arg, ast.Dict)
else var_dict.get(arg.id) else (
if isinstance(arg, ast.Name) var_dict.get(arg.id) if isinstance(arg, ast.Name) else None
else None )
) )
if dnode is not None: if dnode is not None:
lab = _dict_label(dnode) lab = _dict_label(dnode)
@ -378,7 +379,9 @@ def _choices_children(func, todo_dir):
or entry.get("prompt_description") or entry.get("prompt_description")
or "?" or "?"
) )
children.append((lab, "execute_from_configuration", {"instance": entry})) children.append(
(lab, "execute_from_configuration", {"instance": entry})
)
len_disp = _dispatch_len(func) len_disp = _dispatch_len(func)
n_config = len(children) # figé : `children` grossit dans la boucle n_config = len(children) # figé : `children` grossit dans la boucle
n_total = n_config + len(appended) n_total = n_config + len(appended)
@ -389,6 +392,28 @@ def _choices_children(func, todo_dir):
return children return children
def _mixin_files(todo_py: Path) -> list:
"""Les fichiers des mixins que todo.py assemble sur la classe TODO.
Lus dans ses propres imports « from script.todo.X import YMixin » : la
liste suit le code au lieu d'être recopiée ici, et un mixin ajouté demain
apparaîtra sans qu'on y pense.
"""
try:
source = todo_py.read_text(encoding="utf-8")
except OSError:
return []
noms = re.findall(
r"^from script\.todo\.(\w+) import \w*Mixin", source, re.MULTILINE
)
fichiers = []
for nom in noms:
chemin = todo_py.parent / f"{nom}.py"
if chemin.exists():
fichiers.append(chemin)
return fichiers
def build_code_tree(todo_path=None) -> dict | None: def build_code_tree(todo_path=None) -> dict | None:
"""Construit l'arbre des menus/commandes EN LISANT le code de todo.py """Construit l'arbre des menus/commandes EN LISANT le code de todo.py
(AST). Chaque menu (méthode de _MENU_LABELS) devient un nœud ; ses branches (AST). Chaque menu (méthode de _MENU_LABELS) devient un nœud ; ses branches
@ -400,14 +425,25 @@ def build_code_tree(todo_path=None) -> dict | None:
mod = ast.parse(p.read_text(encoding="utf-8")) mod = ast.parse(p.read_text(encoding="utf-8"))
except (OSError, SyntaxError): except (OSError, SyntaxError):
return None return None
cls = next( cls = next((n for n in ast.walk(mod) if isinstance(n, ast.ClassDef)), None)
(n for n in ast.walk(mod) if isinstance(n, ast.ClassDef)), None
)
if cls is None: if cls is None:
return None return None
methods = { methods = {n.name: n for n in cls.body if isinstance(n, ast.FunctionDef)}
n.name: n for n in cls.body if isinstance(n, ast.FunctionDef) # ET les mixins. Ce lecteur est STATIQUE : il ne voit pas la classe
} # assemblée, seulement le fichier qu'on lui donne. Depuis que les menus
# QEMU/KVM et Proxmox vivent dans des mixins, leur colonne avait disparu
# de cet écran — la commande s'exécutait toujours, mais on ne pouvait plus
# la lancer d'ici ni la lire. Les fichiers sont ceux que todo.py importe,
# donc rien à tenir à jour à la main.
for voisin in _mixin_files(p):
try:
frere = ast.parse(voisin.read_text(encoding="utf-8"))
except (OSError, SyntaxError):
continue
for autre in (n for n in frere.body if isinstance(n, ast.ClassDef)):
for membre in autre.body:
if isinstance(membre, ast.FunctionDef):
methods.setdefault(membre.name, membre)
labels = _menu_labels(cls) labels = _menu_labels(cls)
if "run" not in methods or not labels: if "run" not in methods or not labels:
return None return None
@ -431,9 +467,7 @@ def build_code_tree(todo_path=None) -> dict | None:
node["children"].append(build(target)) node["children"].append(build(target))
else: # commande (feuille) exécutable else: # commande (feuille) exécutable
entry = ( entry = (
centries[num - 1] centries[num - 1] if 0 <= num - 1 < len(centries) else None
if 0 <= num - 1 < len(centries)
else None
) )
lab = ( lab = (
entry["label"] entry["label"]
@ -629,7 +663,10 @@ def system_snapshot(prev, full=True):
m = {} m = {}
if cpu and pcpu and cpu[1] > pcpu[1]: if cpu and pcpu and cpu[1] > pcpu[1]:
m["cpu"] = max( m["cpu"] = max(
0, min(100, round(100 * (1 - (cpu[0] - pcpu[0]) / (cpu[1] - pcpu[1])))) 0,
min(
100, round(100 * (1 - (cpu[0] - pcpu[0]) / (cpu[1] - pcpu[1])))
),
) )
else: else:
m["cpu"] = None m["cpu"] = None
@ -733,7 +770,8 @@ def run_tui(run_app: bool = True, state: dict | None = None):
"""TUI de télémétrie : vue Arbre (issue du code) et vue Kanban (F3), la """TUI de télémétrie : vue Arbre (issue du code) et vue Kanban (F3), la
disposition du Kanban défilant par F4 (colonnes / swimlanes / grille). disposition du Kanban défilant par F4 (colonnes / swimlanes / grille).
Sélectionner une COMMANDE = l'exécuter. `state` restaure la vue + le Sélectionner une COMMANDE = l'exécuter. `state` restaure la vue + le
curseur au retour. Renvoie (action|None, state) ; run_app=False -> l'app.""" curseur au retour. Renvoie (action|None, state) ; run_app=False -> l'app.
"""
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import ( from textual.containers import (
Container, Container,
@ -828,10 +866,8 @@ def run_tui(run_app: bool = True, state: dict | None = None):
# Vue Liste : tous les menus empilés verticalement, chaque menu # Vue Liste : tous les menus empilés verticalement, chaque menu
# avec ses sections et ses commandes (icônes), pour aider le choix. # avec ses sections et ses commandes (icônes), pour aider le choix.
blocks = [] blocks = []
for (label, path, cnt, cmds) in columns: for label, path, cnt, cmds in columns:
blocks.append( blocks.append(Static(f"▸ {path} ({cnt})", classes="listmenu"))
Static(f"▸ {path} ({cnt})", classes="listmenu")
)
for section, group in _group_by_section(cmds): for section, group in _group_by_section(cmds):
if section: if section:
blocks.append( blocks.append(
@ -868,7 +904,7 @@ def run_tui(run_app: bool = True, state: dict | None = None):
if self._kanban_mode == "swimlanes": if self._kanban_mode == "swimlanes":
# Une rangée (swimlane) par menu de NIVEAU 1 (Execute, …). # Une rangée (swimlane) par menu de NIVEAU 1 (Execute, …).
groups, order = {}, [] groups, order = {}, []
for (label, path, cnt, cmds) in columns: for label, path, cnt, cmds in columns:
parts = path.split(" › ") parts = path.split(" › ")
g = parts[1] if len(parts) > 1 else "TODO" g = parts[1] if len(parts) > 1 else "TODO"
if g not in groups: if g not in groups:
@ -878,7 +914,8 @@ def run_tui(run_app: bool = True, state: dict | None = None):
lanes = [] lanes = []
for g in order: for g in order:
lane_cols = [ lane_cols = [
self._col_widget(lb, cn, cm) for (lb, cn, cm) in groups[g] self._col_widget(lb, cn, cm)
for (lb, cn, cm) in groups[g]
] ]
lanes.append( lanes.append(
Vertical( Vertical(
@ -986,9 +1023,7 @@ def run_tui(run_app: bool = True, state: dict | None = None):
def _update_summary(self): def _update_summary(self):
total = sum(paths.values()) total = sum(paths.values())
src = t("tree from code") if code_tree else t("visited paths only") src = t("tree from code") if code_tree else t("visited paths only")
extra = ( extra = f" [{self._kanban_mode}]" if self._mode == "kanban" else ""
f" [{self._kanban_mode}]" if self._mode == "kanban" else ""
)
cur_lbl = VIEW_LABELS.get(self._mode, self._mode) cur_lbl = VIEW_LABELS.get(self._mode, self._mode)
nxt_lbl = VIEW_LABELS.get(self._next_view(), self._next_view()) nxt_lbl = VIEW_LABELS.get(self._next_view(), self._next_view())
self._update_f3_hint() self._update_f3_hint()
@ -1018,7 +1053,9 @@ def run_tui(run_app: bool = True, state: dict | None = None):
def _render_system(self, m): def _render_system(self, m):
lines = [] lines = []
load = ( load = (
" · " + t("load") + " " " · "
+ t("load")
+ " "
+ "/".join(f"{x:.1f}" for x in m["load"]) + "/".join(f"{x:.1f}" for x in m["load"])
if m["load"] if m["load"]
else "" else ""
@ -1055,9 +1092,7 @@ def run_tui(run_app: bool = True, state: dict | None = None):
) )
if m["battery"]: if m["battery"]:
cap, status = m["battery"] cap, status = m["battery"]
lines.append( lines.append(f" 🔋 {t('Battery')} : {cap} % ({status})")
f" 🔋 {t('Battery')} : {cap} % ({status})"
)
temp = m["temp"] temp = m["temp"]
if temp: if temp:
lines.append( lines.append(
@ -1096,9 +1131,9 @@ def run_tui(run_app: bool = True, state: dict | None = None):
print(f"{t('Proposed install command:')}") print(f"{t('Proposed install command:')}")
print(f" {printable}") print(f" {printable}")
print(" sudo sensors-detect --auto") print(" sudo sensors-detect --auto")
ans = input( ans = (
t("Install lm-sensors now? (y/N): ") input(t("Install lm-sensors now? (y/N): ")).strip().lower()
).strip().lower() )
if ans in ("o", "oui", "y", "yes"): if ans in ("o", "oui", "y", "yes"):
os.system(printable + " || true") os.system(printable + " || true")
os.system("sudo sensors-detect --auto || true") os.system("sudo sensors-detect --auto || true")

View file

@ -168,7 +168,7 @@ class MenuCoherence:
# ne doit pas dépendre de l'endroit où quelqu'un met un commentaire. # ne doit pas dépendre de l'endroit où quelqu'un met un commentaire.
RE_DISPATCH_CALL = re.compile( RE_DISPATCH_CALL = re.compile(
r'(?:el)?if status == "(\d+)":\s*\n(?:\s*#.*\n)*' r'(?:el)?if status == "(\d+)":\s*\n(?:\s*#.*\n)*'
r'\s*(?:status = )?self\.(\w+)\(' r"\s*(?:status = )?self\.(\w+)\("
) )
def setUp(self): def setUp(self):
@ -230,6 +230,65 @@ class MenuCoherence:
self.assertEqual(set(self.EXPECTED) - keys, set()) self.assertEqual(set(self.EXPECTED) - keys, set())
class TestLArbreDesMenus(unittest.TestCase):
"""L'écran de télémétrie lit le CODE, pas la classe assemblée.
`build_code_tree()` parse un fichier et n'y prend que la première classe.
Depuis le découpage, les menus QEMU/KVM et Proxmox vivent dans des mixins :
leur colonne avait disparu de cet écran — les commandes s'exécutaient
toujours, mais on ne pouvait plus les lancer de là ni les lire. C'est ce
que « il manque plein d'informations qu'il y avait avant » désignait.
"""
@classmethod
def setUpClass(cls):
from script.todo.todo_telemetry import build_code_tree
cls.arbre = build_code_tree()
def _noeud(self, libelle, noeud=None):
noeud = noeud if noeud is not None else self.arbre
if noeud.get("label") == libelle:
return noeud
for enfant in noeud.get("children") or []:
trouve = self._noeud(libelle, enfant)
if trouve:
return trouve
return None
def test_the_tree_is_built_at_all(self):
self.assertIsNotNone(self.arbre)
def test_the_mixin_files_come_from_the_imports(self):
# Lus dans les imports de todo.py : un mixin ajouté demain apparaît
# sans qu'on pense à l'inscrire ici.
from script.todo.todo_telemetry import _mixin_files
noms = {f.name for f in _mixin_files(TODO_DIR / "todo.py")}
self.assertIn("qemu_menu.py", noms)
self.assertIn("proxmox_menu.py", noms)
def test_the_qemu_column_carries_its_commands(self):
noeud = self._noeud("QEMU/KVM")
self.assertIsNotNone(noeud, "colonne QEMU/KVM absente de l'arbre")
self.assertGreaterEqual(len(noeud.get("children") or []), 15)
def test_the_proxmox_column_too(self):
noeud = self._noeud("Proxmox VE")
self.assertIsNotNone(noeud, "colonne Proxmox VE absente de l'arbre")
self.assertGreaterEqual(len(noeud.get("children") or []), 15)
def test_the_breadcrumb_names_the_proxmox_menu(self):
# Sans étiquette, le fil d'Ariane sautait le menu Proxmox : on lisait
# « TODO › Execute › Deploy » en étant deux niveaux plus bas.
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO as CLASSE
self.assertIn("prompt_execute_proxmox", CLASSE._MENU_LABELS)
class TestQemuMenuNumbering(MenuCoherence, unittest.TestCase): class TestQemuMenuNumbering(MenuCoherence, unittest.TestCase):
"""Le menu QEMU/KVM, désormais dans script/todo/qemu_menu.py.""" """Le menu QEMU/KVM, désormais dans script/todo/qemu_menu.py."""