diff --git a/script/odoo/migration/check_cow_views.py b/script/odoo/migration/check_cow_views.py index d01d2fa..3acca71 100755 --- a/script/odoo/migration/check_cow_views.py +++ b/script/odoo/migration/check_cow_views.py @@ -288,6 +288,8 @@ def main(): lst_at_risk, lst_module_absent, lst_no_counterpart = analyse( config.database, config.target_version ) + database = config.database + target_version = config.target_version if not lst_at_risk: print( @@ -312,13 +314,20 @@ def main(): f" - id={view_id} website={website_id} {key}" 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( " The migration will offer to neutralize them at the bump, and" - " shows what each copy holds before you answer. To do it now:" - "\n ./script/odoo/migration/cow_drift.py -d DB -t odooXX.0" - " (read what they hold)" - "\n ./script/odoo/migration/neutralize_cow_views.py -d DB" - " -t odooXX.0 --apply (reversible with --restore)" + " shows what each copy holds before you answer. To look now," + " without interrupting anything:" + f"\n ./script/odoo/migration/cow_drift.py -d {database}" + f" -t {target_version} (what each copy holds)" + 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( " Or by hand. To neutralize a copy," diff --git a/script/todo/todo.py b/script/todo/todo.py index 321ddf4..ae148e7 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -889,7 +889,17 @@ class TODO: return False elif status == str(len(choices) - 1): 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)): self.upgrade_poetry() else: diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 8e83355..7f45853 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3899,6 +3899,27 @@ TRANSLATIONS = { " contient.", "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 --- "Customised views": { "fr": "Vues personnalisées", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 0daace6..1437fc0 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -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: def __init__(self, todo): self.file_path = None @@ -669,6 +680,47 @@ class TodoUpgrade: ] 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 def rewind_progression(old_dct_progression, 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"): print("[1] Ignore neutralize database") 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() .lower() ) @@ -1491,8 +1547,10 @@ class TodoUpgrade: print( "✨ Aller dans «configuration/Technique/Nettoyage.../Purger» les modules obsolètes" ) - status = input( - "💬 Did you finish to clean database? Press y/Y to open server with selenium, else ignore it : " + status = self.ask_gate( + "💬 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() if status.lower().strip() == "y": @@ -2200,7 +2258,10 @@ class TodoUpgrade: print( 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 if next_version <= 13: erplibre_version = self.install_OCA_openupgrade( diff --git a/test/test_todo_upgrade_steps.py b/test/test_todo_upgrade_steps.py index 5e5d68d..58db583 100644 --- a/test/test_todo_upgrade_steps.py +++ b/test/test_todo_upgrade_steps.py @@ -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__": unittest.main()