[FIX] migration: un clone refait rejoue la liste de son palier

web_responsive avait bien été retiré, et il est revenu. Deux endroits
bâtissent la base intermédiaire : l'étape « Uninstall module », et
« Choose delete missing module » qui la jette et la refait depuis la
version précédente. Le second ne rejouait que les modules choisis là.

Relevé sur test_neutralize_upgrade_18 : retrait au rang 218, clone refait
au rang 230, et la 18 a refusé de charger sur l'exclusion de
muk_web_theme. Les deux chemins passent maintenant par
`uninstall_list_for`, donc ils ne peuvent plus diverger ; un test le
tient par l'AST, pour tout chemin à venir.

--- EN ---

web_responsive had indeed been removed, and it came back. Two places
build the intermediate database: the « Uninstall module » step, and
« Choose delete missing module » which throws it away and rebuilds it
from the previous version. The second replayed only the modules chosen
there.

Traced on test_neutralize_upgrade_18: removal at rank 218, clone rebuilt
at rank 230, and 18 refused to load on muk_web_theme's exclusion. Both
paths now go through `uninstall_list_for`, so they cannot diverge again;
a test holds that through the AST, for any future path.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-23 04:35:58 -04:00
parent 422094ca07
commit 9cb954749c
2 changed files with 172 additions and 14 deletions

View file

@ -2020,17 +2020,8 @@ class TodoUpgrade:
# uninstall_module_list_odoo130_to_odoo140.txt is read HERE, # uninstall_module_list_odoo130_to_odoo140.txt is read HERE,
# right before the 13 -> 14 data migration. Without this the # right before the 13 -> 14 data migration. Without this the
# per-bump files existed in name only and were never read. # per-bump files existed in name only and were never read.
lst_file, lst_detail = self.read_uninstall_module_list( lst_module_to_uninstall = self.uninstall_list_for(
next_version - 1, database_name next_version, database_name, lst_module_to_uninstall
)
if lst_detail:
print(
f"✨ {t('Modules to uninstall before Odoo')}"
f"{next_version} :"
)
self.print_uninstall_reason(lst_detail)
lst_module_to_uninstall = list(
dict.fromkeys(list(lst_module_to_uninstall) + lst_file)
) )
if lst_module_to_uninstall: if lst_module_to_uninstall:
@ -2303,8 +2294,15 @@ class TodoUpgrade:
# Duplicate database # Duplicate database
cmd_clone_database = f"./odoo_bin.sh db --clone --from_database {last_database_name} --database {database_name_upgrade}" cmd_clone_database = f"./odoo_bin.sh db --clone --from_database {last_database_name} --database {database_name_upgrade}"
self.todo_upgrade_execute(cmd_clone_database) self.todo_upgrade_execute(cmd_clone_database)
# Le clone est NEUF : ce qu'on avait retiré du
# précédent est revenu avec lui. La liste du palier
# se rejoue donc ici, avec les modules choisis.
self.uninstall_from_database( self.uninstall_from_database(
lst_module_to_delete, self.uninstall_list_for(
next_version,
database_name,
lst_module_to_delete,
),
database_name_upgrade, database_name_upgrade,
next_version, next_version,
) )
@ -3796,6 +3794,32 @@ class TodoUpgrade:
return [] return []
return lst_present return lst_present
def uninstall_list_for(self, next_version, database_name, extra=()):
"""Ce qu'il faut retirer avant `next_version`, liste du palier comprise.
DEUX endroits bâtissent le clone intermédiaire : l'étape
« Uninstall module », et « Choose delete missing module » qui le
jette et le refait depuis la version précédente. Le second ne
rejouait que les modules choisis là, donc la liste du palier était
perdue avec le clone : web_responsive revenait, et la 18 refusait
de charger sur l'exclusion de muk_web_theme — après que la première
désinstallation eut pourtant réussi. Une seule fonction, pour que
les deux ne puissent plus diverger.
`dict.fromkeys` dédoublonne au passage : la liste choisie porte
parfois deux fois le même nom.
"""
lst_file, lst_detail = self.read_uninstall_module_list(
next_version - 1, database_name
)
if lst_detail:
print(
f"✨ {t('Modules to uninstall before Odoo')}"
f"{next_version} :"
)
self.print_uninstall_reason(lst_detail)
return list(dict.fromkeys(list(extra) + lst_file))
def uninstall_from_database( def uninstall_from_database(
self, lst_module_to_uninstall, database_name, actual_version self, lst_module_to_uninstall, database_name, actual_version
): ):

View file

@ -15,6 +15,7 @@ Le retrait doit se faire pendant qu'on est ENCORE sur l'ancienne
version, là où l'état est légal et où l'ORM fonctionne. version, là où l'état est légal et où l'ORM fonctionne.
""" """
import ast
import io import io
import os import os
import sys import sys
@ -116,10 +117,11 @@ class TestWhenItRuns(unittest.TestCase):
def test_a_private_list_wins_over_the_shared_one(self): def test_a_private_list_wins_over_the_shared_one(self):
# Une base peut avoir ses propres retraits sans qu'on touche à la # Une base peut avoir ses propres retraits sans qu'on touche à la
# liste partagée de tout le monde. # liste partagée de tout le monde.
source = io.open( with io.open(
os.path.join(RACINE, "script", "todo", "todo_upgrade.py"), os.path.join(RACINE, "script", "todo", "todo_upgrade.py"),
encoding="utf-8", encoding="utf-8",
).read() ) as handle:
source = handle.read()
debut = source.index("def read_uninstall_module_list") debut = source.index("def read_uninstall_module_list")
fin = source.index("def split_present_missing") fin = source.index("def split_present_missing")
bloc = source[debut:fin] bloc = source[debut:fin]
@ -129,5 +131,137 @@ class TestWhenItRuns(unittest.TestCase):
) )
class TestTheListFollowsTheClone(unittest.TestCase):
"""Un clone refait doit rejouer la liste de son palier.
Deux endroits bâtissent la base intermédiaire : l'étape « Uninstall
module », et « Choose delete missing module » qui la jette et la
refait depuis la version précédente. Le second ne rejouait que les
modules choisis là. Mesuré sur test_neutralize_upgrade_18 :
web_responsive retiré au rang 218, clone refait au rang 230, et il
était revenu — la 18 a refusé de charger sur l'exclusion de
muk_web_theme, alors que la désinstallation avait réussi.
"""
def source(self):
with io.open(
os.path.join(RACINE, "script", "todo", "todo_upgrade.py"),
encoding="utf-8",
) as handle:
return handle.read()
def fonction(self):
import ast
for noeud in ast.walk(ast.parse(self.source())):
if (
isinstance(noeud, ast.FunctionDef)
and noeud.name == "execute_odoo_upgrade"
):
return noeud
return None
@staticmethod
def _appelle(noeud, methode):
return (
isinstance(noeud, ast.Call)
and isinstance(noeud.func, ast.Attribute)
and noeud.func.attr == methode
)
def appels_uninstall(self):
import ast
return [
n
for n in ast.walk(self.fonction())
if self._appelle(n, "uninstall_from_database")
]
def noms_venant_de_la_liste(self):
"""Les variables affectées depuis `uninstall_list_for(...)`."""
import ast
noms = set()
for n in ast.walk(self.fonction()):
if isinstance(n, ast.Assign) and self._appelle(
n.value, "uninstall_list_for"
):
for cible in n.targets:
if isinstance(cible, ast.Name):
noms.add(cible.id)
return noms
def test_there_is_more_than_one_place_that_uninstalls(self):
# Sans cette borne, le test suivant passerait le jour où un des
# deux chemins disparaît — ou n'existe plus sous cette forme.
self.assertGreaterEqual(len(self.appels_uninstall()), 2)
def test_every_uninstall_goes_through_the_shared_list(self):
import ast
noms = self.noms_venant_de_la_liste()
self.assertTrue(noms, "aucune variable ne vient de uninstall_list_for")
for appel in self.appels_uninstall():
premier = appel.args[0] if appel.args else None
direct = self._appelle(premier, "uninstall_list_for")
indirect = isinstance(premier, ast.Name) and premier.id in noms
self.assertTrue(
direct or indirect,
"un chemin désinstalle sans rejouer la liste du palier :"
f" ligne {appel.lineno}",
)
class TestUninstallListFor(unittest.TestCase):
class Faux(TodoUpgrade):
def __init__(self, fichier=()):
self.fichier = list(fichier)
self.affiche = []
def read_uninstall_module_list(self, depart, database_name):
self.depart = depart
return list(self.fichier), [
(nom, "raison", "f") for nom in self.fichier
]
def print_uninstall_reason(self, detail):
self.affiche.append(detail)
def test_it_reads_the_file_of_the_step_being_left(self):
# Monter vers 18 lit le fichier 17 → 18, pas 18 → 19.
faux = self.Faux(["web_responsive"])
faux.uninstall_list_for(18, "une_base")
self.assertEqual(faux.depart, 17)
def test_the_chosen_modules_come_first(self):
# L'ordre compte : ce que la personne vient de choisir se lit en
# tête de la ligne de commande qui suit.
faux = self.Faux(["duFichier"])
self.assertEqual(
faux.uninstall_list_for(18, "b", ["choisi"]),
["choisi", "duFichier"],
)
def test_a_module_named_twice_is_uninstalled_once(self):
faux = self.Faux(["commun"])
self.assertEqual(
faux.uninstall_list_for(18, "b", ["commun", "commun"]),
["commun"],
)
def test_it_says_why_each_one_goes(self):
faux = self.Faux(["web_responsive"])
faux.uninstall_list_for(18, "b")
self.assertTrue(faux.affiche)
def test_it_stays_quiet_when_the_step_has_no_list(self):
faux = self.Faux([])
self.assertEqual(
faux.uninstall_list_for(18, "b", ["choisi"]), ["choisi"]
)
self.assertEqual(faux.affiche, [])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()