erplibre/script/analyse/openupgrade_analysis.py
Mathieu Benoit ff8b6bcaf9 [ADD] migration quality: lay OpenUpgrade's declared changes over the real ones
The report counted + and -. A field that stops being stored is neither:
it loses its column and keeps its existence. Read as a loss it cries
wolf every step; ignored it would mask a real one. OpenUpgrade already
knows, in the upgrade_analysis.txt files the checkout carries -- the
same source the OCA coverage pages are generated from, read locally, at
the checkout's exact version.

Three categories keep the signal from drowning: a field whose model went
away is not a second finding (544 of them on one real step), a field of
a module OpenUpgrade does not analyse was never its business, and the
rest is grouped by field name -- __last_update counted itself 391 times.

--- FR ---

Le rapport comptait des + et des -. Un champ qui cesse d'être stocké
n'est ni l'un ni l'autre : il perd sa colonne et garde son existence. Lu
comme une perte, il alarme à chaque palier ; ignoré, il en masquerait
une vraie. OpenUpgrade le sait déjà, dans les upgrade_analysis.txt que
porte le checkout -- la source des pages de couverture de l'OCA, lue
localement, à la version exacte.

Trois catégories évitent que le signal se noie : un champ dont le modèle
a disparu n'est pas une trouvaille de plus (544 sur un palier réel), un
champ d'un module non analysé n'a jamais relevé d'OpenUpgrade, et le
reste est groupé par nom -- __last_update se comptait 391 fois.

Assisted-by: Claude Opus 5
2026-08-22 07:24:00 -04:00

267 lines
8.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ce qu'OpenUpgrade DÉCLARE changer, pour l'opposer à ce qui a changé.
Le rapport de qualité comptait des « + » et des « − ». C'est vrai mais
insuffisant : un champ qui cesse d'être stocké disparaît de la base sans
rien perdre — il est calculé maintenant. Compté comme une perte, il
alarme ; ignoré, il masquerait une vraie perte. Il faut un troisième
état, et OpenUpgrade le connaît déjà.
Chaque module cœur d'Odoo porte, dans OpenUpgrade, un fichier
`upgrade_analysis.txt` qui liste ce que le palier change :
---Models in module 'account'---
obsolete model account.unreconcile [transient]
new model product.combo (renamed from pos.combo in module point_of_sale)
---Fields in module 'account'---
account / account.account / code (char) : not stored anymore
account / account.cash.rounding / loss_account_id (many2one) : needs
conversion to v18-style company dependent
C'est la même information que la page « coverage analysis » publiée par
l'OCA — elle est engendrée depuis ces fichiers — mais lue dans le dépôt,
donc sans réseau, et à la version exacte du checkout.
Ce qu'on n'y trouvera pas
-------------------------
Ces fichiers ne couvrent que les modules CŒUR d'Odoo. Un champ d'un
module OCA ou maison n'y sera jamais, et son absence ne veut donc pas
dire « perte non déclarée » au même titre. Le rapport le dit plutôt que
de laisser conclure.
"""
import os
import re
REPO_ROOT = os.path.normpath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
)
# `new model x.y [transient]` ou `new model x.y (renamed from a.b in module m)`
MODELE_NEUF = re.compile(
r"^new model (\S+)(?:\s+\(renamed from (\S+) in module (\S+)\))?"
)
MODELE_OBSOLETE = re.compile(r"^obsolete model (\S+)")
# `module / model / champ (type) : description` — le type est absent sur
# les pseudo-champs comme `_order`, et le « : » n'est pas toujours espacé.
CHAMP = re.compile(
r"^(\S+)\s+/\s+(\S+)\s+/\s+(\S+)\s*(?:\(([^)]*)\))?\s*:\s*(.*)$"
)
# `module is now 'sale' ('point_of_sale')`
DEPLACE = re.compile(r"module is now '([^']+)'")
SECTIONS = {
"Models": "model",
"Fields": "field",
"XML records": "xml",
}
def scripts_dir(version, root=None):
"""Où vivent les analyses pour ce palier d'ARRIVÉE."""
return os.path.join(
root or REPO_ROOT,
f"odoo{version}.0",
"OCA_OpenUpgrade",
"openupgrade_scripts",
"scripts",
)
def analysis_files(version, root=None):
"""Les `upgrade_analysis.txt` de ce palier, triés.
On filtre sur le dossier de version : à côté des `18.0.1.0` vivent un
`tests` et, en 14, un `0.0` — les lire mélangerait les paliers.
"""
base = scripts_dir(version, root)
trouves = []
if not os.path.isdir(base):
return trouves
prefixe = f"{version}."
for module in sorted(os.listdir(base)):
dossier = os.path.join(base, module)
if not os.path.isdir(dossier):
continue
for release in sorted(os.listdir(dossier)):
if not release.startswith(prefixe):
continue
chemin = os.path.join(dossier, release, "upgrade_analysis.txt")
if os.path.isfile(chemin):
trouves.append(chemin)
return trouves
_CACHE_MODULES = {}
def analysed_modules(version, root=None):
"""Les modules cœur qu'OpenUpgrade analyse à ce palier.
Les noms de dossiers SONT la liste. Elle sert à distinguer « aucune
déclaration » de « hors de son champ » : OpenUpgrade ne dit rien d'un
module OCA, et le compter comme non déclaré accuserait à tort.
"""
if version in _CACHE_MODULES:
return _CACHE_MODULES[version]
base = scripts_dir(version, root)
trouves = set()
if os.path.isdir(base):
trouves = {
nom
for nom in os.listdir(base)
if os.path.isdir(os.path.join(base, nom))
}
if root is None:
_CACHE_MODULES[version] = trouves
return trouves
def blank_index():
return {
"models_new": set(),
"models_obsolete": set(),
"models_renamed": {},
"fields_new": set(),
"fields_del": set(),
"fields_unstored": set(),
"fields_stored": set(),
"fields_moved": {},
"fields_company_dependent": set(),
"fields_other": {},
"xml_new": 0,
"xml_del": 0,
"modules": 0,
}
def classify(description):
"""La catégorie d'un changement de champ, d'après sa description.
L'ordre compte : « NEW relation: … » commence par NEW, et
« not stored anymore » contient « stored ». On teste donc du plus
précis au plus général plutôt que par appartenance de sous-chaîne.
"""
texte = description.strip()
if texte.startswith("NEW"):
return "new"
if texte.startswith("DEL"):
return "del"
if texte.startswith("not stored anymore"):
return "unstored"
if texte.startswith("is now stored"):
return "stored"
if "needs conversion to" in texte and "company dependent" in texte:
return "company_dependent"
if DEPLACE.search(texte):
return "moved"
return "other"
def parse(texte, index=None):
"""Lire UN fichier d'analyse dans l'index (créé au besoin)."""
index = blank_index() if index is None else index
section = None
for ligne in texte.splitlines():
entete = re.match(r"^---(.+?) in module '([^']+)'---", ligne)
if entete:
section = SECTIONS.get(entete.group(1))
continue
if not ligne.strip():
continue
if section == "model":
_read_model(ligne, index)
elif section == "field":
_read_field(ligne, index)
elif section == "xml":
if ligne.startswith("NEW"):
index["xml_new"] += 1
elif ligne.startswith("DEL"):
index["xml_del"] += 1
return index
def _read_model(ligne, index):
trouve = MODELE_NEUF.match(ligne)
if trouve:
index["models_new"].add(trouve.group(1))
if trouve.group(2):
# Le renommage se lit du côté du NOUVEAU modèle : c'est là
# qu'OpenUpgrade note d'où il vient. L'ancien n'apparaît nulle
# part comme « obsolete », donc sans cette ligne sa
# disparition passerait pour une perte sèche.
index["models_renamed"][trouve.group(2)] = trouve.group(1)
return
trouve = MODELE_OBSOLETE.match(ligne)
if trouve:
index["models_obsolete"].add(trouve.group(1))
def _read_field(ligne, index):
trouve = CHAMP.match(ligne)
if not trouve:
return
_module, modele, champ, _type, description = trouve.groups()
if champ.startswith("_"):
# `_order`, `_sql_constraints` : ce ne sont pas des champs, et les
# compter fausserait le rapprochement avec `ir_model_fields`.
return
cle = f"{modele}.{champ}"
genre = classify(description)
if genre == "new":
index["fields_new"].add(cle)
elif genre == "del":
index["fields_del"].add(cle)
elif genre == "unstored":
index["fields_unstored"].add(cle)
elif genre == "stored":
index["fields_stored"].add(cle)
elif genre == "company_dependent":
index["fields_company_dependent"].add(cle)
elif genre == "moved":
index["fields_moved"][cle] = DEPLACE.search(description).group(1)
else:
index["fields_other"].setdefault(cle, []).append(description.strip())
def load(version, root=None):
"""L'index complet d'un palier. `modules` à 0 = rien de lisible."""
index = blank_index()
for chemin in analysis_files(version, root):
try:
with open(chemin, "r", encoding="utf-8") as handle:
parse(handle.read(), index)
except OSError:
continue
index["modules"] += 1
return index
def model_change(nom, index):
"""Ce qu'OpenUpgrade déclare pour ce modèle disparu, ou None."""
if nom in index["models_renamed"]:
return ("renamed", index["models_renamed"][nom])
if nom in index["models_obsolete"]:
return ("obsolete", None)
return None
def field_change(cle, index):
"""Ce qu'OpenUpgrade déclare pour ce champ disparu, ou None.
« unstored » est le cas qui justifie tout l'outil : le champ EXISTE
toujours, il n'a plus de colonne parce qu'il est calculé. Ce n'est ni
un gain ni une perte — c'est une transformation, et la confondre avec
une perte fait crier au loup à chaque palier.
"""
if cle in index["fields_del"]:
return ("del", None)
if cle in index["fields_unstored"]:
return ("unstored", None)
if cle in index["fields_company_dependent"]:
return ("company_dependent", None)
if cle in index["fields_moved"]:
return ("moved", index["fields_moved"][cle])
return None