[FIX] migration: never ask a question the terminal cannot show
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
This commit is contained in:
parent
109763ab34
commit
1c0635831f
5 changed files with 124 additions and 9 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"<loc>\s*([^<\s]+)\s*</loc>", 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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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é »."""
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue