[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:
Mathieu Benoit 2026-08-17 02:41:40 -04:00
parent 109763ab34
commit 1c0635831f
5 changed files with 124 additions and 9 deletions

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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):

View file

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