A migration asks dozens of questions whose answer is almost always the one offered, and someone has to stand there pressing Enter for hours. Asked before the version choice — so it covers that choice too, now that it has a default — and off by default: it decides in your place. Every prompt of the migration goes through one reader, seventeen of them. A prompt left as a bare input() would know nothing of auto mode and would block the run with nothing to say why; a test reads the function and rejects any that appears. click.prompt had to go: it cannot hand back control after a delay, so auto would have stopped at the first question. select() rather than a thread or an alarm — a thread would leave a blocked input() behind it, stealing the next keystroke. --- FR --- [ADD] migration : une auto-exécution qui prend le défaut après cinq secondes Une migration pose des dizaines de questions dont la réponse est presque toujours celle proposée, et il faut rester devant à taper Entrée pendant des heures. Posée avant le choix de version — donc elle vaut aussi pour lui, maintenant qu'il a un défaut — et éteinte par défaut : elle décide à votre place. Toutes les invites de la migration passent par une seule lecture, dix-sept d'un coup. Une invite laissée en input() nu ne saurait rien du mode auto et bloquerait la migration sans rien dire ; un test lit la fonction et refuse toute apparition. click.prompt devait partir : il ne sait pas rendre la main après un délai, l'auto se serait arrêté à la première question. select() plutôt qu'un fil ou une alarme — un fil laisserait un input() bloqué derrière lui, qui volerait la frappe suivante. Assisted-by: Claude Opus 5
225 lines
7.5 KiB
Python
Executable file
225 lines
7.5 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
"""Enchaîner la migration sans rester devant à taper Entrée.
|
|
|
|
Une migration pose des dizaines de questions dont la réponse est presque
|
|
toujours celle proposée. Le mode auto attend cinq secondes puis prend le
|
|
défaut — assez pour reprendre la main, assez peu pour ne pas immobiliser
|
|
quelqu'un des heures.
|
|
|
|
La question est posée AVANT le choix de version, donc avant la première
|
|
décision : activée, elle vaut pour toutes les suivantes, celle-là comprise.
|
|
|
|
Le test qui compte le plus est le dernier : une invite écrite plus tard en
|
|
`input()` nu ne saurait rien du mode auto, et bloquerait la migration sans
|
|
que rien ne le signale.
|
|
"""
|
|
|
|
import ast
|
|
import inspect
|
|
import io
|
|
import os
|
|
import sys
|
|
import unittest
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
from script.todo import todo_i18n # noqa: E402
|
|
from script.todo.todo_upgrade import TodoUpgrade # noqa: E402
|
|
|
|
|
|
def upgrade(auto, delay=0.2):
|
|
obj = TodoUpgrade.__new__(TodoUpgrade)
|
|
obj.auto_execute = auto
|
|
obj.AUTO_DELAY = delay
|
|
return obj
|
|
|
|
|
|
class TestTheTimedRead(unittest.TestCase):
|
|
def setUp(self):
|
|
self.original = sys.stdin
|
|
self.addCleanup(setattr, sys, "stdin", self.original)
|
|
|
|
def pipe_stdin(self, payload=b""):
|
|
"""Un vrai descripteur : `select` ne sait pas lire un faux objet."""
|
|
read_fd, write_fd = os.pipe()
|
|
if payload:
|
|
os.write(write_fd, payload)
|
|
os.close(write_fd)
|
|
handle = os.fdopen(read_fd)
|
|
self.addCleanup(handle.close)
|
|
sys.stdin = handle
|
|
|
|
def test_without_auto_it_just_asks(self):
|
|
import builtins
|
|
|
|
original = builtins.input
|
|
builtins.input = lambda prompt="": "tapé"
|
|
self.addCleanup(setattr, builtins, "input", original)
|
|
self.assertEqual(upgrade(False).ask("q : ", default="6"), "tapé")
|
|
|
|
def test_with_auto_an_answer_wins(self):
|
|
self.pipe_stdin(b"2\n")
|
|
out = io.StringIO()
|
|
real = sys.stdout
|
|
sys.stdout = out
|
|
try:
|
|
got = upgrade(True).ask("q : ", default="6")
|
|
finally:
|
|
sys.stdout = real
|
|
self.assertEqual(got, "2")
|
|
|
|
def test_a_closed_stdin_gives_the_default(self):
|
|
# Exécution non interactive : le tuyau fermé est lisible TOUT DE
|
|
# SUITE et rend une ligne vide. Sans traiter le vide comme le
|
|
# défaut, le mode auto n'y servirait à rien.
|
|
self.pipe_stdin()
|
|
out = io.StringIO()
|
|
real = sys.stdout
|
|
sys.stdout = out
|
|
try:
|
|
got = upgrade(True).ask("q : ", default="6")
|
|
finally:
|
|
sys.stdout = real
|
|
self.assertEqual(got, "6")
|
|
|
|
def test_silence_gives_the_default_after_the_delay(self):
|
|
# Le tuyau reste OUVERT : rien à lire, et select doit rendre la main
|
|
# au bout du délai plutôt que d'attendre indéfiniment.
|
|
import time
|
|
|
|
read_fd, write_fd = os.pipe()
|
|
self.addCleanup(os.close, write_fd)
|
|
handle = os.fdopen(read_fd)
|
|
self.addCleanup(handle.close)
|
|
sys.stdin = handle
|
|
out = io.StringIO()
|
|
real = sys.stdout
|
|
sys.stdout = out
|
|
debut = time.time()
|
|
try:
|
|
got = upgrade(True, delay=0.3).ask("q : ", default="6")
|
|
finally:
|
|
sys.stdout = real
|
|
self.assertEqual(got, "6")
|
|
self.assertGreaterEqual(time.time() - debut, 0.25)
|
|
|
|
def test_an_unselectable_stdin_falls_back_to_asking(self):
|
|
# Ne pas deviner quand on ne peut pas mesurer : mieux vaut demander
|
|
# que rendre un défaut que personne n'a voulu.
|
|
import builtins
|
|
|
|
class NotSelectable:
|
|
def fileno(self):
|
|
raise ValueError("pas de descripteur")
|
|
|
|
sys.stdin = NotSelectable()
|
|
original = builtins.input
|
|
builtins.input = lambda prompt="": "demandé"
|
|
self.addCleanup(setattr, builtins, "input", original)
|
|
out = io.StringIO()
|
|
real = sys.stdout
|
|
sys.stdout = out
|
|
try:
|
|
got = upgrade(True).ask("q : ", default="6")
|
|
finally:
|
|
sys.stdout = real
|
|
self.assertEqual(got, "demandé")
|
|
|
|
|
|
class TestTheQuestionThatEnablesIt(unittest.TestCase):
|
|
def setUp(self):
|
|
self.addCleanup(
|
|
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
|
)
|
|
todo_i18n._current_lang = "en"
|
|
|
|
def answer(self, typed):
|
|
import builtins
|
|
import contextlib
|
|
|
|
obj = TodoUpgrade.__new__(TodoUpgrade)
|
|
original = builtins.input
|
|
builtins.input = lambda prompt="": typed
|
|
self.addCleanup(setattr, builtins, "input", original)
|
|
out = io.StringIO()
|
|
with contextlib.redirect_stdout(out):
|
|
obj.prompt_auto_execute()
|
|
return obj.auto_execute, out.getvalue()
|
|
|
|
def test_yes_turns_it_on(self):
|
|
on, text = self.answer("y")
|
|
self.assertTrue(on)
|
|
self.assertIn("Auto-run on", text)
|
|
|
|
def test_the_default_is_off(self):
|
|
# Elle prend des décisions à votre place : ce n'est pas le défaut.
|
|
on, text = self.answer("")
|
|
self.assertFalse(on)
|
|
self.assertEqual(text, "")
|
|
|
|
def test_anything_else_is_off(self):
|
|
self.assertFalse(self.answer("peut-être")[0])
|
|
|
|
def test_the_delay_is_five_seconds(self):
|
|
self.assertEqual(TodoUpgrade.AUTO_DELAY, 5)
|
|
|
|
|
|
class TestWhereItIsAsked(unittest.TestCase):
|
|
def source(self):
|
|
return inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
|
|
|
|
def test_it_comes_before_the_version_choice(self):
|
|
# « avant de choisir la version d'Odoo » : activée, elle vaut aussi
|
|
# pour ce choix-là, qui a désormais un défaut.
|
|
source = self.source()
|
|
self.assertLess(
|
|
source.index("prompt_auto_execute"),
|
|
source.index("Which version do you want to upgrade to?"),
|
|
)
|
|
|
|
def test_the_version_choice_goes_through_it(self):
|
|
# click.prompt ne sait pas rendre la main après un délai : le laisser
|
|
# là aurait fait un mode auto qui s'arrête à la première question.
|
|
source = self.source()
|
|
self.assertNotIn("click.prompt(", source)
|
|
|
|
|
|
class TestNoPromptEscapesIt(unittest.TestCase):
|
|
"""Une invite en `input()` nu ne saurait rien du mode auto.
|
|
|
|
Elle bloquerait la migration sans rien signaler — et c'est précisément
|
|
le genre de chose qu'on n'ajoute pas exprès, mais par habitude.
|
|
"""
|
|
|
|
def test_every_prompt_of_the_migration_uses_ask(self):
|
|
path = os.path.join(REPO, "script", "todo", "todo_upgrade.py")
|
|
with open(path) as handle:
|
|
tree = ast.parse(handle.read())
|
|
target = [
|
|
node
|
|
for node in ast.walk(tree)
|
|
if isinstance(node, ast.FunctionDef)
|
|
and node.name == "execute_odoo_upgrade"
|
|
]
|
|
self.assertEqual(len(target), 1, "fonction introuvable")
|
|
bare = [
|
|
node.lineno
|
|
for node in ast.walk(target[0])
|
|
if isinstance(node, ast.Call)
|
|
and getattr(node.func, "id", "") == "input"
|
|
]
|
|
self.assertEqual(bare, [], "invites hors du mode auto")
|
|
|
|
def test_the_gate_uses_it_too(self):
|
|
# `ask_gate` porte le retour en arrière : la laisser en input() nu
|
|
# aurait fait un mode auto qui s'arrête à chaque « b = revenir ».
|
|
source = inspect.getsource(TodoUpgrade.ask_gate)
|
|
self.assertIn("self.ask(", source)
|
|
self.assertNotIn("= input(", source)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|