erplibre/script/analyse/openupgrade_analysis.py

268 lines
8.9 KiB
Python
Raw Permalink Normal View History

# © 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