From 566070b4f854a11cd1cd15a34a229e9d4e3f60ed Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 04:20:13 -0400 Subject: [PATCH] [FIX] migration: Enter exports the backup at the end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After several hours of migration the archive is what one wanted anyway, and the prompt asked for it with an empty default: pressing Enter — or letting auto-run answer — walked past the only artifact of the whole run. The three-way answer moved into a function of its own, because the third way is the one that traps. « n » refuses, « y » or Enter takes the timestamped name, and EVERYTHING ELSE is a filename. So « non.zip » is a file, not a refusal: comparing the whole answer rather than its first letter is what keeps it one. --- FR --- [FIX] migration : Entrée exporte la sauvegarde à la fin Au bout de plusieurs heures de migration, l'archive est ce qu'on voulait de toute façon, et l'invite la proposait avec un défaut vide : appuyer sur Entrée — ou laisser l'auto-exécution répondre — passait à côté du seul artefact de toute la course. La réponse à trois sens est devenue une fonction à part, car c'est le troisième qui piège. « n » refuse, « y » ou Entrée prend le nom horodaté, et TOUT LE RESTE est un nom de fichier. « non.zip » est donc un fichier et non un refus : comparer la réponse entière plutôt que sa première lettre est ce qui le garantit. Assisted-by: Claude Opus 5 --- script/todo/todo_i18n.py | 8 ++++++ script/todo/todo_upgrade.py | 33 ++++++++++++++++++++---- test/test_prompt_defaults.py | 50 ++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 5 deletions(-) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index da1a738..6f4464a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5607,6 +5607,14 @@ TRANSLATIONS = { "fr": "État de la migration (plein écran)", "en": "Migration state (full screen)", }, + "Export a backup?": { + "fr": "Exporter une sauvegarde ?", + "en": "Export a backup?", + }, + "or type filename.zip": { + "fr": "ou tapez nomfichier.zip", + "en": "or type filename.zip", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 8956ead..fa69554 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -851,6 +851,27 @@ class TodoUpgrade: # 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. + @staticmethod + def backup_command(answer, template, default_cmd): + """La commande de sauvegarde à lancer, ou « » pour ne rien faire. + + Trois réponses, trois sens, et le troisième est celui qui piège : + « n » refuse, « y » ou Entrée prend le nom horodaté, et TOUT LE + RESTE est un nom de fichier. Une réponse comme « non.zip » est donc + un fichier, pas un refus — d'où la comparaison exacte plutôt qu'un + début de mot. + + Entrée sauvegarde : au bout d'une migration de plusieurs heures, + l'archive est ce qu'on voulait de toute façon. Mais refuser doit + rester possible, et avoir un mot pour le dire. + """ + reponse = (answer or "").strip() + if reponse.lower() in ("", "n"): + return "" + if reponse.lower() == "y": + return default_cmd + return f"{template} {reponse}" + def show_migration_status(self): """Ouvrir l'état de la migration, en plein écran. @@ -2766,12 +2787,14 @@ class TodoUpgrade: cmd_backup = f"{cmd_backup_template} {database_name_upgrade}_finish_{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}" print(f"✨ {t('A backup can be created')} :\n{cmd_backup}") status = self.ask( - f"💬 {t('Press y/Y or type filename.zip to export, or')}" - f" {t('enter to continue')} : " + f"💬 {t('Export a backup?')}" + f" (Y/n, {t('or type filename.zip')}) : ", + default="y", ).strip() - if status.lower(): - if status.lower() != "y": - cmd_backup = f"{cmd_backup_template} {status}" + cmd_backup = self.backup_command( + status, cmd_backup_template, cmd_backup + ) + if cmd_backup: self.todo_upgrade_execute(cmd_backup) status = self.ask(f"💬 {t('Test the migration, press y/Y')} : ") diff --git a/test/test_prompt_defaults.py b/test/test_prompt_defaults.py index fb0248d..f9f6f71 100644 --- a/test/test_prompt_defaults.py +++ b/test/test_prompt_defaults.py @@ -295,6 +295,56 @@ class TestWhatEnterDoesIsWritten(unittest.TestCase): self.assertIn(refus, self.source(nom), nom) +class TestTheBackupAtTheEnd(unittest.TestCase): + """Au bout de plusieurs heures, l'archive est ce qu'on voulait. + + Trois réponses, trois sens, et le troisième est celui qui piège : « n » + refuse, « y » ou Entrée prend le nom horodaté, et TOUT LE RESTE est un + nom de fichier. « non.zip » est donc un fichier, pas un refus. + """ + + TPL = "./odoo_bin.sh db --backup --database x --restore_image" + DEFAUT = "./odoo_bin.sh db --backup --database x --restore_image x_f_2026" + + def cmd(self, answer): + return TodoUpgrade.backup_command(answer, self.TPL, self.DEFAUT) + + def test_yes_takes_the_timestamped_name(self): + self.assertEqual(self.cmd("y"), self.DEFAUT) + + def test_case_does_not_matter(self): + self.assertEqual(self.cmd("Y"), self.DEFAUT) + + def test_n_refuses(self): + # Le défaut ne retire pas le choix : il ne fait qu'en proposer un. + self.assertEqual(self.cmd("n"), "") + self.assertEqual(self.cmd("N"), "") + + def test_anything_else_is_a_filename(self): + self.assertTrue(self.cmd("archive.zip").endswith(" archive.zip")) + + def test_a_filename_that_STARTS_with_n_is_still_a_filename(self): + # Le piège : comparer un début de mot au lieu de la réponse + # entière ferait passer « non.zip » pour un refus, et l'on + # perdrait l'archive d'une migration de plusieurs heures. + self.assertTrue(self.cmd("non.zip").endswith(" non.zip")) + + def test_blanks_are_not_a_filename(self): + self.assertEqual(self.cmd(" "), "") + + def test_none_does_not_crash(self): + self.assertEqual(self.cmd(None), "") + + def test_enter_backs_up_because_the_default_says_so(self): + import inspect + + source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + debut = source.index("Export a backup?") + fenetre = source[debut : debut + 200] + self.assertIn('default="y"', fenetre) + self.assertIn("(Y/n,", fenetre) + + class TestTheToolsLaunchedApart(unittest.TestCase): def test_the_leftovers_default_to_deleting(self): import theme_leftover