erplibre/script/todo/textual_setup.py
Mathieu Benoit 5d6a262489 [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>
2026-08-03 02:42:52 -04:00

89 lines
3.2 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)
"""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