Ctrl K
ComunidadGuías…

_amorcage.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Amorçage des dépendances d'une application lancée depuis ses sources.

Une application distribuée en code source (``python app.py``) doit, au premier
lancement, trouver ses bibliothèques : créer un environnement isolé (venv), y
installer le verrou ``requirements.txt`` (versions exactes, empreintes SHA-256
vérifiées par pip), puis s'y relancer. Les quatre applications (blink2video,
lidar2map, gpxsolar, watch2notif) en avaient chacune une version, qui avait
dérivé : mêmes fonctions de lecture du verrou, deux moteurs de venv différents,
une troisième installation sans verrou.

Ce module est LA version commune, avec une contrainte qui le distingue des
autres : il tourne AVANT que ses propres dépendances (dont ce paquet) soient
installées. Il ne peut donc pas être importé depuis ``nico579_commons`` au
démarrage. Chaque application en garde une copie octet pour octet
(``_amorcage.py``, à côté de son point d'entrée), que la même suite de tests
compare à celle du paquet installé : toute dérive fait échouer la CI. C'est le
procédé de pip et de setuptools pour leurs dépendances « vendorisées ».

Bibliothèque standard seule, Python 3.8 compris. Aucun effet à l'import.

Quatre modes, choisis par ``--bootstrap=<mode>`` ou ``<NOM>_BOOTSTRAP`` :

``auto``   (défaut) un venv ``~/.<nom>/venv``, créé au premier lancement, remis
           au verrou quand celui-ci change, où le programme se relance ;
``force``  comme ``auto``, même si un autre environnement est déjà actif ;
``pip``    installation du verrou dans l'environnement Python courant ;
``none``   rien n'est installé : on vérifie et on explique ce qui manque.
"""

from __future__ import annotations

import hashlib
import os
import platform
import re
import subprocess
import sys
from pathlib import Path

MODES = ("auto", "pip", "none", "force")
OPTION_INSTALLER = "--installer-deps"
# Anciens raccourcis de lidar2map, conservés : leur priorité est fixe
# (--no-venv l'emporte sur --venv, qui l'emporte sur --no-bootstrap).
ALIAS = (("--no-bootstrap", "none"), ("--venv", "auto"), ("--no-venv", "pip"))
DELAI_PIP_S = 1800

LIBELLES = {
    "en": {
        "aide": (
            "Dependencies when running {nom} from its sources.\n\n"
            "  --bootstrap=auto    (default) isolated venv in {dossier}, created\n"
            "                      on the first launch and brought back to the lock\n"
            "                      when it changes; the program relaunches inside.\n"
            "                      If a conda / venv environment is active, stops and\n"
            "                      points to --bootstrap=pip|none instead of creating\n"
            "                      a parallel venv.\n"
            "  --bootstrap=force   same, even when another environment is active.\n"
            "  --bootstrap=pip     install the lock in the current Python environment\n"
            "                      (--break-system-packages, then --user, if PEP 668).\n"
            "  --bootstrap=none    install nothing: check that the dependencies of\n"
            "                      requirements.in are there, and explain what is missing.\n"
            "  --installer-deps    install the whole lock (build scripts), then exit.\n"
            "  --help-bootstrap    this help.\n\n"
            "Same as environment variable {variable}=auto|force|pip|none.\n"
            "To remove the venv at any time: {suppression}\n"),
        "mode_valeur_manquante": "--bootstrap requires a value among auto, force, pip and none",
        "mode_valeur_invalide": ("invalid value for --bootstrap: {valeur!r} "
                                 "(expected: auto, force, pip or none)"),
        "titre_none": "Mode --bootstrap=none: auto-install disabled",
        "paquets_absents": "Missing Python packages: {liste}",
        "installer_vous_meme": "Install them yourself, at the exact versions of the lock:",
        "titre_impossible": "ERROR: cannot install the Python dependencies",
        "dernier_message_pip": "Last pip message:\n  {message}",
        "solutions": "Possible solutions:",
        "solution_auto": "1. Let {nom} create its own isolated environment (recommended):",
        "solution_none": ("2. Install the lock into an environment of your own, then "
                          "relaunch with --bootstrap=none:"),
        "titre_env_actif": "Active Python environment detected (conda / venv)",
        "env_actif": "Active environment: {env}",
        "eviter_venv_parallele": "To avoid creating a parallel venv in {dossier}:",
        "ou_desactiver": "(or deactivate the active environment to use the isolated venv)",
        "ou_forcer": "(or --bootstrap=force to create the venv anyway)",
        "installer_ici": "install the dependencies in this environment",
        "deja_la": "dependencies are already there",
        "titre_premier_lancement": "First launch - creating an isolated Python environment",
        "premier_1": "(a few tens of MB once installed). No impact on the system Python.",
        "premier_2": "To remove it: {suppression}",
        "premier_3": "To use a direct install (no venv):",
        "creation_venv": "Creating venv {venv}...",
        "echec_venv": "ERROR creating venv: {erreur}",
        "installer_venv_module": ("Install the Python venv module "
                                  "(on Debian/Ubuntu: sudo apt install python3-venv)."),
        "installation_venv": "Installing dependencies in the venv (3-5 min)...",
        "echec_installation_venv": "ERROR installing the dependencies in the venv:",
        "verifier_connexion": "Check your internet connection, then try:",
        "installe": "✓ Dependencies installed.",
        "relance": "Relaunching in venv...",
        "relance_dans": "Relaunching in venv: {venv}",
        "installation_en_cours": "Installing dependencies: {liste}...",
        "installation_reussie": "✓ Install succeeded ({libelle})",
        "pip_absent": "pip missing, bootstrap via ensurepip...",
        "pip_installe": "pip installed.",
        "echec_pip": "ERROR bootstrap pip: {erreur}",
        "installer_pip": "Install pip manually: https://pip.pypa.io/en/stable/installation/",
        "timeout_pip": "pip install timeout (>1800s, network blocked?)",
        "titre_venv_absent": "ERROR: Python module 'venv' missing",
        "venv_separe": "On Ubuntu/Debian, this module is in a separate package.",
        "installer_une_fois": "Install it with (once):",
        "ou_version": "# or, if you use Python {version} explicitly:",
        "puis_relancer": "Then relaunch the script.",
        "installation_verrou": "Installing the locked dependencies...",
        "echec_verrou": "ERROR: pip could not install the lock:",
        "verrou_installe": "All dependencies installed.",
        "strategie_standard": "standard",
        "strategie_standard_venv": "standard (venv)",
        "strategie_pep668": "--break-system-packages (PEP 668)",
        "strategie_user": "--user (local install)",
    },
    "fr": {
        "aide": (
            "Dépendances au lancement de {nom} depuis ses sources.\n\n"
            "  --bootstrap=auto    (défaut) venv isolé dans {dossier}, créé au premier\n"
            "                      lancement et remis au verrou quand il change ; le\n"
            "                      programme s'y relance. Si un environnement conda ou venv\n"
            "                      est actif, s'arrête et oriente vers --bootstrap=pip|none\n"
            "                      plutôt que de créer un venv parallèle.\n"
            "  --bootstrap=force   idem, même si un autre environnement est actif.\n"
            "  --bootstrap=pip     installe le verrou dans l'environnement Python courant\n"
            "                      (--break-system-packages, puis --user, si PEP 668).\n"
            "  --bootstrap=none    n'installe rien : vérifie que les dépendances de\n"
            "                      requirements.in sont là et explique ce qui manque.\n"
            "  --installer-deps    installe tout le verrou (scripts de build), puis quitte.\n"
            "  --help-bootstrap    cette aide.\n\n"
            "Équivalent : variable d'environnement {variable}=auto|force|pip|none.\n"
            "Pour supprimer le venv à tout moment : {suppression}\n"),
        "mode_valeur_manquante": "--bootstrap demande une valeur parmi auto, force, pip et none",
        "mode_valeur_invalide": ("valeur invalide pour --bootstrap : {valeur!r} "
                                 "(attendu : auto, force, pip ou none)"),
        "titre_none": "Mode --bootstrap=none : installation automatique désactivée",
        "paquets_absents": "Paquets Python absents : {liste}",
        "installer_vous_meme": "Installez-les vous-même, aux versions exactes du verrou :",
        "titre_impossible": "ERREUR : impossible d'installer les dépendances Python",
        "dernier_message_pip": "Dernier message de pip :\n  {message}",
        "solutions": "Solutions possibles :",
        "solution_auto": "1. Laisser {nom} créer son propre environnement isolé (recommandé) :",
        "solution_none": ("2. Installer le verrou dans un environnement à vous, puis relancer "
                          "avec --bootstrap=none :"),
        "titre_env_actif": "Environnement Python actif détecté (conda / venv)",
        "env_actif": "Environnement actif : {env}",
        "eviter_venv_parallele": "Pour éviter de créer un venv parallèle dans {dossier} :",
        "ou_desactiver": "(ou désactivez l'environnement actif pour utiliser le venv isolé)",
        "ou_forcer": "(ou --bootstrap=force pour créer le venv malgré tout)",
        "installer_ici": "installe les dépendances dans cet environnement",
        "deja_la": "si les dépendances y sont déjà",
        "titre_premier_lancement": "Premier lancement : création d'un environnement Python isolé",
        "premier_1": "(quelques dizaines de Mo une fois installé). Sans effet sur le Python système.",
        "premier_2": "Pour le supprimer : {suppression}",
        "premier_3": "Pour une installation directe (sans venv) :",
        "creation_venv": "Création de l'environnement isolé dans {venv}...",
        "echec_venv": "ERREUR à la création du venv : {erreur}",
        "installer_venv_module": ("Installez le module venv de Python "
                                  "(sous Debian/Ubuntu : sudo apt install python3-venv)."),
        "installation_venv": "Installation des dépendances dans le venv (3 à 5 min)...",
        "echec_installation_venv": "ERREUR à l'installation des dépendances dans le venv :",
        "verifier_connexion": "Vérifiez votre connexion, puis essayez :",
        "installe": "✓ Dépendances installées.",
        "relance": "Relance dans le venv...",
        "relance_dans": "Relance dans {venv}...",
        "installation_en_cours": "Installation de : {liste}...",
        "installation_reussie": "✓ Installation réussie ({libelle})",
        "pip_absent": "pip absent, amorçage par ensurepip...",
        "pip_installe": "pip installé.",
        "echec_pip": "ERREUR d'amorçage de pip : {erreur}",
        "installer_pip": "Installez pip à la main : https://pip.pypa.io/en/stable/installation/",
        "timeout_pip": "pip install : délai dépassé (plus de 1800 s, réseau bloqué ?)",
        "titre_venv_absent": "ERREUR : le module Python « venv » est absent",
        "venv_separe": "Sous Ubuntu/Debian, ce module est dans un paquet séparé.",
        "installer_une_fois": "Installez-le (une seule fois) :",
        "ou_version": "# ou, si vous utilisez explicitement Python {version} :",
        "puis_relancer": "Puis relancez le programme.",
        "installation_verrou": "Installation des dépendances verrouillées...",
        "echec_verrou": "ERREUR : pip n'a pas pu installer le verrou :",
        "verrou_installe": "Toutes les dépendances sont installées.",
        "strategie_standard": "standard",
        "strategie_standard_venv": "standard (venv)",
        "strategie_pep668": "--break-system-packages (PEP 668)",
        "strategie_user": "--user (installation locale)",
    },
}


# ---------------------------------------------------------------- lecture du verrou

def nom_normalise(nom: str) -> str:
    """Nom de distribution comparable (PEP 503) : « Pillow » et « pillow »,
    « srtm.py » et « srtm-py » se valent."""
    return re.sub(r"[-_.]+", "-", nom).lower()


def dependances_directes(fichier, *, conditionnelles=False) -> list:
    """Noms des paquets de requirements.in, sans version.

    Un paquet qui porte un marqueur d'environnement (« ; sys_platform ... ») est
    conditionnel : absent à bon droit sur certains systèmes (numba sur les Mac
    Intel, faute de roue). Il n'est rendu que sur demande : le contrôle au
    démarrage ne l'exige donc pas."""
    noms = []
    for ligne in Path(fichier).read_text(encoding="utf-8").splitlines():
        ligne = ligne.split("#", 1)[0].strip()
        if not ligne or ligne.startswith("-"):
            continue
        if ";" in ligne and not conditionnelles:
            continue
        noms.append(re.split(r"[\s<>=!~;\[]", ligne, maxsplit=1)[0])
    return noms


def dependances_absentes(noms, distributions=None) -> list:
    """Ceux de ``noms`` qu'aucune distribution installée ne fournit.

    Lit les métadonnées des paquets installés, sans rien importer : pas de table
    paquet-module à tenir (Pillow s'importe PIL), et aucun module lourd chargé au
    démarrage."""
    if distributions is None:
        import importlib.metadata
        distributions = importlib.metadata.distributions()
    installes = {nom_normalise(d.metadata["Name"] or "") for d in distributions}
    return [nom for nom in noms if nom_normalise(nom) not in installes]


def empreinte_verrou(verrou) -> str:
    return hashlib.sha256(Path(verrou).read_bytes()).hexdigest()


def commande_installation(python, *options, verrou, avec_empreintes=True) -> list:
    """pip install du verrou, empreintes vérifiées (``avec_empreintes=False`` pour
    une liste de paquets non verrouillée)."""
    commande = [str(python), "-m", "pip", "install", "-q", "--disable-pip-version-check"]
    if avec_empreintes:
        commande.append("--require-hashes")
    return commande + ["-r", str(verrou), *options]


# --------------------------------------------------------------- résolution du mode

class Resolution:
    """Résultat pur de l'analyse des options précoces."""

    def __init__(self, mode, argv, aide=False):
        self.mode, self.argv, self.aide = mode, tuple(argv), aide


def resoudre_mode(argv, environnement, variable, texte=None) -> Resolution:
    """Résout le mode et rend une copie nettoyée de ``argv``.

    Ne modifie ni la séquence ni l'environnement reçus. L'aide a priorité sur les
    erreurs. Une valeur invalide est rejetée (ValueError) : le résultat reste
    atomique, et une option suivante ne peut pas être avalée comme valeur de
    ``--bootstrap``."""
    texte = texte or (lambda cle, **valeurs: LIBELLES["en"][cle].format(**valeurs))
    args = list(argv)
    mode = "auto"
    if "--help-bootstrap" in args:
        return Resolution(mode, args, aide=True)

    depuis_env = environnement.get(variable, "").lower().strip()
    if depuis_env in MODES:
        mode = depuis_env

    a_retirer = []
    for index, argument in enumerate(args):
        valeur = None
        if argument.startswith("--bootstrap="):
            valeur = argument.split("=", 1)[1].lower().strip()
            a_retirer.append(index)
        elif argument == "--bootstrap":
            if index + 1 >= len(args) or args[index + 1].startswith("-"):
                raise ValueError(texte("mode_valeur_manquante"))
            valeur = args[index + 1].lower().strip()
            a_retirer.extend((index, index + 1))
        if valeur is not None:
            if valeur not in MODES:
                raise ValueError(texte("mode_valeur_invalide", valeur=valeur))
            mode = valeur

    for drapeau, equivalent in ALIAS:
        if drapeau in args:
            mode = equivalent

    for index in sorted(set(a_retirer), reverse=True):
        del args[index]
    for drapeau in [d for d, _ in ALIAS] + ["--help-bootstrap"]:
        while drapeau in args:
            args.remove(drapeau)
    return Resolution(mode, args)


# --------------------------------------------------------------------- le moteur

def _ecrire(texte="") -> None:
    """print() qui ne plante pas sur une console qui ne sait pas afficher les
    cadres (cp1252) : le message passe, au pire en ASCII."""
    try:
        print(texte)
    except UnicodeEncodeError:
        print(str(texte).encode("ascii", "replace").decode("ascii"))


def _boite(titre) -> None:
    _ecrire()
    _ecrire("  ╔" + "═" * 62 + "╗")
    _ecrire("  ║ " + titre.ljust(60) + " ║")
    _ecrire("  ╚" + "═" * 62 + "╝")


class Amorcage:
    """L'amorçage d'une application : ``Amorcage("lidar2map", racine).lancer()``
    tout en haut du point d'entrée, avant l'import de la moindre dépendance.

    nom          nom de l'application ; le venv est ``~/.<nom>/venv``.
    racine       dossier des sources, où se trouvent le verrou et requirements.in.
    variable     variable d'environnement du mode (défaut ``<NOM>_BOOTSTRAP``).
    verrou       fichier verrouillé (défaut requirements.txt) ; ``None`` pour une
                 installation sans verrou, depuis requirements.in (une édition qui
                 ne peut pas utiliser le verrou universel, Python 3.8).
    reutiliser_environnement
                 vrai : en mode ``auto``, si les dépendances sont déjà là et qu'aucun
                 venv de l'application n'existe, on s'en contente (une image Docker,
                 par exemple) ; faux : toujours le venv, pour que le résultat ne
                 dépende pas de ce que la machine a installé par hasard.
    langue       fonction sans argument qui rend ``"fr"`` ou ``"en"`` (défaut en).
    apres_installation
                 fonction appelée une fois les dépendances en place (tous modes sauf
                 ``none``) : lidar2map y rétablit son contexte TLS strict.
    """

    def __init__(self, nom, racine, *, variable=None, verrou="requirements.txt",
                 dependances="requirements.in", reutiliser_environnement=False,
                 langue=None, apres_installation=None):
        self.nom = nom
        self.racine = Path(racine)
        self.variable = variable or re.sub(r"\W", "_", nom).upper() + "_BOOTSTRAP"
        self.fichier_dependances = self.racine / dependances
        self.fichier_verrou = self.racine / verrou if verrou else None
        self.reutiliser_environnement = reutiliser_environnement
        self._langue = langue or (lambda: "en")
        self.apres_installation = apres_installation

    # -- chemins et textes
    @property
    def dossier(self) -> Path:
        return Path.home() / f".{self.nom}"

    @property
    def venv(self) -> Path:
        return self.dossier / "venv"

    @property
    def marque(self) -> str:
        return f"{self.nom}-verrou.sha256"

    def texte(self, cle, **valeurs) -> str:
        libelles = LIBELLES.get(self._langue(), LIBELLES["en"])
        modele = libelles.get(cle) or LIBELLES["en"][cle]
        return modele.format(**valeurs)

    def _suppression(self) -> str:
        if platform.system() == "Windows":
            return f"rmdir /s /q %USERPROFILE%\\.{self.nom}"
        return f"rm -rf ~/.{self.nom}"

    def aide(self) -> str:
        return self.texte("aide", nom=self.nom, variable=self.variable,
                          dossier=f"~/.{self.nom}/venv", suppression=self._suppression())

    # -- verrou
    @property
    def _source(self) -> Path:
        """Ce que pip installe : le verrou, ou à défaut requirements.in."""
        return self.fichier_verrou or self.fichier_dependances

    def empreinte(self) -> str:
        return empreinte_verrou(self._source)

    def commande(self, python, *options) -> list:
        return commande_installation(python, *options, verrou=self._source,
                                     avec_empreintes=self.fichier_verrou is not None)

    def absentes(self) -> list:
        return dependances_absentes(dependances_directes(self.fichier_dependances))

    # -- le mode
    def mode(self) -> str:
        """Résout le mode, nettoie ``sys.argv`` en place, traite l'aide."""
        try:
            resolution = resoudre_mode(sys.argv, os.environ, self.variable, self.texte)
        except ValueError as erreur:
            print(f"ERROR: {erreur}", file=sys.stderr)
            sys.exit(2)
        if resolution.aide:
            _ecrire(self.aide())
            sys.exit(0)
        sys.argv[:] = resolution.argv
        return resolution.mode

    # -- le point d'entrée
    def lancer(self) -> None:
        """Rend la main quand les dépendances sont disponibles dans CE processus,
        se relance dans le venv (sans revenir), ou s'arrête avec un message."""
        mode = self.mode()
        if getattr(sys, "frozen", False):
            # Les dépendances sont dans l'exécutable : pip n'a rien à y faire.
            while OPTION_INSTALLER in sys.argv:
                sys.argv.remove(OPTION_INSTALLER)
            return
        if mode == "none":
            self.verifier()
        elif mode == "pip":
            self.assurer_pip()
        else:
            self.venv_et_relance(force=(mode == "force"))
        if OPTION_INSTALLER in sys.argv:
            sys.argv.remove(OPTION_INSTALLER)
            sys.exit(0 if self.installer_verrou_complet() else 1)
        if mode != "none":
            self.installer_si_besoin()
            if self.apres_installation is not None:
                self.apres_installation()

    # -- mode none
    def verifier(self) -> None:
        manquantes = self.absentes()
        if not manquantes:
            return
        _boite(self.texte("titre_none"))
        _ecrire("  " + self.texte("paquets_absents", liste=", ".join(manquantes)))
        _ecrire()
        _ecrire("  " + self.texte("installer_vous_meme"))
        _ecrire(f"    pip install -r {self._source}")
        _ecrire()
        sys.exit(1)

    # -- mode pip
    def assurer_pip(self) -> None:
        """S'assure que pip est disponible, par ensurepip au besoin."""
        retour = subprocess.run([sys.executable, "-m", "pip", "--version"],
                                capture_output=True)
        if retour.returncode == 0:
            return
        _ecrire("  " + self.texte("pip_absent"))
        try:
            import ensurepip
            ensurepip.bootstrap(upgrade=True)
            _ecrire("  " + self.texte("pip_installe"))
        except Exception as erreur:
            _ecrire("  " + self.texte("echec_pip", erreur=erreur))
            _ecrire("  " + self.texte("installer_pip"))
            sys.exit(1)

    def installer_si_besoin(self) -> None:
        """Installe le verrou dans l'environnement courant s'il manque quelque
        chose. Rapide quand tout est là : lit les métadonnées, rien d'autre.

        Ordre d'essai : standard, ``--break-system-packages`` (PEP 668 : Linux et
        Homebrew récents), ``--user``. Dans un venv, seule la première a un sens.
        S'arrête proprement, avec un message, si toutes échouent."""
        manquantes = self.absentes()
        if not manquantes:
            return
        _ecrire("  " + self.texte("installation_en_cours", liste=", ".join(manquantes)))
        dans_un_venv = (hasattr(sys, "real_prefix")
                        or (hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix))
        if dans_un_venv:
            strategies = [((), self.texte("strategie_standard_venv"))]
        else:
            strategies = [((), self.texte("strategie_standard")),
                          (("--break-system-packages",), self.texte("strategie_pep668")),
                          (("--user",), self.texte("strategie_user"))]

        derniere = ""
        for options, libelle in strategies:
            try:
                retour = subprocess.run(self.commande(sys.executable, *options),
                                        capture_output=True, text=True, timeout=DELAI_PIP_S)
            except (OSError, subprocess.TimeoutExpired) as erreur:
                derniere = f"{libelle} : {erreur}"
                continue
            if retour.returncode == 0:
                _ecrire("  " + self.texte("installation_reussie", libelle=libelle))
                return
            derniere = "\n  ".join((retour.stderr or retour.stdout or "").strip().split("\n")[-3:])

        _boite(self.texte("titre_impossible"))
        _ecrire("  " + self.texte("paquets_absents", liste=", ".join(manquantes)))
        if derniere:
            _ecrire("  " + self.texte("dernier_message_pip", message=derniere))
        _ecrire()
        _ecrire("  " + self.texte("solutions"))
        _ecrire("    " + self.texte("solution_auto", nom=self.nom))
        _ecrire(f"       python {self.nom}.py --bootstrap=auto")
        _ecrire("    " + self.texte("solution_none"))
        _ecrire(f"       pip install -r {self._source}")
        _ecrire()
        sys.exit(1)

    def installer_verrou_complet(self, *, lancer=None, executable=None, ecrire=_ecrire) -> bool:
        """``--installer-deps`` : le verrou entier dans l'environnement courant
        (celui du venv, après la relance). Rend vrai si pip a réussi. Les scripts de
        build y ajoutent ensuite PyInstaller. Les coutures injectables gardent ce
        chemin testable sans réseau."""
        lancer = lancer or subprocess.run
        executable = executable or sys.executable
        ecrire("  " + self.texte("installation_verrou"))
        retour = lancer(self.commande(executable), capture_output=True, text=True)
        if retour.returncode != 0:
            ecrire("    " + self.texte("echec_verrou"))
            ecrire("    " + (retour.stderr or retour.stdout or "").strip()[-800:])
            return False
        ecrire("  " + self.texte("verrou_installe"))
        return True

    # -- modes auto et force
    def verifier_venv_linux(self) -> None:
        """Sur Debian/Ubuntu, ``python3-venv`` est un paquet système séparé : absent
        d'un Python nu, il fait planter la création du venv sans message clair."""
        if platform.system() != "Linux":
            return
        try:
            import venv  # noqa: F401
            return
        except ImportError:
            pass
        # Couvre le cas où le module est présent mais pas importable d'ici.
        if subprocess.run([sys.executable, "-m", "venv", "--help"],
                          capture_output=True).returncode == 0:
            return
        version = f"{sys.version_info.major}.{sys.version_info.minor}"
        _boite(self.texte("titre_venv_absent"))
        _ecrire("  " + self.texte("venv_separe"))
        _ecrire("  " + self.texte("installer_une_fois"))
        _ecrire()
        _ecrire("    sudo apt install python3-venv")
        _ecrire("    " + self.texte("ou_version", version=version))
        _ecrire(f"    sudo apt install python{version}-venv")
        _ecrire()
        _ecrire("  " + self.texte("puis_relancer"))
        sys.exit(1)

    def relancer(self, python_du_venv, est_windows) -> None:
        """Relance le programme avec le Python du venv.

        Unix : ``os.execv`` remplace le processus. Windows : ``execv`` y rend la main
        au shell tout de suite, pendant que le fils tourne (l'invite s'affiche avant
        la sortie) ; on attend donc le fils et on propage son code. Les flux sont
        passés explicitement : sans cela, lancé par une page avec des tubes, le fils
        n'écrit nulle part où la page lise."""
        if est_windows:
            try:
                sys.stdout.flush()
                sys.stderr.flush()
                retour = subprocess.run([str(python_du_venv)] + sys.argv,
                                        stdout=sys.stdout, stderr=sys.stderr, stdin=sys.stdin)
                sys.exit(retour.returncode)
            except KeyboardInterrupt:
                sys.exit(130)
        else:
            os.execv(str(python_du_venv), [str(python_du_venv)] + sys.argv)

    def venv_et_relance(self, force=False) -> None:
        est_windows = platform.system() == "Windows"
        venv = self.venv

        # Déjà dans CE venv : on est la relance, rien d'autre à faire ici.
        try:
            if Path(sys.prefix).resolve() == venv.resolve():
                return
        except OSError:
            pass

        if self.reutiliser_environnement and not force:
            if not self.absentes() and not venv.exists():
                return

        # Un environnement isolé est déjà actif (conda, venv) : en créer un second en
        # silence surprend. On oriente vers les modes adaptés. Détection par les
        # variables standard, déterministe (contrairement à un scan de sys.path).
        actif = os.environ.get("CONDA_PREFIX") or os.environ.get("VIRTUAL_ENV")
        if actif and not force:
            _boite(self.texte("titre_env_actif"))
            _ecrire("  " + self.texte("env_actif", env=actif))
            _ecrire()
            _ecrire("  " + self.texte("eviter_venv_parallele", dossier=f"~/.{self.nom}/"))
            _ecrire(f"    python {self.nom}.py --bootstrap=pip    # "
                    + self.texte("installer_ici"))
            _ecrire(f"    python {self.nom}.py --bootstrap=none   # "
                    + self.texte("deja_la"))
            _ecrire()
            _ecrire("  " + self.texte("ou_desactiver"))
            _ecrire("  " + self.texte("ou_forcer"))
            _ecrire()
            sys.exit(1)

        dossier_bin = venv / ("Scripts" if est_windows else "bin")
        python_du_venv = dossier_bin / ("python.exe" if est_windows else "python")
        marque = venv / self.marque
        empreinte = self.empreinte()

        # Venv installé depuis ce même verrou : on s'y relance, sans pip.
        try:
            a_jour = python_du_venv.exists() and marque.read_text().strip() == empreinte
        except OSError:
            a_jour = False
        if a_jour:
            _ecrire("  " + self.texte("relance_dans", venv=venv))
            self.relancer(python_du_venv, est_windows)
            return  # ne revient pas : execv, ou sys.exit sous Windows

        if not python_du_venv.exists():
            self.verifier_venv_linux()
            _boite(self.texte("titre_premier_lancement"))
            _ecrire("  " + self.texte("premier_1"))
            _ecrire("  " + self.texte("premier_2", suppression=self._suppression()))
            _ecrire("  " + self.texte("premier_3"))
            _ecrire(f"    python {self.nom}.py --bootstrap=pip")
            _ecrire("  " + self.texte("creation_venv", venv=venv))
            try:
                subprocess.run([sys.executable, "-m", "venv", str(venv)], check=True)
            except (subprocess.CalledProcessError, OSError) as erreur:
                _ecrire("  " + self.texte("echec_venv", erreur=erreur))
                _ecrire("  " + self.texte("installer_venv_module"))
                sys.exit(1)

        # Toutes les dépendances d'un coup, aux versions exactes du verrou, dont pip
        # vérifie les empreintes. Un venv plus ancien est remis au verrou actuel.
        _ecrire("  " + self.texte("installation_venv"))
        commande = self.commande(python_du_venv)
        try:
            retour = subprocess.run(commande, capture_output=True, text=True,
                                    timeout=DELAI_PIP_S)
            erreur = "" if retour.returncode == 0 else (retour.stderr or retour.stdout or "")[-800:]
        except subprocess.TimeoutExpired:
            erreur = self.texte("timeout_pip")
        except OSError as exception:
            erreur = str(exception)
        if erreur:
            _ecrire("  " + self.texte("echec_installation_venv"))
            _ecrire(f"  {erreur.strip()}")
            _ecrire("  " + self.texte("verifier_connexion"))
            _ecrire("    " + subprocess.list2cmdline(commande))
            sys.exit(1)
        marque.write_text(empreinte + "\n")
        _ecrire("  " + self.texte("installe"))
        _ecrire("  " + self.texte("relance"))
        self.relancer(python_du_venv, est_windows)