[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:
Mathieu Benoit 2026-08-03 02:42:52 -04:00
parent e8f7e8384a
commit 5d6a262489
4 changed files with 144 additions and 21 deletions

View 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 , 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 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

View file

@ -584,12 +584,15 @@ class TODO:
et la position du curseur sont restaurés) ou de quitter.""" et la position du curseur sont restaurés) ou de quitter."""
from script.todo.todo_telemetry import run_tui from script.todo.todo_telemetry import run_tui
from script.todo import textual_setup
if not textual_setup.ensure():
return
state = None state = None
while True: while True:
try: try:
result = run_tui(state=state) result = run_tui(state=state)
except ImportError: except ImportError:
print(t("Install textual for the telemetry TUI (pip)."))
return return
if not result: if not result:
return return
@ -1843,10 +1846,13 @@ class TODO:
Tout vient de l'historique tenu par le moniteur d'installation Tout vient de l'historique tenu par le moniteur d'installation
(.venv.erplibre/qemu_install_stats.json) et de l'état libvirt courant. (.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: try:
from script.todo import qemu_install_monitor as mon from script.todo import qemu_install_monitor as mon
except ImportError: except ImportError as exc:
print(t("Install textual for the dashboard (pip).")) print(f"{t('Command failed: ')}{exc}")
return return
while True: while True:
@ -2242,7 +2248,10 @@ class TODO:
try: try:
mon.run_monitor(run["manifest"]) mon.run_monitor(run["manifest"])
except ImportError: 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: except Exception as exc:
print(f"{t('Command failed: ')}{exc}") print(f"{t('Command failed: ')}{exc}")
@ -4278,8 +4287,11 @@ class TODO:
run_monitor(manifest) run_monitor(manifest)
except ImportError: except ImportError:
# textual absent : les installs tournent déjà (détachées), on ne # textual absent : les installs tournent déjà (détachées), on ne
# plante pas — on indique juste où sont les logs. # plante donc pas — on propose de l'installer pour rouvrir.
print(f" {t('Install textual for the dashboard (pip).')}") from script.todo import textual_setup
if textual_setup.ensure():
run_monitor(manifest)
print( print(
f"\n{t('Monitor closed. Installs keep running in the background.')}" 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): def _qemu_deploy_form(self, mod, dry_run):
"""Ouvre le formulaire TUI. Renvoie la spec, None si annulé, ou {} """Ouvre le formulaire TUI. Renvoie la spec, None si annulé, ou {}
pour retomber sur les invites en ligne (textual absent).""" pour retomber sur les invites en ligne (textual absent)."""
from script.todo import textual_setup
if not textual_setup.ensure():
return {}
try: try:
from script.todo.qemu_deploy_form import run_deploy_form from script.todo.qemu_deploy_form import run_deploy_form
except ImportError:
print(t("Install textual for the dashboard (pip).")) ctx = self._qemu_form_context(mod)
return {}
ctx = self._qemu_form_context(mod)
try:
spec = run_deploy_form(ctx) spec = run_deploy_form(ctx)
except ImportError: except ImportError:
print(t("Install textual for the dashboard (pip)."))
return {} return {}
if not spec: if not spec:
print(t("Cancelled.")) print(t("Cancelled."))
@ -5255,12 +5267,15 @@ class TODO:
def _qemu_deploy_jobs_tui(self, jobs, workers): def _qemu_deploy_jobs_tui(self, jobs, workers):
"""Même chose, en blocs repliables Textual. Renvoie None si textual """Même chose, en blocs repliables Textual. Renvoie None si textual
manque, pour que l'appelant retombe sur la sortie texte.""" manque, pour que l'appelant retombe sur la sortie texte."""
from script.todo import textual_setup
if not textual_setup.ensure():
return None
try: try:
from script.todo.qemu_deploy_form import run_deploy_progress from script.todo.qemu_deploy_form import run_deploy_progress
return run_deploy_progress(jobs, workers) return run_deploy_progress(jobs, workers)
except ImportError: except ImportError:
print(f" {t('Install textual for the dashboard (pip).')}")
return None return None
def _qemu_run_spec(self, spec): def _qemu_run_spec(self, spec):

View file

@ -2118,9 +2118,29 @@ TRANSLATIONS = {
"fr": "Échec de la commande : ", "fr": "Échec de la commande : ",
"en": "Command failed: ", "en": "Command failed: ",
}, },
"Install textual for the telemetry TUI (pip).": { "Textual is required for this screen.": {
"fr": "Installez textual pour le TUI de télémétrie (pip).", "fr": "Textual est nécessaire pour cet écran.",
"en": "Install textual for the telemetry TUI (pip).", "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": { "Resize a VM disk": {
"fr": "📐 Redimensionner le disque d'une VM", "fr": "📐 Redimensionner le disque d'une VM",
@ -3113,10 +3133,6 @@ TRANSLATIONS = {
"fr": "Fichiers de log :", "fr": "Fichiers de log :",
"en": "Log files:", "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:": { "Parallel deployments (default:": {
"fr": "Déploiements en parallèle (défaut :", "fr": "Déploiements en parallèle (défaut :",
"en": "Parallel deployments (default:", "en": "Parallel deployments (default:",

View file

@ -507,12 +507,15 @@ class TodoUpgrade:
def resume_tui(ctx): def resume_tui(ctx):
"""Resume screen as a TUI. Returns the SAME answer strings as the """Resume screen as a TUI. Returns the SAME answer strings as the
prompt, or None when textual is missing (fall back to the prompt).""" 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: try:
from script.todo.migration_form import run_resume_tui from script.todo.migration_form import run_resume_tui
return run_resume_tui(ctx) return run_resume_tui(ctx)
except ImportError: except ImportError:
print(t("Install textual for the dashboard (pip)."))
return None return None
@staticmethod @staticmethod