Le TUI de télémétrie devient aussi un LANCEUR, avec deux vues : - Sélectionner une COMMANDE (feuille de l'arbre, ou carte du Kanban) + Entrée -> le TUI se ferme et la commande est EXÉCUTÉE (getattr(self, méthode) (**kwargs)). Les kwargs littéraux sont extraits du code (ex. Aperçu -> _qemu_deploy(dry_run=True)), donc la commande est rejouée à l'identique. - Vue KANBAN : une colonne par menu contenant des commandes (issu du code), cartes = commandes exécutables + compteur de visites du menu. - F3 bascule entre vue Arbre et vue Kanban. Résumé mis à jour (Entrée = exécuter, F3 = vue). _dispatch capture désormais (méthode, kwargs) ; les feuilles de l'arbre portent la méthode en data ; run_tui renvoie (méthode, kwargs) à exécuter. Validé headless : 11 colonnes Kanban, kwargs (dry_run/production_ready) capturés, bascule F3, capture de l'action à exécuter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
435 lines
15 KiB
Python
435 lines
15 KiB
Python
#!/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
|