[ADD] analyse: what a migration gained and lost, step by step

A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.

It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.

The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.

Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.

--- FR ---

[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier

Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.

Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.

Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.

Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-19 08:21:12 -04:00
parent 6715700a16
commit 4028421cb8
7 changed files with 1280 additions and 33 deletions

View file

@ -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 —
« <base>_upgrade_<version> » — 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())

View file

@ -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

View file

@ -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é.

View file

@ -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.

View file

@ -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?",

View file

@ -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 « <base>_upgrade_<version> » 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()

View file

@ -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)