237 lines
8 KiB
Python
237 lines
8 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)
|
||
|
|
|
||
|
|
"""L'environnement de lecture des fichiers externes, posé à la demande.
|
||
|
|
|
||
|
|
Excel et Access exigent des bibliothèques qui ne sont dans aucun venv du
|
||
|
|
dépôt : `.venv.erplibre` porte l'outillage du CLI, le venv Odoo porte celui
|
||
|
|
d'Odoo, et charger l'un ou l'autre de ces lecteurs les mélangerait à un
|
||
|
|
sujet qui n'est pas le leur. D'où un venv dédié, bâti au premier besoin.
|
||
|
|
|
||
|
|
Les formats en pur stdlib n'en ont PAS besoin, et c'est ce que
|
||
|
|
`available()` et `engine_python()` tiennent à la place des appelants : un
|
||
|
|
CSV ne doit jamais déclencher la construction d'un venv.
|
||
|
|
|
||
|
|
La pose d'un paquet SYSTÈME ne se réécrit pas ici : `todo_install` la fait
|
||
|
|
déjà pour les quatre familles, et il est testé. On l'appelle.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import os
|
||
|
|
import shutil
|
||
|
|
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
|
||
|
|
|
||
|
|
|
||
|
|
try:
|
||
|
|
from script.todo import todo_install
|
||
|
|
except Exception: # pragma: no cover - repli hors dépôt
|
||
|
|
todo_install = None
|
||
|
|
|
||
|
|
NOM_VENV = ".venv.todo.external_data"
|
||
|
|
REQUIREMENTS = os.path.join("requirement", "todo_external_data.txt")
|
||
|
|
|
||
|
|
# Ce que chaque format exige d'importable. Les formats absents de cette
|
||
|
|
# table sont en pur stdlib et ne demandent rien.
|
||
|
|
IMPORTS_PAR_FORMAT = {
|
||
|
|
"xlsx": ("openpyxl",),
|
||
|
|
"xls": ("xlrd",),
|
||
|
|
"access": ("access_parser",),
|
||
|
|
}
|
||
|
|
|
||
|
|
# Les formats servis par l'interpréteur du CLI, sans venv.
|
||
|
|
FORMATS_STDLIB = ("csv", "json", "xml", "macros")
|
||
|
|
|
||
|
|
# mdbtools rend les requêtes enregistrées d'une base Access lisibles.
|
||
|
|
# PAS d'entrée `pacman` : le paquet n'est pas dans les dépôts officiels
|
||
|
|
# d'Arch — seulement l'AUR — et `install_command` doit alors rendre None
|
||
|
|
# plutôt qu'une commande qui échoue APRÈS le mot de passe sudo.
|
||
|
|
PAQUETS_ACCESS = {
|
||
|
|
"apt-get": ["mdbtools"],
|
||
|
|
"dnf": ["mdbtools"],
|
||
|
|
"zypper": ["mdbtools"],
|
||
|
|
}
|
||
|
|
|
||
|
|
# Le binaire qui les lit. La PRÉSENCE se demande au PATH, jamais au
|
||
|
|
# gestionnaire de paquets : sur une distribution dont ce module ne connaît
|
||
|
|
# pas le paquet — Arch, où mdbtools n'est qu'à l'AUR — l'absence de
|
||
|
|
# commande d'installation était rendue comme l'absence de l'outil, et
|
||
|
|
# l'entrée disait « aucun paquet connu » devant un `mdb-queries` installé.
|
||
|
|
BINAIRE_REQUETES = "mdb-queries"
|
||
|
|
|
||
|
|
|
||
|
|
def racine() -> str:
|
||
|
|
"""La racine du dépôt, quel que soit le répertoire courant."""
|
||
|
|
return os.path.normpath(
|
||
|
|
os.path.join(os.path.dirname(__file__), "..", "..")
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def venv_path() -> str:
|
||
|
|
return os.path.join(racine(), NOM_VENV)
|
||
|
|
|
||
|
|
|
||
|
|
def python_path() -> str | None:
|
||
|
|
"""L'interpréteur du venv dédié, ou None s'il n'existe pas."""
|
||
|
|
candidat = os.path.join(venv_path(), "bin", "python")
|
||
|
|
return candidat if os.path.isfile(candidat) else None
|
||
|
|
|
||
|
|
|
||
|
|
def engine_python(fmt: str | None = None) -> str:
|
||
|
|
"""L'interpréteur qui servira ce format.
|
||
|
|
|
||
|
|
`sys.executable` pour les formats en pur stdlib — le moteur s'y importe
|
||
|
|
déjà, ses imports tiers étant paresseux — et en repli quand le venv
|
||
|
|
dédié n'existe pas encore, pour que l'appelant obtienne toujours un
|
||
|
|
chemin exécutable plutôt qu'un None à tester.
|
||
|
|
"""
|
||
|
|
if fmt in FORMATS_STDLIB:
|
||
|
|
return sys.executable
|
||
|
|
return python_path() or sys.executable
|
||
|
|
|
||
|
|
|
||
|
|
def _importable_par(interpreteur: str, module: str) -> bool:
|
||
|
|
try:
|
||
|
|
acheve = subprocess.run(
|
||
|
|
[interpreteur, "-c", f"import {module}"],
|
||
|
|
stdout=subprocess.DEVNULL,
|
||
|
|
stderr=subprocess.DEVNULL,
|
||
|
|
timeout=60,
|
||
|
|
)
|
||
|
|
return acheve.returncode == 0
|
||
|
|
except (OSError, subprocess.SubprocessError):
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
def available(fmt: str | None = None) -> bool:
|
||
|
|
"""Ce format est-il lisible ici, maintenant ?
|
||
|
|
|
||
|
|
Vrai d'office pour les formats en pur stdlib : exiger un venv pour lire
|
||
|
|
un CSV ferait construire un environnement pour rien.
|
||
|
|
"""
|
||
|
|
if fmt in FORMATS_STDLIB:
|
||
|
|
return True
|
||
|
|
interpreteur = python_path()
|
||
|
|
if not interpreteur:
|
||
|
|
return False
|
||
|
|
modules = IMPORTS_PAR_FORMAT.get(fmt) if fmt else ("openpyxl",)
|
||
|
|
return all(_importable_par(interpreteur, m) for m in modules or ())
|
||
|
|
|
||
|
|
|
||
|
|
def requetes_access_lisibles() -> bool:
|
||
|
|
"""`mdb-queries` est-il sur le PATH ?
|
||
|
|
|
||
|
|
Les requêtes enregistrées d'une base Access ne se lisent pas en pur
|
||
|
|
Python : `access-parser` rend les TABLES, et le catalogue où vivent
|
||
|
|
les requêtes est écarté exprès. Cette question est donc distincte de
|
||
|
|
« le format access est-il lisible », qui reste vraie sans mdbtools.
|
||
|
|
"""
|
||
|
|
return shutil.which(BINAIRE_REQUETES) is not None
|
||
|
|
|
||
|
|
|
||
|
|
def capabilities() -> dict:
|
||
|
|
"""Ce que la machine sait lire. Pour l'entrée du même nom.
|
||
|
|
|
||
|
|
Les requêtes enregistrées y figurent comme une entrée à part : elles
|
||
|
|
ne viennent pas du venv mais d'un paquet système, et une base Access
|
||
|
|
reste lisible sans elles — seulement, ce qu'elles portent n'est alors
|
||
|
|
ni lu ni compté, et l'opérateur doit le savoir avant de transmettre.
|
||
|
|
"""
|
||
|
|
etat = {nom: True for nom in FORMATS_STDLIB}
|
||
|
|
for fmt in IMPORTS_PAR_FORMAT:
|
||
|
|
etat[fmt] = available(fmt)
|
||
|
|
etat["xlsb"] = False
|
||
|
|
etat["access queries"] = requetes_access_lisibles()
|
||
|
|
return etat
|
||
|
|
|
||
|
|
|
||
|
|
def create(ask=input, executeur=None) -> bool:
|
||
|
|
"""Bâtir le venv puis y poser les requirements. Booléen.
|
||
|
|
|
||
|
|
La commande s'affiche AVANT la question : on approuve ce qu'on a lu.
|
||
|
|
"""
|
||
|
|
cible = venv_path()
|
||
|
|
requirements = os.path.join(racine(), REQUIREMENTS)
|
||
|
|
if not os.path.isfile(requirements):
|
||
|
|
print(f" ⚠ {t('Not readable: check the permissions.')}")
|
||
|
|
print(f" {requirements}")
|
||
|
|
return False
|
||
|
|
|
||
|
|
creation = [sys.executable, "-m", "venv", cible]
|
||
|
|
print(f" {t('Will execute:')} {' '.join(creation)}")
|
||
|
|
print(
|
||
|
|
f" {t('Will execute:')} {os.path.join(cible, 'bin', 'pip')}"
|
||
|
|
f" install -r {REQUIREMENTS}"
|
||
|
|
)
|
||
|
|
if not _oui(ask(t("Create it now? (Y/n): "))):
|
||
|
|
print(t("Nothing to do."))
|
||
|
|
return False
|
||
|
|
|
||
|
|
if not python_path():
|
||
|
|
code = _lancer(creation, executeur)
|
||
|
|
if code:
|
||
|
|
print(f" ⚠ {t('python -m venv exited with')} {code}")
|
||
|
|
return False
|
||
|
|
pip = os.path.join(cible, "bin", "pip")
|
||
|
|
code = _lancer([pip, "install", "-r", requirements], executeur)
|
||
|
|
if code:
|
||
|
|
print(f" ⚠ {t('pip exited with')} {code}")
|
||
|
|
if available("xlsx"):
|
||
|
|
print(f"✅ {t('The environment is ready.')}")
|
||
|
|
return True
|
||
|
|
print(f" ⚠ {t('Creation finished but the libraries are still missing.')}")
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
def _lancer(commande, executeur=None) -> int:
|
||
|
|
if executeur is not None:
|
||
|
|
return executeur(commande)
|
||
|
|
try:
|
||
|
|
return subprocess.run(commande).returncode
|
||
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
||
|
|
print(f" ⚠ {exc}")
|
||
|
|
return 1
|
||
|
|
|
||
|
|
|
||
|
|
def _oui(reponse) -> bool:
|
||
|
|
"""« Défaut oui » : la réponse vide accepte."""
|
||
|
|
valeur = str(reponse or "").strip().lower()
|
||
|
|
return valeur == "" or valeur in ("y", "yes", "o", "oui")
|
||
|
|
|
||
|
|
|
||
|
|
def ensure(fmt: str, prompt: bool = True, ask=input) -> bool:
|
||
|
|
"""Le format est-il lisible ? Sinon, proposer d'y remédier.
|
||
|
|
|
||
|
|
`prompt=False` se contente de constater — pour les appels qui ne
|
||
|
|
peuvent pas poser de question.
|
||
|
|
"""
|
||
|
|
if available(fmt):
|
||
|
|
return True
|
||
|
|
print(
|
||
|
|
f"\n⚠ {t('A dedicated environment is required to read this format.')}"
|
||
|
|
)
|
||
|
|
if not prompt:
|
||
|
|
return False
|
||
|
|
return create(ask=ask)
|
||
|
|
|
||
|
|
|
||
|
|
def system_packages_cmd():
|
||
|
|
"""La commande qui pose `mdbtools`, ou None si personne ne le connaît.
|
||
|
|
|
||
|
|
Un simple passe-plat vers `todo_install`, qui décide déjà de la famille
|
||
|
|
par l'ID de /etc/os-release avant le PATH. Écrire une cascade de plus
|
||
|
|
serait refaire le défaut que ce module a supprimé.
|
||
|
|
"""
|
||
|
|
if todo_install is None: # pragma: no cover - hors dépôt
|
||
|
|
return None
|
||
|
|
return todo_install.install_command(PAQUETS_ACCESS)
|