erplibre/script/analyse/check_migration_quality.py
Mathieu Benoit 4028421cb8 [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
2026-08-22 07:23:59 -04:00

499 lines
17 KiB
Python
Executable file
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.

#!/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())