[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,
# right before the 13 -> 14 data migration. Without this the
# per-bump files existed in name only and were never read.
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)
lst_module_to_uninstall = list(
dict.fromkeys(list(lst_module_to_uninstall) + lst_file)
lst_module_to_uninstall = self.uninstall_list_for(
next_version, database_name, lst_module_to_uninstall
)
if lst_module_to_uninstall:
@ -2303,8 +2294,15 @@ class TodoUpgrade:
# Duplicate database
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)
# 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_list_for(
next_version,
database_name,
lst_module_to_delete,
),
database_name_upgrade,
next_version,
)
@ -3796,6 +3794,32 @@ class TodoUpgrade:
return []
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(
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.
"""
import ast
import io
import os
import sys
@ -116,10 +117,11 @@ class TestWhenItRuns(unittest.TestCase):
def test_a_private_list_wins_over_the_shared_one(self):
# Une base peut avoir ses propres retraits sans qu'on touche à la
# liste partagée de tout le monde.
source = io.open(
with io.open(
os.path.join(RACINE, "script", "todo", "todo_upgrade.py"),
encoding="utf-8",
).read()
) as handle:
source = handle.read()
debut = source.index("def read_uninstall_module_list")
fin = source.index("def split_present_missing")
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__":
unittest.main()