[ADD] migration: go back a step from a prompt, and name the DB
Realising at a prompt that an earlier step deserved another answer had one way out: Ctrl+C. That leaves the progression as it stands and makes you find the resume screen again to rewind. « b » does it properly — it shows the steps, rewinds the state, writes it, and says what to relaunch. Cancelling that must not stop the migration, which is the trap: it returns to the same prompt, exactly where you were. Removing that guard makes two tests fail. The COW warning printed « -d DB -t odooXX.0 » for commands meant to be pasted. It knows both values, so it prints them, and offers --shape as well since that is half the answer. Checked on the four outcomes: a normal answer passes through, « b » then a step rewinds and stops, cancelling and an unknown step both continue. 15 tests. --- FR --- S'apercevoir à une invite qu'une étape antérieure méritait un autre choix n'avait qu'une issue : Ctrl+C. Cela laisse la progression telle quelle et oblige à retrouver l'écran de reprise pour rembobiner. « b » le fait proprement — il montre les étapes, rembobine l'état, l'écrit, et dit quoi relancer. Y renoncer ne doit pas arrêter la migration, et c'est le piège : on revient à la même invite, exactement là où l'on était. Retirer ce garde-fou fait tomber deux tests. L'avertissement COW affichait « -d DB -t odooXX.0 » pour des commandes faites pour être collées. Il connaît les deux valeurs, donc il les écrit, et propose aussi --shape puisque c'est la moitié de la réponse. Vérifié sur les quatre issues : une réponse normale passe, « b » puis une étape rembobine et arrête, annuler et un choix inconnu continuent. 15 tests. Assisted-by: Claude Opus 5
This commit is contained in:
parent
4ce612b008
commit
95532ec96f
5 changed files with 193 additions and 10 deletions
|
|
@ -288,6 +288,8 @@ def main():
|
||||||
lst_at_risk, lst_module_absent, lst_no_counterpart = analyse(
|
lst_at_risk, lst_module_absent, lst_no_counterpart = analyse(
|
||||||
config.database, config.target_version
|
config.database, config.target_version
|
||||||
)
|
)
|
||||||
|
database = config.database
|
||||||
|
target_version = config.target_version
|
||||||
|
|
||||||
if not lst_at_risk:
|
if not lst_at_risk:
|
||||||
print(
|
print(
|
||||||
|
|
@ -312,13 +314,20 @@ def main():
|
||||||
f" - id={view_id} website={website_id} {key}"
|
f" - id={view_id} website={website_id} {key}"
|
||||||
f" : {mode} -> {target_mode} ({reason})"
|
f" : {mode} -> {target_mode} ({reason})"
|
||||||
)
|
)
|
||||||
|
# La base et la version cible sont connues ici : les remplacer par
|
||||||
|
# « DB » et « odooXX.0 » oblige à les retrouver, au moment précis où
|
||||||
|
# l'on veut juste copier-coller la commande.
|
||||||
print(
|
print(
|
||||||
" The migration will offer to neutralize them at the bump, and"
|
" The migration will offer to neutralize them at the bump, and"
|
||||||
" shows what each copy holds before you answer. To do it now:"
|
" shows what each copy holds before you answer. To look now,"
|
||||||
"\n ./script/odoo/migration/cow_drift.py -d DB -t odooXX.0"
|
" without interrupting anything:"
|
||||||
" (read what they hold)"
|
f"\n ./script/odoo/migration/cow_drift.py -d {database}"
|
||||||
"\n ./script/odoo/migration/neutralize_cow_views.py -d DB"
|
f" -t {target_version} (what each copy holds)"
|
||||||
" -t odooXX.0 --apply (reversible with --restore)"
|
f"\n ./script/odoo/migration/cow_drift.py -d {database}"
|
||||||
|
f" -t {target_version} --shape (why it breaks)"
|
||||||
|
f"\n ./script/odoo/migration/neutralize_cow_views.py"
|
||||||
|
f" -d {database} -t {target_version} --apply"
|
||||||
|
" (reversible with --restore)"
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
" Or by hand. To neutralize a copy,"
|
" Or by hand. To neutralize a copy,"
|
||||||
|
|
|
||||||
|
|
@ -889,7 +889,17 @@ class TODO:
|
||||||
return False
|
return False
|
||||||
elif status == str(len(choices) - 1):
|
elif status == str(len(choices) - 1):
|
||||||
upgrade = todo_upgrade.TodoUpgrade(self)
|
upgrade = todo_upgrade.TodoUpgrade(self)
|
||||||
upgrade.execute_odoo_upgrade()
|
try:
|
||||||
|
upgrade.execute_odoo_upgrade()
|
||||||
|
except todo_upgrade.MigrationRewind:
|
||||||
|
# L'état est déjà rembobiné et écrit : il ne reste qu'à
|
||||||
|
# relancer, et l'écran de reprise repartira de l'étape
|
||||||
|
# choisie. Sortir d'ici plutôt que de rappeler la méthode
|
||||||
|
# évite de la reprendre au milieu de son état local.
|
||||||
|
print(
|
||||||
|
f"\n⏪ {t('Rewound.')}"
|
||||||
|
f" {t('Relaunch the migration to resume from there.')}"
|
||||||
|
)
|
||||||
elif status == str(len(choices)):
|
elif status == str(len(choices)):
|
||||||
self.upgrade_poetry()
|
self.upgrade_poetry()
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -3899,6 +3899,27 @@ TRANSLATIONS = {
|
||||||
" contient.",
|
" contient.",
|
||||||
"en": "at the version bump itself, showing what each one holds.",
|
"en": "at the version bump itself, showing what each one holds.",
|
||||||
},
|
},
|
||||||
|
"Press to continue": {
|
||||||
|
"fr": "Appuyez pour continuer",
|
||||||
|
"en": "Press to continue",
|
||||||
|
},
|
||||||
|
"(b = go back to a previous step)": {
|
||||||
|
"fr": "(b = revenir à une étape précédente)",
|
||||||
|
"en": "(b = go back to a previous step)",
|
||||||
|
},
|
||||||
|
"Neutralize database, press to continue": {
|
||||||
|
"fr": "Neutraliser la base, appuyez pour continuer",
|
||||||
|
"en": "Neutralize database, press to continue",
|
||||||
|
},
|
||||||
|
"Replay from which step? (empty to cancel)": {
|
||||||
|
"fr": "Rejouer à partir de quelle étape ? (vide pour annuler)",
|
||||||
|
"en": "Replay from which step? (empty to cancel)",
|
||||||
|
},
|
||||||
|
"Rewound.": {"fr": "Rembobiné.", "en": "Rewound."},
|
||||||
|
"Relaunch the migration to resume from there.": {
|
||||||
|
"fr": "Relancez la migration pour reprendre à partir de là.",
|
||||||
|
"en": "Relaunch the migration to resume from there.",
|
||||||
|
},
|
||||||
# --- script/analyse : vues personnalisées ---
|
# --- script/analyse : vues personnalisées ---
|
||||||
"Customised views": {
|
"Customised views": {
|
||||||
"fr": "Vues personnalisées",
|
"fr": "Vues personnalisées",
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,17 @@ MIGRATION_STEP = [
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class MigrationRewind(Exception):
|
||||||
|
"""L'utilisateur a demandé de revenir à une étape antérieure.
|
||||||
|
|
||||||
|
Une exception, et non un code de retour : la demande peut venir de
|
||||||
|
n'importe laquelle des invites, à n'importe quelle profondeur d'une
|
||||||
|
méthode de mille lignes. La faire remonter par des valeurs de retour
|
||||||
|
obligerait chaque appelant intermédiaire à la reconnaître et à la
|
||||||
|
propager — autant d'endroits où l'oublier.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class TodoUpgrade:
|
class TodoUpgrade:
|
||||||
def __init__(self, todo):
|
def __init__(self, todo):
|
||||||
self.file_path = None
|
self.file_path = None
|
||||||
|
|
@ -669,6 +680,47 @@ class TodoUpgrade:
|
||||||
]
|
]
|
||||||
return dct_kept
|
return dct_kept
|
||||||
|
|
||||||
|
def ask_gate(self, prompt):
|
||||||
|
"""Une invite d'attente, avec une porte de sortie vers l'arrière.
|
||||||
|
|
||||||
|
Ces invites ne demandent qu'à continuer. Quand on s'aperçoit à ce
|
||||||
|
moment-là qu'une étape précédente méritait un autre choix, la seule
|
||||||
|
issue était Ctrl+C — qui laisse la progression telle quelle et oblige
|
||||||
|
à retrouver l'écran de reprise. « b » fait le travail proprement :
|
||||||
|
il rembobine l'état, l'écrit, et s'arrête en disant quoi relancer.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
answer = input(prompt)
|
||||||
|
if (answer or "").strip().lower() != "b":
|
||||||
|
return answer
|
||||||
|
if self.rewind_to_chosen_step():
|
||||||
|
raise MigrationRewind()
|
||||||
|
# Renoncer au retour en arrière ne doit pas arrêter la migration :
|
||||||
|
# on revient à la même invite, exactement là où l'on était.
|
||||||
|
|
||||||
|
def rewind_to_chosen_step(self):
|
||||||
|
"""Demander l'étape et rembobiner jusqu'à elle. Écrit la progression."""
|
||||||
|
ctx = self.resume_context(self.dct_progression)
|
||||||
|
print()
|
||||||
|
print(f"📍 {t('Migration in progress')}")
|
||||||
|
for item in ctx["steps"]:
|
||||||
|
print(
|
||||||
|
f" [{item['step']}] {item['icon']} {item['label']:<44}"
|
||||||
|
f" {item['detail']}"
|
||||||
|
)
|
||||||
|
answer = input(
|
||||||
|
f"💬 {t('Replay from which step? (empty to cancel)')} : "
|
||||||
|
)
|
||||||
|
answer = (answer or "").strip()
|
||||||
|
if not answer.isdigit() or int(answer) > MIGRATION_STEP[-1][0]:
|
||||||
|
print(f"⚠️ {t('Unknown choice, continuing where it stopped')}.")
|
||||||
|
return False
|
||||||
|
self.dct_progression = self.rewind_progression(
|
||||||
|
self.dct_progression, int(answer)
|
||||||
|
)
|
||||||
|
self.write_config()
|
||||||
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def rewind_progression(old_dct_progression, step):
|
def rewind_progression(old_dct_progression, step):
|
||||||
"""Drop the progression of `step` and of every later step.
|
"""Drop the progression of `step` and of every later step.
|
||||||
|
|
@ -1276,7 +1328,11 @@ class TodoUpgrade:
|
||||||
if not self.dct_progression.get("state_1_neutralize_database"):
|
if not self.dct_progression.get("state_1_neutralize_database"):
|
||||||
print("[1] Ignore neutralize database")
|
print("[1] Ignore neutralize database")
|
||||||
wait_continue = (
|
wait_continue = (
|
||||||
input("💬 Neutralize database, press to continue : ")
|
self.ask_gate(
|
||||||
|
"💬 "
|
||||||
|
+ t("Neutralize database, press to continue")
|
||||||
|
+ f" {t('(b = go back to a previous step)')} : "
|
||||||
|
)
|
||||||
.strip()
|
.strip()
|
||||||
.lower()
|
.lower()
|
||||||
)
|
)
|
||||||
|
|
@ -1491,8 +1547,10 @@ class TodoUpgrade:
|
||||||
print(
|
print(
|
||||||
"✨ Aller dans «configuration/Technique/Nettoyage.../Purger» les modules obsolètes"
|
"✨ Aller dans «configuration/Technique/Nettoyage.../Purger» les modules obsolètes"
|
||||||
)
|
)
|
||||||
status = input(
|
status = self.ask_gate(
|
||||||
"💬 Did you finish to clean database? Press y/Y to open server with selenium, else ignore it : "
|
"💬 Did you finish to clean database? Press y/Y to open"
|
||||||
|
" server with selenium, else ignore it"
|
||||||
|
f" {t('(b = go back to a previous step)')} : "
|
||||||
).strip()
|
).strip()
|
||||||
|
|
||||||
if status.lower().strip() == "y":
|
if status.lower().strip() == "y":
|
||||||
|
|
@ -2200,7 +2258,10 @@ class TodoUpgrade:
|
||||||
print(
|
print(
|
||||||
f"🚸 Please, validate this path into config.conf : '{path_addons_openupgrade}'."
|
f"🚸 Please, validate this path into config.conf : '{path_addons_openupgrade}'."
|
||||||
)
|
)
|
||||||
status = input(f"💬 Press to continue {msg} : ").strip()
|
status = self.ask_gate(
|
||||||
|
f"💬 {t('Press to continue')} {msg}"
|
||||||
|
f" {t('(b = go back to a previous step)')} : "
|
||||||
|
).strip()
|
||||||
# The technique change at version 14
|
# The technique change at version 14
|
||||||
if next_version <= 13:
|
if next_version <= 13:
|
||||||
erplibre_version = self.install_OCA_openupgrade(
|
erplibre_version = self.install_OCA_openupgrade(
|
||||||
|
|
|
||||||
|
|
@ -100,5 +100,87 @@ class TestNeedsUpdateAll(unittest.TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackGate(unittest.TestCase):
|
||||||
|
"""Revenir à une étape précédente depuis une invite en cours de route.
|
||||||
|
|
||||||
|
Ces invites ne demandaient qu'à continuer. S'apercevoir à ce moment-là
|
||||||
|
qu'une étape antérieure méritait un autre choix n'avait qu'une issue :
|
||||||
|
Ctrl+C, qui laisse la progression telle quelle et oblige à retrouver
|
||||||
|
l'écran de reprise. « b » fait le travail proprement.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
self.addCleanup(os.chdir, os.getcwd())
|
||||||
|
os.chdir(tempfile.mkdtemp())
|
||||||
|
os.makedirs(".venv.erplibre", exist_ok=True)
|
||||||
|
self.addCleanup(
|
||||||
|
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
||||||
|
)
|
||||||
|
todo_i18n._current_lang = "en"
|
||||||
|
|
||||||
|
def gate(self, answers):
|
||||||
|
"""(a arrêté ?, valeur rendue, progression) après ces réponses."""
|
||||||
|
import builtins
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
|
||||||
|
from script.todo.todo_upgrade import MigrationRewind
|
||||||
|
|
||||||
|
upgrade = TodoUpgrade.__new__(TodoUpgrade)
|
||||||
|
upgrade.dct_progression = {
|
||||||
|
"config_database_name": "db",
|
||||||
|
"state_0_install_odoo": True,
|
||||||
|
"state_1_restore_database": True,
|
||||||
|
"state_2_update_all": True,
|
||||||
|
"state_3_clean_database": True,
|
||||||
|
}
|
||||||
|
upgrade.lst_command_executed = []
|
||||||
|
seq = iter(answers)
|
||||||
|
original = builtins.input
|
||||||
|
builtins.input = lambda *a: next(seq)
|
||||||
|
self.addCleanup(setattr, builtins, "input", original)
|
||||||
|
stopped, returned = False, None
|
||||||
|
try:
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()):
|
||||||
|
returned = upgrade.ask_gate("? ")
|
||||||
|
except MigrationRewind:
|
||||||
|
stopped = True
|
||||||
|
return stopped, returned, upgrade.dct_progression
|
||||||
|
|
||||||
|
def test_a_normal_answer_passes_straight_through(self):
|
||||||
|
stopped, returned, _ = self.gate(["y"])
|
||||||
|
self.assertFalse(stopped)
|
||||||
|
self.assertEqual(returned, "y")
|
||||||
|
|
||||||
|
def test_b_then_a_step_rewinds_and_stops(self):
|
||||||
|
stopped, _, progression = self.gate(["b", "2"])
|
||||||
|
self.assertTrue(stopped)
|
||||||
|
# L'étape choisie et les suivantes sont effacées, les précédentes non.
|
||||||
|
self.assertIn("state_1_restore_database", progression)
|
||||||
|
self.assertNotIn("state_2_update_all", progression)
|
||||||
|
self.assertNotIn("state_3_clean_database", progression)
|
||||||
|
|
||||||
|
def test_cancelling_the_rewind_does_not_stop_the_migration(self):
|
||||||
|
# LE piège : renoncer au retour en arrière arrêtait quand même tout.
|
||||||
|
# On revient à la même invite, exactement là où l'on était.
|
||||||
|
stopped, returned, progression = self.gate(["b", "", "y"])
|
||||||
|
self.assertFalse(stopped)
|
||||||
|
self.assertEqual(returned, "y")
|
||||||
|
self.assertIn("state_3_clean_database", progression)
|
||||||
|
|
||||||
|
def test_an_unknown_step_is_not_a_rewind(self):
|
||||||
|
stopped, returned, progression = self.gate(["b", "zzz", ""])
|
||||||
|
self.assertFalse(stopped)
|
||||||
|
self.assertEqual(returned, "")
|
||||||
|
self.assertIn("state_3_clean_database", progression)
|
||||||
|
|
||||||
|
def test_the_answer_is_case_insensitive(self):
|
||||||
|
stopped, _, _ = self.gate(["B", "1"])
|
||||||
|
self.assertTrue(stopped)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue