#!/usr/bin/env bash # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) # # Bibliothèque SOURÇABLE : « une version de Python » -> « un interpréteur ». # # C'est le seul endroit du dépôt qui décide COMMENT un interpréteur est # obtenu. Tout le reste ne consomme que des chemins de venv, jamais pyenv ni # mise. Ajouter un fournisseur se fait donc ici, et nulle part ailleurs. # # Deux fournisseurs : # pyenv historique. Compile CPython depuis les sources : 1 à 3 min sur une # machine récente, bien plus sous émulation, et il lui faut une # douzaine de -dev (openssl, zlib, readline, sqlite, bzip2, xz, tk…). # mise pose un CPython PRÉCOMPILÉ (astral python-build-standalone) : # quelques secondes, aucun compilateur requis. # # EL_PYTHON_PROVIDER choisit : auto (défaut), mise, ou pyenv. # # En mode « auto », un interpréteur DÉJÀ présent gagne, quel que soit le # fournisseur qui l'a posé. Une installation pyenv qui marche n'est donc # jamais doublée par un second CPython de 150 Mo. # Ne jamais laisser mise compiler en silence : sans build précompilée pour la # plateforme, il retomberait sur python-build — le moteur de pyenv — et on # aurait la lenteur de pyenv sans son outillage. « false » = télécharger ou # échouer ; l'échec nous fait basculer proprement sur le repli. export MISE_PYTHON_COMPILE=false el_pyenv_root() { echo "${PYENV_ROOT:-${HOME}/.pyenv}" } # Chemin de l'interpréteur pyenv d'une version, qu'il existe ou non. el_pyenv_exec_path() { echo "$(el_pyenv_root)/versions/$1/bin/python" } # Interpréteur déjà posé par mise, sans le moindre accès réseau. el_mise_exec_path() { command -v mise > /dev/null 2>&1 || return 1 local out out="$(mise where "python@$1" 2> /dev/null)" || return 1 [ -n "${out}" ] && [ -x "${out}/bin/python" ] || return 1 echo "${out}/bin/python" } # Vrai si l'exécutable rend EXACTEMENT la version demandée. Un interpréteur # présent mais d'une autre version casserait Poetry, dont le pyproject exige # le patch au près (>=3.12.10,<3.13). el_python_is_version() { local exe="$1" want="$2" got [ -x "${exe}" ] || return 1 got="$("${exe}" -c 'import platform;print(platform.python_version())' 2> /dev/null)" [ "${got}" = "${want}" ] } # Vrai si l'exécutable CONVIENT. Deux exigences, selon ce qui borne l'appelant. # # patch même majeure.mineure, et patch au moins égal. C'est ce que le # pyproject d'Odoo demande — « >=3.12.10,<3.13 » —, et Poetry # refuserait un patch inférieur. # mineure même majeure.mineure, quel que soit le patch. RIEN n'épingle le # patch du venv d'OUTILLAGE : l'exiger écarte le Python des # distributions dès qu'il est d'un patch en retard — NixOS 25.11 # livre 3.14.2 quand conf/ demande 3.14.7 — et force pyenv à # COMPILER CPython pour une différence qui ne gêne personne. # # L'égalité stricte, elle, rejetterait aussi un patch plus RÉCENT : c'est # el_python_is_version, et elle ne sert qu'aux fournisseurs qui posent une # version nommée. el_python_is_compatible() { local exe="$1" want="$2" exigence="${3:-patch}" got [ -x "${exe}" ] || return 1 got="$("${exe}" -c 'import platform;print(platform.python_version())' \ 2> /dev/null)" [ -n "${got}" ] || return 1 # Même majeure.mineure : 3.13 ne convient pas à un pyproject borné à <3.13. [ "${got%.*}" = "${want%.*}" ] || return 1 [ "${exigence}" = "mineure" ] && return 0 # Patch au moins égal, comparé en version et non en chaîne (3.12.9 < 3.12.10). [ "$(printf '%s\n%s\n' "${want}" "${got}" | sort -V | head -1)" = "${want}" ] } # Interpréteur de la DISTRIBUTION qui conviendrait, s'il y en a un. # # Cherché avant toute compilation : sur une architecture émulée, bâtir CPython # prend des dizaines de minutes quand il aboutit. Le nom suit la convention de # toutes les distributions, « python3.12 ». el_distro_python_exec() { local want="$1" exigence="${2:-patch}" exe # Des chemins SYSTEME, jamais « command -v » : dans un venv activé celui-ci # rend le python DU VENV, et l'on bâtirait un venv depuis un venv. Le PATH # d'une session interactive n'a rien à faire dans cette décision. # Le profil du systeme AVANT /usr/bin, et c'est NixOS qui l'impose : la-bas # /usr/bin est servi par envfs, qui repond a open() et a execve() mais PAS a # stat(). Le test « -x » y reussit donc, l'interpreteur s'execute, et # « python -m venv » echoue quand meme -- il stat son propre interpreteur # pour le recopier : « Error: [Errno 2] No such file or directory ». Le # chemin du profil, lui, est un vrai lien symbolique. Ailleurs il n'existe # pas, et la boucle passe a la suite sans rien couter. for exe in "/run/current-system/sw/bin/python${want%.*}" \ "/usr/bin/python${want%.*}" "/usr/local/bin/python${want%.*}"; do if el_python_is_compatible "${exe}" "${want}" "${exigence}"; then echo "${exe}" return 0 fi done return 1 } # Installe la version via mise. Renvoie le chemin, ou échoue. el_mise_install() { local version="$1" exe command -v mise > /dev/null 2>&1 || return 1 echo "---- Python ${version} via mise (precompile) ----" >&2 mise install "python@${version}" >&2 || return 1 exe="$(el_mise_exec_path "${version}")" || return 1 el_python_is_version "${exe}" "${version}" || return 1 echo "${exe}" } # Installe la version via pyenv, en posant pyenv lui-même au besoin. el_pyenv_install() { local version="$1" root exe installer root="$(el_pyenv_root)" exe="$(el_pyenv_exec_path "${version}")" if [[ ! -d "${root}" ]]; then echo "---- Installation de pyenv dans ${root} ----" >&2 # Téléchargé dans un fichier, PUIS exécuté. Quand curl alimente bash par # un tube, le statut du tube est celui de bash, qui rend 0 sur une entrée # vide : sans pipefail — que rien ne pose dans cette chaîne —, un # « || return 1 » placé après le tube ne se déclencherait jamais. Un # téléchargement raté mènerait alors à « pyenv: command not found », puis # à un échec de compilation, deux messages qui accusent la mauvaise # étape. Lu seul, le statut de curl nomme la vraie cause. # # « -f » : sans lui, curl écrit le CORPS d'une erreur HTTP — page d'erreur # de miroir, portail captif, 504 d'un cache hors ligne —, que bash # exécuterait ensuite comme un script. installer="$(mktemp)" || return 1 if ! curl -fsSL -o "${installer}" \ https://raw.githubusercontent.com/pyenv/pyenv-installer/master/bin/pyenv-installer; then echo "Telechargement de l'installateur pyenv impossible" \ "(reseau ou cache) : pyenv n'est pas pose." >&2 rm -f "${installer}" return 1 fi if ! bash "${installer}" >&2; then echo "L'installateur de pyenv a echoue (voir ci-dessus)." >&2 rm -f "${installer}" return 1 fi rm -f "${installer}" fi export PATH="${root}/bin:$PATH" eval "$(pyenv init - 2> /dev/null)" || true eval "$(pyenv virtualenv-init - 2> /dev/null)" || true if [[ ! -d "${root}/versions/${version}" ]]; then # pyenv COMPILE CPython : sans compilateur C, il télécharge l'archive, # lance configure et s'arrête sur « no acceptable C compiler found », après # avoir gaspillé le téléchargement. On le dit AVANT, et on nomme le paquet. if ! command -v cc > /dev/null 2>&1 \ && ! command -v gcc > /dev/null 2>&1; then echo "Aucun compilateur C : pyenv ne peut pas compiler Python." >&2 echo " Installez le necessaire de compilation (build-essential," >&2 echo " gcc/gcc-c++, ou le motif devel_basis) puis relancez." >&2 return 1 fi echo "---- Python ${version} via pyenv (compilation) ----" >&2 # La liste des versions connues vient du dépôt git de pyenv : sans ce # « pull », une version récente est « not a known version ». (cd "${root}" && git pull) >&2 || true # Le contrôle d'erreur d'origine testait un $retVal jamais affecté ici : # un échec de compilation passait inaperçu jusqu'au test d'existence. if ! yes n | pyenv install "${version}" >&2; then echo "pyenv install ${version} a echoue." >&2 return 1 fi fi el_python_is_version "${exe}" "${version}" || return 1 echo "${exe}" } # Annonce la compilation pyenv AVANT de la subir, et le geste qui l'évite : # poser mise, ou lire ce que mise reproche quand il est déjà là. Muet si pyenv # porte déjà la version, puisque rien ne sera compilé. el_warn_pyenv_fallback() { local version="$1" [ -d "$(el_pyenv_root)/versions/${version}" ] && return 0 if command -v mise > /dev/null 2>&1; then echo "mise n'a pas pu fournir Python ${version} : repli sur pyenv," >&2 echo " qui COMPILE CPython. Pour lire ce que mise reproche :" >&2 echo " mise install python@${version} (reseau requis)" >&2 else echo "mise est absent : pyenv va etre pose, puis COMPILER CPython" >&2 echo " ${version} -- quelques minutes, bien plus sous emulation, et il" >&2 echo " lui faut une douzaine de -dev (openssl, zlib, readline, sqlite," >&2 echo " bzip2, xz, tk). Pour l'eviter : Ctrl+C, puis" >&2 echo " make install_mise" >&2 echo " qui pose un CPython precompile en quelques secondes, et relancez." >&2 fi } # API publique : imprime le chemin absolu d'un interpréteur de cette version, # ou rien (et rend non nul) si aucun fournisseur n'y parvient. el_python_exec() { local version="$1" exigence="${2:-patch}" local provider="${EL_PYTHON_PROVIDER:-auto}" exe # 1) Déjà présent ? On ne réinstalle rien et on ne touche pas au réseau. # C'est ce qui rend le changement sans effet pour une installation # pyenv existante. if [ "${provider}" != "mise" ]; then exe="$(el_pyenv_exec_path "${version}")" if el_python_is_version "${exe}" "${version}"; then echo "${exe}" return 0 fi fi if [ "${provider}" != "pyenv" ]; then if exe="$(el_mise_exec_path "${version}")" \ && el_python_is_version "${exe}" "${version}"; then echo "${exe}" return 0 fi fi # 2) Un interpréteur de la DISTRIBUTION qui convient ? Rien à installer, # rien à compiler. Vérifié avant mise et pyenv : c'est le seul chemin # qui ne coûte rien, et sur s390x le seul qui aboutisse partout — mise # n'y publie aucun binaire, et pyenv doit compiler. # Uniquement en mode « auto » : demander mise ou pyenv explicitement doit # être respecté, sinon le réglage ne veut plus rien dire. if [ "${provider}" = "auto" ] \ && exe="$(el_distro_python_exec "${version}" "${exigence}")"; then echo "Python $("${exe}" -V 2>&1 | awk '{print $2}') de la distribution :" \ "aucune compilation." >&2 echo "${exe}" return 0 fi # 3) Rien de posé : il faut provisionner. mise d'abord quand il est là, # parce qu'il télécharge au lieu de compiler. if [ "${provider}" != "pyenv" ]; then if exe="$(el_mise_install "${version}")"; then echo "${exe}" return 0 fi [ "${provider}" = "mise" ] && return 1 el_warn_pyenv_fallback "${version}" fi el_pyenv_install "${version}" }