erplibre/script/todo/auto_ask.py
Mathieu Benoit 7ba3c6504c [UPD] invites : 15 secondes pour décider, et le fichier nomme la base
Cinq secondes ne suffisaient pas à LIRE la question. Le compte à rebours
n'existe pas pour aller vite mais pour qu'on puisse s'absenter ; trop
court, il fait l'inverse — on répond par réflexe, ou l'on subit un défaut
non lu. Et « test » ne disait rien de ce qu'on migrait : des migrations
successives portaient toutes le même nom, quand le fichier de sauvegarde
en porte déjà un parlant. Le nom est assaini — il finit dans un createdb —
et borné à 41 caractères : le pilote ajoute « _neutralize_upgrade_18 » et
PostgreSQL tronque à 63, ce qui ferait finir deux paliers sur le même nom.
Un téléchargement distant garde celui que le serveur a donné.

--- EN ---

Five seconds were not enough to READ the question. The countdown does not
exist to go fast but so you can step away; too short, it does the
opposite — you answer by reflex, or you get a default you never read. And
"test" said nothing about what was being migrated: successive migrations
all carried the same name, when the backup file already carries a telling
one. The name is sanitised — it ends up in a createdb — and capped at 41
characters: the driver appends "_neutralize_upgrade_18" and PostgreSQL
truncates at 63, which would land two steps on one name. A remote
download keeps the name the server gave.

Assisted-by: Claude Opus 5
(cherry picked from commit dad0f6b0ef4551898fe2964e177ff5c1c888b5dc)
2026-08-29 02:11:04 -04:00

128 lines
4.8 KiB
Python

#!/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
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
ENV_ENABLED = "ERPLIBRE_AUTO_EXECUTE"
ENV_DELAY = "ERPLIBRE_AUTO_DELAY"
DEFAULT_DELAY = 15
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
attente = delay() if seconds is None else seconds
# DIRE qu'on répondra tout seul, et dans combien de temps. Une invite
# qui a l'air d'attendre indéfiniment n'invite pas à s'absenter, et
# c'est pourtant tout l'intérêt du mode auto. Devant l'invite plutôt
# qu'après : elle se lit avant qu'on décide de partir.
sys.stdout.write(f"⏱{attente:g}s {prompt}")
sys.stdout.flush()
try:
ready, _, _ = select.select([sys.stdin], [], [], attente)
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
# « Entrée », parce que c'est ce que l'invite a proposé. Elle annonce
# « Entrée = effacer, k = garder » et ne mentionne nulle part « d » :
# afficher la valeur brute nommait donc une réponse qui n'était pas au
# menu, et l'on se demandait ce que l'outil venait de décider. La
# valeur reste, entre parenthèses, pour qui veut la précision.
detail = f" ({default})" if default else ""
print(f" ⏱ → {t('Enter')}{detail}")
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