[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": "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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue