[ADD] migration: prédire la copie de site qui rendra 500 après le palier

`check_cow_views` ne voyait que la rupture qui ARRÊTE la migration : la
forme que l'arch doit avoir change. Deux autres la laissent finir sans un
mot — un ancrage qu'une vue héritière de la cible réclame, un t-call vers
un gabarit que la cible ne livre plus — et ne se voient qu'à l'ouverture
de la page, c'est-à-dire jamais avant la fin.

Rejouée sur la chaîne 12 → 18, la prédiction nomme le défaut de /contact
dès le palier 13 → 14, en 0,8 s. Il avait attendu six paliers.

Ces copies ne sont PAS neutralisées : chacune porte une page écrite par
quelqu'un. Le pilote appelle la réparation après OpenUpgrade, là où la
vue module porte enfin l'arch de la cible.

--- EN ---

`check_cow_views` only saw the break that STOPS the migration: the shape
the arch must have changes. Two others let it finish without a word — an
anchor a target child view requires, a t-call to a template the target no
longer ships — and only show when the page is opened, which is to say
never before the end.

Replayed on the 12 → 18 chain, the prediction names /contact's defect as
early as the 13 → 14 step, in 0.8 s. It had waited six steps.

Those copies are NOT neutralized: each holds a page someone wrote. The
driver calls the repair after OpenUpgrade, where the module view finally
carries the target's arch.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-24 03:48:17 -04:00
parent 291fd67841
commit 32f62fda04
6 changed files with 618 additions and 2 deletions

View file

@ -201,6 +201,187 @@ def declared_view_shape(module_dir, template_id):
return None
def run_sql(database, sql):
"""Le texte brut d'une requête. RuntimeError si la base ne répond pas."""
result = subprocess.run(
["psql", "-X", "-w", "-d", database, "-tA", "-c", sql],
capture_output=True,
text=True,
)
if result.returncode:
raise RuntimeError(
f"Cannot read '{database}': {result.stderr.strip()}"
)
return result.stdout
def query_cow_archs(database):
"""[(id, clé, mode, website_id, {langue: arch})] — arch ENTIÈRES.
`query_cow_views` tronque à 400 caractères : assez pour lire la
première balise, pas pour évaluer un xpath — un fragment coupé n'est
même pas du XML. Et `arch_db` est un jsonb par langue depuis la 17 :
une page cassée en fr_CA et saine en en_US, cela existe, on l'a vu.
"""
jsonb = "jsonb" in run_sql(
database,
"SELECT data_type FROM information_schema.columns"
" WHERE table_name='ir_ui_view' AND column_name='arch_db'",
)
expr = "arch_db" if jsonb else "json_build_object('', arch_db)"
brut = run_sql(
database,
"SELECT coalesce(json_agg(json_build_object("
"'id', id, 'key', coalesce(key, ''), 'mode', mode,"
f" 'website_id', website_id, 'arch', {expr})), '[]')"
" FROM ir_ui_view WHERE website_id IS NOT NULL",
)
try:
lignes = json.loads(brut.strip() or "[]")
except ValueError:
return []
return [
(
ligne["id"],
ligne["key"],
ligne["mode"],
ligne["website_id"],
ligne["arch"] if isinstance(ligne["arch"], dict) else {},
)
for ligne in lignes
]
def installed_modules(database):
"""Les modules installés. Sans eux le balayage des sources est vain.
Un enfant peut vivre dans N'IMPORTE quel module — `website_crm` hérite
de `website.contactus` — donc il faut balayer large. Mais balayer TOUT
l'arbre cible coûterait des milliers de fichiers pour rien : seuls les
modules installés produiront des vues.
"""
output = run_sql(
database,
"SELECT name FROM ir_module_module WHERE state IN"
" ('installed', 'to upgrade')",
)
return [ligne.strip() for ligne in output.splitlines() if ligne.strip()]
def full_key(module_name, valeur):
"""« contactus » dans le module website devient « website.contactus »."""
valeur = (valeur or "").strip()
if not valeur:
return None
return valeur if "." in valeur else f"{module_name}.{valeur}"
def scan_target_views(target_version, lst_module):
"""(déclarés, héritages) tels que la version CIBLE les livre.
`déclarés` : les clés de gabarit que la cible fournit. Sert à repérer
un `t-call` vers un gabarit qui n'existe plus.
`héritages` : {clé parente: [expressions xpath]}. C'est le manque qui
a coûté quatre paliers de silence — la cible ajoute un ancrage dans
une vue module, sa vue héritière le vise, et la copie de site, qui
n'est jamais réécrite, ne l'a pas.
"""
declares = set()
heritages = {}
for module_name in lst_module:
module_dir = find_module_dir(target_version, module_name)
if module_dir is None:
continue
motif = os.path.join(module_dir, "**", "*.xml")
for file_path in sorted(glob.glob(motif, recursive=True)):
try:
racine = ET.parse(file_path).getroot()
except ET.ParseError:
continue
for element in racine.iter("template"):
cle = full_key(module_name, element.get("id"))
if cle:
declares.add(cle)
parent = full_key(module_name, element.get("inherit_id"))
if not parent:
continue
exprs = [
noeud.get("expr")
for noeud in element.iter("xpath")
if noeud.get("expr")
]
if exprs:
heritages.setdefault(parent, []).extend(exprs)
return declares, heritages
def will_not_render(database, target_version):
"""Les copies qui passeront la migration et rendront 500 ensuite.
Deux ruptures que `analyse` ne voit pas, parce qu'elles ne touchent
pas la FORME de la copie :
ancrage manquant un enfant de la CIBLE vise `//t[@t-set='x']` que
la copie n'a pas.
t-call pendant la copie appelle un gabarit que la cible ne
livre plus.
Celles-là ne se neutralisent PAS : la copie porte une page écrite par
quelqu'un, et la mettre de côté l'effacerait du site. Elles se
réparent — `fix_cow_render.py` remet l'ancrage depuis la vue module
et retire l'appel mort, sans toucher au contenu.
"""
from fix_cow_render import dangling_calls, locates
copies = query_cow_archs(database)
if not copies:
return []
declares, heritages = scan_target_views(
target_version, installed_modules(database)
)
connus = declares | {cle for _i, cle, _m, _w, _a in copies if cle}
risques = []
for view_id, key, mode, website_id, langues in copies:
if not key:
continue
# Chaque langue : une page peut être cassée en fr_CA et saine en
# en_US, et c'est celle du site qui décide de ce qu'on voit.
manques = set()
pendants = []
for texte in langues.values():
for expr in heritages.get(key, []):
if not locates(texte, expr):
manques.add(expr)
for nom in dangling_calls(texte, connus):
if nom not in pendants:
pendants.append(nom)
for expr in sorted(manques):
risques.append(
(
view_id,
key,
mode,
mode,
website_id,
f"a child of the target needs an anchor this copy lacks:"
f" {expr}",
)
)
for nom in pendants:
risques.append(
(
view_id,
key,
mode,
mode,
website_id,
f"calls a template the target no longer ships: {nom}",
)
)
return risques
def analyse(database, target_version):
"""Sort COW views into three buckets by comparing with the target sources.
@ -304,9 +485,29 @@ def main():
lst_at_risk, lst_module_absent, lst_no_counterpart = analyse(
config.database, config.target_version
)
# L'autre famille de rupture : la copie garde sa forme et cesse de se
# RENDRE. Elle ne se neutralise pas — la copie porte une page écrite
# par quelqu'un — elle se répare.
lst_no_render = will_not_render(config.database, config.target_version)
database = config.database
target_version = config.target_version
if lst_no_render:
print(
f"⚠️ {len(lst_no_render)}"
f" {t('website COW view(s) will survive the bump and then fail')}"
f" {t('to render:')}"
)
for view_id, key, _mode, _cible, website_id, raison in lst_no_render:
print(f" - id={view_id} website={website_id} {key}")
print(f" {t(raison)}")
print(
f" {t('Repair rather than neutralize — the copy holds a page')}"
f" {t('someone wrote:')}"
f" ./script/odoo/migration/fix_cow_render.py -d {config.database}"
)
print("")
if not lst_at_risk:
print(
f"✅ -> {t('No website COW view changes shape in')}"
@ -378,7 +579,9 @@ def main():
# 0 = rien à signaler, 1 = des copies casseront, 2 = l'outil a échoué.
# Le pilote lisait le texte anglais de cette sortie pour savoir s'il
# devait poser sa question : traduire le message le rendait aveugle.
return 1 if lst_at_risk else 0
# Les deux familles comptent : une copie qui rendra 500 est une
# trouvaille, même si la migration, elle, ne s'arrêtera pas.
return 1 if (lst_at_risk or lst_no_render) else 0
if __name__ == "__main__":

View file

@ -6710,6 +6710,22 @@ TRANSLATIONS = {
"fr": "Chaque copie de site sait encore se rendre.",
"en": "Every website copy still renders.",
},
"website COW view(s) will survive the bump and then fail": {
"fr": "vue(s) COW de site passeront le palier puis échoueront",
"en": "website COW view(s) will survive the bump and then fail",
},
"to render:": {
"fr": "à se rendre :",
"en": "to render:",
},
"Repair rather than neutralize — the copy holds a page": {
"fr": "Réparer plutôt que neutraliser — la copie porte une page",
"en": "Repair rather than neutralize — the copy holds a page",
},
"someone wrote:": {
"fr": "écrite par quelqu'un :",
"en": "someone wrote:",
},
"Every website copy renders again.": {
"fr": "Chaque copie de site sait de nouveau se rendre.",
"en": "Every website copy renders again.",

View file

@ -2705,6 +2705,10 @@ class TodoUpgrade:
f"before_{next_version}",
f"after_{next_version}",
)
# ICI et pas avant : la réparation prend l'ancrage manquant
# dans la vue MODULE, qui ne porte l'arch de la cible
# qu'une fois la migration passée.
self.repair_cow_render(database_name_upgrade)
lst_upgrade_odoo[index] = cmd_upgrade
self.dct_progression["state_4_upgrade_odoo_lst"] = (
@ -3656,6 +3660,30 @@ class TodoUpgrade:
f" -d {database_name} --list"
)
def repair_cow_render(self, database_name):
"""Les copies de site qui passent le palier et rendent 500 après.
Deux ruptures qu'aucune autre étape ne voit : un ancrage que la
vue héritière de la CIBLE réclame et que la copie n'a jamais eu,
et un `t-call` vers un gabarit que la cible ne livre plus. Mesuré
sur une chaîne 12 → 18 : /contact rendait 500 depuis le palier
14 → 15, et rien ne l'a dit avant le test de fumée final.
On ne neutralise pas : la copie porte une page écrite par
quelqu'un. On répare, et le contenu reste.
`wait_at_error=False` : le code 1 de cet outil veut dire « j'ai
trouvé », pas « je suis tombé ». Sans cela le pilote ouvrirait son
menu d'erreur sur une réparation qui s'est bien passée.
"""
outil = os.path.join(PATH_MIGRATION_GLOBAL, "fix_cow_render.py")
if not os.path.isfile(outil):
return
self.todo_upgrade_execute(
f"{PYTHON_BIN} ./{outil} -d {database_name} --apply",
wait_at_error=False,
)
def diff_cow_views(self, database_name, label_before, label_after):
"""Print what the version bump did to the website COW views."""
directory = os.path.join(

View file

@ -0,0 +1,266 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Prédire, AVANT le palier, la copie qui rendra 500 après.
`check_cow_views` cherchait la rupture qui ARRÊTE la migration : la forme
que l'arch doit avoir change. Deux autres la laissent finir et ne se
voient qu'à l'ouverture de la page — et personne n'ouvre les pages avant
la fin.
Mesuré sur une chaîne 12 → 18 réelle : /contact rendait 500 depuis le
palier 14 → 15. Rejouée avec ce contrôle, la prédiction nomme le défaut
dès le palier 13 → 14, cinq paliers avant que quiconque s'en aperçoive.
Ces copies-là ne se NEUTRALISENT pas : chacune porte une page écrite par
quelqu'un, et la mettre de côté l'effacerait du site. Elles se réparent.
"""
import os
import shutil
import sys
import tempfile
import unittest
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
sys.path.append(
os.path.normpath(
os.path.join(
os.path.dirname(__file__), "..", "script", "odoo", "migration"
)
)
)
import check_cow_views as cow # noqa: E402
# La copie du client : sa page, sans l'ancrage que la cible attend, et
# avec un appel vers un gabarit que la cible ne livre plus.
ARCH_COPIE = (
"<t t-name='website.contactus'>"
"<t t-call='website.layout'>"
"<div id='wrap'><h1>Parlons de votre projet</h1>"
"<t t-call='website.company_description'/></div>"
"</t></t>"
)
MODULE_ENFANT = """<odoo>
<template id="contactus_form" inherit_id="website.contactus">
<xpath expr="//t[@t-set='contactus_form_values']" position="after">
<t t-set="autre" t-value="2"/>
</xpath>
</template>
</odoo>
"""
MODULE_PARENT = """<odoo>
<template id="contactus" name="Contact">
<t t-call="website.layout"><div id="wrap"/></t>
</template>
<template id="layout" name="Layout"><div/></template>
</odoo>
"""
class TestFullKey(unittest.TestCase):
def test_a_bare_id_takes_its_module(self):
self.assertEqual(
cow.full_key("website", "contactus"), "website.contactus"
)
def test_a_qualified_id_is_left_alone(self):
self.assertEqual(
cow.full_key("website_crm", "website.contactus"),
"website.contactus",
)
def test_nothing_stays_nothing(self):
self.assertIsNone(cow.full_key("website", ""))
self.assertIsNone(cow.full_key("website", None))
class TestScanningTheTargetSources(unittest.TestCase):
def setUp(self):
self.racine = tempfile.mkdtemp(prefix="el_cow_")
self.addCleanup(shutil.rmtree, self.racine, True)
for module, contenu in (
("website", MODULE_PARENT),
("website_crm", MODULE_ENFANT),
):
chemin = os.path.join(self.racine, "odoo", "addons", module)
os.makedirs(chemin)
with open(
os.path.join(chemin, "views.xml"), "w", encoding="utf-8"
) as f:
f.write(contenu)
def scan(self, modules=("website", "website_crm")):
return cow.scan_target_views(self.racine, list(modules))
def test_it_lists_what_the_target_ships(self):
declares, _ = self.scan()
self.assertIn("website.contactus", declares)
self.assertIn("website.layout", declares)
def test_it_maps_a_child_xpath_to_the_parent_it_needs(self):
# C'est LE renseignement qui manquait : quelle vue de la cible
# exige quel ancrage, et dans quel parent.
_, heritages = self.scan()
self.assertEqual(
heritages["website.contactus"],
["//t[@t-set='contactus_form_values']"],
)
def test_a_module_absent_from_the_target_is_skipped(self):
declares, _ = self.scan(["website", "jamais_livre"])
self.assertIn("website.contactus", declares)
def test_a_template_without_xpath_adds_no_requirement(self):
_, heritages = self.scan(["website"])
self.assertEqual(heritages, {})
class TestWillNotRender(unittest.TestCase):
"""Le verdict, en remplaçant ce qui touche la base et le disque."""
def setUp(self):
self.vrai = (
cow.query_cow_archs,
cow.installed_modules,
cow.scan_target_views,
)
def tearDown(self):
(
cow.query_cow_archs,
cow.installed_modules,
cow.scan_target_views,
) = self.vrai
def poser(self, copies, declares, heritages):
cow.query_cow_archs = lambda d: copies
cow.installed_modules = lambda d: ["website"]
cow.scan_target_views = lambda v, m: (declares, heritages)
def copie(self, arch=ARCH_COPIE, langues=None):
return [
(
1228,
"website.contactus",
"primary",
1,
langues or {"en_US": arch},
)
]
def test_it_names_the_anchor_a_target_child_will_need(self):
self.poser(
self.copie(),
{
"website.contactus",
"website.layout",
"website.company_description",
},
{"website.contactus": ["//t[@t-set='contactus_form_values']"]},
)
risques = cow.will_not_render("db", "odoo18.0")
self.assertEqual(1, len(risques), risques)
self.assertIn("contactus_form_values", risques[0][5])
def test_an_anchor_the_copy_already_has_is_not_reported(self):
arch = ARCH_COPIE.replace(
"<div id='wrap'>",
"<t t-set='contactus_form_values' t-value='1'/><div id='wrap'>",
)
self.poser(
self.copie(arch),
{
"website.contactus",
"website.layout",
"website.company_description",
},
{"website.contactus": ["//t[@t-set='contactus_form_values']"]},
)
self.assertEqual([], cow.will_not_render("db", "odoo18.0"))
def test_it_names_a_template_the_target_no_longer_ships(self):
self.poser(
self.copie(),
{"website.contactus", "website.layout"},
{},
)
risques = cow.will_not_render("db", "odoo18.0")
self.assertEqual(1, len(risques), risques)
self.assertIn("company_description", risques[0][5])
def test_another_copy_counts_as_a_known_template(self):
# Un gabarit peut n'exister qu'en base : une copie de site qui en
# appelle une autre n'est pas cassée pour autant.
copies = self.copie() + [
(
99,
"website.company_description",
"primary",
1,
{"en_US": "<t/>"},
)
]
self.poser(copies, {"website.contactus", "website.layout"}, {})
self.assertEqual([], cow.will_not_render("db", "odoo18.0"))
def test_a_copy_broken_in_one_language_only_is_still_reported(self):
# Le site rend dans SA langue : réparer l'anglais et laisser le
# français cassé donne une page en 500 et un rapport vert.
sain = ARCH_COPIE.replace(
"<div id='wrap'>",
"<t t-set='contactus_form_values' t-value='1'/><div id='wrap'>",
)
self.poser(
self.copie(langues={"en_US": sain, "fr_CA": ARCH_COPIE}),
{
"website.contactus",
"website.layout",
"website.company_description",
},
{"website.contactus": ["//t[@t-set='contactus_form_values']"]},
)
risques = cow.will_not_render("db", "odoo18.0")
self.assertEqual(1, len(risques), risques)
def test_a_copy_without_a_key_is_left_alone(self):
self.poser(
[(7, "", "primary", 1, {"en_US": ARCH_COPIE})],
{"website.company_description"},
{},
)
self.assertEqual([], cow.will_not_render("db", "odoo18.0"))
def test_no_copy_at_all_is_not_an_error(self):
self.poser([], set(), {})
self.assertEqual([], cow.will_not_render("db", "odoo18.0"))
class TestItStaysOutOfTheNeutralizer(unittest.TestCase):
"""Ces copies se réparent ; les neutraliser effacerait la page."""
def test_the_neutralizer_only_acts_on_the_shape_bucket(self):
with open(
os.path.join(
os.path.dirname(__file__),
"..",
"script",
"odoo",
"migration",
"neutralize_cow_views.py",
),
encoding="utf-8",
) as handle:
source = handle.read()
self.assertIn("lst_at_risk, _, _ = analyse(", source)
self.assertNotIn("will_not_render", source)
if __name__ == "__main__":
unittest.main()

View file

@ -351,5 +351,89 @@ class TestTheReport(unittest.TestCase):
self.assertIn("⚠", texte)
class TestItRunsInsideTheMigration(unittest.TestCase):
"""La réparation doit tourner APRÈS OpenUpgrade, pas avant.
Elle prend l'ancrage manquant dans la vue MODULE, qui ne porte l'arch
de la version cible qu'une fois la migration passée. Lancée avant,
elle recopierait l'ancien ancrage — ou rien.
"""
def pilote(self):
chemin = os.path.join(
os.path.dirname(__file__),
"..",
"script",
"todo",
"todo_upgrade.py",
)
with open(chemin, encoding="utf-8") as handle:
return handle.read()
def corps_de_la_reparation(self):
"""Le CODE de la méthode, docstring retirée.
La docstring nomme `wait_at_error=False` pour l'expliquer : un
test qui lit tout le texte le trouve même quand l'argument a
disparu, et croit garder ce qu'il a laissé partir. Mesuré.
"""
import ast
for noeud in ast.walk(ast.parse(self.pilote())):
if (
isinstance(noeud, ast.FunctionDef)
and noeud.name == "repair_cow_render"
):
sans_texte = [
n
for n in noeud.body
if not (
isinstance(n, ast.Expr)
and isinstance(n.value, ast.Constant)
and isinstance(n.value.value, str)
)
]
return ast.dump(ast.Module(body=sans_texte, type_ignores=[]))
return ""
def test_the_scan_finds_the_method_at_all(self):
# Sans cette borne, les tests suivants passeraient sur une chaîne
# vide le jour où la méthode est renommée.
self.assertTrue(self.corps_de_la_reparation())
def test_the_driver_calls_the_repair(self):
self.assertIn("repair_cow_render", self.pilote())
def test_it_applies_rather_than_only_reporting(self):
corps = self.corps_de_la_reparation()
self.assertIn("fix_cow_render.py", corps)
self.assertIn("--apply", corps)
def test_a_finding_does_not_open_the_error_menu(self):
# Le code 1 de cet outil veut dire « j'ai trouvé », pas « je suis
# tombé ». Sans ce drapeau le pilote s'arrête sur une réparation
# réussie.
corps = self.corps_de_la_reparation()
self.assertIn("wait_at_error", corps)
self.assertIn("value=False", corps)
def test_it_is_called_after_the_data_migration(self):
source = self.pilote()
migration = source.index("cmd_upgrade,\n new_env=")
appel = source.index("self.repair_cow_render(")
self.assertLess(
migration,
appel,
"la réparation doit suivre OpenUpgrade : avant, la vue module"
" porte encore l'arch de l'ancienne version",
)
def test_a_missing_tool_is_not_a_crash(self):
# Un vieux checkout n'a pas le fichier ; la migration ne doit pas
# s'arrêter pour autant.
corps = self.corps_de_la_reparation()
self.assertIn("isfile", corps)
if __name__ == "__main__":
unittest.main()

View file

@ -218,11 +218,30 @@ class TestTheExitCodesTheDriverRelieson(unittest.TestCase):
def test_check_cow_views_reports_findings_with_1(self):
# Le chemin qui compte demande une base ; on vérifie la décision
# elle-même, à la source.
#
# On lit le RETURN, pas une ligne recopiée : l'outil a gagné une
# seconde famille de trouvailles (les copies qui rendront 500),
# et figer le texte faisait échouer le test sur un ajout juste.
# Ce qui doit rester vrai, c'est que CHAQUE famille pèse sur le
# code de sortie.
import ast
path = os.path.join(
REPO, "script", "odoo", "migration", "check_cow_views.py"
)
with open(path) as handle:
self.assertIn("return 1 if lst_at_risk else 0", handle.read())
arbre = ast.parse(handle.read())
retours = [
ast.dump(noeud)
for fonction in ast.walk(arbre)
if isinstance(fonction, ast.FunctionDef)
and fonction.name == "main"
for noeud in ast.walk(fonction)
if isinstance(noeud, ast.Return)
]
decision = [r for r in retours if "lst_at_risk" in r]
self.assertEqual(1, len(decision), retours)
self.assertIn("lst_no_render", decision[0])
class TestAllToolsKeepWorkingStandalone(unittest.TestCase):