Textual était déclaré sans version. Les quatre écrans TUI du dépôt sont écrits pour la 8, et la bibliothèque casse son API entre majeures : un « pip install -U » les casse tous sans prévenir. Borner le fichier de requirements ne suffisait pas. install_command() faisait « pip install textual », sans borne : « make install » aurait pris la 8, et l'installation proposée à l'écran la majeure suivante. Deux chemins pour la même dépendance, qui ne disent pas la même chose. La borne vit donc dans une seule constante, TEXTUAL_SPEC, que install_command() utilise, et que le fichier de requirements recopie avec un commentaire qui pointe dessus. La borne ne s'applique qu'à l'installation : ensure() vérifie « est-ce importable », pas « à quelle version ». Un Textual 9 déjà présent passe, et c'est volontaire — refuser de démarrer sur une version qui marche peut-être serait pire que le problème. lxml devient une dépendance déclarée. Il n'arrivait que par pykeepass, openupgradelib et odoo-module-migrator ; le jour où l'un d'eux s'en passe, il disparaît d'un venv sans que rien ne le réclame. Sans borne : cyclonedx-python-lib demande déjà « lxml >=4,<7 », en ajouter une seconde n'apporterait qu'un conflit possible. Vérifié : install_command() porte la borne, l'insertion de « --user » hors venv reste au bon rang, et le Textual installé (8.2.8) la satisfait. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
98 lines
3.7 KiB
Python
98 lines
3.7 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
|
|
|
|
|
|
# Borne de version, à UN seul endroit. Les écrans TUI du dépôt sont écrits pour
|
|
# Textual 8 ; la bibliothèque casse son API entre majeures. « pip install
|
|
# textual » sans borne installerait la majeure suivante et casserait les écrans
|
|
# sans prévenir, alors même que requirement/erplibre_require-ments.txt la borne.
|
|
# Les deux chemins d'installation doivent dire la même chose : ce littéral est
|
|
# recopié dans le fichier de requirements, avec un commentaire qui pointe ici.
|
|
TEXTUAL_SPEC = "textual>=8,<9"
|
|
|
|
|
|
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_SPEC]
|
|
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
|