From 0cfb6f6a0070d3df2d8c9ddb8e424d3027ee8378 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 24 Aug 2026 04:17:56 -0400 Subject: [PATCH] =?UTF-8?q?[FIX]=20todo=20:=20rendre=20au=20d=C3=A9coupage?= =?UTF-8?q?=20ses=20colonnes=20de=20t=C3=A9l=C3=A9m=C3=A9trie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « 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 --- script/todo/todo.py | 1 + script/todo/todo_telemetry.py | 101 +++++++++++++++++++++++----------- test/test_todo_menu.py | 61 +++++++++++++++++++- 3 files changed, 129 insertions(+), 34 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index 9487319..5f52c1c 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -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", } diff --git a/script/todo/todo_telemetry.py b/script/todo/todo_telemetry.py index 1dcb9d8..d3f8492 100644 --- a/script/todo/todo_telemetry.py +++ b/script/todo/todo_telemetry.py @@ -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") diff --git a/test/test_todo_menu.py b/test/test_todo_menu.py index 5888970..6e3d7a6 100644 --- a/test/test_todo_menu.py +++ b/test/test_todo_menu.py @@ -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."""