erplibre/script/todo/auto_ask.py
Mathieu Benoit 6dff7aa304 [ADD] migration: announce the countdown, and cycle three panel states
Every prompt now carries « ⏱5s » before the question. One that looks like
it waits forever does not invite you to step away, which is the whole
point of auto-run. The same clock reports the answer afterwards: it
announces the wait, then says what it decided.

Enter on the auto-run question already meant no; it now says so, with an
explicit default instead of a deduction. And the flag is cleared before
asking — an ERPLIBRE_AUTO_EXECUTE inherited from the shell would have
answered yes on someone's behalf, and that answer commits every later one.

« p » cycles three states rather than toggling one: a plain toggle freed
four lines, while what takes the room is the left COLUMN — on a server log
every character of width counts. All → no summary → detail only, and back.
The state is named in the sub-title, which stays visible in all three: a
panel that vanishes without a word reads as a broken screen.

--- FR ---

[ADD] migration : annoncer le compte à rebours, et trois états de panneaux

Chaque invite porte désormais « ⏱5s » devant la question. 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. Le même chronomètre rend compte ensuite.

Entrée sur la question d'auto-exécution voulait déjà dire non ; elle le
DIT maintenant, par un défaut explicite plutôt qu'une déduction. Et le
drapeau est effacé avant de demander : une variable héritée du shell
aurait répondu « oui » à la place de quelqu'un.

« p » enchaîne trois états au lieu d'en basculer un : ce qui prend la
place, c'est la colonne de gauche. Tout → sans résumé → détail seul, et
l'on revient. L'état est nommé dans le sous-titre.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -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 = 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
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