erplibre/test/test_auto_execute.py
Mathieu Benoit cb77394869 [ADD] migration: an auto-run that takes the default after five seconds
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
2026-08-22 07:23:59 -04:00

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()