[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:
parent
422094ca07
commit
9cb954749c
2 changed files with 172 additions and 14 deletions
|
|
@ -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
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue