[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
This commit is contained in:
Mathieu Benoit 2026-08-21 05:55:54 -04:00
parent 8c7ff096b3
commit ff8b6bcaf9
5 changed files with 892 additions and 2 deletions

View file

@ -218,6 +218,21 @@ def inspect(database):
"SELECT model || '.' || name FROM ir_model_fields ORDER BY 1",
)
etat["field"] = sorted(ligne[0] for ligne in champs or [])
# À quel module appartient chaque champ. Sans cela, un champ d'un
# module OCA compterait comme « non déclaré par OpenUpgrade » —
# littéralement vrai, et trompeur : OpenUpgrade n'analyse que le cœur
# d'Odoo, il ne pouvait rien en dire.
origines = run_psql(
database,
"SELECT f.model || '.' || f.name, d.module FROM ir_model_fields f"
" JOIN ir_model_data d ON d.model = 'ir.model.fields'"
" AND d.res_id = f.id",
)
dct_origine = {}
for ligne in origines or []:
if len(ligne) >= 2:
dct_origine.setdefault(ligne[0], set()).add(ligne[1])
etat["field_module"] = {cle: sorted(v) for cle, v in dct_origine.items()}
# Les copies COW par leur CLÉ : c'est elle qu'on réinitialise, et
# c'est par elle qu'on les retrouve d'une version à l'autre.
copies = run_psql(
@ -577,6 +592,91 @@ def as_version(etat):
return None
_CACHE_DECLARE = {}
def declared_index(version):
"""L'index OpenUpgrade du palier, mis en cache par version.
Lire 402 fichiers coûte 0,04 s — négligeable une fois, pas six fois
par rapport quand on compare tous les paliers d'affilée.
"""
if version not in _CACHE_DECLARE:
from script.analyse import openupgrade_analysis
_CACHE_DECLARE[version] = openupgrade_analysis.load(version)
return _CACHE_DECLARE[version]
def overlay_declared(
version, modeles_perdus, champs_perdus, origine_champ=None
):
"""Le THÉORIQUE posé sur le PRATIQUE : qui avait été annoncé ?
Une perte déclarée par OpenUpgrade n'est pas un incident, c'est le
palier qui fait son travail. Les séparer transforme « 300 champs
disparus » — un chiffre devant lequel on ne peut rien faire — en une
poignée de champs que personne n'a annoncés, et qu'il faut regarder.
Le cas qui justifie tout : « devenu calculé ». Le champ EXISTE
toujours ; il n'a plus de colonne. Ni gain ni perte : transformation.
"""
from script.analyse import openupgrade_analysis
if not version:
return {"available": False, "reason": "version"}
index = declared_index(version)
if not index["modules"]:
return {"available": False, "reason": "missing"}
modeles = {"obsolete": [], "renamed": [], "undeclared": []}
for nom in modeles_perdus:
change = openupgrade_analysis.model_change(nom, index)
if not change:
modeles["undeclared"].append(nom)
elif change[0] == "renamed":
modeles["renamed"].append((nom, change[1]))
else:
modeles["obsolete"].append(nom)
champs = {
"del": [],
"unstored": [],
"company_dependent": [],
"moved": [],
"model_gone": [],
"not_analysed": [],
"undeclared": [],
}
partis = tuple(f"{nom}." for nom in modeles_perdus)
analyses = openupgrade_analysis.analysed_modules(version)
for cle in champs_perdus:
change = openupgrade_analysis.field_change(cle, index)
if change:
if change[0] == "moved":
champs["moved"].append((cle, change[1]))
else:
champs[change[0]].append(cle)
continue
# Un champ dont le MODÈLE a disparu n'est pas une trouvaille de
# plus : c'est la même, comptée une fois par champ. Sur un palier
# réel cela triplait la liste et noyait le vrai signal.
if partis and cle.startswith(partis):
champs["model_gone"].append(cle)
continue
origines = set((origine_champ or {}).get(cle) or [])
if origines and not (origines & analyses):
champs["not_analysed"].append(cle)
continue
champs["undeclared"].append(cle)
return {
"available": True,
"modules": index["modules"],
"models": modeles,
"fields": champs,
}
def explain_loss(table, version):
"""Ce qu'Odoo a fait de cette table à cette version, ou None.
@ -635,14 +735,22 @@ def compare(avant, apres):
# Les plus grosses pertes EN TÊTE : on attaque une liste de
# cinquante-sept par le haut, pas par ordre alphabétique.
lignes_perdues.sort(key=lambda item: -(item[1] - item[2]))
champs_perdus = sorted(champ_avant - champ_apres)
modeles_perdus = sorted(mod_avant - mod_apres)
return {
"fields_lost": sorted(champ_avant - champ_apres),
"declared": overlay_declared(
version,
modeles_perdus,
champs_perdus,
avant.get("field_module"),
),
"fields_lost": champs_perdus,
"fields_gained": sorted(champ_apres - champ_avant),
"cow_lost": sorted(cow_avant - cow_apres),
"cow_gained": sorted(cow_apres - cow_avant),
"modules_lost": sorted(inst_avant - inst_apres),
"modules_gained": sorted(inst_apres - inst_avant),
"models_lost": sorted(mod_avant - mod_apres),
"models_lost": modeles_perdus,
"models_gained": sorted(mod_apres - mod_avant),
"rows_lost": lignes_perdues,
"rows_gained": lignes_gagnees,
@ -735,6 +843,115 @@ def overall(lst_snapshot):
return compare(presents[0], presents[-1])
DECLARE_MODELES = (
("obsolete", "declared obsolete", "dim"),
("renamed", "renamed by Odoo", "dim"),
("undeclared", "NOT declared by OpenUpgrade", "warn"),
)
# L'ordre range du plus rassurant au plus inquiétant : ce qu'on doit
# regarder finit la liste, donc reste sous les yeux.
DECLARE_CHAMPS = (
("del", "declared removed", "dim"),
("moved", "moved to another module", "dim"),
("company_dependent", "became a per-company jsonb column", "dim"),
(
"unstored",
"computed now — the field remains, the column does not",
"ok",
),
("model_gone", "their model went away too", "dim"),
("not_analysed", "in a module OpenUpgrade does not analyse", "dim"),
("undeclared", "NOT declared by OpenUpgrade", "warn"),
)
def group_by_field_name(cles):
"""[(nom, combien, exemple)] par nom de champ, les plus nombreux d'abord.
Un champ retiré d'un MIXIN disparaît de tous les modèles qui en
héritent : `__last_update` s'est ainsi compté 580 fois au palier 17.
C'est UN changement. Listé modèle par modèle, il remplissait l'écran
et cachait les vingt autres ; groupé, il tient sur une ligne.
"""
par_nom = {}
for cle in cles:
nom = cle.rsplit(".", 1)[-1] if "." in cle else cle
par_nom.setdefault(nom, []).append(cle)
return sorted(
((nom, len(lst), sorted(lst)[0]) for nom, lst in par_nom.items()),
key=lambda item: (-item[1], item[0]),
)
def render_declared(declare, colour, limit=8):
"""Le théorique posé sur le pratique. Rien si on ne peut pas le lire.
Se taire quand l'analyse manque plutôt que d'afficher des zéros :
« 0 déclaré » et « analyse introuvable » se ressemblent à l'œil et ne
veulent pas du tout dire la même chose.
"""
from script.todo.migration_status import paint
if not declare.get("available"):
if declare.get("reason") == "missing":
return [
f" {paint('⇄', 'dim', colour)}"
f" {t('No OpenUpgrade analysis for this step.')}"
]
return []
lignes = [
f" {paint('⇄', 'dim', colour)} {t('Against OpenUpgrade')}"
f" ({declare['modules']} {t('core module(s) analysed')})"
]
for titre, groupes, table in (
(t("model(s) lost"), declare["models"], DECLARE_MODELES),
(t("field(s) lost"), declare["fields"], DECLARE_CHAMPS),
):
total = sum(len(groupes.get(cle) or []) for cle, _l, _c in table)
if not total:
continue
lignes.append(f" {total} {titre} :")
for cle, libelle, teinte in table:
trouves = groupes.get(cle) or []
if not trouves:
continue
lignes.append(
f" {paint(str(len(trouves)).rjust(5), teinte, colour)}"
f" {t(libelle)}"
)
if cle not in ("undeclared", "unstored"):
# Ces deux-là seuls méritent des noms : l'un parce qu'il
# faut aller voir, l'autre parce qu'on croirait à une
# perte. Nommer les autres noierait le rapport.
continue
plats = [
element[0] if isinstance(element, tuple) else element
for element in trouves
]
if table is DECLARE_CHAMPS:
groupes_nom = group_by_field_name(plats)
for nom, combien, exemple in groupes_nom[:limit]:
suffixe = (
f" × {combien} {t('model(s)')}" if combien > 1 else ""
)
montre = exemple if combien == 1 else nom
lignes.append(f" {montre}{suffixe}")
if len(groupes_nom) > limit:
lignes.append(
f" … {len(groupes_nom) - limit}"
f" {t('other field name(s)')}"
)
else:
for nom in plats[:limit]:
lignes.append(f" {nom}")
if len(plats) > limit:
lignes.append(
f" … {len(plats) - limit} {t('more')}"
)
return lignes
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
@ -868,6 +1085,7 @@ def render_compare(diff, colour, limit=8):
lignes.append(f" {connu['why']}")
if len(connues) > limit:
lignes.append(f" … {len(connues) - limit} {t('more')}")
lignes.extend(render_declared(diff.get("declared") or {}, colour, limit))
delta = diff["delta"]
lignes.append(
" "

View file

@ -0,0 +1,267 @@
# © 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

View file

@ -5891,6 +5891,70 @@ TRANSLATIONS = {
"fr": "ligne(s)",
"en": "row(s)",
},
"Against OpenUpgrade": {
"fr": "Face à OpenUpgrade",
"en": "Against OpenUpgrade",
},
"NOT declared by OpenUpgrade": {
"fr": "NON déclarés par OpenUpgrade",
"en": "NOT declared by OpenUpgrade",
},
"No OpenUpgrade analysis for this step.": {
"fr": "Pas d'analyse OpenUpgrade pour ce palier.",
"en": "No OpenUpgrade analysis for this step.",
},
"became a per-company jsonb column": {
"fr": "passés en colonne jsonb par société",
"en": "became a per-company jsonb column",
},
"computed now — the field remains, the column does not": {
"fr": "devenus calculés — le champ demeure, la colonne non",
"en": "computed now — the field remains, the column does not",
},
"core module(s) analysed": {
"fr": "module(s) cœur analysé(s)",
"en": "core module(s) analysed",
},
"declared obsolete": {
"fr": "déclarés obsolètes",
"en": "declared obsolete",
},
"declared removed": {
"fr": "déclarés supprimés",
"en": "declared removed",
},
"field(s) lost": {
"fr": "champ(s) perdu(s)",
"en": "field(s) lost",
},
"model(s) lost": {
"fr": "modèle(s) perdu(s)",
"en": "model(s) lost",
},
"moved to another module": {
"fr": "déplacés vers un autre module",
"en": "moved to another module",
},
"renamed by Odoo": {
"fr": "renommés par Odoo",
"en": "renamed by Odoo",
},
"their model went away too": {
"fr": "leur modèle a disparu aussi",
"en": "their model went away too",
},
"in a module OpenUpgrade does not analyse": {
"fr": "dans un module qu'OpenUpgrade n'analyse pas",
"en": "in a module OpenUpgrade does not analyse",
},
"model(s)": {
"fr": "modèle(s)",
"en": "model(s)",
},
"other field name(s)": {
"fr": "autre(s) nom(s) de champ",
"en": "other field name(s)",
},
"Census": {
"fr": "Recensement",
"en": "Census",

View file

@ -1175,5 +1175,160 @@ class TestItRefusesToNestItself(Base):
self.assertIn("its own", source)
class TestTheOpenUpgradeOverlay(unittest.TestCase):
"""Le théorique posé sur le pratique.
Le rapport comptait des « + » et des « − ». Un champ devenu calculé
n'est ni l'un ni l'autre : il perd sa colonne et garde son existence.
Sans troisième état, on alarmait à chaque palier.
"""
INDEX = {
"models_new": {"product.combo"},
"models_obsolete": {"account.unreconcile"},
"models_renamed": {"pos.combo": "product.combo"},
"fields_new": set(),
"fields_del": {"account.journal.secure_sequence_id"},
"fields_unstored": {"account.account.code"},
"fields_stored": set(),
"fields_moved": {"pos.combo.base_price": "product"},
"fields_company_dependent": {"account.cash.rounding.loss_account_id"},
"fields_other": {},
"xml_new": 0,
"xml_del": 0,
"modules": 402,
}
def setUp(self):
quality._CACHE_DECLARE[18] = self.INDEX
from script.analyse import openupgrade_analysis as oa
self.vrai = oa.analysed_modules
oa.analysed_modules = lambda version, root=None: {"account", "sale"}
self.oa = oa
def tearDown(self):
quality._CACHE_DECLARE.pop(18, None)
self.oa.analysed_modules = self.vrai
def pose(self, modeles=(), champs=(), origines=None):
return quality.overlay_declared(
18, list(modeles), list(champs), origines
)
def test_a_declared_obsolete_model_is_not_a_finding(self):
d = self.pose(modeles=["account.unreconcile"])
self.assertEqual(d["models"]["obsolete"], ["account.unreconcile"])
self.assertEqual(d["models"]["undeclared"], [])
def test_a_renamed_model_names_its_destination(self):
d = self.pose(modeles=["pos.combo"])
self.assertEqual(
d["models"]["renamed"], [("pos.combo", "product.combo")]
)
def test_an_unknown_model_stays_undeclared(self):
d = self.pose(modeles=["ma.bebelle"])
self.assertEqual(d["models"]["undeclared"], ["ma.bebelle"])
def test_a_field_that_became_computed_is_its_own_category(self):
# NI perte NI gain : le champ demeure, la colonne non. C'est tout
# l'objet de l'ajout.
d = self.pose(champs=["account.account.code"])
self.assertEqual(d["fields"]["unstored"], ["account.account.code"])
self.assertEqual(d["fields"]["undeclared"], [])
def test_declared_removals_and_moves_land_apart(self):
d = self.pose(
champs=[
"account.journal.secure_sequence_id",
"pos.combo.base_price",
"account.cash.rounding.loss_account_id",
]
)
self.assertEqual(
d["fields"]["del"], ["account.journal.secure_sequence_id"]
)
self.assertEqual(
d["fields"]["moved"], [("pos.combo.base_price", "product")]
)
self.assertEqual(
d["fields"]["company_dependent"],
["account.cash.rounding.loss_account_id"],
)
def test_a_field_whose_model_vanished_is_not_counted_again(self):
# 544 champs pour un seul modèle disparu, mesuré sur un vrai
# palier : listés un par un, ils cachaient les vingt vraies
# trouvailles.
d = self.pose(
modeles=["account.unreconcile"],
champs=["account.unreconcile.name"],
)
self.assertEqual(
d["fields"]["model_gone"], ["account.unreconcile.name"]
)
self.assertEqual(d["fields"]["undeclared"], [])
def test_a_field_of_an_unanalysed_module_is_not_accused(self):
# OpenUpgrade n'analyse que le cœur : dire « non déclaré » d'un
# champ OCA est vrai à la lettre et faux en esprit.
d = self.pose(
champs=["x.mon_champ"],
origines={"x.mon_champ": ["mon_module_oca"]},
)
self.assertEqual(d["fields"]["not_analysed"], ["x.mon_champ"])
self.assertEqual(d["fields"]["undeclared"], [])
def test_a_core_field_with_no_declaration_SURVIVES_as_undeclared(self):
# Le signal ne doit pas se faire absorber par les catégories
# rassurantes : c'est la seule ligne qui demande un examen.
d = self.pose(
champs=["account.account.mystere"],
origines={"account.account.mystere": ["account"]},
)
self.assertEqual(
d["fields"]["undeclared"], ["account.account.mystere"]
)
def test_no_analysis_is_said_not_shown_as_zero(self):
# « 0 déclaré » et « analyse absente » se ressemblent à l'œil.
quality._CACHE_DECLARE[18] = dict(self.INDEX, modules=0)
d = self.pose(modeles=["x.y"])
self.assertFalse(d["available"])
self.assertEqual(d["reason"], "missing")
texte = "\n".join(quality.render_declared(d, False))
self.assertIn(
todo_i18n.t("No OpenUpgrade analysis for this step."), texte
)
def test_an_unknown_version_declares_nothing_quietly(self):
d = quality.overlay_declared(None, ["x.y"], [])
self.assertFalse(d["available"])
self.assertEqual(quality.render_declared(d, False), [])
class TestGroupingByFieldName(unittest.TestCase):
def test_one_mixin_field_is_one_finding(self):
# `__last_update` s'est compté 391 fois sur un vrai palier : c'est
# UN changement.
groupes = quality.group_by_field_name(
[f"modele{i}.zz_last_update" for i in range(391)]
+ ["account.account.aa_autre"]
)
self.assertEqual(groupes[0][0], "zz_last_update")
self.assertEqual(groupes[0][1], 391)
def test_the_most_widespread_comes_first(self):
groupes = quality.group_by_field_name(
["a.zzz", "b.zzz", "c.zzz", "d.aaa"]
)
self.assertEqual([nom for nom, _n, _e in groupes], ["zzz", "aaa"])
def test_a_lone_field_keeps_its_full_key_as_example(self):
groupes = quality.group_by_field_name(["account.account.seul"])
self.assertEqual(groupes[0], ("seul", 1, "account.account.seul"))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,186 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Lire ce qu'OpenUpgrade déclare, sans le déformer.
La catégorie qui porte tout l'outil est « unstored » : le champ EXISTE
encore, il n'a plus de colonne parce qu'il est calculé. Le ranger avec
les suppressions ferait crier au loup à chaque palier ; l'oublier
masquerait une vraie perte.
"""
import os
import shutil
import sys
import tempfile
import unittest
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
from script.analyse import openupgrade_analysis as oa # noqa: E402
EXEMPLE = """---Models in module 'account'---
obsolete model account.unreconcile [transient]
new model account.code.mapping [sql_view]
new model product.combo (renamed from pos.combo in module point_of_sale)
---Fields in module 'account'---
account / account.account / _order : _order is now 'code' ('id')
account / account.account / code (char) : not stored anymore
account / account.account / code_store (char) : NEW
account / account.journal / secure_sequence_id (many2one) : DEL relation: ir.sequence
account / account.account / company_id (many2one) : is now stored
account / account.cash.rounding / loss_account_id (many2one) : needs conversion to v18-style company dependent
account / pos.combo / base_price (float) : module is now 'product' ('point_of_sale')
account / account.journal / type (selection) : selection_keys added: [credit]
---XML records in module 'account'---
NEW ir.ui.view: account.view_x
DEL ir.ui.view: account.view_y
DEL ir.ui.view: account.view_z
"""
class TestParsing(unittest.TestCase):
def setUp(self):
self.index = oa.parse(EXEMPLE)
def test_obsolete_and_new_models(self):
self.assertIn("account.unreconcile", self.index["models_obsolete"])
self.assertIn("account.code.mapping", self.index["models_new"])
def test_a_rename_is_read_from_the_NEW_side(self):
# OpenUpgrade ne note le renommage que sur le nouveau modèle ;
# l'ancien n'apparaît jamais comme « obsolete ». Sans cette
# lecture, sa disparition passerait pour une perte sèche.
self.assertEqual(
self.index["models_renamed"].get("pos.combo"), "product.combo"
)
def test_each_field_lands_in_its_category(self):
for cle, categorie in (
("account.account.code", "fields_unstored"),
("account.account.code_store", "fields_new"),
("account.journal.secure_sequence_id", "fields_del"),
("account.account.company_id", "fields_stored"),
(
"account.cash.rounding.loss_account_id",
"fields_company_dependent",
),
):
self.assertIn(cle, self.index[categorie], f"{cle} → {categorie}")
def test_a_moved_field_keeps_its_new_module(self):
self.assertEqual(
self.index["fields_moved"].get("pos.combo.base_price"), "product"
)
def test_pseudo_fields_are_not_fields(self):
# `_order` n'est pas dans `ir_model_fields` : le compter fausserait
# le rapprochement avec la base.
for cle in list(self.index["fields_other"]) + list(
self.index["fields_new"]
):
self.assertNotIn("._order", cle)
def test_an_unclassified_change_is_kept_not_dropped(self):
# « selection_keys added » n'a pas de catégorie : le perdre
# silencieusement ferait mentir les totaux.
self.assertIn("account.journal.type", self.index["fields_other"])
def test_xml_records_are_counted(self):
self.assertEqual(self.index["xml_new"], 1)
self.assertEqual(self.index["xml_del"], 2)
class TestClassify(unittest.TestCase):
def test_new_with_details_is_still_new(self):
self.assertEqual(
oa.classify("NEW relation: res.company, required"), "new"
)
def test_not_stored_is_not_confused_with_stored(self):
# « not stored anymore » CONTIENT « stored » : tester par
# appartenance de sous-chaîne inverserait le sens.
self.assertEqual(oa.classify("not stored anymore"), "unstored")
self.assertEqual(oa.classify("is now stored"), "stored")
def test_an_unknown_wording_falls_back_to_other(self):
self.assertEqual(oa.classify("something brand new"), "other")
class TestFindingTheFiles(unittest.TestCase):
def setUp(self):
self.racine = tempfile.mkdtemp()
base = os.path.join(
self.racine,
"odoo18.0",
"OCA_OpenUpgrade",
"openupgrade_scripts",
"scripts",
)
for module, release in (
("account", "18.0.1.0"),
("account", "17.0.1.0"),
("sale", "18.0.2.0"),
("tests", "18.0.1.0"),
):
dossier = os.path.join(base, module, release)
os.makedirs(dossier)
with open(
os.path.join(dossier, "upgrade_analysis.txt"),
"w",
encoding="utf-8",
) as handle:
handle.write(EXEMPLE)
def tearDown(self):
shutil.rmtree(self.racine)
def test_only_the_target_version_is_read(self):
# À côté des `18.0.x` vivent des dossiers d'autres paliers : les
# mélanger attribuerait à la 18 des changements de la 17.
trouves = oa.analysis_files(18, self.racine)
self.assertTrue(all("18.0" in c for c in trouves), trouves)
self.assertEqual(len(trouves), 3)
def test_a_missing_checkout_is_empty_not_a_crash(self):
self.assertEqual(oa.analysis_files(13, self.racine), [])
self.assertEqual(oa.load(13, self.racine)["modules"], 0)
def test_the_module_list_comes_from_the_directories(self):
modules = oa.analysed_modules(18, self.racine)
self.assertIn("account", modules)
self.assertIn("sale", modules)
def test_loading_counts_the_modules_it_read(self):
self.assertEqual(oa.load(18, self.racine)["modules"], 3)
class TestTheRealCheckout(unittest.TestCase):
"""Le dépôt lui-même, quand il est là. Sinon on ne prétend rien."""
def setUp(self):
if not oa.analysis_files(18):
self.skipTest("OCA_OpenUpgrade absent du checkout 18.0")
def test_it_reads_hundreds_of_core_modules(self):
index = oa.load(18)
self.assertGreater(index["modules"], 300)
def test_it_finds_the_company_dependent_conversion(self):
# C'est la refonte qui a fait disparaître `ir_property` en 18 :
# si l'outil ne la voit pas, il ne sert à rien sur ce palier-là.
index = oa.load(18)
self.assertTrue(
any(
cle.startswith("account.cash.rounding.")
for cle in index["fields_company_dependent"]
),
sorted(index["fields_company_dependent"])[:5],
)
if __name__ == "__main__":
unittest.main()