From 7517cc814b03d6d3f90f0c5b40c74261c0432f72 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 29 Jul 2026 09:49:13 +0000 Subject: [PATCH] =?UTF-8?q?[IMP]=20script=20todo:=20t=C3=A9l=C3=A9m=C3=A9t?= =?UTF-8?q?rie=20=E2=80=94=20arbre=20de=20navigation=20D=C3=89RIV=C3=89=20?= =?UTF-8?q?DU=20CODE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La télémétrie n'affichait que les chemins effectivement visités. Elle observe désormais les MÉTADONNÉES DE NAVIGATION issues du CODE : todo.py est analysé (AST) pour reconstruire l'arbre RÉEL des menus et commandes. - build_code_tree() lit _MENU_LABELS + les listes « choices » + le dispatch « status == N: self.X() » de chaque menu pour bâtir l'arborescence complète (sous-menus = cibles présentes dans _MENU_LABELS ; sinon commandes-feuilles, libellées par leur prompt_description). - Le TUI affiche cet arbre COMPLET (tous les menus, même jamais visités) avec le compteur de visites en surimpression sur chaque menu (via la télémétrie persistée). Repli sur l'arbre des seuls chemins visités si l'analyse échoue. Validé : l'arbre reconstruit couvre Execute -> Deploy -> SSH/QEMU (+ toutes leurs commandes : Resize, Delete, …), Git -> Git local server, GPT code -> RTK, etc. ; compteurs corrects (menus visités > 0, autres 0). Co-Authored-By: Claude Opus 4.8 (1M context) --- script/todo/todo_i18n.py | 8 ++ script/todo/todo_telemetry.py | 199 ++++++++++++++++++++++++++++++++-- 2 files changed, 197 insertions(+), 10 deletions(-) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index b967865..eaec7b8 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1302,6 +1302,14 @@ TRANSLATIONS = { "fr": "Télémétrie réinitialisée.", "en": "Telemetry reset.", }, + "tree from code": { + "fr": "arbre issu du code", + "en": "tree from code", + }, + "visited paths only": { + "fr": "chemins visités seulement", + "en": "visited paths only", + }, "Install textual for the telemetry TUI (pip).": { "fr": "Installez textual pour le TUI de télémétrie (pip).", "en": "Install textual for the telemetry TUI (pip).", diff --git a/script/todo/todo_telemetry.py b/script/todo/todo_telemetry.py index bbf05c5..5798fc7 100644 --- a/script/todo/todo_telemetry.py +++ b/script/todo/todo_telemetry.py @@ -12,6 +12,7 @@ DIAGRAMME arborescent des fonctionnalités dans un TUI Textual, trié par usage. """ from __future__ import annotations +import ast import json import os import time @@ -85,6 +86,162 @@ def _nested(paths: dict) -> dict: return root +# --------------------------------------------------------------------------- # +# Métadonnées de navigation DÉRIVÉES DU CODE (structure réelle des menus) +# --------------------------------------------------------------------------- # +def _str_of(node) -> str | None: + """Chaîne d'un nœud AST : littéral, t("…") ou f-string (parties Constant).""" + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "t" + and node.args + and isinstance(node.args[0], ast.Constant) + ): + return node.args[0].value + if isinstance(node, ast.JoinedStr): + return "".join( + p.value + for p in node.values + if isinstance(p, ast.Constant) and isinstance(p.value, str) + ) + return None + + +def _choice_labels(func) -> list: + """Libellés NUMÉROTÉS d'un menu « choices = [...] » (dans l'ordre, sections + exclues car non numérotées par fill_help_info).""" + for node in ast.walk(func): + if ( + isinstance(node, ast.Assign) + and any( + isinstance(tg, ast.Name) and tg.id == "choices" + for tg in node.targets + ) + and isinstance(node.value, ast.List) + ): + labels = [] + for el in node.value.elts: + if not isinstance(el, ast.Dict): + continue + for k, v in zip(el.keys, el.values): + if isinstance(k, ast.Constant) and k.value == "prompt_description": + s = _str_of(v) + if s: + labels.append(s) + return labels + return [] + + +def _dispatch(func) -> dict: + """{numéro: nom_de_méthode} depuis les branches « status == "N": self.X() ».""" + out = {} + for node in ast.walk(func): + if not isinstance(node, ast.If): + continue + test = node.test + if ( + isinstance(test, ast.Compare) + and isinstance(test.left, ast.Name) + and test.left.id == "status" + and len(test.ops) == 1 + and isinstance(test.ops[0], ast.Eq) + and isinstance(test.comparators[0], ast.Constant) + ): + try: + num = int(test.comparators[0].value) + except (TypeError, ValueError): + continue + for stmt in node.body: + for n in ast.walk(stmt): + if ( + isinstance(n, ast.Call) + and isinstance(n.func, ast.Attribute) + and isinstance(n.func.value, ast.Name) + and n.func.value.id == "self" + ): + out[num] = n.func.attr + break + if num in out: + break + return out + + +def _menu_labels(cls) -> dict: + """{méthode: label} depuis l'attribut de classe _MENU_LABELS.""" + for node in ast.walk(cls): + if ( + isinstance(node, ast.Assign) + and any( + isinstance(tg, ast.Name) and tg.id == "_MENU_LABELS" + for tg in node.targets + ) + and isinstance(node.value, ast.Dict) + ): + return { + k.value: v.value + for k, v in zip(node.value.keys, node.value.values) + if isinstance(k, ast.Constant) and isinstance(v, ast.Constant) + } + return {} + + +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 + « status == N » deviennent des sous-menus (si la cible est un menu) ou des + commandes (feuilles). None si l'analyse échoue.""" + p = Path(todo_path) if todo_path else (Path(__file__).parent / "todo.py") + try: + 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 + ) + if cls is None: + return None + methods = { + n.name: n for n in cls.body if isinstance(n, ast.FunctionDef) + } + labels = _menu_labels(cls) + if "run" not in methods or not labels: + return None + seen = set() + + def build(method): + node = { + "label": labels.get(method, method), + "is_menu": True, + "children": [], + } + if method in seen or method not in methods: + return node + seen.add(method) + func = methods[method] + disp = _dispatch(func) + clabels = _choice_labels(func) + for num in sorted(disp): + target = disp[num] + if target in labels: # sous-menu + node["children"].append(build(target)) + else: # commande (feuille) + lab = ( + clabels[num - 1] + if 0 <= num - 1 < len(clabels) + else target.lstrip("_").replace("_", " ") + ) + node["children"].append( + {"label": lab, "is_menu": False, "children": []} + ) + seen.discard(method) + return node + + return build("run") + + def run_tui(run_app: bool = True): """Ouvre le TUI arborescent. `run_app=False` renvoie l'app (tests).""" from textual.app import App, ComposeResult @@ -115,24 +272,46 @@ def run_tui(run_app: bool = True): data = load() paths = data.get("paths", {}) total = sum(paths.values()) - self.query_one("#summary", Static).update( - f" {total} {t('navigations')} · {len(paths)} {t('menus')}" - ) tree = self.query_one("#nav", Tree) tree.clear() - nested = _nested(paths) - todo = nested.get("TODO", {"count": 0, "children": nested}) - tree.root.set_label(f"📍 TODO ({todo['count']})") - self._add(tree.root, todo["children"]) + code_tree = build_code_tree() + if code_tree is not None: + # Structure RÉELLE issue du code + compteurs de visites. + cnt = paths.get("TODO", 0) + tree.root.set_label(f"📍 TODO ({cnt})") + self._add_code(tree.root, code_tree["children"], "TODO", paths) + src = t("tree from code") + else: + # Repli : arbre des seuls chemins visités. + nested = _nested(paths) + todo = nested.get("TODO", {"count": 0, "children": nested}) + tree.root.set_label(f"📍 TODO ({todo['count']})") + self._add_visited(tree.root, todo["children"]) + src = t("visited paths only") tree.root.expand() + self.query_one("#summary", Static).update( + f" {total} {t('navigations')} · {len(paths)} " + f"{t('menus')} · {src}" + ) - def _add(self, tnode, children): - # Tri par visites décroissantes : les plus utilisés en tête. + def _add_code(self, tnode, children, parent_path, paths): + """Nœuds depuis le code : menus (avec compteur de visites) et + commandes (feuilles préfixées « · »).""" + for c in children: + if c["is_menu"]: + path = f"{parent_path} › {c['label']}" + cnt = paths.get(path, 0) + child = tnode.add(f"{c['label']} ({cnt})", expand=True) + self._add_code(child, c["children"], path, paths) + else: + tnode.add_leaf(f"· {c['label']}") + + def _add_visited(self, tnode, children): for name, node in sorted( children.items(), key=lambda kv: -kv[1]["count"] ): child = tnode.add(f"{name} ({node['count']})", expand=True) - self._add(child, node["children"]) + self._add_visited(child, node["children"]) def action_reset(self): reset()