erplibre/script/analyse/monitoring_tui.py
Mathieu Benoit 67a59d0522 [ADD] analyse: anonymiser une copie, sans IA et sans rien casser
Des mots pris dans une liste, des nombres tirés entre 0 et 1000, écrits
en SQL. Aucun modèle, aucun réseau — un test le vérifie sur les imports.

Le difficile n'est pas de remplacer, c'est de savoir ce qu'on n'a PAS le
droit de toucher. Mesuré sur une base 18 réelle : 505 champs `selection`
sont stockés en varchar, 2693 many2one sont des entiers, 194 textes sont
des jsonb par langue, 301 contraintes d'unicité attendent une collision.
« Tous les champs string » n'existe pas ; on croise ir_model_fields,
pg_attribute et pg_constraint, et aucune des trois ne suffit seule.

Trois pièges ont été trouvés en LANÇANT l'outil, pas en le relisant :
PostgreSQL refuse d'indexer un ARRAY[...] sans parenthèses,
res_partner.credit_limit est un jsonb qu'Odoo appelle float, et
crm_lead.probability porte un CHECK qui interdit 1000. Chaque fois
l'écriture a échoué et la base est restée intacte : une seule
transaction, tout ou rien.

Preuve sur copie jetable : empreinte du schéma identique, 848 tables,
6495 contraintes, arch_db et xmlid intacts, lang et many2one inchangés —
seules les colonnes visées ont changé.

--- EN ---

Words from a list, numbers drawn between 0 and 1000, written in SQL. No
model, no network — a test checks that on the imports.

The hard part is not replacing, it is knowing what must NOT be touched.
Measured on a real 18 database: 505 `selection` fields are stored as
varchar, 2693 many2one are integers, 194 texts are per-language jsonb,
301 unique constraints await a collision. "All string fields" does not
exist; we cross ir_model_fields, pg_attribute and pg_constraint, and none
of the three is enough alone.

Three traps were found by RUNNING it, not by rereading it: PostgreSQL
refuses to subscript a bare ARRAY[...], res_partner.credit_limit is a
jsonb Odoo calls float, and crm_lead.probability has a CHECK forbidding
1000. Each time the write failed and the database stayed intact: one
transaction, all or nothing.

Proof on a throwaway copy: identical schema fingerprint, 848 tables, 6495
constraints, arch_db and xmlids intact, lang and many2one unchanged —
only the targeted columns changed.

Assisted-by: Claude Opus 5
2026-08-25 03:31:12 -04:00

191 lines
6.3 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Demander quelle auscultation lancer — et rien d'autre.
Cet écran ne CONTIENT aucune analyse. Il en choisit une, se referme, et
rend la clé choisie ; l'appelant lance ensuite l'outil, qui dispose alors
du terminal entier et peut ouvrir sa propre TUI.
Deux raisons, toutes deux mesurées dans ce dépôt. `run_tui` refuse de
s'ouvrir quand une boucle asyncio tourne déjà — un écran qui en ouvrirait
un autre afficherait « open it in its own process instead » et rien
d'autre. Et une analyse lourde appelée depuis un gestionnaire de touche
bloque la boucle d'événements : plus de rafraîchissement, plus de frappe,
un écran qui paraît planté pendant des minutes.
Les analyses que la provenance ne permet pas restent AFFICHÉES, grisées,
avec la raison. Les retirer laisserait croire qu'elles n'existent pas ;
les proposer les ferait échouer à l'ouverture.
"""
from __future__ import annotations
import os
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
from script.analyse import monitoring # noqa: E402
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
CSS = """
Screen { layout: vertical; }
#head { height: 3; padding: 0 1; background: $panel; color: $text; }
#body { height: 1fr; }
#left { width: 52; border-right: solid $accent; }
#pane { width: 1fr; padding: 0 1; }
.off { color: $text-disabled; }
"""
def rows(kind):
"""Les lignes de l'écran : (clé, étiquette, utilisable, raison).
Fonction pure, et c'est délibéré — c'est elle qui décide ce qu'on peut
lancer, et une décision qui ne se teste qu'en ouvrant un terminal ne
se teste pas.
"""
lignes = []
for analyse in monitoring.ANALYSES:
utilisable = kind in analyse["kinds"]
lignes.append(
(
analyse["key"],
t(analyse["title"]),
utilisable,
"" if utilisable else t(analyse["why_not"]),
)
)
return lignes
def detail(key, kind):
"""Le texte du panneau de droite pour cette analyse."""
analyse = monitoring.analysis_by_key(key)
if not analyse:
return ""
morceaux = [t(analyse["title"]), "", t(analyse["why"]), ""]
if kind in analyse["kinds"]:
morceaux.append(f"▶ {t('Enter to run it.')}")
else:
morceaux.append(f"✖ {t('Not available for this source.')}")
morceaux.append(f" {t(analyse['why_not'])}")
morceaux.append("")
morceaux.append(f" {analyse['script']}")
return "\n".join(morceaux)
def build_app(kind, target):
"""Construire l'écran. ImportError si Textual manque — l'appelant le dit."""
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.widgets import Footer, Header, ListItem, ListView, Static
lignes = rows(kind)
class MonitoringChooser(App):
CSS = globals()["CSS"]
BINDINGS = [
("enter", "choose", t("Run")),
("q", "quit", t("Quit")),
("escape", "quit", t("Quit")),
]
def compose(self) -> ComposeResult:
yield Header()
yield Static(monitoring.describe_source(kind, target), id="head")
with Horizontal(id="body"):
items = []
for key, label, utilisable, _ in lignes:
marque = " " if utilisable else "✖ "
item = ListItem(Static(f"{marque}{label}"))
if not utilisable:
item.add_class("off")
items.append(item)
yield ListView(*items, id="left")
yield Static("", id="pane")
yield Footer()
def on_mount(self) -> None:
self.query_one("#left", ListView).focus()
self._refresh_pane(0)
def _refresh_pane(self, index) -> None:
if 0 <= index < len(lignes):
self.query_one("#pane", Static).update(
detail(lignes[index][0], kind)
)
def on_list_view_selected(self, event) -> None:
# `ListView` consomme Entrée pour émettre `Selected` : la
# liaison de l'application ne la voit jamais. Sans ceci,
# l'écran ne répondait pas à Entrée — et aucune fonction pure
# ne pouvait le montrer.
self.action_choose()
def on_list_view_highlighted(self, event) -> None:
self._refresh_pane(self.query_one("#left", ListView).index or 0)
def action_choose(self) -> None:
index = self.query_one("#left", ListView).index
if index is None or not (0 <= index < len(lignes)):
return
key, _, utilisable, raison = lignes[index]
if not utilisable:
# Refuser en le DISANT : un Entrée sans effet se lit comme
# un écran figé, et l'on appuie plus fort.
self.query_one("#pane", Static).update(
f"✖ {t('Not available for this source.')}\n\n {raison}"
)
return
self.exit(key)
return MonitoringChooser()
def in_event_loop():
"""Une boucle asyncio tourne-t-elle déjà dans CE processus ?"""
import asyncio
try:
asyncio.get_running_loop()
except RuntimeError:
return False
return True
def run_tui(kind, target, run_app=True):
"""Ouvrir le choix. Rendre la clé choisie, ou None — en disant pourquoi."""
try:
from script.todo import textual_setup
except Exception: # pragma: no cover - repli si l'aide manque
textual_setup = None
if textual_setup and not textual_setup.ensure():
return None
try:
app = build_app(kind, target)
except ImportError:
print(
f"ℹ️ {t('Textual is missing from this interpreter:')}"
f" {sys.executable}"
)
return None
if not run_app:
return app
if in_event_loop():
print(
f"ℹ️ {t('Already inside a running screen: open it in its own')}"
f" {t('process instead.')}"
)
return None
return app.run()