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é »."""