[IMP] todo: offer to install textual instead of just naming it
« Installez textual pour le TUI (pip) » left the user to work out which pip,
which interpreter, which package — and it was printed from eight different
places. Every TUI screen now asks:
⚠ Textual est nécessaire pour cet écran.
L'installer maintenant ? (O/n, défaut : oui)
The command targets sys.executable, the interpreter that will have to import
it — installing a distribution package would land somewhere the venv never
looks. Outside a venv it adds --user, which is also what gets past the refusal
of distributions whose environment is externally managed (PEP 668).
Two details that decide whether this works at all:
· importlib.invalidate_caches() after installing. A failed import is
remembered, so without it textual stays « missing » for the rest of the
session despite having just been installed.
· a pip that exits non-zero never reports success. The check is « is it
importable NOW », not « did pip return 0 », and the failure suggests the
distribution package by name.
It lives in its own module rather than as a TODO method: todo_upgrade needs it
too and is imported BY todo, so putting it there would close a cycle.
One call site is deliberately NOT converted. The statistics screen only reads
files; it never touches Textual, and its old message claimed otherwise. An
import failure there is a real module problem and now says so.
Verified: already-present asks nothing and runs nothing; refusal installs
nothing; pip failing returns False and points at python3-textual; pip
succeeding returns True; prompt=False reports without asking. Then each of the
four TUI entries — telemetry, deploy form, deploy progress, migration resume —
offers and falls back cleanly on refusal, while the statistics screen stays
silent.
Also caught by those tests: « import importlib » alone does not expose
importlib.util, so availability could not be checked at all.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e8f7e8384a
commit
5d6a262489
4 changed files with 144 additions and 21 deletions
89
script/todo/textual_setup.py
Normal file
89
script/todo/textual_setup.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
"""Disponibilité de Textual, et installation à la demande.
|
||||
|
||||
Les écrans TUI du CLI TODO (télémétrie, dashboard d'installation, formulaire
|
||||
de déploiement, reprise de migration) dépendent tous de Textual. Sans lui, ils
|
||||
se contentaient d'un « Installez textual (pip) » qui laissait l'utilisateur
|
||||
chercher la bonne commande pour son système.
|
||||
|
||||
`ensure(...)` répond à la question à sa place : Textual est-il là, et sinon
|
||||
veut-on l'installer maintenant ?
|
||||
|
||||
Module à part, et non une méthode de `TODO` : `todo_upgrade` en a besoin
|
||||
aussi, et il est importé PAR `todo` — le mettre là créerait un cycle.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.util # « import importlib » seul n'expose PAS .util
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
try:
|
||||
from script.todo.todo_i18n import t
|
||||
except Exception: # pragma: no cover - repli si i18n indisponible
|
||||
|
||||
def t(key: str) -> str:
|
||||
return key
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
"""Textual est-il importable maintenant ?"""
|
||||
return importlib.util.find_spec("textual") is not None
|
||||
|
||||
|
||||
def in_venv() -> bool:
|
||||
"""Vrai si l'interpréteur courant est dans un environnement virtuel."""
|
||||
return sys.prefix != getattr(sys, "base_prefix", sys.prefix)
|
||||
|
||||
|
||||
def install_command():
|
||||
"""Commande d'installation adaptée à l'interpréteur QUI TOURNE.
|
||||
|
||||
C'est lui qui devra importer Textual, pas le python du système : viser
|
||||
`sys.executable` évite d'installer un paquet distribution que le venv ne
|
||||
verrait jamais. Hors venv, « --user » contourne le refus des
|
||||
distributions dont l'environnement est « externally managed » (PEP 668).
|
||||
"""
|
||||
cmd = [sys.executable, "-m", "pip", "install", "textual"]
|
||||
if not in_venv():
|
||||
cmd.insert(4, "--user")
|
||||
return cmd
|
||||
|
||||
|
||||
def ensure(prompt=True, ask=input):
|
||||
"""Textual disponible ? Sinon proposer de l'installer. Renvoie un booléen.
|
||||
|
||||
`prompt=False` se contente de constater — pour les appels qui ne peuvent
|
||||
pas poser de question. `ask` est injectable pour les tests.
|
||||
"""
|
||||
if available():
|
||||
return True
|
||||
print(f"\n⚠ {t('Textual is required for this screen.')}")
|
||||
if not prompt:
|
||||
return False
|
||||
answer = ask(t("Install it now? (Y/n): ")).strip().lower()
|
||||
if answer and answer not in ("y", "yes", "o", "oui"):
|
||||
return False
|
||||
|
||||
cmd = install_command()
|
||||
print(f" {t('Will execute:')} {' '.join(cmd)}")
|
||||
try:
|
||||
status = subprocess.run(cmd).returncode
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
print(f" ⚠ {exc}")
|
||||
return False
|
||||
|
||||
# Un import négatif est mémorisé : sans purge du cache, Textual resterait
|
||||
# « absent » pour ce processus alors qu'il vient d'être installé.
|
||||
importlib.invalidate_caches()
|
||||
if available():
|
||||
print(f"✅ {t('Textual is installed.')}")
|
||||
return True
|
||||
print(f" ⚠ {t('Installation finished but textual is still missing.')}")
|
||||
if status:
|
||||
print(f" {t('pip exited with')} {status}")
|
||||
print(f" {t('Your distribution may package it as python3-textual.')}")
|
||||
return False
|
||||
|
|
@ -584,12 +584,15 @@ class TODO:
|
|||
et la position du curseur sont restaurés) ou de quitter."""
|
||||
from script.todo.todo_telemetry import run_tui
|
||||
|
||||
from script.todo import textual_setup
|
||||
|
||||
if not textual_setup.ensure():
|
||||
return
|
||||
state = None
|
||||
while True:
|
||||
try:
|
||||
result = run_tui(state=state)
|
||||
except ImportError:
|
||||
print(t("Install textual for the telemetry TUI (pip)."))
|
||||
return
|
||||
if not result:
|
||||
return
|
||||
|
|
@ -1843,10 +1846,13 @@ class TODO:
|
|||
Tout vient de l'historique tenu par le moniteur d'installation
|
||||
(.venv.erplibre/qemu_install_stats.json) et de l'état libvirt courant.
|
||||
"""
|
||||
# Cet écran ne lit que des fichiers : il n'a pas besoin de Textual,
|
||||
# contrairement au dashboard du même module. Un échec d'import est
|
||||
# donc un vrai problème de module, pas une dépendance manquante.
|
||||
try:
|
||||
from script.todo import qemu_install_monitor as mon
|
||||
except ImportError:
|
||||
print(t("Install textual for the dashboard (pip)."))
|
||||
except ImportError as exc:
|
||||
print(f"{t('Command failed: ')}{exc}")
|
||||
return
|
||||
|
||||
while True:
|
||||
|
|
@ -2242,7 +2248,10 @@ class TODO:
|
|||
try:
|
||||
mon.run_monitor(run["manifest"])
|
||||
except ImportError:
|
||||
print(t("Install textual for the dashboard (pip)."))
|
||||
from script.todo import textual_setup
|
||||
|
||||
if textual_setup.ensure():
|
||||
mon.run_monitor(run["manifest"])
|
||||
except Exception as exc:
|
||||
print(f"{t('Command failed: ')}{exc}")
|
||||
|
||||
|
|
@ -4278,8 +4287,11 @@ class TODO:
|
|||
run_monitor(manifest)
|
||||
except ImportError:
|
||||
# textual absent : les installs tournent déjà (détachées), on ne
|
||||
# plante pas — on indique juste où sont les logs.
|
||||
print(f" {t('Install textual for the dashboard (pip).')}")
|
||||
# plante donc pas — on propose de l'installer pour rouvrir.
|
||||
from script.todo import textual_setup
|
||||
|
||||
if textual_setup.ensure():
|
||||
run_monitor(manifest)
|
||||
print(
|
||||
f"\n{t('Monitor closed. Installs keep running in the background.')}"
|
||||
)
|
||||
|
|
@ -4916,16 +4928,16 @@ class TODO:
|
|||
def _qemu_deploy_form(self, mod, dry_run):
|
||||
"""Ouvre le formulaire TUI. Renvoie la spec, None si annulé, ou {}
|
||||
pour retomber sur les invites en ligne (textual absent)."""
|
||||
from script.todo import textual_setup
|
||||
|
||||
if not textual_setup.ensure():
|
||||
return {}
|
||||
try:
|
||||
from script.todo.qemu_deploy_form import run_deploy_form
|
||||
except ImportError:
|
||||
print(t("Install textual for the dashboard (pip)."))
|
||||
return {}
|
||||
ctx = self._qemu_form_context(mod)
|
||||
try:
|
||||
|
||||
ctx = self._qemu_form_context(mod)
|
||||
spec = run_deploy_form(ctx)
|
||||
except ImportError:
|
||||
print(t("Install textual for the dashboard (pip)."))
|
||||
return {}
|
||||
if not spec:
|
||||
print(t("Cancelled."))
|
||||
|
|
@ -5255,12 +5267,15 @@ class TODO:
|
|||
def _qemu_deploy_jobs_tui(self, jobs, workers):
|
||||
"""Même chose, en blocs repliables Textual. Renvoie None si textual
|
||||
manque, pour que l'appelant retombe sur la sortie texte."""
|
||||
from script.todo import textual_setup
|
||||
|
||||
if not textual_setup.ensure():
|
||||
return None
|
||||
try:
|
||||
from script.todo.qemu_deploy_form import run_deploy_progress
|
||||
|
||||
return run_deploy_progress(jobs, workers)
|
||||
except ImportError:
|
||||
print(f" {t('Install textual for the dashboard (pip).')}")
|
||||
return None
|
||||
|
||||
def _qemu_run_spec(self, spec):
|
||||
|
|
|
|||
|
|
@ -2118,9 +2118,29 @@ TRANSLATIONS = {
|
|||
"fr": "Échec de la commande : ",
|
||||
"en": "Command failed: ",
|
||||
},
|
||||
"Install textual for the telemetry TUI (pip).": {
|
||||
"fr": "Installez textual pour le TUI de télémétrie (pip).",
|
||||
"en": "Install textual for the telemetry TUI (pip).",
|
||||
"Textual is required for this screen.": {
|
||||
"fr": "Textual est nécessaire pour cet écran.",
|
||||
"en": "Textual is required for this screen.",
|
||||
},
|
||||
"Install it now? (Y/n): ": {
|
||||
"fr": "L'installer maintenant ? (O/n, défaut : oui) : ",
|
||||
"en": "Install it now? (Y/n, default: yes): ",
|
||||
},
|
||||
"Textual is installed.": {
|
||||
"fr": "Textual est installé.",
|
||||
"en": "Textual is installed.",
|
||||
},
|
||||
"Installation finished but textual is still missing.": {
|
||||
"fr": "Installation terminée mais textual reste introuvable.",
|
||||
"en": "Installation finished but textual is still missing.",
|
||||
},
|
||||
"pip exited with": {
|
||||
"fr": "pip a retourné",
|
||||
"en": "pip exited with",
|
||||
},
|
||||
"Your distribution may package it as python3-textual.": {
|
||||
"fr": "Votre distribution le fournit peut-être en python3-textual.",
|
||||
"en": "Your distribution may package it as python3-textual.",
|
||||
},
|
||||
"Resize a VM disk": {
|
||||
"fr": "📐 Redimensionner le disque d'une VM",
|
||||
|
|
@ -3113,10 +3133,6 @@ TRANSLATIONS = {
|
|||
"fr": "Fichiers de log :",
|
||||
"en": "Log files:",
|
||||
},
|
||||
"Install textual for the dashboard (pip).": {
|
||||
"fr": "Installez textual pour le dashboard (pip).",
|
||||
"en": "Install textual for the dashboard (pip).",
|
||||
},
|
||||
"Parallel deployments (default:": {
|
||||
"fr": "Déploiements en parallèle (défaut :",
|
||||
"en": "Parallel deployments (default:",
|
||||
|
|
|
|||
|
|
@ -507,12 +507,15 @@ class TodoUpgrade:
|
|||
def resume_tui(ctx):
|
||||
"""Resume screen as a TUI. Returns the SAME answer strings as the
|
||||
prompt, or None when textual is missing (fall back to the prompt)."""
|
||||
from script.todo import textual_setup
|
||||
|
||||
if not textual_setup.ensure():
|
||||
return None
|
||||
try:
|
||||
from script.todo.migration_form import run_resume_tui
|
||||
|
||||
return run_resume_tui(ctx)
|
||||
except ImportError:
|
||||
print(t("Install textual for the dashboard (pip)."))
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
Loading…
Reference in a new issue