From 1c0635831fcddd81c5711bee40fe3f7ce681dc3c Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 17 Aug 2026 02:41:40 -0400 Subject: [PATCH] [FIX] migration: never ask a question the terminal cannot show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The theme uninstaller ends by asking what to do with the leftovers, and the migration ran it through the capturing executor. Its stdout was a pipe, Python buffers by blocks, so the prompt stayed invisible while the process waited. It reads as a freeze: you press Enter blind, the first keystroke answers unseen and the extra ones fall into the next question. Two locks, because one is not enough. The migration runs the script on the real terminal. And the three tools that ask now require BOTH ends: something to read the answer from, and something to show the question on. Guarding on stdin alone was the bug — measured, stdin tty=True with stdout tty=False, and the question was asked into a pipe. --- FR --- [FIX] migration : ne jamais poser une question que le terminal ne montre pas Le désinstalleur de thème finit par demander quoi faire des restes, et la migration le lançait par l'exécuteur qui capture. Sa sortie partait dans un tube, Python bufferise par blocs, et l'invite restait invisible pendant l'attente. Cela se lit comme un blocage : on tape Entrée à l'aveugle, la première frappe répond sans être vue et les suivantes tombent dans la question d'après. Deux verrous, car un seul ne suffit pas. La migration lance le script sur le vrai terminal. Et les trois outils qui questionnent exigent désormais les DEUX bouts : de quoi lire la réponse, et de quoi montrer la question. Ne garder que stdin était le défaut — mesuré, stdin tty=True et stdout tty=False, la question partait dans un tube. Assisted-by: Claude Opus 5 --- script/addons/theme_leftover.py | 17 +++++- script/odoo/migration/check_stale_scss.py | 17 ++++-- script/odoo/migration/smoke_public_url.py | 14 ++++- script/todo/todo_upgrade.py | 11 +++- test/test_uninstall_addons_theme.py | 74 +++++++++++++++++++++++ 5 files changed, 124 insertions(+), 9 deletions(-) diff --git a/script/addons/theme_leftover.py b/script/addons/theme_leftover.py index 80e70c2..9e2951b 100755 --- a/script/addons/theme_leftover.py +++ b/script/addons/theme_leftover.py @@ -37,6 +37,18 @@ except Exception: # pragma: no cover - repli si i18n indisponible return key +def can_ask(): + """Peut-on poser une question ICI ? + + Il faut deux choses, pas une : de quoi LIRE la réponse (stdin sur un + terminal) et de quoi MONTRER la question (stdout aussi). Ne tester que + stdin laisse poser une invite qui part dans un tube : elle reste en + tampon, invisible, pendant que le processus attend — on croit à un + blocage et l'on tape Entrée à l'aveugle. + """ + return sys.stdin.isatty() and sys.stdout.isatty() + + def run_psql(database, sql): """Interroger la base en lecture seule, garantie côté serveur. @@ -256,7 +268,10 @@ def main(argv=None): return 2 print(f"✅ -> {len(attachments)} {t('attachment(s) deleted.')}") return 0 - if not config.report_only and sys.stdin.isatty(): + # Voir ET pouvoir répondre : une invite dont la sortie part dans un + # tube reste invisible — Python bufferise par blocs — pendant que le + # processus attend. Mesuré : l'utilisateur tape Entrée à l'aveugle. + if not config.report_only and can_ask(): if prompt( config.database, config.theme, diff --git a/script/odoo/migration/check_stale_scss.py b/script/odoo/migration/check_stale_scss.py index 43f8496..82b16fd 100755 --- a/script/odoo/migration/check_stale_scss.py +++ b/script/odoo/migration/check_stale_scss.py @@ -64,6 +64,18 @@ except Exception: # pragma: no cover - repli si i18n indisponible return key +def can_ask(): + """Peut-on poser une question ICI ? + + Il faut deux choses, pas une : de quoi LIRE la réponse (stdin sur un + terminal) et de quoi MONTRER la question (stdout aussi). Ne tester que + stdin laisse poser une invite qui part dans un tube : elle reste en + tampon, invisible, pendant que le processus attend — on croit à un + blocage et l'on tape Entrée à l'aveugle. + """ + return sys.stdin.isatty() and sys.stdout.isatty() + + # Une variable SCSS commence par $ ; celles dont le nom commence par un tiret # sont locales au fichier par convention Odoo ($-seen, $-font-numbers) et se # définissent toujours au-dessus de leur usage. @@ -625,10 +637,7 @@ def main(argv=None): # laisser retrouver soi-même les deux arguments de reset_asset. Mais # seulement devant un terminal — dans un tube, une invite bloquerait # l'appelant sans que personne ne voie la question. - if ( - not (config.diff or config.tui or config.report_only) - and sys.stdin.isatty() - ): + if not (config.diff or config.tui or config.report_only) and can_ask(): if prompt(lst_finding, config.database, config.config): return 0 return 1 diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py index 624142e..527a420 100755 --- a/script/odoo/migration/smoke_public_url.py +++ b/script/odoo/migration/smoke_public_url.py @@ -54,6 +54,18 @@ except Exception: # pragma: no cover - repli si i18n indisponible return key +def can_ask(): + """Peut-on poser une question ICI ? + + Il faut deux choses, pas une : de quoi LIRE la réponse (stdin sur un + terminal) et de quoi MONTRER la question (stdout aussi). Ne tester que + stdin laisse poser une invite qui part dans un tube : elle reste en + tampon, invisible, pendant que le processus attend — on croit à un + blocage et l'on tape Entrée à l'aveugle. + """ + return sys.stdin.isatty() and sys.stdout.isatty() + + RE_LOC = re.compile(r"\s*([^<\s]+)\s*", re.I) # « [view_id: 3282, xml_id: n/a, model: n/a, parent_id: 3281] ». Seule la @@ -515,7 +527,7 @@ def main(argv=None): f"⧖ {t('Starting Odoo on')} '{config.database}'" f" ({t('port')} {config.port})…" ) - interactive = not config.report_only and sys.stdin.isatty() + interactive = not config.report_only and can_ask() try: lst_url, lst_failure, lst_key, lst_again = run( config.database, diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 232dbf9..44c44d9 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -2833,10 +2833,15 @@ class TodoUpgrade: print(f"ℹ -> {t('Kept. Nothing was uninstalled.')}") return for theme in lst_theme: - self.todo_upgrade_execute( + # Sur le VRAI terminal : ce script finit par theme_leftover.py, + # qui pose une question. L'exécuteur capture la sortie par un + # tube — Python la met alors en tampon par blocs — et l'invite + # restait invisible pendant que le processus attendait une + # réponse. On tape Entrée à l'aveugle, plusieurs fois, et les + # frappes en trop vont à la question suivante. + self.run_on_terminal( f"./script/addons/uninstall_addons_theme.sh" - f" {database_name} {theme}", - wait_at_error=False, + f" {database_name} {theme}" ) def stale_cow_keys(self, database_name): diff --git a/test/test_uninstall_addons_theme.py b/test/test_uninstall_addons_theme.py index f8fd8c8..38b3298 100755 --- a/test/test_uninstall_addons_theme.py +++ b/test/test_uninstall_addons_theme.py @@ -154,6 +154,9 @@ class TestTheMigrationOffersIt(unittest.TestCase): self.lst_cmd.append(cmd), (False, cmd, []), )[1] + # 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 return obj @@ -302,6 +305,77 @@ class TestKeepOrDeleteTheLeftovers(unittest.TestCase): self.assertIn("sys.stdin.isatty()", handle.read()) +class TestTheQuestionMustBeVisible(unittest.TestCase): + """Une invite qu'on ne voit pas est une invite à laquelle on répond mal. + + Le script se termine par theme_leftover.py, qui pose une question. Lancé + par l'exécuteur qui CAPTURE la sortie, son stdout est un tube : Python + bufferise par blocs et l'invite reste invisible pendant que le processus + attend. Vécu — on croit à un blocage, on tape Entrée plusieurs fois, la + première frappe répond à l'aveugle et les suivantes vont à la question + d'après. + + Deux verrous, car un seul ne suffit pas : la migration lance ce script + sur le vrai terminal, ET l'outil refuse de questionner s'il ne peut pas + se faire voir. + """ + + def test_the_migration_runs_it_on_the_real_terminal(self): + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.prompt_uninstall_theme) + self.assertIn("run_on_terminal", source) + self.assertNotIn("todo_upgrade_execute", source) + + def test_asking_needs_stdout_not_just_stdin(self): + # LE défaut : ne tester que stdin laisse poser une question dans un + # tube. Il faut de quoi LIRE la réponse ET MONTRER la question. + import io + import sys + + class Fake(io.StringIO): + def __init__(self, tty): + super().__init__() + self.tty = tty + + def isatty(self): + return self.tty + + real_in, real_out = sys.stdin, sys.stdout + self.addCleanup(setattr, sys, "stdin", real_in) + self.addCleanup(setattr, sys, "stdout", real_out) + for stdin_tty, stdout_tty, expected in ( + (True, True, True), + (True, False, False), # le cas mesuré + (False, True, False), + (False, False, False), + ): + sys.stdin, sys.stdout = Fake(stdin_tty), Fake(stdout_tty) + got = theme_leftover.can_ask() + sys.stdin, sys.stdout = real_in, real_out + with self.subTest(stdin=stdin_tty, stdout=stdout_tty): + self.assertEqual(got, expected) + + def test_the_other_tools_guard_the_same_way(self): + # Trois outils posent des questions ; un seul corrigé laisserait le + # même piège ailleurs. + import os + + REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + for relative in ( + "script/odoo/migration/smoke_public_url.py", + "script/odoo/migration/check_stale_scss.py", + "script/addons/theme_leftover.py", + ): + with open(os.path.join(REPO_ROOT, relative)) as handle: + source = handle.read() + with self.subTest(tool=relative): + self.assertIn("def can_ask()", source) + self.assertIn("sys.stdout.isatty()", source) + + class TestTheMisleadingErrorCode(unittest.TestCase): """« 1 » veut dire « il reste des choses », pas « ça a raté »."""