[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:
parent
1a8f1525b1
commit
0cfb6f6a00
3 changed files with 129 additions and 34 deletions
|
|
@ -608,6 +608,7 @@ class TODO(
|
|||
"prompt_execute_deploy": "Deploy",
|
||||
"prompt_execute_deploy_ssh": "SSH",
|
||||
"prompt_execute_qemu": "QEMU/KVM",
|
||||
"prompt_execute_proxmox": "Proxmox VE",
|
||||
"prompt_configuration": "Configuration",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import asyncio
|
|||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
|
|
@ -180,8 +181,7 @@ def _dispatch(func) -> dict:
|
|||
kwargs = {
|
||||
kw.arg: kw.value.value
|
||||
for kw in n.keywords
|
||||
if kw.arg
|
||||
and isinstance(kw.value, ast.Constant)
|
||||
if kw.arg and isinstance(kw.value, ast.Constant)
|
||||
}
|
||||
found = (n.func.attr, kwargs)
|
||||
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
|
||||
ne s'applique pas. Les entrées de CONFIG rejouent via
|
||||
execute_from_configuration ; les entrées APPENDÉES via leur méthode."""
|
||||
|
||||
def _dict_label(dnode):
|
||||
d = {}
|
||||
for k, v in zip(dnode.keys, dnode.values):
|
||||
|
|
@ -361,9 +362,9 @@ def _choices_children(func, todo_dir):
|
|||
dnode = (
|
||||
arg
|
||||
if isinstance(arg, ast.Dict)
|
||||
else var_dict.get(arg.id)
|
||||
if isinstance(arg, ast.Name)
|
||||
else None
|
||||
else (
|
||||
var_dict.get(arg.id) if isinstance(arg, ast.Name) else None
|
||||
)
|
||||
)
|
||||
if dnode is not None:
|
||||
lab = _dict_label(dnode)
|
||||
|
|
@ -378,7 +379,9 @@ def _choices_children(func, todo_dir):
|
|||
or entry.get("prompt_description")
|
||||
or "?"
|
||||
)
|
||||
children.append((lab, "execute_from_configuration", {"instance": entry}))
|
||||
children.append(
|
||||
(lab, "execute_from_configuration", {"instance": entry})
|
||||
)
|
||||
len_disp = _dispatch_len(func)
|
||||
n_config = len(children) # figé : `children` grossit dans la boucle
|
||||
n_total = n_config + len(appended)
|
||||
|
|
@ -389,6 +392,28 @@ def _choices_children(func, todo_dir):
|
|||
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:
|
||||
"""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
|
||||
|
|
@ -400,14 +425,25 @@ def build_code_tree(todo_path=None) -> dict | None:
|
|||
mod = ast.parse(p.read_text(encoding="utf-8"))
|
||||
except (OSError, SyntaxError):
|
||||
return None
|
||||
cls = next(
|
||||
(n for n in ast.walk(mod) if isinstance(n, ast.ClassDef)), None
|
||||
)
|
||||
cls = next((n for n in ast.walk(mod) if isinstance(n, ast.ClassDef)), None)
|
||||
if cls is None:
|
||||
return None
|
||||
methods = {
|
||||
n.name: n for n in cls.body if isinstance(n, ast.FunctionDef)
|
||||
}
|
||||
methods = {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)
|
||||
if "run" not in methods or not labels:
|
||||
return None
|
||||
|
|
@ -431,9 +467,7 @@ def build_code_tree(todo_path=None) -> dict | None:
|
|||
node["children"].append(build(target))
|
||||
else: # commande (feuille) exécutable
|
||||
entry = (
|
||||
centries[num - 1]
|
||||
if 0 <= num - 1 < len(centries)
|
||||
else None
|
||||
centries[num - 1] if 0 <= num - 1 < len(centries) else None
|
||||
)
|
||||
lab = (
|
||||
entry["label"]
|
||||
|
|
@ -629,7 +663,10 @@ def system_snapshot(prev, full=True):
|
|||
m = {}
|
||||
if cpu and pcpu and cpu[1] > pcpu[1]:
|
||||
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:
|
||||
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
|
||||
disposition du Kanban défilant par F4 (colonnes / swimlanes / grille).
|
||||
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.containers import (
|
||||
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
|
||||
# avec ses sections et ses commandes (icônes), pour aider le choix.
|
||||
blocks = []
|
||||
for (label, path, cnt, cmds) in columns:
|
||||
blocks.append(
|
||||
Static(f"▸ {path} ({cnt})", classes="listmenu")
|
||||
)
|
||||
for label, path, cnt, cmds in columns:
|
||||
blocks.append(Static(f"▸ {path} ({cnt})", classes="listmenu"))
|
||||
for section, group in _group_by_section(cmds):
|
||||
if section:
|
||||
blocks.append(
|
||||
|
|
@ -868,7 +904,7 @@ def run_tui(run_app: bool = True, state: dict | None = None):
|
|||
if self._kanban_mode == "swimlanes":
|
||||
# Une rangée (swimlane) par menu de NIVEAU 1 (Execute, …).
|
||||
groups, order = {}, []
|
||||
for (label, path, cnt, cmds) in columns:
|
||||
for label, path, cnt, cmds in columns:
|
||||
parts = path.split(" › ")
|
||||
g = parts[1] if len(parts) > 1 else "TODO"
|
||||
if g not in groups:
|
||||
|
|
@ -878,7 +914,8 @@ def run_tui(run_app: bool = True, state: dict | None = None):
|
|||
lanes = []
|
||||
for g in order:
|
||||
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(
|
||||
Vertical(
|
||||
|
|
@ -986,9 +1023,7 @@ def run_tui(run_app: bool = True, state: dict | None = None):
|
|||
def _update_summary(self):
|
||||
total = sum(paths.values())
|
||||
src = t("tree from code") if code_tree else t("visited paths only")
|
||||
extra = (
|
||||
f" [{self._kanban_mode}]" if self._mode == "kanban" else ""
|
||||
)
|
||||
extra = f" [{self._kanban_mode}]" if self._mode == "kanban" else ""
|
||||
cur_lbl = VIEW_LABELS.get(self._mode, self._mode)
|
||||
nxt_lbl = VIEW_LABELS.get(self._next_view(), self._next_view())
|
||||
self._update_f3_hint()
|
||||
|
|
@ -1018,7 +1053,9 @@ def run_tui(run_app: bool = True, state: dict | None = None):
|
|||
def _render_system(self, m):
|
||||
lines = []
|
||||
load = (
|
||||
" · " + t("load") + " "
|
||||
" · "
|
||||
+ t("load")
|
||||
+ " "
|
||||
+ "/".join(f"{x:.1f}" for x in m["load"])
|
||||
if m["load"]
|
||||
else ""
|
||||
|
|
@ -1055,9 +1092,7 @@ def run_tui(run_app: bool = True, state: dict | None = None):
|
|||
)
|
||||
if m["battery"]:
|
||||
cap, status = m["battery"]
|
||||
lines.append(
|
||||
f" 🔋 {t('Battery')} : {cap} % ({status})"
|
||||
)
|
||||
lines.append(f" 🔋 {t('Battery')} : {cap} % ({status})")
|
||||
temp = m["temp"]
|
||||
if temp:
|
||||
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" {printable}")
|
||||
print(" sudo sensors-detect --auto")
|
||||
ans = input(
|
||||
t("Install lm-sensors now? (y/N): ")
|
||||
).strip().lower()
|
||||
ans = (
|
||||
input(t("Install lm-sensors now? (y/N): ")).strip().lower()
|
||||
)
|
||||
if ans in ("o", "oui", "y", "yes"):
|
||||
os.system(printable + " || true")
|
||||
os.system("sudo sensors-detect --auto || true")
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ class MenuCoherence:
|
|||
# ne doit pas dépendre de l'endroit où quelqu'un met un commentaire.
|
||||
RE_DISPATCH_CALL = re.compile(
|
||||
r'(?:el)?if status == "(\d+)":\s*\n(?:\s*#.*\n)*'
|
||||
r'\s*(?:status = )?self\.(\w+)\('
|
||||
r"\s*(?:status = )?self\.(\w+)\("
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
|
|
@ -230,6 +230,65 @@ class MenuCoherence:
|
|||
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):
|
||||
"""Le menu QEMU/KVM, désormais dans script/todo/qemu_menu.py."""
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue