diff --git a/script/addons/theme_leftover.py b/script/addons/theme_leftover.py index ec8cd80..f333d7d 100755 --- a/script/addons/theme_leftover.py +++ b/script/addons/theme_leftover.py @@ -37,6 +37,12 @@ except Exception: # pragma: no cover - repli si i18n indisponible return key +try: + from script.todo import auto_ask +except Exception: # pragma: no cover - repli si le pilote est absent + auto_ask = None + + def can_ask(): """Peut-on poser une question ICI ? @@ -199,14 +205,32 @@ def delete_attachments(database, lst_row, config_path="./config.conf"): return done.returncode, done.stdout + done.stderr -def prompt(database, theme, attachments, views, config_path, ask=input): - """Garder ou effacer. « Garder » par défaut, et la sauvegarde d'abord.""" +DEFAULT_ANSWER = "d" + + +def prompt(database, theme, attachments, views, config_path, ask=None): + """Effacer ou garder. Effacer par défaut, et la sauvegarde D'ABORD. + + Ce qui rend ce défaut tenable, c'est l'ordre : le contenu part dans un + fichier avant que la base ne soit touchée. Sans cette sauvegarde, le + défaut aurait dû rester « garder » — on ne fait pas d'une décision + irréversible la réponse que l'on obtient en ne répondant pas. + """ if not attachments: return False + if ask is None: + # Lancé à part par la migration : sans lecteur temporisé, cette + # question arrêtait net une exécution automatique. + ask = ( + auto_ask.make_ask(DEFAULT_ANSWER) + if auto_ask + else (lambda prompt="": input(prompt) or DEFAULT_ANSWER) + ) answer = ( ask( f"💬 {t('Delete these leftovers, or keep them?')}" - f" ({t('Enter = keep')}, d = {t('delete, after saving them')}) : " + f" ({t('Enter = delete, after saving them')}," + f" k = {t('keep')}) : " ) .strip() .lower() diff --git a/script/odoo/migration/check_stale_scss.py b/script/odoo/migration/check_stale_scss.py index 82b16fd..b5ec509 100755 --- a/script/odoo/migration/check_stale_scss.py +++ b/script/odoo/migration/check_stale_scss.py @@ -64,6 +64,12 @@ except Exception: # pragma: no cover - repli si i18n indisponible return key +try: + from script.todo import auto_ask +except Exception: # pragma: no cover - repli si le pilote est absent + auto_ask = None + + def can_ask(): """Peut-on poser une question ICI ? @@ -471,27 +477,41 @@ def render(lst_finding, database, version_dir): return "\n".join(lines) + "\n" -def prompt(lst_finding, database, config_path="./config.conf", ask=input): +def prompt(lst_finding, database, config_path="./config.conf", ask=None): """Montrer, puis proposer de corriger. Rend True si l'on a écrit. Répondre « oui, réinitialise » sans avoir vu l'écart, c'est accepter de perdre on ne sait quoi. L'invite revient donc après chaque lecture : regarder ne répond pas à la question. + + Le défaut suit ce que le checkout SAIT faire : réinitialiser quand + `reset_asset` existe, rien du tout avant la 13.0. Un défaut « a » là où + la remise à zéro n'existe pas ferait boucler l'invite sur elle-même. """ odoo_dir = running_odoo_dir() can_reset = reset_supported(odoo_dir) if not can_reset: print(too_early_message(odoo_dir, database)) + defaut = "a" if can_reset else "n" + if ask is None: + ask = ( + auto_ask.make_ask(defaut) + if auto_ask + else (lambda prompt="": input(prompt) or defaut) + ) while True: choix = ( - f" a = {t('reset them onto the module file')}" if can_reset else "" + f" {t('Enter = reset them onto the module file')}," + if can_reset + else "" ) answer = ( ask( f"💬 {t('What do you want to do with these customizations?')}" - f" ({t('Enter = nothing')}," + f" ({choix}" f" v = {t('what the copy changed')}," - f" w = {t('full screen')}{choix}) : " + f" w = {t('full screen')}," + f" n = {t('nothing')}) : " ) .strip() .lower() diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py index 3673161..dd6023e 100755 --- a/script/odoo/migration/smoke_public_url.py +++ b/script/odoo/migration/smoke_public_url.py @@ -54,6 +54,12 @@ except Exception: # pragma: no cover - repli si i18n indisponible return key +try: + from script.todo import auto_ask +except Exception: # pragma: no cover - repli si le pilote est absent + auto_ask = None + + def can_ask(): """Peut-on poser une question ICI ? @@ -367,7 +373,10 @@ def apply_reset(database, lst_key): return done.returncode, done.stdout + done.stderr -def prompt(database, lst_failure, lst_key, ask=input): +DEFAULT_ANSWER = "a" + + +def prompt(database, lst_failure, lst_key, ask=None): """Proposer de corriger, puis dire ce qu'il reste. Rend les clés traitées. Détecter sans offrir le geste, c'est laisser relever des identifiants @@ -377,6 +386,14 @@ def prompt(database, lst_failure, lst_key, ask=input): if not lst_key: print(f"ℹ -> {t('No parent view named: nothing to offer.')}") return [] + if ask is None: + # Lancé à part par la migration : sans lecteur temporisé, cette + # question arrêtait net une exécution automatique. + ask = ( + auto_ask.make_ask(DEFAULT_ANSWER) + if auto_ask + else (lambda prompt="": input(prompt) or DEFAULT_ANSWER) + ) print(f"\n✨ {t('Copies to reset onto their module view')} :") for index, key in enumerate(lst_key, start=1): print(f" [{index}] {key}") @@ -384,13 +401,15 @@ def prompt(database, lst_failure, lst_key, ask=input): answer = ( ask( f"💬 {t('Which one(s) to reset?')}" - f" ({t('numbers separated by commas, a = all, empty =')}" + f" ({t('numbers separated by commas, Enter = all, n =')}" f" {t('nothing')}) : " ) .strip() .lower() ) - if not answer: + # « n », et non plus le vide : Entrée vaut « toutes » maintenant, et une + # sortie sans mot pour dire non serait une sortie sans issue. + if not answer or answer == "n": print(f"ℹ -> {t('Kept. Nothing was reset.')}") return [] if answer == "a": diff --git a/script/todo/auto_ask.py b/script/todo/auto_ask.py new file mode 100644 index 0000000..eefee6e --- /dev/null +++ b/script/todo/auto_ask.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Lire une réponse, ou prendre le défaut au bout du délai. + +Le mode auto de la migration ne vaut que si TOUTES les invites le +connaissent. Or six d'entre elles vivent dans des outils lancés en +sous-processus — le désinstalleur de thème, le détecteur de SCSS figé, le +test de fumée. Elles ne partagent ni l'objet du pilote ni sa mémoire ; sans +rien de commun, elles attendraient indéfiniment une frappe qui ne vient +pas, et l'automatisation s'arrêterait là sans rien dire. + +Ce qui traverse un `fork`, c'est l'environnement. Le pilote y pose +`ERPLIBRE_AUTO_EXECUTE`, chaque outil le lit, et il n'existe qu'une seule +implémentation du compte à rebours. + +`select` plutôt qu'un fil ou une alarme : la question est « quelque chose +est-il LISIBLE maintenant », et il faut pouvoir renoncer. Un fil laisserait +derrière lui un `input()` toujours bloqué, qui volerait la frappe suivante. +""" + +import os +import sys + +ENV_ENABLED = "ERPLIBRE_AUTO_EXECUTE" +ENV_DELAY = "ERPLIBRE_AUTO_DELAY" +DEFAULT_DELAY = 5 + + +def enabled(): + """Le mode auto est-il actif pour CE processus ?""" + return os.environ.get(ENV_ENABLED, "") == "1" + + +def delay(): + """Combien de secondes attendre. Une valeur illisible ne bloque rien.""" + try: + value = float(os.environ.get(ENV_DELAY, DEFAULT_DELAY)) + except (TypeError, ValueError): + return DEFAULT_DELAY + # Un délai nul ou négatif rendrait la reprise en main impossible : on + # ne peut pas interrompre ce qui ne laisse aucune fenêtre. + return value if value > 0 else DEFAULT_DELAY + + +def export(on, seconds=None): + """Poser le mode auto dans l'environnement, pour tout ce qu'on lancera. + + C'est le seul canal qu'un sous-processus partage avec nous. Le retirer + quand on l'éteint, plutôt que d'y écrire « 0 », évite qu'un reste de + session précédente décide à notre place. + """ + if on: + os.environ[ENV_ENABLED] = "1" + os.environ[ENV_DELAY] = str( + seconds if seconds is not None else DEFAULT_DELAY + ) + else: + os.environ.pop(ENV_ENABLED, None) + os.environ.pop(ENV_DELAY, None) + + +def ask(prompt, default="", seconds=None): + """Poser la question. Rendre `default` si rien n'arrive à temps. + + Hors mode auto, c'est un `input()` ordinaire — sauf qu'une réponse vide + vaut le défaut, comme partout ailleurs en ligne de commande. C'est ce + qui permet d'écrire l'attendu dans la question : « (Y/n) » doit dire la + vérité pour la personne qui appuie sur Entrée, pas seulement pour le + compte à rebours. + """ + if not enabled(): + return input(prompt) or default + import select + + sys.stdout.write(prompt) + sys.stdout.flush() + try: + ready, _, _ = select.select( + [sys.stdin], [], [], delay() if seconds is None else seconds + ) + except (OSError, ValueError): + # stdin n'est pas sélectionnable : on ne devine pas, on demande. + return input("") or default + if ready: + answer = sys.stdin.readline().rstrip("\n") + # Une réponse VIDE vaut « prends le défaut » : c'est tout le propos + # du mode auto. Un stdin fermé — exécution non interactive — est + # lisible tout de suite et rend justement une ligne vide ; sans + # ceci, le défaut ne servirait jamais là. + return answer or default + print(f" ⏱ → {default or '(default)'}") + return default + + +def make_ask(default, seconds=None): + """Un `ask` à un seul argument, pour les outils qui en injectent un. + + Ils appellent `ask(question)` sans savoir ce qu'est un défaut : celui-ci + appartient à l'outil, pas à l'appelant, et se fige donc ici. + """ + + def ask_one(prompt=""): + return ask(prompt, default=default, seconds=seconds) + + return ask_one diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index d24cee4..ead6173 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5242,6 +5242,50 @@ TRANSLATIONS = { "fr": "rien n'a été modifié", "en": "nothing was changed", }, + "Enter = 1": { + "fr": "Entrée = 1", + "en": "Enter = 1", + }, + "Enter = all, n = none": { + "fr": "Entrée = tous, n = aucun", + "en": "Enter = all, n = none", + }, + "Enter = delete, after saving them": { + "fr": "Entrée = effacer, après les avoir sauvegardés", + "en": "Enter = delete, after saving them", + }, + "Enter = neutralize now, reversible": { + "fr": "Entrée = neutraliser maintenant, réversible", + "en": "Enter = neutralize now, reversible", + }, + "Enter = reset them onto the module file": { + "fr": "Entrée = les réinitialiser sur le fichier du module", + "en": "Enter = reset them onto the module file", + }, + "Enter upgrades all addons of the": { + "fr": "Entrée met à jour tous les modules de la", + "en": "Enter upgrades all addons of the", + }, + "Open it at EVERY version bump, stop asking": { + "fr": "L'ouvrir à CHAQUE palier de version, ne plus demander", + "en": "Open it at EVERY version bump, stop asking", + }, + "Testing every bump, as chosen earlier.": { + "fr": "Test à chaque palier, comme choisi plus tôt.", + "en": "Testing every bump, as chosen earlier.", + }, + "numbers separated by commas, Enter = all, n =": { + "fr": "numéros séparés par des virgules, Entrée = toutes, n =", + "en": "numbers separated by commas, Enter = all, n =", + }, + "decide at the version bump": { + "fr": "décider au palier de version", + "en": "decide at the version bump", + }, + "keep": { + "fr": "garder", + "en": "keep", + }, "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 123104e..d4e693a 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -14,7 +14,7 @@ import zipfile from uuid import uuid4 -from script.todo import todo_file_browser +from script.todo import auto_ask, todo_file_browser from script.todo.version_manager import get_odoo_version try: @@ -781,6 +781,12 @@ class TodoUpgrade: .lower() ) self.auto_execute = answer == "y" + # Dans l'ENVIRONNEMENT, car c'est le seul canal qu'un sous-processus + # partage avec nous : la moitié des invites d'une migration sont + # posées par des outils lancés à part — désinstalleur de thème, + # détecteur de SCSS figé — et sans cela elles attendraient une + # frappe qui ne vient jamais. + auto_ask.export(self.auto_execute, self.AUTO_DELAY) if self.auto_execute: print( f"⏱ {t('Auto-run on: each prompt waits')}" @@ -788,34 +794,20 @@ class TodoUpgrade: ) def ask(self, prompt, default=""): - """Lire une réponse ; en mode auto, rendre le défaut après le délai. + """Lire une réponse ; rendre le défaut si rien n'arrive. - `select` plutôt qu'un fil ou une alarme : on veut savoir si quelque - chose est LISIBLE, et rendre la main sinon. Un fil laisserait un - `input()` bloqué derrière lui, qui volerait la frappe suivante. + Le compte à rebours vit dans `auto_ask`, pas ici : les outils lancés + en sous-processus posent EUX AUSSI des questions, et une deuxième + implémentation aurait dérivé de celle-ci sans que rien ne le dise. + + L'export à chaque appel n'est pas de la précaution : l'environnement + est le SEUL canal qui traverse un `fork`. Sans lui, un outil lancé + plus bas attendrait indéfiniment une frappe qui ne vient pas. """ - if not getattr(self, "auto_execute", False): - return input(prompt) - import select + auto_ask.export(getattr(self, "auto_execute", False), self.AUTO_DELAY) + return auto_ask.ask(prompt, default=default, seconds=self.AUTO_DELAY) - sys.stdout.write(prompt) - sys.stdout.flush() - try: - ready, _, _ = select.select([sys.stdin], [], [], self.AUTO_DELAY) - except (OSError, ValueError): - # stdin n'est pas sélectionnable : on ne devine pas, on demande. - return input("") - if ready: - answer = sys.stdin.readline().rstrip("\n") - # Une réponse VIDE vaut « prends le défaut » : c'est tout le - # propos du mode auto. Un stdin fermé — exécution non - # interactive — est lisible tout de suite et rend justement - # une ligne vide ; sans ceci, le défaut ne servirait jamais là. - return answer or default - print(f" ⏱ → {default or t('(default)')}") - return default - - def ask_gate(self, prompt): + def ask_gate(self, prompt, default=""): """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 @@ -825,7 +817,7 @@ class TodoUpgrade: il rembobine l'état, l'écrit, et s'arrête en disant quoi relancer. """ while True: - answer = self.ask(prompt) + answer = self.ask(prompt, default=default) if (answer or "").strip().lower() != "b": return answer if self.rewind_to_chosen_step(): @@ -2126,7 +2118,9 @@ class TodoUpgrade: self.ask( f"💬 {t('List the missing modules to delete,')}" f" {t('separated by commas. The others will be')}" - f" {t('migrated')} : " + f" {t('migrated')}" + f" ({t('Enter = all, n = none')}) : ", + default="a", ) .strip() .lower() @@ -2134,6 +2128,11 @@ class TodoUpgrade: is_delete_all = False + # « n » saute le bloc ENTIER, ajouts par version compris : + # Entrée valant « toutes », il faut un mot pour dire non, + # et il doit vraiment ne rien supprimer. + if want_continue == "n": + want_continue = "" if want_continue: lst_want_continue = [ a.strip() for a in want_continue.split(",") @@ -2618,8 +2617,9 @@ class TodoUpgrade: self.ask( f"💬 {t('Do you want to upgrade all')}" f"{str_wait_next_version} ?" - f" {t('Press y/Y to upgrade all addons of the')}" - f" {t('database')} : " + f" (Y/n, {t('Enter upgrades all addons of the')}" + f" {t('database')}) : ", + default="y", ) .strip() .lower() @@ -2647,14 +2647,31 @@ class TodoUpgrade: self.prompt_smoke_public_url(database_name_upgrade) print(f"[y] {t('Open the server with Selenium')}") - status = ( - self.ask( - f"💬 {t('Do you want to test this upgrade? Choose')}" - f" {t('or press enter to ignore it')} : " + print(f"[a] {t('Open it at EVERY version bump, stop asking')}") + # Une migration traverse jusqu'à six paliers. Répondre « y » + # à chacun oblige à rester devant ; « a » est la réponse + # qu'on donnait de toute façon six fois de suite, dite une + # seule fois — et retenue, donc valable après une reprise. + if self.dct_progression.get("state_4_selenium_every_bump"): + status = "y" + print( + f"ℹ -> {t('Testing every bump, as chosen earlier.')}" ) - .strip() - .lower() - ) + else: + status = ( + self.ask( + f"💬 {t('Do you want to test this upgrade? Choose')}" + f" {t('or press enter to ignore it')} : " + ) + .strip() + .lower() + ) + if status == "a": + self.dct_progression["state_4_selenium_every_bump"] = ( + True + ) + self.write_config() + status = "y" "make repo_show_status" if status == "y": self.todo.prompt_execute_selenium_and_run_db( @@ -2873,7 +2890,8 @@ class TodoUpgrade: answer = ( self.ask_gate( f"💬 {t('Uninstall them properly before migrating?')}" - f" (y/N, {t('(b = go back to a previous step)')}) : " + f" (Y/n, {t('(b = go back to a previous step)')}) : ", + default="y", ) .strip() .lower() @@ -2931,13 +2949,16 @@ class TodoUpgrade: answer = ( self.ask_gate( f"💬 {t('Which one(s) to reset onto the module view?')}" - f" ({t('numbers separated by commas, a = all, empty =')}" - f" {t('nothing')}) : " + f" ({t('numbers separated by commas, Enter = all, n =')}" + f" {t('nothing')}) : ", + default="a", ) .strip() .lower() ) - if not answer: + # « n », et non plus le vide : Entrée vaut « toutes » maintenant, et + # une sortie sans mot pour dire non serait une sortie sans issue. + if not answer or answer == "n": print(f"ℹ -> {t('Kept. Nothing was reset.')}") return if answer == "a": @@ -2970,7 +2991,8 @@ class TodoUpgrade: answer = ( self.ask_gate( f"💬 {t('Clean the database before testing the pages?')}" - f" (y/N, {t('(b = go back to a previous step)')}) : " + f" (Y/n, {t('(b = go back to a previous step)')}) : ", + default="y", ) .strip() .lower() @@ -3007,7 +3029,8 @@ class TodoUpgrade: answer = ( self.ask_gate( f"💬 {t('Request every public URL of this database now?')}" - f" (y/N, {t('(b = go back to a previous step)')}) : " + f" (Y/n, {t('(b = go back to a previous step)')}) : ", + default="y", ) .strip() .lower() @@ -3071,14 +3094,18 @@ class TodoUpgrade: f" -d {database_name} -t odoo{next_version}.0" ) while True: + # `self.ask`, pas `input` : cette invite boucle, et un `input` + # nu ici arrêtait net une migration automatique — sans rien + # afficher, puisque la question attendait déjà. answer = ( - input( + self.ask( f"💬 {t('What do you want to do with these COW copies?')}" - f" ({t('Enter = decide at the version bump')}," + f" ({t('Enter = neutralize now, reversible')}," f" v = {t('what each copy holds')}," f" s = {t('why it breaks')}," f" w = {t('full screen')}," - f" a = {t('neutralize now, reversible')}) : " + f" n = {t('decide at the version bump')}) : ", + default="a", ) .strip() .lower() @@ -3131,10 +3158,11 @@ class TodoUpgrade: # rien dans l'avertissement ne permet de les distinguer. while True: answer = ( - input( + self.ask( "💬 Neutralize these copies so the upgrade can proceed?" " Their arch is kept and the change is reversible." - " (Y/n, v = view the differences, w = full screen) : " + " (Y/n, v = view the differences, w = full screen) : ", + default="y", ) .strip() .lower() @@ -3290,7 +3318,9 @@ class TodoUpgrade: ) print(f" [2] {t('Try the whole list anyway (it will fail)')}") print(f" [3] {t('Uninstall nothing, continue')}") - answer = input(f"💬 {t('Your choice')} : ").strip() + answer = self.ask( + f"💬 {t('Your choice')} ({t('Enter = 1')}) : ", default="1" + ).strip() if answer == "2": return lst_present + lst_missing if answer == "3" or not lst_present: @@ -3483,8 +3513,11 @@ class TodoUpgrade: ) print(f"[3] {t('Reset one of them onto its module view')}") print(f"[4] {t('Browse the differences full screen')}") + # `self.ask` : une migration automatique s'arrêtait ICI, + # sur une invite qui ne demande qu'à continuer, et restait + # bloquée sans que rien ne le signale. wait_status = ( - input( + self.ask( f"💬 {t('Error detected, press enter to continue or')}" f" ctrl+c {t('to stop')} : " ) diff --git a/test/test_auto_execute.py b/test/test_auto_execute.py index 13cdf17..66c6985 100755 --- a/test/test_auto_execute.py +++ b/test/test_auto_execute.py @@ -37,8 +37,36 @@ def upgrade(auto, delay=0.2): return obj -class TestTheTimedRead(unittest.TestCase): +class EnvCase(unittest.TestCase): + """Rendre l'environnement comme on l'a trouvé. + + `ask` pose le mode auto dans l'environnement — c'est ainsi qu'il atteint + les outils lancés à part. Le laisser posé le ferait fuir dans TOUTE la + suite : une invite sans rapport prendrait son défaut au lieu d'attendre, + et le test qui échouerait ne serait pas celui qui a fauté. + """ + def setUp(self): + from script.todo import auto_ask + + avant = { + key: os.environ.get(key) + for key in (auto_ask.ENV_ENABLED, auto_ask.ENV_DELAY) + } + + def remettre(): + for key, value in avant.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + self.addCleanup(remettre) + + +class TestTheTimedRead(EnvCase): + def setUp(self): + super().setUp() self.original = sys.stdin self.addCleanup(setattr, sys, "stdin", self.original) @@ -129,8 +157,9 @@ class TestTheTimedRead(unittest.TestCase): self.assertEqual(got, "demandé") -class TestTheQuestionThatEnablesIt(unittest.TestCase): +class TestTheQuestionThatEnablesIt(EnvCase): def setUp(self): + super().setUp() self.addCleanup( setattr, todo_i18n, "_current_lang", todo_i18n._current_lang ) @@ -166,6 +195,23 @@ class TestTheQuestionThatEnablesIt(unittest.TestCase): def test_the_delay_is_five_seconds(self): self.assertEqual(TodoUpgrade.AUTO_DELAY, 5) + def test_yes_also_arms_the_tools_launched_apart(self): + # La moitié des invites d'une migration sont posées par d'autres + # processus. Sans cette variable, ils attendraient une frappe qui + # ne vient jamais — et l'automatisation s'arrêterait là, en + # silence, puisque la question a bien été posée. + from script.todo import auto_ask + + self.answer("y") + self.assertEqual(os.environ.get(auto_ask.ENV_ENABLED), "1") + + def test_no_leaves_nothing_behind(self): + from script.todo import auto_ask + + os.environ[auto_ask.ENV_ENABLED] = "1" + self.answer("") + self.assertNotIn(auto_ask.ENV_ENABLED, os.environ) + class TestWhereItIsAsked(unittest.TestCase): def source(self): diff --git a/test/test_database_cleanup.py b/test/test_database_cleanup.py index 828e727..e4d8b3f 100755 --- a/test/test_database_cleanup.py +++ b/test/test_database_cleanup.py @@ -577,8 +577,8 @@ class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase): self.assertIn("run_on_terminal", source) self.assertNotIn("todo_upgrade_execute", source) - def test_the_default_cleans_nothing(self): - # Cela ÉCRIT en base : ce n'est pas à la migration de le décider. + def test_saying_no_cleans_nothing(self): + # Le défaut ne retire pas le choix : il ne fait qu'en proposer un. import contextlib import io @@ -595,11 +595,32 @@ class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase): upgrade.write_config = lambda: None lst_cmd = [] upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 - upgrade.ask_gate = lambda prompt: "" + upgrade.ask_gate = lambda prompt, default="": "n" or default with contextlib.redirect_stdout(io.StringIO()): upgrade.prompt_database_cleanup("db") self.assertEqual(lst_cmd, []) + def test_the_default_cleans(self): + # Entrée nettoie : le nettoyage précède les tests de fumée, et une + # base encombrée de tables mortes fait échouer des pages pour une + # raison qui n'a rien à voir avec la migration. + import contextlib + import io + + from script.todo.todo_upgrade import TodoUpgrade + + upgrade = TodoUpgrade.__new__(TodoUpgrade) + upgrade.dct_progression = {} + upgrade.lst_command_executed = [] + upgrade.write_config = lambda: None + lst_cmd = [] + upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 + upgrade.ask_gate = lambda prompt, default="": "" or default + with contextlib.redirect_stdout(io.StringIO()): + upgrade.prompt_database_cleanup("db") + self.assertEqual(len(lst_cmd), 1) + self.assertIn("database_cleanup.py", lst_cmd[0]) + def test_yes_runs_it_on_that_database(self): from script.todo.todo_upgrade import TodoUpgrade @@ -609,7 +630,7 @@ class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase): upgrade.write_config = lambda: None lst_cmd = [] upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 - upgrade.ask_gate = lambda prompt: "y" + upgrade.ask_gate = lambda prompt, default="": "y" or default upgrade.prompt_database_cleanup("db_upgrade_18") self.assertEqual(len(lst_cmd), 1) self.assertIn("database_cleanup.py", lst_cmd[0]) diff --git a/test/test_prompt_defaults.py b/test/test_prompt_defaults.py new file mode 100644 index 0000000..bb773ea --- /dev/null +++ b/test/test_prompt_defaults.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce que fait Entrée, et ce que fait le silence. + +Une migration pose une quarantaine de questions dont la réponse était +presque toujours la même. Le mode auto promettait de les prendre « par +défaut » — sauf que le défaut était VIDE partout : il ne faisait rien, et +la moitié des invites vivent de toute façon dans des outils lancés à part, +qui ne savaient rien du mode auto et attendaient une frappe pour toujours. + +Deux exigences, donc, et elles sont indissociables : + +- ce que fait Entrée doit être ÉCRIT dans la question. Une invite qui + annonce « y/N » et désinstalle est pire que pas d'invite du tout ; +- aucune invite du chemin de migration ne doit rester un `input()` nu. Ce + n'est pas une faute qu'on commet exprès, c'est une faute qu'on commet + par habitude — d'où le test qui refuse la construction elle-même. +""" + +import ast +import contextlib +import io +import os +import subprocess +import sys +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration")) +sys.path.insert(0, os.path.join(REPO, "script", "addons")) + +from script.todo import auto_ask # noqa: E402 +from script.todo.todo_upgrade import TodoUpgrade # noqa: E402 + + +class EnvCase(unittest.TestCase): + def setUp(self): + avant = { + key: os.environ.get(key) + for key in (auto_ask.ENV_ENABLED, auto_ask.ENV_DELAY) + } + + def remettre(): + for key, value in avant.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + self.addCleanup(remettre) + auto_ask.export(False) + + +class TestTheChannelThatCrossesAFork(EnvCase): + """L'environnement, parce que c'est le SEUL canal partagé. + + Le désinstalleur de thème, le détecteur de SCSS figé et le test de + fumée sont des processus séparés. Ils ne voient ni l'objet du pilote ni + sa mémoire ; sans une variable d'environnement, ils attendraient une + frappe qui ne vient jamais, et l'automatisation s'arrêterait là — sans + message, puisque la question, elle, a bien été posée. + """ + + def test_export_puts_it_where_a_child_will_read_it(self): + auto_ask.export(True, 3) + self.assertEqual(os.environ[auto_ask.ENV_ENABLED], "1") + self.assertEqual(os.environ[auto_ask.ENV_DELAY], "3") + self.assertTrue(auto_ask.enabled()) + + def test_turning_it_off_REMOVES_it(self): + # Y écrire « 0 » laisserait un reste de session précédente décider à + # notre place : ce qui n'existe pas ne peut pas se tromper. + auto_ask.export(True, 3) + auto_ask.export(False) + self.assertNotIn(auto_ask.ENV_ENABLED, os.environ) + self.assertFalse(auto_ask.enabled()) + + def test_a_real_child_process_takes_the_default(self): + # LE test qui compte : un vrai sous-processus, un vrai stdin fermé. + code = ( + "import sys; sys.path.insert(0, %r);" + "from script.todo import auto_ask;" + "print(auto_ask.ask('q : ', default='d'))" % REPO + ) + env = dict(os.environ) + env[auto_ask.ENV_ENABLED] = "1" + env[auto_ask.ENV_DELAY] = "1" + done = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + stdin=subprocess.DEVNULL, + timeout=30, + cwd=REPO, + env=env, + ) + self.assertEqual(done.returncode, 0, done.stderr) + self.assertIn("d", done.stdout.strip().splitlines()[-1]) + + def test_a_child_without_the_flag_still_asks(self): + # Hors mode auto, on ne décide à la place de personne : le tuyau + # fermé rend une ligne vide, et le défaut n'arrive que là. + code = ( + "import sys; sys.path.insert(0, %r);" + "from script.todo import auto_ask;" + "print('RÉPONSE=' + auto_ask.ask('q : ', default='d'))" % REPO + ) + done = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + input="k\n", + timeout=30, + cwd=REPO, + ) + self.assertIn("RÉPONSE=k", done.stdout) + + def test_a_broken_delay_does_not_freeze_anything(self): + os.environ[auto_ask.ENV_DELAY] = "plus tard" + self.assertEqual(auto_ask.delay(), auto_ask.DEFAULT_DELAY) + + def test_a_zero_delay_is_refused(self): + # Sans fenêtre, on ne peut pas reprendre la main : « auto » ne veut + # pas dire « sans recours ». + os.environ[auto_ask.ENV_DELAY] = "0" + self.assertEqual(auto_ask.delay(), auto_ask.DEFAULT_DELAY) + + +class TestEnterMeansTheDefault(EnvCase): + """Hors mode auto AUSSI : sinon la question mentirait à qui la lit.""" + + def test_an_empty_answer_takes_the_default(self): + import builtins + + original = builtins.input + builtins.input = lambda prompt="": "" + self.addCleanup(setattr, builtins, "input", original) + self.assertEqual(auto_ask.ask("q : ", default="y"), "y") + + def test_a_typed_answer_wins(self): + import builtins + + original = builtins.input + builtins.input = lambda prompt="": "n" + self.addCleanup(setattr, builtins, "input", original) + self.assertEqual(auto_ask.ask("q : ", default="y"), "n") + + def test_the_driver_exports_it_on_every_ask(self): + # Un outil lancé plus bas doit voir le mode auto même si la question + # d'activation date d'une reprise précédente. + import builtins + + original = builtins.input + builtins.input = lambda prompt="": "" + self.addCleanup(setattr, builtins, "input", original) + obj = TodoUpgrade.__new__(TodoUpgrade) + obj.auto_execute = True + obj.AUTO_DELAY = 0.2 + with contextlib.redirect_stdout(io.StringIO()): + obj.ask("q : ", default="y") + self.assertEqual(os.environ.get(auto_ask.ENV_ENABLED), "1") + + +class TestNoPromptOfTheMigrationCanHang(unittest.TestCase): + """Un `input()` nu ne sait rien du mode auto : il attend, pour toujours. + + Vécu à l'échelle du fichier : deux invites avaient échappé au premier + passage — la prédiction COW et le choix de désinstallation — parce que + le garde-fou ne regardait qu'`execute_odoo_upgrade`. On regarde + désormais TOUTES les méthodes du chemin de migration. + """ + + METHODES = ( + "execute_odoo_upgrade", + "prompt_cow_prediction", + "prompt_uninstall_theme", + "prompt_reset_stale_cow_views", + "prompt_database_cleanup", + "prompt_smoke_public_url", + "split_present_missing", + "prompt_uninstall_missing", + "todo_upgrade_execute", + ) + + def bare_inputs(self, nom): + chemin = os.path.join(REPO, "script", "todo", "todo_upgrade.py") + with open(chemin) as handle: + tree = ast.parse(handle.read()) + cible = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name == nom + ] + self.assertEqual(len(cible), 1, f"méthode introuvable : {nom}") + return [ + node.lineno + for node in ast.walk(cible[0]) + if isinstance(node, ast.Call) + and getattr(node.func, "id", "") == "input" + ] + + def test_none_of_them_asks_outside_the_timer(self): + coupables = { + nom: self.bare_inputs(nom) + for nom in self.METHODES + if self.bare_inputs(nom) + } + self.assertEqual(coupables, {}, "invites hors du mode auto") + + +class TestWhatEnterDoesIsWritten(unittest.TestCase): + """La question doit dire la vérité à qui appuie sur Entrée.""" + + def source(self, nom): + import inspect + + return inspect.getsource(getattr(TodoUpgrade, nom)) + + def test_the_theme_prompt_announces_Y(self): + source = self.source("prompt_uninstall_theme") + self.assertIn("(Y/n,", source) + self.assertIn('default="y"', source) + + def test_the_cleanup_prompt_announces_Y(self): + source = self.source("prompt_database_cleanup") + self.assertIn("(Y/n,", source) + self.assertIn('default="y"', source) + + def test_the_smoke_prompt_announces_Y(self): + source = self.source("prompt_smoke_public_url") + self.assertIn("(Y/n,", source) + self.assertIn('default="y"', source) + + def test_the_reset_prompt_says_enter_is_all(self): + source = self.source("prompt_reset_stale_cow_views") + self.assertIn("Enter = all", source) + self.assertIn('default="a"', source) + + def test_the_cow_prediction_says_enter_neutralizes(self): + source = self.source("prompt_cow_prediction") + self.assertIn("Enter = neutralize now", source) + self.assertIn('default="a"', source) + + def test_the_uninstall_strategy_defaults_to_one(self): + source = self.source("prompt_uninstall_missing") + self.assertIn("Enter = 1", source) + self.assertIn('default="1"', source) + + def test_every_default_has_a_way_to_say_no(self): + # Un défaut qui agit sans issue n'est plus un défaut, c'est un ordre. + for nom, refus in ( + ("prompt_reset_stale_cow_views", '== "n"'), + ("prompt_uninstall_missing", '== "3"'), + ("prompt_cow_prediction", "n = "), + ): + self.assertIn(refus, self.source(nom), nom) + + +class TestTheToolsLaunchedApart(unittest.TestCase): + def test_the_leftovers_default_to_deleting(self): + import theme_leftover + + self.assertEqual(theme_leftover.DEFAULT_ANSWER, "d") + + def test_the_leftovers_are_saved_BEFORE_being_deleted(self): + # C'est ce qui rend ce défaut tenable. Sans la sauvegarde, il aurait + # dû rester « garder » : on ne fait pas d'une décision irréversible + # la réponse qu'on obtient en ne répondant pas. + import inspect + + import theme_leftover + + source = inspect.getsource(theme_leftover.prompt) + self.assertLess( + source.index("backup_attachments"), + source.index("delete_attachments"), + ) + + def test_the_leftovers_prompt_says_enter_deletes(self): + import inspect + + import theme_leftover + + source = inspect.getsource(theme_leftover.prompt) + self.assertIn("Enter = delete", source) + self.assertIn("k = ", source) + + def test_the_smoke_reset_defaults_to_all(self): + import smoke_public_url + + self.assertEqual(smoke_public_url.DEFAULT_ANSWER, "a") + + def test_the_scss_default_follows_what_the_checkout_can_do(self): + # « a » là où `reset_asset` n'existe pas (avant la 13.0) ferait + # boucler l'invite sur elle-même : le défaut suit la capacité. + import inspect + + import check_stale_scss + + source = inspect.getsource(check_stale_scss.prompt) + self.assertIn('defaut = "a" if can_reset else "n"', source) + + def test_each_of_them_can_run_without_a_driver(self): + # Le repli compte : ces outils se lancent aussi à la main, et une + # ImportError les rendrait inutilisables hors migration. + import check_stale_scss + import smoke_public_url + import theme_leftover + + for module in (theme_leftover, smoke_public_url, check_stale_scss): + import inspect + + self.assertIn( + "auto_ask = None", + inspect.getsource(module), + module.__name__, + ) + + +class TestTestingEveryBump(unittest.TestCase): + """« a » dit une fois ce qu'on répétait six fois.""" + + def source(self): + import inspect + + return inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + + def test_the_option_is_offered(self): + self.assertIn("Open it at EVERY version bump", self.source()) + + def test_it_is_remembered_in_the_progression(self): + # Sinon une reprise après interruption reposerait la question, et + # l'automatisation s'arrêterait au premier palier suivant. + source = self.source() + self.assertIn("state_4_selenium_every_bump", source) + self.assertIn("self.write_config()", source) + + def test_the_memory_stops_the_question(self): + source = self.source() + debut = source.index("state_4_selenium_every_bump") + fenetre = source[debut : debut + 400] + self.assertIn('status = "y"', fenetre) + + def test_it_belongs_to_step_four(self): + # Le nom porte sa propriété : un retour avant l'étape 4 doit + # l'effacer, comme tout ce que l'étape 4 a décidé. + from script.todo.todo_upgrade import STEP_PREFIX_RE + + match = STEP_PREFIX_RE.match("state_4_selenium_every_bump") + self.assertIsNotNone(match) + self.assertEqual(match.group(1), "4") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_reset_stale_cow_prompt.py b/test/test_reset_stale_cow_prompt.py index 35550e1..8833489 100755 --- a/test/test_reset_stale_cow_prompt.py +++ b/test/test_reset_stale_cow_prompt.py @@ -45,7 +45,9 @@ class PromptCase(unittest.TestCase): upgrade.stale_cow_keys = lambda db: lst_key lst_cmd = [] upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 - upgrade.ask_gate = lambda prompt: answer + # Le doublon HONORE le défaut, comme le vrai `ask_gate` : sinon + # les tests de défaut ne testeraient que le doublon. + upgrade.ask_gate = lambda prompt, default="": answer or default out = io.StringIO() with contextlib.redirect_stdout(out): upgrade.prompt_reset_stale_cow_views("db") @@ -88,10 +90,17 @@ class TestWhatEachAnswerRuns(PromptCase): lst_cmd, _ = self.run_prompt("a") self.assertIn("--reset all", lst_cmd[0]) - def test_empty_resets_nothing(self): - # Par défaut on ne touche à rien : une copie porte parfois une vraie - # personnalisation, et la réinitialiser la jette. - lst_cmd, text = self.run_prompt("") + def test_enter_resets_them_all(self): + # Entrée les prend TOUTES : elles ne sont dans cette liste que + # parce qu'un enfant n'y trouve plus son ancrage, et l'outil sauve + # ce que chaque copie portait avant de la réinitialiser. + lst_cmd, _text = self.run_prompt("") + self.assertEqual(len(lst_cmd), 1) + self.assertIn("--reset all", lst_cmd[0]) + + def test_n_resets_nothing(self): + # Le défaut ne retire pas le choix : il ne fait qu'en proposer un. + lst_cmd, text = self.run_prompt("n") self.assertEqual(lst_cmd, []) self.assertIn("Kept", text) diff --git a/test/test_smoke_public_url.py b/test/test_smoke_public_url.py index 630d7e5..8d2c9db 100755 --- a/test/test_smoke_public_url.py +++ b/test/test_smoke_public_url.py @@ -414,14 +414,21 @@ class TestTheMigrationOffersIt(unittest.TestCase): upgrade.write_config = lambda: None lst_cmd = [] upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 - upgrade.ask_gate = lambda prompt: answer + # Le doublon HONORE le défaut, comme le vrai `ask_gate`. + upgrade.ask_gate = lambda prompt, default="": answer or default upgrade.prompt_smoke_public_url("db_upgrade_13") return lst_cmd - def test_the_default_runs_nothing(self): - # Cela démarre un serveur et peut durer : pas à chaque palier sans - # qu'on l'ait demandé. - self.assertEqual(self.run_prompt(""), []) + def test_the_default_runs_it(self): + # Une page cassée qu'on ne demande pas reste cassée. Le coût est + # quelques minutes de serveur ; le prix de l'ignorer est de + # découvrir le 500 six paliers plus loin. + lst_cmd = self.run_prompt("") + self.assertEqual(len(lst_cmd), 1) + self.assertIn("smoke_public_url.py", lst_cmd[0]) + + def test_saying_no_still_skips_it(self): + self.assertEqual(self.run_prompt("n"), []) def test_yes_runs_it_on_the_upgraded_database(self): lst_cmd = self.run_prompt("y") @@ -476,7 +483,7 @@ class TestTheMigrationOffersIt(unittest.TestCase): upgrade.lst_command_executed = [] upgrade.write_config = lambda: None upgrade.run_on_terminal = lambda cmd: 0 - upgrade.ask_gate = lambda prompt: "" + upgrade.ask_gate = lambda prompt, default="": "n" out = io.StringIO() with contextlib.redirect_stdout(out): upgrade.prompt_smoke_public_url("db", baseline=True) diff --git a/test/test_todo_upgrade_cow_prompt.py b/test/test_todo_upgrade_cow_prompt.py index 9188cb3..323986d 100644 --- a/test/test_todo_upgrade_cow_prompt.py +++ b/test/test_todo_upgrade_cow_prompt.py @@ -60,24 +60,33 @@ class PromptCase(unittest.TestCase): class TestWhatEachAnswerRuns(PromptCase): - def test_enter_runs_nothing_and_says_so(self): - lst_cmd, text = self.run_prompt([""]) + def test_enter_neutralizes_now(self): + # Entrée neutralise : c'est réversible — l'arch est conservée — et + # ce qu'on neutralise ici vaut pour TOUS les paliers, au lieu + # d'attendre des dizaines de minutes pour redécider à chaque fois. + lst_cmd, _text = self.run_prompt([""]) + self.assertEqual(len(lst_cmd), 1) + self.assertIn("--apply", lst_cmd[0]) + + def test_n_runs_nothing_and_says_so(self): + # Le défaut ne retire pas le choix : il ne fait qu'en proposer un. + lst_cmd, text = self.run_prompt(["n"]) self.assertEqual(lst_cmd, []) self.assertIn("version bump", text) def test_v_shows_what_the_copies_hold(self): - lst_cmd, _ = self.run_prompt(["v", ""]) + lst_cmd, _ = self.run_prompt(["v", "n"]) self.assertEqual(len(lst_cmd), 1) self.assertIn("cow_drift.py", lst_cmd[0]) self.assertIn("-d db -t odoo13.0", lst_cmd[0]) self.assertNotIn("--shape", lst_cmd[0]) def test_s_shows_why_it_breaks(self): - lst_cmd, _ = self.run_prompt(["s", ""]) + lst_cmd, _ = self.run_prompt(["s", "n"]) self.assertTrue(lst_cmd[0].endswith("--shape")) def test_w_opens_the_full_screen_view(self): - lst_cmd, _ = self.run_prompt(["w", ""]) + lst_cmd, _ = self.run_prompt(["w", "n"]) self.assertTrue(lst_cmd[0].endswith("--tui")) def test_looking_does_not_answer_the_question(self): diff --git a/test/test_uninstall_addons_theme.py b/test/test_uninstall_addons_theme.py index 1b17920..af5ffa3 100755 --- a/test/test_uninstall_addons_theme.py +++ b/test/test_uninstall_addons_theme.py @@ -157,7 +157,9 @@ class TestTheMigrationOffersIt(unittest.TestCase): # Le désinstalleur ne passe PLUS par l'exécuteur qui capture : il # pose une question, et un tube la rendrait invisible. obj.run_on_terminal = lambda cmd: self.lst_cmd.append(cmd) or 0 - obj.ask_gate = lambda prompt: answer + # Le doublon HONORE le défaut, comme le vrai `ask_gate` : sinon + # les tests de défaut ne testeraient que le doublon. + obj.ask_gate = lambda prompt, default="": answer or default return obj def run_prompt(self, lst_theme, answer): @@ -170,13 +172,32 @@ class TestTheMigrationOffersIt(unittest.TestCase): obj.prompt_uninstall_theme("db") return self.lst_cmd, out.getvalue() - def test_the_default_answer_uninstalls_nothing(self): - # « Entrée » ne doit RIEN faire : la migration ne décide pas de - # l'apparence d'un site à la place de son propriétaire. - lst_cmd, text = self.run_prompt(["theme_technolibre"], "") + def test_the_default_answer_uninstalls_them(self): + # « Entrée » désinstalle : c'est la réponse qu'on donnait à chaque + # migration, et un thème traversé sans être retiré est justement ce + # qui casse au palier suivant. + lst_cmd, _text = self.run_prompt(["theme_technolibre"], "") + self.assertEqual(len(lst_cmd), 1) + self.assertIn("uninstall_addons_theme.sh", lst_cmd[0]) + + def test_saying_no_still_keeps_them(self): + # Le défaut ne doit pas retirer le choix : il ne fait qu'en proposer + # un. Sans cette issue, la question ne serait plus une question. + lst_cmd, text = self.run_prompt(["theme_technolibre"], "n") self.assertEqual(lst_cmd, []) self.assertIn("Kept", text) + def test_the_question_says_that_enter_uninstalls(self): + # Une invite qui annonce « y/N » et fait l'inverse est pire que pas + # d'invite du tout. + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.prompt_uninstall_theme) + self.assertIn("(Y/n,", source) + self.assertNotIn("(y/N,", source) + def test_no_theme_means_no_question(self): lst_cmd, text = self.run_prompt([], "y") self.assertEqual(lst_cmd, [])