diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py new file mode 100755 index 0000000..ff08063 --- /dev/null +++ b/script/analyse/check_migration_quality.py @@ -0,0 +1,499 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce qu'une migration gagne et ce qu'elle perd, palier par palier. + +La question qu'on se pose après six paliers n'est pas « a-t-elle fini » — +le journal le dit — mais « qu'est-ce qui a changé en chemin ». Un module +désinstallé en 15 pour débloquer la mise à jour, une table vidée sans que +personne ne le voie, deux cents vues apparues : rien de tout cela +n'apparaît dans un journal qu'on lit ligne à ligne. + +Ce que l'outil compare +---------------------- +Une migration laisse une base PAR PALIER — `x`, `x_upgrade_13`, … — et +elles existent toutes encore. On les interroge donc côte à côte, en +lecture seule, plutôt que de rejouer quoi que ce soit. + +Pourquoi pas en démarrant Odoo +------------------------------ +Six démarrages coûteraient une heure, écriraient dans les bases et +demanderaient de basculer le checkout à chaque palier. Mesuré : la même +inspection en SQL prend moins d'une demi-seconde par base, et ne touche à +rien. Ce qu'on y perd — les modèles abstraits, les champs calculés — ne +se compare pas d'une version à l'autre de toute façon. + +Ce qui compte le plus +--------------------- +Les LIGNES perdues. Un module en moins se voit ; une table qui passe de +quatre mille lignes à zéro ne se voit nulle part. Un renommage de table +entre deux versions s'y lit comme une perte suivie d'un gain : le rapport +les rapproche quand le compte correspond, plutôt que de crier au loup. + +Codes de sortie : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué. +""" + +import json +import os +import subprocess +import sys + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +) + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +DEFAULT_PROGRESSION = ".venv.erplibre/odoo_database_migration_log.json" +FILESTORE = os.path.join( + os.path.expanduser("~"), ".local", "share", "Odoo", "filestore" +) +SEP = "\x1f" + + +def run_psql(database, sql): + """Interroger la base en lecture seule, garantie par le SERVEUR. + + `default_transaction_read_only` n'est pas une promesse de l'outil : + PostgreSQL refusera l'écriture même si le SQL en contenait une. On + inspecte des bases de migration, parfois la seule copie qui reste. + """ + env = os.environ.copy() + env["PGOPTIONS"] = "-c default_transaction_read_only=on" + env["PSQLRC"] = "" + done = subprocess.run( + ["psql", "-X", "-w", "-d", database, "-tAF", SEP, "-c", sql], + capture_output=True, + text=True, + env=env, + ) + if done.returncode: + return None + return [ligne.split(SEP) for ligne in done.stdout.splitlines() if ligne] + + +def read_progression(path=DEFAULT_PROGRESSION): + try: + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + except (OSError, ValueError): + return {} + + +def chain(dct): + """[(version, base)] du départ à l'arrivée, dans l'ordre du parcours. + + Le nom des bases de palier suit la convention du pilote — + « _upgrade_ » — et la liste des paliers se déduit de la + cible et du nombre d'entrées `state_4_*_odoo_lst`. On ne devine donc + rien : on relit ce que la migration a écrit. + """ + base = dct.get("config_database_name") + if not base: + return [] + try: + cible = int(float(dct.get("target_odoo_version") or 0)) + except (TypeError, ValueError): + return [] + total = max( + [ + len(valeur) + for cle, valeur in dct.items() + if cle.startswith("state_4_") + and cle.endswith("_odoo_lst") + and isinstance(valeur, list) + ] + or [0] + ) + if not total or not cible: + return [(None, base)] + lst_version = list(range(cible - total + 1, cible + 1)) + depart = lst_version[0] - 1 + return [(depart, base)] + [ + (version, f"{base}_upgrade_{version}") for version in lst_version + ] + + +META_SQL = """ +SELECT 'odoo', latest_version FROM ir_module_module WHERE name = 'base' +UNION ALL SELECT 'view', count(*)::text FROM ir_ui_view +UNION ALL SELECT 'view_cow', count(*)::text FROM ir_ui_view + WHERE website_id IS NOT NULL +UNION ALL SELECT 'menu', count(*)::text FROM ir_ui_menu +UNION ALL SELECT 'action', count(*)::text FROM ir_act_window +UNION ALL SELECT 'attachment', count(*)::text FROM ir_attachment +UNION ALL SELECT 'attachment_stored', count(*)::text FROM ir_attachment + WHERE store_fname IS NOT NULL +UNION ALL SELECT 'language', count(*)::text FROM res_lang WHERE active +""" + + +def table_counts(database): + """{table: lignes} pour toutes les tables. Une seule requête. + + Construite côté serveur puis exécutée d'un bloc : huit cents requêtes + séparées coûteraient huit cents allers-retours, là où celle-ci prend + quatre dixièmes de seconde — mesuré sur une base de 890 tables. + """ + fabrique = run_psql( + database, + "SELECT string_agg(" + "format('SELECT %L t, count(*) n FROM %I', table_name, table_name)," + " ' UNION ALL ') FROM information_schema.tables" + " WHERE table_schema = 'public' AND table_type = 'BASE TABLE'", + ) + if not fabrique or not fabrique[0][0]: + return {} + lignes = run_psql(database, fabrique[0][0]) + if lignes is None: + return {} + return { + nom: int(nombre) + for nom, nombre in (ligne[:2] for ligne in lignes) + if nombre.isdigit() + } + + +def missing_files(database, lst_store_fname): + """Les pièces jointes dont le FICHIER a disparu du filestore. + + Une base peut référencer des milliers de pièces jointes dont le + contenu n'a pas suivi le clonage. Rien ne le signale : la page se + charge, l'image est vide. + """ + racine = os.path.join(FILESTORE, database) + if not os.path.isdir(racine): + return None + return [ + nom + for nom in lst_store_fname + if nom and not os.path.isfile(os.path.join(racine, nom)) + ] + + +def inspect(database): + """L'état d'une base, en lecture seule. `exists` False si absente.""" + etat = {"database": database, "exists": False} + meta = run_psql(database, META_SQL) + if meta is None: + return etat + etat["exists"] = True + dct_meta = {ligne[0]: ligne[1] for ligne in meta if len(ligne) > 1} + etat["odoo"] = (dct_meta.get("odoo") or "?").rsplit(".", 2)[0] + for cle in ( + "view", + "view_cow", + "menu", + "action", + "attachment", + "attachment_stored", + "language", + ): + etat[cle] = int(dct_meta.get(cle) or 0) + + modules = run_psql( + database, "SELECT name, state FROM ir_module_module ORDER BY name" + ) + etat["module"] = { + nom: statut for nom, statut in (m[:2] for m in modules or []) + } + etat["installed"] = sorted( + nom for nom, statut in etat["module"].items() if statut == "installed" + ) + modeles = run_psql(database, "SELECT model FROM ir_model ORDER BY model") + etat["model"] = sorted(m[0] for m in modeles or []) + etat["table"] = table_counts(database) + + # Les modèles sans table : abstraits, mixins et vues SQL pour la + # plupart — mais leur NOMBRE qui bouge d'un palier à l'autre dit + # quelque chose, alors que la liste brute ne dit rien. + tables = set(etat["table"]) + etat["model_without_table"] = [ + m for m in etat["model"] if m.replace(".", "_") not in tables + ] + + stockees = run_psql( + database, + "SELECT DISTINCT store_fname FROM ir_attachment" + " WHERE store_fname IS NOT NULL", + ) + absents = missing_files(database, [ligne[0] for ligne in stockees or []]) + etat["attachment_missing"] = None if absents is None else len(absents) + return etat + + +def compare(avant, apres): + """Ce qui a été gagné et ce qui a été perdu entre deux paliers.""" + if not avant.get("exists") or not apres.get("exists"): + return {"unavailable": True} + inst_avant, inst_apres = set(avant["installed"]), set(apres["installed"]) + mod_avant, mod_apres = set(avant["model"]), set(apres["model"]) + tbl_avant, tbl_apres = avant["table"], apres["table"] + + lignes_perdues = [] + for table, nombre in sorted(tbl_avant.items()): + reste = tbl_apres.get(table) + if reste is None and nombre: + lignes_perdues.append((table, nombre, 0)) + elif reste is not None and reste < nombre: + lignes_perdues.append((table, nombre, reste)) + lignes_gagnees = [ + (table, tbl_avant.get(table, 0), nombre) + for table, nombre in sorted(tbl_apres.items()) + if nombre > tbl_avant.get(table, 0) + ] + return { + "modules_lost": sorted(inst_avant - inst_apres), + "modules_gained": sorted(inst_apres - inst_avant), + "models_lost": sorted(mod_avant - mod_apres), + "models_gained": sorted(mod_apres - mod_avant), + "rows_lost": lignes_perdues, + "rows_gained": lignes_gagnees, + "renamed": probable_renames(lignes_perdues, lignes_gagnees), + "delta": { + cle: apres.get(cle, 0) - avant.get(cle, 0) + for cle in ( + "view", + "view_cow", + "menu", + "action", + "attachment", + "language", + ) + }, + } + + +def probable_renames(perdues, gagnees): + """Rapprocher une table disparue d'une table apparue au même compte. + + Odoo renomme des tables entre versions — `mail_channel` est devenu + `discuss_channel` en 17. Sans ce rapprochement, chaque renommage se + lit comme une perte de données doublée d'une apparition, et l'on + cherche un dégât là où il n'y en a pas. + """ + disparues = { + table: avant for table, avant, apres in perdues if apres == 0 and avant + } + apparues = { + table: apres for table, avant, apres in gagnees if avant == 0 and apres + } + couples = [] + for table, nombre in sorted(disparues.items()): + for autre, combien in sorted(apparues.items()): + if combien == nombre and looks_renamed(table, autre): + couples.append((table, autre, nombre)) + del apparues[autre] + break + return couples + + +RENAME_RATIO = 0.75 + + +def looks_renamed(un, deux): + """Les deux noms se ressemblent-ils assez pour être le même sujet ? + + Deux garde-fous ont été essayés et rejetés, mesurés sur une vraie + migration. Le seul nombre de lignes accouplait + `account_account_tag_account_tax_template_rel` à `dms_directory` : les + deux comptaient sept lignes. Un mot commun d'au moins cinq lettres + accouplait `cleanup_purge_wizard_menu` à + `cleanup_create_indexes_line` — « cleanup » ne dit rien. + + La ressemblance d'ENSEMBLE tranche : `muk_dms_directory` et + `dms_directory` se ressemblent à 87 %, `website_redirect` et + `website_rewrite` à 83 %, tandis que les faux couples restent sous + 50 %. + """ + import difflib + + return difflib.SequenceMatcher(None, un, deux).ratio() >= RENAME_RATIO + + +def survey(dct, echo=None): + """Inspecter toute la chaîne, du départ à l'arrivée.""" + lst = [] + for version, database in chain(dct): + if echo: + echo(f"{database} …") + etat = inspect(database) + etat["version"] = version + lst.append(etat) + return lst + + +def overall(lst_snapshot): + """La comparaison du PREMIER au DERNIER palier. + + Elle ne se déduit pas des comparaisons deux à deux : un module retiré + en 15 puis remis en 17 n'a rien perdu du tout, et l'addition des + étapes le compterait deux fois. + """ + presents = [x for x in lst_snapshot if x.get("exists")] + if len(presents) < 2: + return {"unavailable": True} + return compare(presents[0], presents[-1]) + + +def render_text(lst_snapshot, colour=None, limit=8): + """Le rapport complet. C'est aussi le repli du plein écran.""" + from script.todo.migration_status import paint, supports_colour + + if colour is None: + colour = supports_colour() + lignes = [f"📐 {t('Migration quality, step by step')}"] + for etat in lst_snapshot: + if not etat.get("exists"): + lignes.append( + f" ⚠️ {etat['database']} : {t('database not found')}" + ) + continue + lignes.append( + f" {paint(f'{etat['odoo']:<6}', 'step', colour)}" + f" {etat['database']:<34}" + f" {len(etat['installed']):>4} {t('modules')}" + f" · {len(etat['model']):>4} {t('models')}" + f" · {etat['view']:>5} {t('views')}" + f" · {etat['attachment']:>6} {t('attachments')}" + ) + if etat.get("attachment_missing"): + lignes.append( + f" {paint('❌', 'fail', colour)}" + f" {etat['attachment_missing']}" + f" {t('attachment files missing from the filestore')}" + ) + + presents = [x for x in lst_snapshot if x.get("exists")] + for avant, apres in zip(presents, presents[1:]): + lignes.append(f"\n🔀 {avant['odoo']} → {apres['odoo']}") + lignes.extend(render_compare(compare(avant, apres), colour, limit)) + + lignes.append( + f"\n🏁 {t('From start to finish')} :" + f" {presents[0]['odoo'] if presents else '?'}" + f" → {presents[-1]['odoo'] if presents else '?'}" + ) + lignes.extend(render_compare(overall(lst_snapshot), colour, limit)) + return "\n".join(lignes) + + +def render_compare(diff, colour, limit=8): + """Les gains et les pertes d'un palier, en quelques lignes.""" + from script.todo.migration_status import paint + + if diff.get("unavailable"): + return [f" {t('not comparable: a database is missing')}"] + lignes = [] + for cle, symbole, teinte in ( + ("modules_lost", "−", "fail"), + ("modules_gained", "+", "ok"), + ): + lst = diff[cle] + if lst: + lignes.append( + f" {paint(symbole, teinte, colour)} {len(lst)}" + f" {t('modules')} : {', '.join(lst[:limit])}" + + (" …" if len(lst) > limit else "") + ) + for cle, symbole, teinte in ( + ("models_lost", "−", "fail"), + ("models_gained", "+", "ok"), + ): + lst = diff[cle] + if lst: + lignes.append( + f" {paint(symbole, teinte, colour)} {len(lst)}" + f" {t('models')} : {', '.join(lst[:limit])}" + + (" …" if len(lst) > limit else "") + ) + if diff["renamed"]: + lignes.append( + f" ↻ {len(diff['renamed'])} {t('probable table rename(s)')} :" + f" {', '.join(f'{a} → {b}' for a, b, _n in diff['renamed'][:4])}" + ) + perdues = diff["rows_lost"] + if perdues: + # LE signal qui compte : un module en moins se voit, une table qui + # passe de quatre mille lignes à zéro ne se voit nulle part. + # + # Une table probablement renommée reste dans la LISTE, annotée. L'en + # retirer était le vrai danger : un rapprochement faux — et il y en + # a eu — aurait fait disparaître une perte réelle du rapport. + vers = {a: b for a, b, _n in diff["renamed"]} + lignes.append( + f" {paint('▼', 'fail', colour)} {len(perdues)}" + f" {t('table(s) lost rows')} :" + ) + for table, avant, apres in perdues[:limit]: + note = ( + f" ↻ {t('probably renamed to')} {vers[table]}" + if table in vers + else "" + ) + lignes.append(f" {table:<40} {avant:>8} → {apres}{note}") + delta = diff["delta"] + lignes.append( + " " + + " ".join( + f"{cle} {valeur:+d}" for cle, valeur in delta.items() if valeur + ) + or " =" + ) + return lignes + + +def main(argv=None): + import argparse + + parser = argparse.ArgumentParser( + description=( + "Compare every database a migration left behind, step by step," + " and report what was gained and what was lost." + ) + ) + parser.add_argument( + "-f", + "--file", + default=DEFAULT_PROGRESSION, + help="migration progression file", + ) + parser.add_argument( + "--text", + action="store_true", + help="print the report instead of opening the full screen", + ) + parser.add_argument("--limit", type=int, default=8) + config = parser.parse_args(argv) + + dct = read_progression(config.file) + if not chain(dct): + print(f"ℹ️ {t('No migration in progress.')}") + return 0 + lst = survey(dct, echo=lambda texte: print(f"⧖ {texte}", flush=True)) + if not config.text: + try: + from script.analyse.check_migration_quality_tui import run_tui + except Exception: + run_tui = None + if run_tui and run_tui(lst): + return 0 + print(render_text(lst, limit=config.limit)) + manque = [x for x in lst if not x.get("exists")] + perdu = overall(lst) + trouvailles = bool(manque) or bool( + not perdu.get("unavailable") + and (perdu["modules_lost"] or perdu["rows_lost"]) + ) + return 1 if trouvailles else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/analyse/check_migration_quality_tui.py b/script/analyse/check_migration_quality_tui.py new file mode 100644 index 0000000..1f570a2 --- /dev/null +++ b/script/analyse/check_migration_quality_tui.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""La qualité d'une migration, palier par palier, en plein écran. + +Le rapport texte dit tout d'un coup : sur six paliers il fait plusieurs +centaines de lignes, et ce qu'on cherche — quel palier a perdu quoi — se +trouve quelque part au milieu. Un écran qui se parcourt règle cela. + +Les données viennent de `check_migration_quality`, comme le rapport texte. +Deux assemblages sépareraient les deux vues, et l'on finirait par lire deux +états contradictoires de la même migration. +""" + +import os +import sys + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +) + +from script.analyse import check_migration_quality as quality # noqa: E402 +from script.todo import migration_status as status # 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: 40; border-right: solid $accent; } +#pane { width: 1fr; padding: 0 1; } +""" + + +def rows(lst_snapshot): + """Un palier par ligne, puis le bilan d'ensemble en dernier. + + Le bilan EN DERNIER et non en tête : on descend la liste comme on a + vécu la migration, et la question « qu'est-ce qu'il en reste » se pose + une fois qu'on a vu le chemin. + """ + lst = [] + presents = [x for x in lst_snapshot if x.get("exists")] + for index, etat in enumerate(lst_snapshot): + if not etat.get("exists"): + lst.append( + { + "kind": "missing", + "label": f"⚠️ {etat['database'][:30]}", + "detail": "", + "data": etat, + } + ) + continue + precedent = None + rang = presents.index(etat) + if rang: + precedent = presents[rang - 1] + diff = quality.compare(precedent, etat) if precedent else None + perdu = ( + 0 + if diff is None or diff.get("unavailable") + else len(diff["rows_lost"]) + ) + lst.append( + { + "kind": "step", + "label": f"{etat['odoo']:<6} {etat['database'][:24]}", + "detail": str(perdu) if perdu else "", + "data": etat, + "diff": diff, + } + ) + if len(presents) >= 2: + lst.append( + { + "kind": "overall", + "label": f"🏁 {presents[0]['odoo']} → {presents[-1]['odoo']}", + "detail": "", + "data": None, + "diff": quality.overall(lst_snapshot), + } + ) + return lst + + +def head_text(lst_snapshot): + presents = [x for x in lst_snapshot if x.get("exists")] + manquants = len(lst_snapshot) - len(presents) + if not presents: + return t("No migration in progress.") + return ( + f"{presents[0]['database']} · {len(presents)} {t('steps')}" + f" · {presents[0]['odoo']} → {presents[-1]['odoo']}" + + ( + f" · ⚠️ {manquants} {t('database not found')}" + if manquants + else "" + ) + ) + + +def pane_text(lst_snapshot, row, colour=False): + """Le détail du palier choisi, ou le bilan d'ensemble.""" + if row is None: + return t("Nothing to show yet.") + if row["kind"] == "missing": + return f"⚠️ {row['data']['database']} : {t('database not found')}" + lignes = [] + etat = row["data"] + if etat: + lignes.append( + status.paint(f"{etat['odoo']} {etat['database']}", "step", colour) + ) + lignes.append("") + for libelle, valeur in ( + (t("modules"), len(etat["installed"])), + (t("models"), len(etat["model"])), + (t("views"), etat["view"]), + (" · COW", etat["view_cow"]), + (t("menus"), etat["menu"]), + (t("attachments"), etat["attachment"]), + ): + lignes.append(f" {libelle:<28} {valeur:>7}") + if etat.get("attachment_missing"): + lignes.append( + f" {status.paint('❌ ' + t('attachment files missing from' + ' the filestore'), 'fail', colour)}" + f" {etat['attachment_missing']}" + ) + lignes.append("") + diff = row.get("diff") + if diff: + lignes.extend(quality.render_compare(diff, colour, limit=40)) + return "\n".join(lignes) + + +def build_app(lst_snapshot): + """Textual est importé ICI : le module reste testable sans lui.""" + from rich.text import Text + from textual.app import App, ComposeResult + from textual.containers import Horizontal, VerticalScroll + from textual.widgets import DataTable, Footer, Header, Static + + class QualityApp(App): + CSS = globals()["CSS"] + BINDINGS = [("q,escape", "quit", t("Quit"))] + + def __init__(self, lst_snapshot): + super().__init__() + self.lst_snapshot = lst_snapshot + self.lst_row = rows(lst_snapshot) + self.index = 0 + + def compose(self) -> ComposeResult: + yield Header() + yield Static("", id="head") + with Horizontal(id="body"): + yield DataTable(id="left", cursor_type="row") + with VerticalScroll(id="pane"): + yield Static("", id="content") + yield Footer() + + def on_mount(self): + self.title = t("Migration quality, step by step") + table = self.query_one("#left", DataTable) + table.add_columns(t("steps"), "▼") + for row in self.lst_row: + table.add_row(row["label"][:34], row["detail"]) + self.query_one("#head", Static).update( + head_text(self.lst_snapshot) + ) + self._show() + + def _show(self): + row = ( + self.lst_row[self.index] + if self.lst_row and self.index < len(self.lst_row) + else None + ) + self.query_one("#content", Static).update( + Text.from_ansi(pane_text(self.lst_snapshot, row, colour=True)) + ) + + def on_data_table_row_highlighted(self, event): + if event.data_table.id == "left" and self.lst_row: + self.index = event.cursor_row + self._show() + + return QualityApp(lst_snapshot) + + +def run_tui(lst_snapshot, run_app=True): + """Ouvrir l'écran. False si l'on n'a pas pu — et alors on DIT pourquoi.""" + if not lst_snapshot: + return False + if not sys.stdout.isatty(): + print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}") + return False + try: + from script.todo import textual_setup + except Exception: + textual_setup = None + if textual_setup and not textual_setup.ensure(): + return False + try: + app = build_app(lst_snapshot) + except ImportError: + print( + f"ℹ️ {t('Textual is missing from this interpreter:')}" + f" {sys.executable}" + ) + return False + if not run_app: + return app + app.run() + return True diff --git a/script/todo/migration_status_tui.py b/script/todo/migration_status_tui.py index f823d2f..8311fb4 100644 --- a/script/todo/migration_status_tui.py +++ b/script/todo/migration_status_tui.py @@ -234,6 +234,34 @@ def apply_width(app, delta): app.query_one("#left").styles.width = app.left_width +def fill_table(app): + """(Re)construire la liste de gauche depuis les données courantes.""" + table = app.query_one("#left") + table.clear(columns=True) + table.add_columns(t("Test results"), "#", "❌") + for row in app.lst_row: + table.add_row(row["label"][:38], row["detail"], row.get("severe", "")) + table.styles.width = app.left_width + app.query_one("#head").update(head_text(app.dct)) + + +def show_pane(app): + """Peindre le détail de la ligne choisie. + + `from_ansi` fait DEUX choses : il rend les couleurs, et il traite le + reste comme du texte LITTÉRAL. Sans lui, une commande contenant + « [1] » passait pour du balisage Rich et disparaissait sans un mot. + """ + from rich.text import Text + + row = current_row(app.lst_row, app.index) + app.query_one("#content").update( + Text.from_ansi( + pane_text(app.dct, row, colour=True, show_log=app.show_log) + ) + ) + + def build_app(dct, path=None): """Textual est importé ICI, pas au chargement du module. @@ -241,7 +269,6 @@ def build_app(dct, path=None): Textual, et c'est aussi ce qui permet à l'appelant de retomber sur le rapport texte plutôt que d'échouer. """ - from rich.text import Text from textual.app import App, ComposeResult from textual.containers import Horizontal, VerticalScroll from textual.widgets import DataTable, Footer, Header, Static @@ -253,6 +280,7 @@ def build_app(dct, path=None): ("r", "refresh", t("Refresh")), ("l", "toggle_log", t("Logs")), ("p", "cycle_panels", t("Panels")), + ("k", "quality", t("Quality")), ("plus,equal", "wider", t("Wider")), ("minus,underscore", "narrower", t("Narrower")), ] @@ -282,39 +310,14 @@ def build_app(dct, path=None): def on_mount(self): self.title = t("Migration state") - self._fill_table() + fill_table(self) apply_panels(self) - self._show() - - def _fill_table(self): - table = self.query_one("#left", DataTable) - table.clear(columns=True) - table.add_columns(t("Test results"), "#", "❌") - for row in self.lst_row: - table.add_row( - row["label"][:38], row["detail"], row.get("severe", "") - ) - table.styles.width = self.left_width - self.query_one("#head", Static).update(head_text(self.dct)) - - def _show(self): - row = current_row(self.lst_row, self.index) - # `from_ansi` fait DEUX choses : il rend les couleurs, et il - # traite le reste comme du texte LITTÉRAL. Sans lui, une - # commande contenant « [1] » passait pour du balisage Rich et - # disparaissait de l'écran sans que rien ne le signale. - self.query_one("#content", Static).update( - Text.from_ansi( - pane_text( - self.dct, row, colour=True, show_log=self.show_log - ) - ) - ) + show_pane(self) def on_data_table_row_highlighted(self, event): if event.data_table.id == "left" and self.lst_row: self.index = event.cursor_row - self._show() + show_pane(self) def action_refresh(self): """Relire le disque. La migration écrit PENDANT qu'on regarde. @@ -329,12 +332,41 @@ def build_app(dct, path=None): self.dct = neuf self.lst_row = rows(self.dct) self.index = min(self.index, max(0, len(self.lst_row) - 1)) - self._fill_table() - self._show() + fill_table(self) + show_pane(self) + + def action_quality(self): + """Ouvrir le rapport de qualité, sans quitter celui-ci. + + Les deux écrans répondent à deux questions voisines : « où en + est-on » et « qu'a-t-on gagné ou perdu en chemin ». Les + séparer par une touche plutôt que par deux commandes à retenir + est ce qui les rend utilisables ensemble. + + `suspend` rend le terminal à l'autre plein écran : deux + applications Textual ne peuvent pas peindre le même écran en + même temps. + """ + from script.analyse import check_migration_quality as quality + from script.analyse.check_migration_quality_tui import ( + run_tui as run_quality, + ) + + dct = quality.read_progression() + if not quality.chain(dct): + self.notify(t("No migration in progress.")) + return + with self.suspend(): + lst = quality.survey( + dct, echo=lambda texte: print(f"⧖ {texte}", flush=True) + ) + if not run_quality(lst): + print(quality.render_text(lst)) + input(f"💬 {t('press to continue')} : ") def action_toggle_log(self): self.show_log = not self.show_log - self._show() + show_pane(self) def action_cycle_panels(self): """Trois états, du plus complet au plus dépouillé. diff --git a/script/todo/todo.py b/script/todo/todo.py index ae148e7..4614ffa 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -7712,6 +7712,8 @@ class TODO: ) }, {"prompt_description": t("Studio and hand-made x_ fields")}, + {"section": t("Migration")}, + {"prompt_description": t("Quality of a migration, step by step")}, ] help_info = self.fill_help_info(choices) @@ -7726,9 +7728,41 @@ class TODO: self.execute_analyse_view_custom() elif status == "3": self.execute_analyse_custom_field() + elif status == "4": + self.execute_analyse_migration_quality() else: print(t("Command not found !")) + def execute_analyse_migration_quality(self): + """Ce qu'une migration a gagné et perdu, palier par palier. + + Appelé comme les autres analyses — même interpréteur, aucun + sous-processus — mais l'écran plein est ouvert par l'outil + lui-même, qui sait retomber sur son rapport texte s'il ne peut pas. + + Lecture seule de bout en bout : les bases de palier sont parfois la + seule copie qui reste d'un état intermédiaire. + """ + from script.analyse import check_migration_quality as quality + + dct = quality.read_progression() + if not quality.chain(dct): + print(f"\nℹ️ {t('No migration in progress.')}") + return + try: + lst = quality.survey( + dct, echo=lambda texte: print(f"⧖ {texte}", flush=True) + ) + except Exception as exc: + print(f"❌ {t('Analysis failed: ')}{exc}") + return + try: + from script.analyse.check_migration_quality_tui import run_tui + except Exception: + run_tui = None + if not (run_tui and run_tui(lst)): + print(quality.render_text(lst)) + def _analyse_select_source(self): """(est_une_sauvegarde, cible), ou None si l'on renonce. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index f11e2e9..3ddea51 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5691,6 +5691,62 @@ TRANSLATIONS = { "fr": "détail seul", "en": "detail only", }, + "errors in the log": { + "fr": "erreurs dans le journal", + "en": "errors in the log", + }, + "Migration quality, step by step": { + "fr": "Qualité de la migration, palier par palier", + "en": "Migration quality, step by step", + }, + "database not found": { + "fr": "base introuvable", + "en": "database not found", + }, + "attachment files missing from the filestore": { + "fr": "fichiers de pièces jointes absents du filestore", + "en": "attachment files missing from the filestore", + }, + "From start to finish": { + "fr": "Du départ à l'arrivée", + "en": "From start to finish", + }, + "not comparable: a database is missing": { + "fr": "non comparable : une base manque", + "en": "not comparable: a database is missing", + }, + "probable table rename(s)": { + "fr": "renommage(s) de table probable(s)", + "en": "probable table rename(s)", + }, + "table(s) lost rows": { + "fr": "table(s) ont perdu des lignes", + "en": "table(s) lost rows", + }, + "attachments": { + "fr": "pièces jointes", + "en": "attachments", + }, + "probably renamed to": { + "fr": "probablement renommée en", + "en": "probably renamed to", + }, + "steps": { + "fr": "paliers", + "en": "steps", + }, + "Migration": { + "fr": "Migration", + "en": "Migration", + }, + "Quality of a migration, step by step": { + "fr": "Qualité d'une migration, palier par palier", + "en": "Quality of a migration, step by step", + }, + "Quality": { + "fr": "Qualité", + "en": "Quality", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py new file mode 100644 index 0000000..fd26953 --- /dev/null +++ b/test/test_check_migration_quality.py @@ -0,0 +1,399 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce qu'une migration gagne et ce qu'elle perd, palier par palier. + +La question qu'on se pose après six paliers n'est pas « a-t-elle fini » — +le journal le dit — mais « qu'est-ce qui a changé en chemin ». Une +migration laisse une base PAR PALIER, et elles existent toutes encore : on +les compare côte à côte plutôt que de rejouer quoi que ce soit. + +Le point le plus délicat est le rapprochement des tables renommées. Deux +garde-fous ont été essayés et rejetés SUR UNE VRAIE MIGRATION avant celui +qui tient, et un faux rapprochement ne serait pas une coquetterie : il +ferait DISPARAÎTRE une perte réelle du rapport. D'où la règle qui compte +plus que tout ici — une perte est toujours listée, jamais retirée. +""" + +import io +import os +import sys +import unittest +from contextlib import redirect_stdout + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "script", "analyse")) + +from script.analyse import check_migration_quality as quality # noqa: E402 +from script.analyse import check_migration_quality_tui as qtui # noqa: E402 +from script.todo import todo_i18n # noqa: E402 + + +def snapshot(**override): + etat = { + "database": "db", + "exists": True, + "odoo": "13.0", + "version": 13, + "installed": ["account", "sale"], + "module": {"account": "installed", "sale": "installed"}, + "model": ["account.move", "sale.order"], + "table": {"account_move": 100, "sale_order": 20}, + "model_without_table": [], + "view": 2000, + "view_cow": 40, + "menu": 400, + "action": 300, + "attachment": 5000, + "attachment_stored": 4000, + "attachment_missing": 0, + "language": 2, + } + etat.update(override) + return etat + + +class Base(unittest.TestCase): + def setUp(self): + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + +class TestFindingTheChain(Base): + """Les bases se déduisent de ce que la migration a ÉCRIT. + + Le pilote nomme ses bases de palier « _upgrade_ » et + retient sa cible. Redemander ces noms serait redemander ce qu'on sait. + """ + + PROGRESSION = { + "config_database_name": "test_neutralize", + "target_odoo_version": "18.0", + "state_4_upgrade_odoo_lst": [[], [], [], [], [], []], + } + + def test_it_walks_from_the_start_to_the_target(self): + lst = quality.chain(self.PROGRESSION) + self.assertEqual( + [version for version, _db in lst], [12, 13, 14, 15, 16, 17, 18] + ) + + def test_the_first_one_is_the_origin_database(self): + # Le palier 12 n'a pas de base « _upgrade_12 » : c'est la base + # d'origine, celle qu'on a restaurée. + self.assertEqual( + quality.chain(self.PROGRESSION)[0][1], "test_neutralize" + ) + + def test_the_others_follow_the_naming_of_the_driver(self): + noms = [db for _v, db in quality.chain(self.PROGRESSION)] + self.assertIn("test_neutralize_upgrade_13", noms) + self.assertIn("test_neutralize_upgrade_18", noms) + + def test_a_migration_not_started_yields_the_origin_alone(self): + lst = quality.chain( + {"config_database_name": "db", "target_odoo_version": "18.0"} + ) + self.assertEqual(lst, [(None, "db")]) + + def test_no_progression_yields_nothing(self): + self.assertEqual(quality.chain({}), []) + + +class TestWhatIsGainedAndLost(Base): + def test_a_module_uninstalled_is_a_loss(self): + diff = quality.compare( + snapshot(installed=["account", "sale"]), + snapshot(installed=["account"]), + ) + self.assertEqual(diff["modules_lost"], ["sale"]) + self.assertEqual(diff["modules_gained"], []) + + def test_a_model_that_appears_is_a_gain(self): + diff = quality.compare( + snapshot(model=["account.move"]), + snapshot(model=["account.move", "account.edi"]), + ) + self.assertEqual(diff["models_gained"], ["account.edi"]) + + def test_a_table_that_empties_is_reported(self): + # LE signal : un module en moins se voit, une table qui passe de + # cent lignes à zéro ne se voit nulle part. + diff = quality.compare( + snapshot(table={"account_invoice": 651}), + snapshot(table={"account_invoice": 0}), + ) + self.assertEqual(diff["rows_lost"], [("account_invoice", 651, 0)]) + + def test_a_table_that_disappears_counts_as_emptied(self): + diff = quality.compare( + snapshot(table={"account_invoice": 651}), snapshot(table={}) + ) + self.assertEqual(diff["rows_lost"], [("account_invoice", 651, 0)]) + + def test_an_empty_table_that_disappears_is_not_a_loss(self): + # Rien à perdre : le signaler noierait les vraies pertes. + diff = quality.compare(snapshot(table={"vide": 0}), snapshot(table={})) + self.assertEqual(diff["rows_lost"], []) + + def test_counts_move_with_their_sign(self): + diff = quality.compare(snapshot(view=2000), snapshot(view=3733)) + self.assertEqual(diff["delta"]["view"], 1733) + + def test_a_missing_database_is_not_comparable(self): + diff = quality.compare(snapshot(), {"exists": False}) + self.assertTrue(diff["unavailable"]) + + +class TestNotCryingWolfOnRenames(Base): + """Deux garde-fous rejetés sur une VRAIE migration avant celui-ci.""" + + def test_the_row_count_alone_is_not_enough(self): + # Il accouplait `account_account_tag_account_tax_template_rel` à + # `dms_directory` : les deux comptaient sept lignes. + self.assertFalse( + quality.looks_renamed( + "account_account_tag_account_tax_template_rel", "dms_directory" + ) + ) + + def test_a_shared_word_is_not_enough_either(self): + # « cleanup » ne dit rien, « users » non plus. + self.assertFalse( + quality.looks_renamed( + "cleanup_purge_wizard_menu", "cleanup_create_indexes_line" + ) + ) + self.assertFalse( + quality.looks_renamed( + "digest_tip_res_users_rel", "project_allowed_portal_users_rel" + ) + ) + + def test_real_renames_are_recognised(self): + for un, deux in ( + ("muk_dms_directory", "dms_directory"), + ("website_redirect", "website_rewrite"), + ( + "account_invoice_purchase_order_rel", + "account_move_purchase_order_rel", + ), + ): + self.assertTrue(quality.looks_renamed(un, deux), (un, deux)) + + def test_a_rename_needs_the_same_row_count(self): + # Un nom qui se ressemble mais un compte qui change n'est pas un + # renommage : c'est un renommage ET une perte, qu'on ne fusionne pas. + diff = quality.compare( + snapshot(table={"muk_dms_directory": 10}), + snapshot(table={"dms_directory": 4}), + ) + self.assertEqual(diff["renamed"], []) + + def test_a_renamed_table_is_STILL_listed_as_lost(self): + """La règle qui compte plus que tout ici. + + Retirer une perte parce qu'on croit à un renommage, c'est faire + disparaître du rapport ce qu'on est venu y chercher — et le + rapprochement s'est déjà trompé. + """ + diff = quality.compare( + snapshot(table={"muk_dms_directory": 7}), + snapshot(table={"dms_directory": 7}), + ) + self.assertEqual( + diff["renamed"], [("muk_dms_directory", "dms_directory", 7)] + ) + self.assertIn(("muk_dms_directory", 7, 0), diff["rows_lost"]) + + def test_the_report_says_it_is_only_probable(self): + diff = quality.compare( + snapshot(table={"muk_dms_directory": 7}), + snapshot(table={"dms_directory": 7}), + ) + texte = "\n".join(quality.render_compare(diff, colour=False)) + self.assertIn("muk_dms_directory", texte) + self.assertIn("probably renamed", texte) + + +class TestTheOverallReport(Base): + def test_it_compares_the_ENDS_not_the_sum_of_steps(self): + """Un module retiré en 15 puis remis en 17 n'a rien perdu. + + L'addition des comparaisons deux à deux le compterait deux fois, + une en perte et une en gain, et le bilan mentirait dans les deux + sens à la fois. + """ + lst = [ + snapshot(odoo="12.0", installed=["a", "b"]), + snapshot(odoo="15.0", installed=["a"]), + snapshot(odoo="18.0", installed=["a", "b"]), + ] + bilan = quality.overall(lst) + self.assertEqual(bilan["modules_lost"], []) + self.assertEqual(bilan["modules_gained"], []) + + def test_it_needs_two_databases(self): + self.assertTrue(quality.overall([snapshot()])["unavailable"]) + + def test_missing_databases_are_skipped_not_fatal(self): + lst = [ + snapshot(odoo="12.0", installed=["a"]), + {"database": "absente", "exists": False}, + snapshot(odoo="18.0", installed=["a", "b"]), + ] + self.assertEqual(quality.overall(lst)["modules_gained"], ["b"]) + + +class TestTheReportItself(Base): + def test_every_step_is_listed(self): + texte = quality.render_text( + [ + snapshot(odoo="12.0", database="a"), + snapshot(odoo="13.0", database="b"), + ], + colour=False, + ) + self.assertIn("12.0", texte) + self.assertIn("13.0", texte) + + def test_a_missing_database_is_named_not_hidden(self): + texte = quality.render_text( + [snapshot(), {"database": "absente", "exists": False}], + colour=False, + ) + self.assertIn("absente", texte) + self.assertIn("not found", texte) + + def test_missing_attachment_files_are_surfaced(self): + # La trouvaille faite à la main sur une vraie migration : 254 + # fichiers absents du filestore, que rien ne signalait. + texte = quality.render_text( + [snapshot(attachment_missing=254)], colour=False + ) + self.assertIn("254", texte) + + def test_it_ends_with_the_start_to_finish_comparison(self): + texte = quality.render_text( + [snapshot(odoo="12.0"), snapshot(odoo="18.0")], colour=False + ) + self.assertIn("From start to finish", texte) + self.assertLess(texte.index("12.0"), texte.index("From start")) + + +class TestItNeverWrites(Base): + """Les bases de palier sont parfois la seule copie d'un état.""" + + def test_the_connection_is_read_only_at_the_server(self): + import inspect + + source = inspect.getsource(quality.run_psql) + self.assertIn("default_transaction_read_only=on", source) + + def test_no_odoo_is_started(self): + # Six démarrages coûteraient une heure ET écriraient dans les + # bases. L'inspection en SQL prend moins d'une demi-seconde. + import inspect + + source = inspect.getsource(quality) + for interdit in ("odoo_bin", "run.sh", "--update", "-u all"): + self.assertNotIn(interdit, source, interdit) + + +class TestTheFullScreen(Base): + def test_one_row_per_step_plus_the_overall(self): + lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")] + lignes = qtui.rows(lst) + self.assertEqual( + [x["kind"] for x in lignes], ["step", "step", "overall"] + ) + + def test_the_overall_comes_LAST(self): + # On descend la liste comme on a vécu la migration ; « qu'en + # reste-t-il » se pose une fois le chemin vu. + lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")] + self.assertEqual(qtui.rows(lst)[-1]["kind"], "overall") + + def test_a_missing_database_gets_its_own_row(self): + lignes = qtui.rows( + [snapshot(), {"database": "absente", "exists": False}] + ) + self.assertIn("missing", [x["kind"] for x in lignes]) + + def test_the_column_counts_the_lost_tables(self): + lst = [ + snapshot(odoo="12.0", table={"t": 100}), + snapshot(odoo="13.0", table={"t": 0}), + ] + self.assertEqual(qtui.rows(lst)[1]["detail"], "1") + + def test_a_step_that_lost_nothing_leaves_it_empty(self): + lst = [snapshot(odoo="12.0"), snapshot(odoo="13.0")] + self.assertEqual(qtui.rows(lst)[1]["detail"], "") + + def test_the_pane_uses_the_same_comparison_as_the_text(self): + import inspect + + self.assertIn( + "quality.render_compare", inspect.getsource(qtui.pane_text) + ) + + def test_a_pipe_is_explained_rather_than_silent(self): + out = io.StringIO() + with redirect_stdout(out): + self.assertFalse(qtui.run_tui([snapshot()])) + self.assertTrue(out.getvalue().strip()) + + def test_nothing_to_show_stays_silent(self): + out = io.StringIO() + with redirect_stdout(out): + self.assertFalse(qtui.run_tui([])) + self.assertEqual(out.getvalue(), "") + + +class TestWhereItIsOffered(Base): + def test_the_analyse_menu_offers_it(self): + chemin = os.path.join(REPO, "script", "todo", "todo.py") + with open(chemin) as handle: + texte = handle.read() + self.assertIn("Quality of a migration, step by step", texte) + self.assertIn("execute_analyse_migration_quality", texte) + + def test_the_existing_menu_numbers_did_not_move(self): + # Quelqu'un connaît « 1 », « 2 », « 3 » : les décaler pour insérer + # une entrée au milieu se paierait à chaque usage. + import inspect + + from script.todo.todo import TODO + + source = inspect.getsource(TODO.prompt_execute_analyse) + self.assertLess( + source.index("Tables and database size"), + source.index("Quality of a migration"), + ) + + def test_the_state_screen_offers_it_too(self): + import inspect + + from script.todo import migration_status_tui as stui + + source = inspect.getsource(stui.build_app) + self.assertIn('"k", "quality"', source) + self.assertIn("check_migration_quality", source) + + def test_it_hands_the_terminal_over(self): + # Deux applications Textual ne peuvent pas peindre le même écran. + import inspect + + from script.todo import migration_status_tui as stui + + source = inspect.getsource(stui.build_app) + debut = source.index("def action_quality") + self.assertIn("self.suspend()", source[debut : debut + 1200]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_migration_status.py b/test/test_migration_status.py index a724295..53ccef3 100644 --- a/test/test_migration_status.py +++ b/test/test_migration_status.py @@ -953,7 +953,9 @@ class TestTheFullScreenColoursToo(Base): def test_the_screen_goes_through_from_ansi(self): import inspect - source = inspect.getsource(tui.build_app) + # Le rendu est sorti de la classe pour la rendre testable : c'est + # `show_pane` qui porte maintenant les deux propriétés. + source = inspect.getsource(tui.show_pane) self.assertIn("Text.from_ansi", source) self.assertIn("colour=True", source)