#!/usr/bin/env python3 # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) """Télémétrie de navigation du CLI TODO. Enregistre les fils d'Ariane visités (« A › B › C » -> compteur) et affiche un DIAGRAMME arborescent des fonctionnalités dans un TUI Textual, trié par usage. - record(path) : appelé par Todo._menu_header à chaque affichage de menu ; on dédupe les ré-affichages consécutifs pour ne compter que les TRANSITIONS. - run_tui() : ouvre l'arbre de navigation (compteurs par menu). """ from __future__ import annotations import ast import json import os import time from pathlib import Path try: from script.todo.todo_i18n import t except Exception: # pragma: no cover - repli si i18n indisponible def t(key: str) -> str: return key # Dernier chemin enregistré : évite de recompter chaque ré-affichage du même # menu (une commande qui revient au menu ne compte pas comme une navigation). _LAST = [None] def _path() -> Path: base = Path(os.path.expanduser("~/.erplibre")) base.mkdir(parents=True, exist_ok=True) return base / "todo_telemetry.json" def load() -> dict: try: return json.loads(_path().read_text()) except (OSError, ValueError): return {"paths": {}} def _save(data: dict) -> None: try: _path().write_text(json.dumps(data, ensure_ascii=False, indent=2)) except OSError: pass def record(path: str) -> None: """Incrémente le compteur du menu `path`. Best-effort : ne lève jamais (la télémétrie ne doit jamais casser la navigation).""" if not path or path == _LAST[0]: return _LAST[0] = path try: data = load() paths = data.setdefault("paths", {}) paths[path] = paths.get(path, 0) + 1 data["updated"] = int(time.time()) _save(data) except Exception: pass def reset() -> None: _save({"paths": {}}) def _nested(paths: dict) -> dict: """{« A › B › C »: count} -> arbre {nom: {'count', 'children'}}. Chaque menu enregistre son propre chemin, donc chaque nœud a son compteur.""" root: dict = {} for path, count in paths.items(): cur = root parts = path.split(" › ") for i, name in enumerate(parts): node = cur.setdefault(name, {"count": 0, "children": {}}) if i == len(parts) - 1: node["count"] += count cur = node["children"] 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, kwargs)} depuis « status == "N": self.X(...) ». Les kwargs LITTÉRAUX (ex. dry_run=True) sont capturés pour pouvoir rejouer la commande à l'identique depuis la télémétrie.""" 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: found = None 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" ): kwargs = { kw.arg: kw.value.value for kw in n.keywords if kw.arg and isinstance(kw.value, ast.Constant) } found = (n.func.attr, kwargs) break if found: out[num] = found 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, kwargs = disp[num] if target in labels: # sous-menu node["children"].append(build(target)) else: # commande (feuille) exécutable lab = ( clabels[num - 1] if 0 <= num - 1 < len(clabels) else target.lstrip("_").replace("_", " ") ) node["children"].append( { "label": lab, "is_menu": False, "children": [], "method": target, "kwargs": kwargs, } ) seen.discard(method) return node return build("run") def _command_columns(tree, paths): """Colonnes du Kanban : (label, chemin, count, [commandes]) pour CHAQUE menu qui contient des commandes directes (parcours profondeur d'abord).""" cols = [] def walk(node, path): cmds = [c for c in node["children"] if not c["is_menu"]] if cmds: cols.append((node["label"], path, paths.get(path, 0), cmds)) for c in node["children"]: if c["is_menu"]: walk(c, f"{path} › {c['label']}") if tree: walk(tree, "TODO") return cols def run_tui(run_app: bool = True): """TUI de télémétrie : arbre (issu du code) + vue Kanban (F3), sélection d'une commande = exécution. Renvoie (méthode, kwargs) à exécuter, ou None. `run_app=False` renvoie l'app (tests).""" from textual.app import App, ComposeResult from textual.containers import HorizontalScroll, VerticalScroll from textual.widgets import ( Footer, Header, Label, ListItem, ListView, Static, Tree, ) paths = load().get("paths", {}) code_tree = build_code_tree() class CmdItem(ListItem): """Carte Kanban portant la commande à exécuter.""" def __init__(self, label, method, kwargs): super().__init__(Label(label)) self.cmd_method = method self.cmd_kwargs = kwargs or {} class Telemetry(App): CSS = """ #summary { height: 1; color: $text-muted; } Tree { border: solid $accent; } #kanban { display: none; height: 1fr; } .kcol { width: 40; border: solid $accent; margin: 0 1 0 0; } .ktitle { height: 1; color: $accent; } """ BINDINGS = [ ("q", "quit", "Quitter"), ("f3", "toggle_view", "Vue arbre/Kanban"), ("e", "expand_all", "Tout déplier"), ("r", "reset", "Réinitialiser"), ] def __init__(self): super().__init__() self._action = None self._mode = "tree" def compose(self) -> ComposeResult: yield Header(show_clock=True) yield Static("", id="summary") yield Tree("📍 TODO", id="nav") with HorizontalScroll(id="kanban"): for label, _path, cnt, cmds in _command_columns( code_tree, paths ): with VerticalScroll(classes="kcol"): yield Static(f"{label} ({cnt})", classes="ktitle") yield ListView( *[ CmdItem( f"· {c['label']}", c.get("method"), c.get("kwargs"), ) for c in cmds ] ) yield Footer() def on_mount(self): self.title = t("TODO navigation telemetry") self._populate_tree() self._update_summary() def _update_summary(self): total = sum(paths.values()) src = t("tree from code") if code_tree else t("visited paths only") self.query_one("#summary", Static).update( f" {total} {t('navigations')} · {len(paths)} {t('menus')} · " f"{src} · {t('Enter = run, F3 = view')}" ) def _populate_tree(self): tree = self.query_one("#nav", Tree) tree.clear() if code_tree is not None: tree.root.set_label(f"📍 TODO ({paths.get('TODO', 0)})") self._add_code(tree.root, code_tree["children"], "TODO") else: 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"]) tree.root.expand() def _add_code(self, tnode, children, parent_path): for c in children: if c["is_menu"]: path = f"{parent_path} › {c['label']}" child = tnode.add( f"{c['label']} ({paths.get(path, 0)})", expand=True ) self._add_code(child, c["children"], path) else: # Feuille EXÉCUTABLE : la méthode est portée en data. tnode.add_leaf( f"· {c['label']}", data={ "method": c.get("method"), "kwargs": c.get("kwargs"), }, ) 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_visited(child, node["children"]) def _run(self, method, kwargs): if not method: return self._action = (method, kwargs or {}) self.exit() def on_tree_node_selected(self, event): data = getattr(event.node, "data", None) if isinstance(data, dict) and data.get("method"): self._run(data["method"], data.get("kwargs")) def on_list_view_selected(self, event): if isinstance(event.item, CmdItem): self._run(event.item.cmd_method, event.item.cmd_kwargs) def action_toggle_view(self): self._mode = "kanban" if self._mode == "tree" else "tree" self.query_one("#nav").display = self._mode == "tree" self.query_one("#kanban").display = self._mode == "kanban" self._update_summary() def action_expand_all(self): if self._mode == "tree": self.query_one("#nav", Tree).root.expand_all() def action_reset(self): reset() paths.clear() self._populate_tree() self._update_summary() self.notify(t("Telemetry reset.")) app = Telemetry() if run_app: app.run() return app._action return app