diff --git a/script/todo/textual_setup.py b/script/todo/textual_setup.py new file mode 100644 index 0000000..070c0c0 --- /dev/null +++ b/script/todo/textual_setup.py @@ -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 diff --git a/script/todo/todo.py b/script/todo/todo.py index bff23f5..dfeedb4 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -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): diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 318262c..b6af56d 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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:", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 7565aa7..6c37836 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -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