[ADD] migration: make Enter mean the answer you always give

Auto-run promised to "take the default after five seconds", but every
default was EMPTY: it took nothing. Worse, half the prompts of a
migration are asked by separate processes — the theme uninstaller, the
stale-SCSS detector, the smoke test. They knew nothing of auto-run and
waited forever for a keystroke that never came.

The countdown now lives in one file and travels through the ENVIRONMENT,
the only channel a fork shares. Enter takes the default everywhere, not
only under auto-run: a prompt that prints (Y/n) and does otherwise is
worse than no prompt. Every text was rewritten to say what Enter does,
and every default kept an explicit way out.

Two defaults now write. Both are tenable only because the backup runs
first, and a test locks that ORDER rather than trusting a promise.

--- FR ---

[ADD] migration : faire d'Entrée la réponse qu'on donne toujours

L'auto-exécution promettait « le défaut après cinq secondes », mais tous
les défauts étaient VIDES : elle ne prenait rien. Pire, la moitié des
invites d'une migration sont posées par d'autres processus — le
désinstalleur de thème, le détecteur de SCSS figé, le test de fumée. Ils
ignoraient l'auto-exécution et attendaient sans fin une frappe.

Le compte à rebours tient désormais dans un seul fichier et voyage par
l'ENVIRONNEMENT, le seul canal qu'un fork partage. Entrée vaut le défaut
partout, pas seulement en auto : une invite qui affiche (Y/n) et fait
l'inverse est pire que pas d'invite. Chaque texte dit ce que fait Entrée,
et chaque défaut garde une issue explicite.

Deux défauts écrivent. Ils ne tiennent que parce que la sauvegarde passe
d'abord, et un test verrouille cet ORDRE plutôt qu'une promesse.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-17 20:47:17 -04:00
parent e52d36277b
commit 95a6ba558e
13 changed files with 804 additions and 87 deletions

View file

@ -37,6 +37,12 @@ except Exception: # pragma: no cover - repli si i18n indisponible
return key
try:
from script.todo import auto_ask
except Exception: # pragma: no cover - repli si le pilote est absent
auto_ask = None
def can_ask():
"""Peut-on poser une question ICI ?
@ -199,14 +205,32 @@ def delete_attachments(database, lst_row, config_path="./config.conf"):
return done.returncode, done.stdout + done.stderr
def prompt(database, theme, attachments, views, config_path, ask=input):
"""Garder ou effacer. « Garder » par défaut, et la sauvegarde d'abord."""
DEFAULT_ANSWER = "d"
def prompt(database, theme, attachments, views, config_path, ask=None):
"""Effacer ou garder. Effacer par défaut, et la sauvegarde D'ABORD.
Ce qui rend ce défaut tenable, c'est l'ordre : le contenu part dans un
fichier avant que la base ne soit touchée. Sans cette sauvegarde, le
défaut aurait dû rester « garder » — on ne fait pas d'une décision
irréversible la réponse que l'on obtient en ne répondant pas.
"""
if not attachments:
return False
if ask is None:
# Lancé à part par la migration : sans lecteur temporisé, cette
# question arrêtait net une exécution automatique.
ask = (
auto_ask.make_ask(DEFAULT_ANSWER)
if auto_ask
else (lambda prompt="": input(prompt) or DEFAULT_ANSWER)
)
answer = (
ask(
f"💬 {t('Delete these leftovers, or keep them?')}"
f" ({t('Enter = keep')}, d = {t('delete, after saving them')}) : "
f" ({t('Enter = delete, after saving them')},"
f" k = {t('keep')}) : "
)
.strip()
.lower()

View file

@ -64,6 +64,12 @@ except Exception: # pragma: no cover - repli si i18n indisponible
return key
try:
from script.todo import auto_ask
except Exception: # pragma: no cover - repli si le pilote est absent
auto_ask = None
def can_ask():
"""Peut-on poser une question ICI ?
@ -471,27 +477,41 @@ def render(lst_finding, database, version_dir):
return "\n".join(lines) + "\n"
def prompt(lst_finding, database, config_path="./config.conf", ask=input):
def prompt(lst_finding, database, config_path="./config.conf", ask=None):
"""Montrer, puis proposer de corriger. Rend True si l'on a écrit.
Répondre « oui, réinitialise » sans avoir vu l'écart, c'est accepter de
perdre on ne sait quoi. L'invite revient donc après chaque lecture :
regarder ne répond pas à la question.
Le défaut suit ce que le checkout SAIT faire : réinitialiser quand
`reset_asset` existe, rien du tout avant la 13.0. Un défaut « a » là où
la remise à zéro n'existe pas ferait boucler l'invite sur elle-même.
"""
odoo_dir = running_odoo_dir()
can_reset = reset_supported(odoo_dir)
if not can_reset:
print(too_early_message(odoo_dir, database))
defaut = "a" if can_reset else "n"
if ask is None:
ask = (
auto_ask.make_ask(defaut)
if auto_ask
else (lambda prompt="": input(prompt) or defaut)
)
while True:
choix = (
f" a = {t('reset them onto the module file')}" if can_reset else ""
f" {t('Enter = reset them onto the module file')},"
if can_reset
else ""
)
answer = (
ask(
f"💬 {t('What do you want to do with these customizations?')}"
f" ({t('Enter = nothing')},"
f" ({choix}"
f" v = {t('what the copy changed')},"
f" w = {t('full screen')}{choix}) : "
f" w = {t('full screen')},"
f" n = {t('nothing')}) : "
)
.strip()
.lower()

View file

@ -54,6 +54,12 @@ except Exception: # pragma: no cover - repli si i18n indisponible
return key
try:
from script.todo import auto_ask
except Exception: # pragma: no cover - repli si le pilote est absent
auto_ask = None
def can_ask():
"""Peut-on poser une question ICI ?
@ -367,7 +373,10 @@ def apply_reset(database, lst_key):
return done.returncode, done.stdout + done.stderr
def prompt(database, lst_failure, lst_key, ask=input):
DEFAULT_ANSWER = "a"
def prompt(database, lst_failure, lst_key, ask=None):
"""Proposer de corriger, puis dire ce qu'il reste. Rend les clés traitées.
Détecter sans offrir le geste, c'est laisser relever des identifiants
@ -377,6 +386,14 @@ def prompt(database, lst_failure, lst_key, ask=input):
if not lst_key:
print(f"ℹ -> {t('No parent view named: nothing to offer.')}")
return []
if ask is None:
# Lancé à part par la migration : sans lecteur temporisé, cette
# question arrêtait net une exécution automatique.
ask = (
auto_ask.make_ask(DEFAULT_ANSWER)
if auto_ask
else (lambda prompt="": input(prompt) or DEFAULT_ANSWER)
)
print(f"\n✨ {t('Copies to reset onto their module view')} :")
for index, key in enumerate(lst_key, start=1):
print(f" [{index}] {key}")
@ -384,13 +401,15 @@ def prompt(database, lst_failure, lst_key, ask=input):
answer = (
ask(
f"💬 {t('Which one(s) to reset?')}"
f" ({t('numbers separated by commas, a = all, empty =')}"
f" ({t('numbers separated by commas, Enter = all, n =')}"
f" {t('nothing')}) : "
)
.strip()
.lower()
)
if not answer:
# « n », et non plus le vide : Entrée vaut « toutes » maintenant, et une
# sortie sans mot pour dire non serait une sortie sans issue.
if not answer or answer == "n":
print(f"ℹ -> {t('Kept. Nothing was reset.')}")
return []
if answer == "a":

107
script/todo/auto_ask.py Normal file
View file

@ -0,0 +1,107 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Lire une réponse, ou prendre le défaut au bout du délai.
Le mode auto de la migration ne vaut que si TOUTES les invites le
connaissent. Or six d'entre elles vivent dans des outils lancés en
sous-processus — le désinstalleur de thème, le détecteur de SCSS figé, le
test de fumée. Elles ne partagent ni l'objet du pilote ni sa mémoire ; sans
rien de commun, elles attendraient indéfiniment une frappe qui ne vient
pas, et l'automatisation s'arrêterait là sans rien dire.
Ce qui traverse un `fork`, c'est l'environnement. Le pilote y pose
`ERPLIBRE_AUTO_EXECUTE`, chaque outil le lit, et il n'existe qu'une seule
implémentation du compte à rebours.
`select` plutôt qu'un fil ou une alarme : la question est « quelque chose
est-il LISIBLE maintenant », et il faut pouvoir renoncer. Un fil laisserait
derrière lui un `input()` toujours bloqué, qui volerait la frappe suivante.
"""
import os
import sys
ENV_ENABLED = "ERPLIBRE_AUTO_EXECUTE"
ENV_DELAY = "ERPLIBRE_AUTO_DELAY"
DEFAULT_DELAY = 5
def enabled():
"""Le mode auto est-il actif pour CE processus ?"""
return os.environ.get(ENV_ENABLED, "") == "1"
def delay():
"""Combien de secondes attendre. Une valeur illisible ne bloque rien."""
try:
value = float(os.environ.get(ENV_DELAY, DEFAULT_DELAY))
except (TypeError, ValueError):
return DEFAULT_DELAY
# Un délai nul ou négatif rendrait la reprise en main impossible : on
# ne peut pas interrompre ce qui ne laisse aucune fenêtre.
return value if value > 0 else DEFAULT_DELAY
def export(on, seconds=None):
"""Poser le mode auto dans l'environnement, pour tout ce qu'on lancera.
C'est le seul canal qu'un sous-processus partage avec nous. Le retirer
quand on l'éteint, plutôt que d'y écrire « 0 », évite qu'un reste de
session précédente décide à notre place.
"""
if on:
os.environ[ENV_ENABLED] = "1"
os.environ[ENV_DELAY] = str(
seconds if seconds is not None else DEFAULT_DELAY
)
else:
os.environ.pop(ENV_ENABLED, None)
os.environ.pop(ENV_DELAY, None)
def ask(prompt, default="", seconds=None):
"""Poser la question. Rendre `default` si rien n'arrive à temps.
Hors mode auto, c'est un `input()` ordinaire — sauf qu'une réponse vide
vaut le défaut, comme partout ailleurs en ligne de commande. C'est ce
qui permet d'écrire l'attendu dans la question : « (Y/n) » doit dire la
vérité pour la personne qui appuie sur Entrée, pas seulement pour le
compte à rebours.
"""
if not enabled():
return input(prompt) or default
import select
sys.stdout.write(prompt)
sys.stdout.flush()
try:
ready, _, _ = select.select(
[sys.stdin], [], [], delay() if seconds is None else seconds
)
except (OSError, ValueError):
# stdin n'est pas sélectionnable : on ne devine pas, on demande.
return input("") or default
if ready:
answer = sys.stdin.readline().rstrip("\n")
# Une réponse VIDE vaut « prends le défaut » : c'est tout le propos
# du mode auto. Un stdin fermé — exécution non interactive — est
# lisible tout de suite et rend justement une ligne vide ; sans
# ceci, le défaut ne servirait jamais là.
return answer or default
print(f" ⏱ → {default or '(default)'}")
return default
def make_ask(default, seconds=None):
"""Un `ask` à un seul argument, pour les outils qui en injectent un.
Ils appellent `ask(question)` sans savoir ce qu'est un défaut : celui-ci
appartient à l'outil, pas à l'appelant, et se fige donc ici.
"""
def ask_one(prompt=""):
return ask(prompt, default=default, seconds=seconds)
return ask_one

View file

@ -5242,6 +5242,50 @@ TRANSLATIONS = {
"fr": "rien n'a été modifié",
"en": "nothing was changed",
},
"Enter = 1": {
"fr": "Entrée = 1",
"en": "Enter = 1",
},
"Enter = all, n = none": {
"fr": "Entrée = tous, n = aucun",
"en": "Enter = all, n = none",
},
"Enter = delete, after saving them": {
"fr": "Entrée = effacer, après les avoir sauvegardés",
"en": "Enter = delete, after saving them",
},
"Enter = neutralize now, reversible": {
"fr": "Entrée = neutraliser maintenant, réversible",
"en": "Enter = neutralize now, reversible",
},
"Enter = reset them onto the module file": {
"fr": "Entrée = les réinitialiser sur le fichier du module",
"en": "Enter = reset them onto the module file",
},
"Enter upgrades all addons of the": {
"fr": "Entrée met à jour tous les modules de la",
"en": "Enter upgrades all addons of the",
},
"Open it at EVERY version bump, stop asking": {
"fr": "L'ouvrir à CHAQUE palier de version, ne plus demander",
"en": "Open it at EVERY version bump, stop asking",
},
"Testing every bump, as chosen earlier.": {
"fr": "Test à chaque palier, comme choisi plus tôt.",
"en": "Testing every bump, as chosen earlier.",
},
"numbers separated by commas, Enter = all, n =": {
"fr": "numéros séparés par des virgules, Entrée = toutes, n =",
"en": "numbers separated by commas, Enter = all, n =",
},
"decide at the version bump": {
"fr": "décider au palier de version",
"en": "decide at the version bump",
},
"keep": {
"fr": "garder",
"en": "keep",
},
"Clean the database before testing the pages?": {
"fr": "Nettoyer la base avant de tester les pages ?",
"en": "Clean the database before testing the pages?",

View file

@ -14,7 +14,7 @@ import zipfile
from uuid import uuid4
from script.todo import todo_file_browser
from script.todo import auto_ask, todo_file_browser
from script.todo.version_manager import get_odoo_version
try:
@ -781,6 +781,12 @@ class TodoUpgrade:
.lower()
)
self.auto_execute = answer == "y"
# Dans l'ENVIRONNEMENT, car c'est le seul canal qu'un sous-processus
# partage avec nous : la moitié des invites d'une migration sont
# posées par des outils lancés à part — désinstalleur de thème,
# détecteur de SCSS figé — et sans cela elles attendraient une
# frappe qui ne vient jamais.
auto_ask.export(self.auto_execute, self.AUTO_DELAY)
if self.auto_execute:
print(
f"⏱ {t('Auto-run on: each prompt waits')}"
@ -788,34 +794,20 @@ class TodoUpgrade:
)
def ask(self, prompt, default=""):
"""Lire une réponse ; en mode auto, rendre le défaut après le délai.
"""Lire une réponse ; rendre le défaut si rien n'arrive.
`select` plutôt qu'un fil ou une alarme : on veut savoir si quelque
chose est LISIBLE, et rendre la main sinon. Un fil laisserait un
`input()` bloqué derrière lui, qui volerait la frappe suivante.
Le compte à rebours vit dans `auto_ask`, pas ici : les outils lancés
en sous-processus posent EUX AUSSI des questions, et une deuxième
implémentation aurait dérivé de celle-ci sans que rien ne le dise.
L'export à chaque appel n'est pas de la précaution : l'environnement
est le SEUL canal qui traverse un `fork`. Sans lui, un outil lancé
plus bas attendrait indéfiniment une frappe qui ne vient pas.
"""
if not getattr(self, "auto_execute", False):
return input(prompt)
import select
auto_ask.export(getattr(self, "auto_execute", False), self.AUTO_DELAY)
return auto_ask.ask(prompt, default=default, seconds=self.AUTO_DELAY)
sys.stdout.write(prompt)
sys.stdout.flush()
try:
ready, _, _ = select.select([sys.stdin], [], [], self.AUTO_DELAY)
except (OSError, ValueError):
# stdin n'est pas sélectionnable : on ne devine pas, on demande.
return input("")
if ready:
answer = sys.stdin.readline().rstrip("\n")
# Une réponse VIDE vaut « prends le défaut » : c'est tout le
# propos du mode auto. Un stdin fermé — exécution non
# interactive — est lisible tout de suite et rend justement
# une ligne vide ; sans ceci, le défaut ne servirait jamais là.
return answer or default
print(f" ⏱ → {default or t('(default)')}")
return default
def ask_gate(self, prompt):
def ask_gate(self, prompt, default=""):
"""Une invite d'attente, avec une porte de sortie vers l'arrière.
Ces invites ne demandent qu'à continuer. Quand on s'aperçoit à ce
@ -825,7 +817,7 @@ class TodoUpgrade:
il rembobine l'état, l'écrit, et s'arrête en disant quoi relancer.
"""
while True:
answer = self.ask(prompt)
answer = self.ask(prompt, default=default)
if (answer or "").strip().lower() != "b":
return answer
if self.rewind_to_chosen_step():
@ -2126,7 +2118,9 @@ class TodoUpgrade:
self.ask(
f"💬 {t('List the missing modules to delete,')}"
f" {t('separated by commas. The others will be')}"
f" {t('migrated')} : "
f" {t('migrated')}"
f" ({t('Enter = all, n = none')}) : ",
default="a",
)
.strip()
.lower()
@ -2134,6 +2128,11 @@ class TodoUpgrade:
is_delete_all = False
# « n » saute le bloc ENTIER, ajouts par version compris :
# Entrée valant « toutes », il faut un mot pour dire non,
# et il doit vraiment ne rien supprimer.
if want_continue == "n":
want_continue = ""
if want_continue:
lst_want_continue = [
a.strip() for a in want_continue.split(",")
@ -2618,8 +2617,9 @@ class TodoUpgrade:
self.ask(
f"💬 {t('Do you want to upgrade all')}"
f"{str_wait_next_version} ?"
f" {t('Press y/Y to upgrade all addons of the')}"
f" {t('database')} : "
f" (Y/n, {t('Enter upgrades all addons of the')}"
f" {t('database')}) : ",
default="y",
)
.strip()
.lower()
@ -2647,14 +2647,31 @@ class TodoUpgrade:
self.prompt_smoke_public_url(database_name_upgrade)
print(f"[y] {t('Open the server with Selenium')}")
status = (
self.ask(
f"💬 {t('Do you want to test this upgrade? Choose')}"
f" {t('or press enter to ignore it')} : "
print(f"[a] {t('Open it at EVERY version bump, stop asking')}")
# Une migration traverse jusqu'à six paliers. Répondre « y »
# à chacun oblige à rester devant ; « a » est la réponse
# qu'on donnait de toute façon six fois de suite, dite une
# seule fois — et retenue, donc valable après une reprise.
if self.dct_progression.get("state_4_selenium_every_bump"):
status = "y"
print(
f"ℹ -> {t('Testing every bump, as chosen earlier.')}"
)
.strip()
.lower()
)
else:
status = (
self.ask(
f"💬 {t('Do you want to test this upgrade? Choose')}"
f" {t('or press enter to ignore it')} : "
)
.strip()
.lower()
)
if status == "a":
self.dct_progression["state_4_selenium_every_bump"] = (
True
)
self.write_config()
status = "y"
"make repo_show_status"
if status == "y":
self.todo.prompt_execute_selenium_and_run_db(
@ -2873,7 +2890,8 @@ class TodoUpgrade:
answer = (
self.ask_gate(
f"💬 {t('Uninstall them properly before migrating?')}"
f" (y/N, {t('(b = go back to a previous step)')}) : "
f" (Y/n, {t('(b = go back to a previous step)')}) : ",
default="y",
)
.strip()
.lower()
@ -2931,13 +2949,16 @@ class TodoUpgrade:
answer = (
self.ask_gate(
f"💬 {t('Which one(s) to reset onto the module view?')}"
f" ({t('numbers separated by commas, a = all, empty =')}"
f" {t('nothing')}) : "
f" ({t('numbers separated by commas, Enter = all, n =')}"
f" {t('nothing')}) : ",
default="a",
)
.strip()
.lower()
)
if not answer:
# « n », et non plus le vide : Entrée vaut « toutes » maintenant, et
# une sortie sans mot pour dire non serait une sortie sans issue.
if not answer or answer == "n":
print(f"ℹ -> {t('Kept. Nothing was reset.')}")
return
if answer == "a":
@ -2970,7 +2991,8 @@ class TodoUpgrade:
answer = (
self.ask_gate(
f"💬 {t('Clean the database before testing the pages?')}"
f" (y/N, {t('(b = go back to a previous step)')}) : "
f" (Y/n, {t('(b = go back to a previous step)')}) : ",
default="y",
)
.strip()
.lower()
@ -3007,7 +3029,8 @@ class TodoUpgrade:
answer = (
self.ask_gate(
f"💬 {t('Request every public URL of this database now?')}"
f" (y/N, {t('(b = go back to a previous step)')}) : "
f" (Y/n, {t('(b = go back to a previous step)')}) : ",
default="y",
)
.strip()
.lower()
@ -3071,14 +3094,18 @@ class TodoUpgrade:
f" -d {database_name} -t odoo{next_version}.0"
)
while True:
# `self.ask`, pas `input` : cette invite boucle, et un `input`
# nu ici arrêtait net une migration automatique — sans rien
# afficher, puisque la question attendait déjà.
answer = (
input(
self.ask(
f"💬 {t('What do you want to do with these COW copies?')}"
f" ({t('Enter = decide at the version bump')},"
f" ({t('Enter = neutralize now, reversible')},"
f" v = {t('what each copy holds')},"
f" s = {t('why it breaks')},"
f" w = {t('full screen')},"
f" a = {t('neutralize now, reversible')}) : "
f" n = {t('decide at the version bump')}) : ",
default="a",
)
.strip()
.lower()
@ -3131,10 +3158,11 @@ class TodoUpgrade:
# rien dans l'avertissement ne permet de les distinguer.
while True:
answer = (
input(
self.ask(
"💬 Neutralize these copies so the upgrade can proceed?"
" Their arch is kept and the change is reversible."
" (Y/n, v = view the differences, w = full screen) : "
" (Y/n, v = view the differences, w = full screen) : ",
default="y",
)
.strip()
.lower()
@ -3290,7 +3318,9 @@ class TodoUpgrade:
)
print(f" [2] {t('Try the whole list anyway (it will fail)')}")
print(f" [3] {t('Uninstall nothing, continue')}")
answer = input(f"💬 {t('Your choice')} : ").strip()
answer = self.ask(
f"💬 {t('Your choice')} ({t('Enter = 1')}) : ", default="1"
).strip()
if answer == "2":
return lst_present + lst_missing
if answer == "3" or not lst_present:
@ -3483,8 +3513,11 @@ class TodoUpgrade:
)
print(f"[3] {t('Reset one of them onto its module view')}")
print(f"[4] {t('Browse the differences full screen')}")
# `self.ask` : une migration automatique s'arrêtait ICI,
# sur une invite qui ne demande qu'à continuer, et restait
# bloquée sans que rien ne le signale.
wait_status = (
input(
self.ask(
f"💬 {t('Error detected, press enter to continue or')}"
f" ctrl+c {t('to stop')} : "
)

View file

@ -37,8 +37,36 @@ def upgrade(auto, delay=0.2):
return obj
class TestTheTimedRead(unittest.TestCase):
class EnvCase(unittest.TestCase):
"""Rendre l'environnement comme on l'a trouvé.
`ask` pose le mode auto dans l'environnement — c'est ainsi qu'il atteint
les outils lancés à part. Le laisser posé le ferait fuir dans TOUTE la
suite : une invite sans rapport prendrait son défaut au lieu d'attendre,
et le test qui échouerait ne serait pas celui qui a fauté.
"""
def setUp(self):
from script.todo import auto_ask
avant = {
key: os.environ.get(key)
for key in (auto_ask.ENV_ENABLED, auto_ask.ENV_DELAY)
}
def remettre():
for key, value in avant.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
self.addCleanup(remettre)
class TestTheTimedRead(EnvCase):
def setUp(self):
super().setUp()
self.original = sys.stdin
self.addCleanup(setattr, sys, "stdin", self.original)
@ -129,8 +157,9 @@ class TestTheTimedRead(unittest.TestCase):
self.assertEqual(got, "demandé")
class TestTheQuestionThatEnablesIt(unittest.TestCase):
class TestTheQuestionThatEnablesIt(EnvCase):
def setUp(self):
super().setUp()
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
@ -166,6 +195,23 @@ class TestTheQuestionThatEnablesIt(unittest.TestCase):
def test_the_delay_is_five_seconds(self):
self.assertEqual(TodoUpgrade.AUTO_DELAY, 5)
def test_yes_also_arms_the_tools_launched_apart(self):
# La moitié des invites d'une migration sont posées par d'autres
# processus. Sans cette variable, ils attendraient une frappe qui
# ne vient jamais — et l'automatisation s'arrêterait là, en
# silence, puisque la question a bien été posée.
from script.todo import auto_ask
self.answer("y")
self.assertEqual(os.environ.get(auto_ask.ENV_ENABLED), "1")
def test_no_leaves_nothing_behind(self):
from script.todo import auto_ask
os.environ[auto_ask.ENV_ENABLED] = "1"
self.answer("")
self.assertNotIn(auto_ask.ENV_ENABLED, os.environ)
class TestWhereItIsAsked(unittest.TestCase):
def source(self):

View file

@ -577,8 +577,8 @@ class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase):
self.assertIn("run_on_terminal", source)
self.assertNotIn("todo_upgrade_execute", source)
def test_the_default_cleans_nothing(self):
# Cela ÉCRIT en base : ce n'est pas à la migration de le décider.
def test_saying_no_cleans_nothing(self):
# Le défaut ne retire pas le choix : il ne fait qu'en proposer un.
import contextlib
import io
@ -595,11 +595,32 @@ class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase):
upgrade.write_config = lambda: None
lst_cmd = []
upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
upgrade.ask_gate = lambda prompt: ""
upgrade.ask_gate = lambda prompt, default="": "n" or default
with contextlib.redirect_stdout(io.StringIO()):
upgrade.prompt_database_cleanup("db")
self.assertEqual(lst_cmd, [])
def test_the_default_cleans(self):
# Entrée nettoie : le nettoyage précède les tests de fumée, et une
# base encombrée de tables mortes fait échouer des pages pour une
# raison qui n'a rien à voir avec la migration.
import contextlib
import io
from script.todo.todo_upgrade import TodoUpgrade
upgrade = TodoUpgrade.__new__(TodoUpgrade)
upgrade.dct_progression = {}
upgrade.lst_command_executed = []
upgrade.write_config = lambda: None
lst_cmd = []
upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
upgrade.ask_gate = lambda prompt, default="": "" or default
with contextlib.redirect_stdout(io.StringIO()):
upgrade.prompt_database_cleanup("db")
self.assertEqual(len(lst_cmd), 1)
self.assertIn("database_cleanup.py", lst_cmd[0])
def test_yes_runs_it_on_that_database(self):
from script.todo.todo_upgrade import TodoUpgrade
@ -609,7 +630,7 @@ class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase):
upgrade.write_config = lambda: None
lst_cmd = []
upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
upgrade.ask_gate = lambda prompt: "y"
upgrade.ask_gate = lambda prompt, default="": "y" or default
upgrade.prompt_database_cleanup("db_upgrade_18")
self.assertEqual(len(lst_cmd), 1)
self.assertIn("database_cleanup.py", lst_cmd[0])

View file

@ -0,0 +1,357 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ce que fait Entrée, et ce que fait le silence.
Une migration pose une quarantaine de questions dont la réponse était
presque toujours la même. Le mode auto promettait de les prendre « par
défaut » — sauf que le défaut était VIDE partout : il ne faisait rien, et
la moitié des invites vivent de toute façon dans des outils lancés à part,
qui ne savaient rien du mode auto et attendaient une frappe pour toujours.
Deux exigences, donc, et elles sont indissociables :
- ce que fait Entrée doit être ÉCRIT dans la question. Une invite qui
annonce « y/N » et désinstalle est pire que pas d'invite du tout ;
- aucune invite du chemin de migration ne doit rester un `input()` nu. Ce
n'est pas une faute qu'on commet exprès, c'est une faute qu'on commet
par habitude — d'où le test qui refuse la construction elle-même.
"""
import ast
import contextlib
import io
import os
import subprocess
import sys
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration"))
sys.path.insert(0, os.path.join(REPO, "script", "addons"))
from script.todo import auto_ask # noqa: E402
from script.todo.todo_upgrade import TodoUpgrade # noqa: E402
class EnvCase(unittest.TestCase):
def setUp(self):
avant = {
key: os.environ.get(key)
for key in (auto_ask.ENV_ENABLED, auto_ask.ENV_DELAY)
}
def remettre():
for key, value in avant.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
self.addCleanup(remettre)
auto_ask.export(False)
class TestTheChannelThatCrossesAFork(EnvCase):
"""L'environnement, parce que c'est le SEUL canal partagé.
Le désinstalleur de thème, le détecteur de SCSS figé et le test de
fumée sont des processus séparés. Ils ne voient ni l'objet du pilote ni
sa mémoire ; sans une variable d'environnement, ils attendraient une
frappe qui ne vient jamais, et l'automatisation s'arrêterait là — sans
message, puisque la question, elle, a bien été posée.
"""
def test_export_puts_it_where_a_child_will_read_it(self):
auto_ask.export(True, 3)
self.assertEqual(os.environ[auto_ask.ENV_ENABLED], "1")
self.assertEqual(os.environ[auto_ask.ENV_DELAY], "3")
self.assertTrue(auto_ask.enabled())
def test_turning_it_off_REMOVES_it(self):
# Y écrire « 0 » laisserait un reste de session précédente décider à
# notre place : ce qui n'existe pas ne peut pas se tromper.
auto_ask.export(True, 3)
auto_ask.export(False)
self.assertNotIn(auto_ask.ENV_ENABLED, os.environ)
self.assertFalse(auto_ask.enabled())
def test_a_real_child_process_takes_the_default(self):
# LE test qui compte : un vrai sous-processus, un vrai stdin fermé.
code = (
"import sys; sys.path.insert(0, %r);"
"from script.todo import auto_ask;"
"print(auto_ask.ask('q : ', default='d'))" % REPO
)
env = dict(os.environ)
env[auto_ask.ENV_ENABLED] = "1"
env[auto_ask.ENV_DELAY] = "1"
done = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
timeout=30,
cwd=REPO,
env=env,
)
self.assertEqual(done.returncode, 0, done.stderr)
self.assertIn("d", done.stdout.strip().splitlines()[-1])
def test_a_child_without_the_flag_still_asks(self):
# Hors mode auto, on ne décide à la place de personne : le tuyau
# fermé rend une ligne vide, et le défaut n'arrive que là.
code = (
"import sys; sys.path.insert(0, %r);"
"from script.todo import auto_ask;"
"print('RÉPONSE=' + auto_ask.ask('q : ', default='d'))" % REPO
)
done = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
input="k\n",
timeout=30,
cwd=REPO,
)
self.assertIn("RÉPONSE=k", done.stdout)
def test_a_broken_delay_does_not_freeze_anything(self):
os.environ[auto_ask.ENV_DELAY] = "plus tard"
self.assertEqual(auto_ask.delay(), auto_ask.DEFAULT_DELAY)
def test_a_zero_delay_is_refused(self):
# Sans fenêtre, on ne peut pas reprendre la main : « auto » ne veut
# pas dire « sans recours ».
os.environ[auto_ask.ENV_DELAY] = "0"
self.assertEqual(auto_ask.delay(), auto_ask.DEFAULT_DELAY)
class TestEnterMeansTheDefault(EnvCase):
"""Hors mode auto AUSSI : sinon la question mentirait à qui la lit."""
def test_an_empty_answer_takes_the_default(self):
import builtins
original = builtins.input
builtins.input = lambda prompt="": ""
self.addCleanup(setattr, builtins, "input", original)
self.assertEqual(auto_ask.ask("q : ", default="y"), "y")
def test_a_typed_answer_wins(self):
import builtins
original = builtins.input
builtins.input = lambda prompt="": "n"
self.addCleanup(setattr, builtins, "input", original)
self.assertEqual(auto_ask.ask("q : ", default="y"), "n")
def test_the_driver_exports_it_on_every_ask(self):
# Un outil lancé plus bas doit voir le mode auto même si la question
# d'activation date d'une reprise précédente.
import builtins
original = builtins.input
builtins.input = lambda prompt="": ""
self.addCleanup(setattr, builtins, "input", original)
obj = TodoUpgrade.__new__(TodoUpgrade)
obj.auto_execute = True
obj.AUTO_DELAY = 0.2
with contextlib.redirect_stdout(io.StringIO()):
obj.ask("q : ", default="y")
self.assertEqual(os.environ.get(auto_ask.ENV_ENABLED), "1")
class TestNoPromptOfTheMigrationCanHang(unittest.TestCase):
"""Un `input()` nu ne sait rien du mode auto : il attend, pour toujours.
Vécu à l'échelle du fichier : deux invites avaient échappé au premier
passage — la prédiction COW et le choix de désinstallation — parce que
le garde-fou ne regardait qu'`execute_odoo_upgrade`. On regarde
désormais TOUTES les méthodes du chemin de migration.
"""
METHODES = (
"execute_odoo_upgrade",
"prompt_cow_prediction",
"prompt_uninstall_theme",
"prompt_reset_stale_cow_views",
"prompt_database_cleanup",
"prompt_smoke_public_url",
"split_present_missing",
"prompt_uninstall_missing",
"todo_upgrade_execute",
)
def bare_inputs(self, nom):
chemin = os.path.join(REPO, "script", "todo", "todo_upgrade.py")
with open(chemin) as handle:
tree = ast.parse(handle.read())
cible = [
node
for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name == nom
]
self.assertEqual(len(cible), 1, f"méthode introuvable : {nom}")
return [
node.lineno
for node in ast.walk(cible[0])
if isinstance(node, ast.Call)
and getattr(node.func, "id", "") == "input"
]
def test_none_of_them_asks_outside_the_timer(self):
coupables = {
nom: self.bare_inputs(nom)
for nom in self.METHODES
if self.bare_inputs(nom)
}
self.assertEqual(coupables, {}, "invites hors du mode auto")
class TestWhatEnterDoesIsWritten(unittest.TestCase):
"""La question doit dire la vérité à qui appuie sur Entrée."""
def source(self, nom):
import inspect
return inspect.getsource(getattr(TodoUpgrade, nom))
def test_the_theme_prompt_announces_Y(self):
source = self.source("prompt_uninstall_theme")
self.assertIn("(Y/n,", source)
self.assertIn('default="y"', source)
def test_the_cleanup_prompt_announces_Y(self):
source = self.source("prompt_database_cleanup")
self.assertIn("(Y/n,", source)
self.assertIn('default="y"', source)
def test_the_smoke_prompt_announces_Y(self):
source = self.source("prompt_smoke_public_url")
self.assertIn("(Y/n,", source)
self.assertIn('default="y"', source)
def test_the_reset_prompt_says_enter_is_all(self):
source = self.source("prompt_reset_stale_cow_views")
self.assertIn("Enter = all", source)
self.assertIn('default="a"', source)
def test_the_cow_prediction_says_enter_neutralizes(self):
source = self.source("prompt_cow_prediction")
self.assertIn("Enter = neutralize now", source)
self.assertIn('default="a"', source)
def test_the_uninstall_strategy_defaults_to_one(self):
source = self.source("prompt_uninstall_missing")
self.assertIn("Enter = 1", source)
self.assertIn('default="1"', source)
def test_every_default_has_a_way_to_say_no(self):
# Un défaut qui agit sans issue n'est plus un défaut, c'est un ordre.
for nom, refus in (
("prompt_reset_stale_cow_views", '== "n"'),
("prompt_uninstall_missing", '== "3"'),
("prompt_cow_prediction", "n = "),
):
self.assertIn(refus, self.source(nom), nom)
class TestTheToolsLaunchedApart(unittest.TestCase):
def test_the_leftovers_default_to_deleting(self):
import theme_leftover
self.assertEqual(theme_leftover.DEFAULT_ANSWER, "d")
def test_the_leftovers_are_saved_BEFORE_being_deleted(self):
# C'est ce qui rend ce défaut tenable. Sans la sauvegarde, il aurait
# dû rester « garder » : on ne fait pas d'une décision irréversible
# la réponse qu'on obtient en ne répondant pas.
import inspect
import theme_leftover
source = inspect.getsource(theme_leftover.prompt)
self.assertLess(
source.index("backup_attachments"),
source.index("delete_attachments"),
)
def test_the_leftovers_prompt_says_enter_deletes(self):
import inspect
import theme_leftover
source = inspect.getsource(theme_leftover.prompt)
self.assertIn("Enter = delete", source)
self.assertIn("k = ", source)
def test_the_smoke_reset_defaults_to_all(self):
import smoke_public_url
self.assertEqual(smoke_public_url.DEFAULT_ANSWER, "a")
def test_the_scss_default_follows_what_the_checkout_can_do(self):
# « a » là où `reset_asset` n'existe pas (avant la 13.0) ferait
# boucler l'invite sur elle-même : le défaut suit la capacité.
import inspect
import check_stale_scss
source = inspect.getsource(check_stale_scss.prompt)
self.assertIn('defaut = "a" if can_reset else "n"', source)
def test_each_of_them_can_run_without_a_driver(self):
# Le repli compte : ces outils se lancent aussi à la main, et une
# ImportError les rendrait inutilisables hors migration.
import check_stale_scss
import smoke_public_url
import theme_leftover
for module in (theme_leftover, smoke_public_url, check_stale_scss):
import inspect
self.assertIn(
"auto_ask = None",
inspect.getsource(module),
module.__name__,
)
class TestTestingEveryBump(unittest.TestCase):
"""« a » dit une fois ce qu'on répétait six fois."""
def source(self):
import inspect
return inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
def test_the_option_is_offered(self):
self.assertIn("Open it at EVERY version bump", self.source())
def test_it_is_remembered_in_the_progression(self):
# Sinon une reprise après interruption reposerait la question, et
# l'automatisation s'arrêterait au premier palier suivant.
source = self.source()
self.assertIn("state_4_selenium_every_bump", source)
self.assertIn("self.write_config()", source)
def test_the_memory_stops_the_question(self):
source = self.source()
debut = source.index("state_4_selenium_every_bump")
fenetre = source[debut : debut + 400]
self.assertIn('status = "y"', fenetre)
def test_it_belongs_to_step_four(self):
# Le nom porte sa propriété : un retour avant l'étape 4 doit
# l'effacer, comme tout ce que l'étape 4 a décidé.
from script.todo.todo_upgrade import STEP_PREFIX_RE
match = STEP_PREFIX_RE.match("state_4_selenium_every_bump")
self.assertIsNotNone(match)
self.assertEqual(match.group(1), "4")
if __name__ == "__main__":
unittest.main()

View file

@ -45,7 +45,9 @@ class PromptCase(unittest.TestCase):
upgrade.stale_cow_keys = lambda db: lst_key
lst_cmd = []
upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
upgrade.ask_gate = lambda prompt: answer
# Le doublon HONORE le défaut, comme le vrai `ask_gate` : sinon
# les tests de défaut ne testeraient que le doublon.
upgrade.ask_gate = lambda prompt, default="": answer or default
out = io.StringIO()
with contextlib.redirect_stdout(out):
upgrade.prompt_reset_stale_cow_views("db")
@ -88,10 +90,17 @@ class TestWhatEachAnswerRuns(PromptCase):
lst_cmd, _ = self.run_prompt("a")
self.assertIn("--reset all", lst_cmd[0])
def test_empty_resets_nothing(self):
# Par défaut on ne touche à rien : une copie porte parfois une vraie
# personnalisation, et la réinitialiser la jette.
lst_cmd, text = self.run_prompt("")
def test_enter_resets_them_all(self):
# Entrée les prend TOUTES : elles ne sont dans cette liste que
# parce qu'un enfant n'y trouve plus son ancrage, et l'outil sauve
# ce que chaque copie portait avant de la réinitialiser.
lst_cmd, _text = self.run_prompt("")
self.assertEqual(len(lst_cmd), 1)
self.assertIn("--reset all", lst_cmd[0])
def test_n_resets_nothing(self):
# Le défaut ne retire pas le choix : il ne fait qu'en proposer un.
lst_cmd, text = self.run_prompt("n")
self.assertEqual(lst_cmd, [])
self.assertIn("Kept", text)

View file

@ -414,14 +414,21 @@ class TestTheMigrationOffersIt(unittest.TestCase):
upgrade.write_config = lambda: None
lst_cmd = []
upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
upgrade.ask_gate = lambda prompt: answer
# Le doublon HONORE le défaut, comme le vrai `ask_gate`.
upgrade.ask_gate = lambda prompt, default="": answer or default
upgrade.prompt_smoke_public_url("db_upgrade_13")
return lst_cmd
def test_the_default_runs_nothing(self):
# Cela démarre un serveur et peut durer : pas à chaque palier sans
# qu'on l'ait demandé.
self.assertEqual(self.run_prompt(""), [])
def test_the_default_runs_it(self):
# Une page cassée qu'on ne demande pas reste cassée. Le coût est
# quelques minutes de serveur ; le prix de l'ignorer est de
# découvrir le 500 six paliers plus loin.
lst_cmd = self.run_prompt("")
self.assertEqual(len(lst_cmd), 1)
self.assertIn("smoke_public_url.py", lst_cmd[0])
def test_saying_no_still_skips_it(self):
self.assertEqual(self.run_prompt("n"), [])
def test_yes_runs_it_on_the_upgraded_database(self):
lst_cmd = self.run_prompt("y")
@ -476,7 +483,7 @@ class TestTheMigrationOffersIt(unittest.TestCase):
upgrade.lst_command_executed = []
upgrade.write_config = lambda: None
upgrade.run_on_terminal = lambda cmd: 0
upgrade.ask_gate = lambda prompt: ""
upgrade.ask_gate = lambda prompt, default="": "n"
out = io.StringIO()
with contextlib.redirect_stdout(out):
upgrade.prompt_smoke_public_url("db", baseline=True)

View file

@ -60,24 +60,33 @@ class PromptCase(unittest.TestCase):
class TestWhatEachAnswerRuns(PromptCase):
def test_enter_runs_nothing_and_says_so(self):
lst_cmd, text = self.run_prompt([""])
def test_enter_neutralizes_now(self):
# Entrée neutralise : c'est réversible — l'arch est conservée — et
# ce qu'on neutralise ici vaut pour TOUS les paliers, au lieu
# d'attendre des dizaines de minutes pour redécider à chaque fois.
lst_cmd, _text = self.run_prompt([""])
self.assertEqual(len(lst_cmd), 1)
self.assertIn("--apply", lst_cmd[0])
def test_n_runs_nothing_and_says_so(self):
# Le défaut ne retire pas le choix : il ne fait qu'en proposer un.
lst_cmd, text = self.run_prompt(["n"])
self.assertEqual(lst_cmd, [])
self.assertIn("version bump", text)
def test_v_shows_what_the_copies_hold(self):
lst_cmd, _ = self.run_prompt(["v", ""])
lst_cmd, _ = self.run_prompt(["v", "n"])
self.assertEqual(len(lst_cmd), 1)
self.assertIn("cow_drift.py", lst_cmd[0])
self.assertIn("-d db -t odoo13.0", lst_cmd[0])
self.assertNotIn("--shape", lst_cmd[0])
def test_s_shows_why_it_breaks(self):
lst_cmd, _ = self.run_prompt(["s", ""])
lst_cmd, _ = self.run_prompt(["s", "n"])
self.assertTrue(lst_cmd[0].endswith("--shape"))
def test_w_opens_the_full_screen_view(self):
lst_cmd, _ = self.run_prompt(["w", ""])
lst_cmd, _ = self.run_prompt(["w", "n"])
self.assertTrue(lst_cmd[0].endswith("--tui"))
def test_looking_does_not_answer_the_question(self):

View file

@ -157,7 +157,9 @@ class TestTheMigrationOffersIt(unittest.TestCase):
# 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
# Le doublon HONORE le défaut, comme le vrai `ask_gate` : sinon
# les tests de défaut ne testeraient que le doublon.
obj.ask_gate = lambda prompt, default="": answer or default
return obj
def run_prompt(self, lst_theme, answer):
@ -170,13 +172,32 @@ class TestTheMigrationOffersIt(unittest.TestCase):
obj.prompt_uninstall_theme("db")
return self.lst_cmd, out.getvalue()
def test_the_default_answer_uninstalls_nothing(self):
# « Entrée » ne doit RIEN faire : la migration ne décide pas de
# l'apparence d'un site à la place de son propriétaire.
lst_cmd, text = self.run_prompt(["theme_technolibre"], "")
def test_the_default_answer_uninstalls_them(self):
# « Entrée » désinstalle : c'est la réponse qu'on donnait à chaque
# migration, et un thème traversé sans être retiré est justement ce
# qui casse au palier suivant.
lst_cmd, _text = self.run_prompt(["theme_technolibre"], "")
self.assertEqual(len(lst_cmd), 1)
self.assertIn("uninstall_addons_theme.sh", lst_cmd[0])
def test_saying_no_still_keeps_them(self):
# Le défaut ne doit pas retirer le choix : il ne fait qu'en proposer
# un. Sans cette issue, la question ne serait plus une question.
lst_cmd, text = self.run_prompt(["theme_technolibre"], "n")
self.assertEqual(lst_cmd, [])
self.assertIn("Kept", text)
def test_the_question_says_that_enter_uninstalls(self):
# Une invite qui annonce « y/N » et fait l'inverse est pire que pas
# d'invite du tout.
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.prompt_uninstall_theme)
self.assertIn("(Y/n,", source)
self.assertNotIn("(y/N,", source)
def test_no_theme_means_no_question(self):
lst_cmd, text = self.run_prompt([], "y")
self.assertEqual(lst_cmd, [])