#!/usr/bin/env python3 # © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) """install_venv.sh détruit un venv : sous quelles conditions, exactement ? C'est le seul endroit du dépôt qui efface le travail de quelqu'un. Un venv porte ce qu'on y a posé à la main — bin/repo, uv, des paquets — et le nom .venv.erplibre ne dit pas sa version : rien ne distingue de l'extérieur celui qu'il faut rebâtir de celui qu'il faut garder. Ces tests pèsent donc les REFUS autant que les destructions : - un répertoire sans pyvenv.cfg n'est pas un venv et garde son contenu ; - un venv sur un système de fichiers monté appartient à une autre machine, et l'effacer le ferait à travers le réseau ; - un venv utilisable est conservé, sans quoi chaque installation rebâtirait ce qui marche. L'interpréteur est un bouchon annoncé par PYENV_ROOT : les tests restent hors réseau, et rien ne dépend de ce que cette machine a installé. """ import os import shutil import subprocess import tempfile import unittest from pathlib import Path RACINE = Path(__file__).resolve().parents[1] SCRIPT = RACINE / "script/install/install_venv.sh" VOULUE = "9.99.0" AUTRE = "9.98.0" # Un python qui suffit à ce que le script lui demande : sa version, la création # d'un venv, et la présence de pip. BOUCHON = """#!/bin/sh VERSION=__VERSION__ case "$1" in -V|--version) echo "Python ${VERSION}" ;; -c) echo "${VERSION}" ;; -m) case "$2" in venv) mkdir -p "$3/bin" || exit 1 printf 'version = %s\\n' "${VERSION}" > "$3/pyvenv.cfg" sed "s/^VERSION=.*/VERSION=${VERSION}/" "$0" > "$3/bin/python" chmod +x "$3/bin/python" : > "$3/bin/activate" ;; pip) echo "pip 99.0 from nowhere" ;; *) exit 1 ;; esac ;; *) exit 1 ;; esac exit 0 """ def pose_bouchon(chemin, version): chemin.parent.mkdir(parents=True, exist_ok=True) chemin.write_text( BOUCHON.replace("__VERSION__", version), encoding="utf-8" ) chemin.chmod(0o755) class BancInstallVenv(unittest.TestCase): """Un PYENV_ROOT bouchonné, et un répertoire de travail jetable.""" def setUp(self): self.coin = Path(tempfile.mkdtemp()) self.addCleanup(shutil.rmtree, self.coin, ignore_errors=True) pose_bouchon( self.coin / "pyenv" / "versions" / VOULUE / "bin" / "python", VOULUE, ) self.bin = self.coin / "bin" self.bin.mkdir() def env(self): return dict( os.environ, PYENV_ROOT=str(self.coin / "pyenv"), PATH=f"{self.bin}:{os.environ['PATH']}", EL_PYTHON_PROVIDER="pyenv", ) def lance(self, chemin_venv, contexte="ERPLibre"): return subprocess.run( [str(SCRIPT), contexte, str(chemin_venv), VOULUE], cwd=RACINE, capture_output=True, text=True, env=self.env(), ) def venv_factice(self, nom, version): """Un venv complet, comme « python -m venv » le laisse.""" chemin = self.coin / nom (chemin / "bin").mkdir(parents=True) (chemin / "pyvenv.cfg").write_text(f"version = {version}\n") pose_bouchon(chemin / "bin" / "python", version) (chemin / "bin" / "activate").touch() (chemin / "TEMOIN").write_text("pose a la main\n") return chemin class TestCeQuIlConserve(BancInstallVenv): def test_un_venv_a_la_bonne_version_est_garde(self): venv = self.venv_factice("bon", VOULUE) fin = self.lance(venv) self.assertEqual(0, fin.returncode, fin.stdout + fin.stderr) self.assertTrue( (venv / "TEMOIN").exists(), "il a été rebâti pour rien" ) def test_un_chemin_neuf_est_simplement_cree(self): venv = self.coin / "neuf" fin = self.lance(venv) self.assertEqual(0, fin.returncode, fin.stdout + fin.stderr) self.assertTrue((venv / "pyvenv.cfg").exists()) class TestCeQuIlDetruit(BancInstallVenv): def test_un_venv_d_une_autre_version_est_rebati(self): venv = self.venv_factice("perime", AUTRE) fin = self.lance(venv) self.assertEqual(0, fin.returncode, fin.stdout + fin.stderr) self.assertIn("DESTRUCTION", fin.stdout) self.assertFalse((venv / "TEMOIN").exists()) self.assertIn(VOULUE, (venv / "pyvenv.cfg").read_text()) def test_la_destruction_est_annoncee_avant_d_avoir_lieu(self): """Dans un journal de milliers de lignes, l'avis doit précéder.""" venv = self.venv_factice("perime", AUTRE) fin = self.lance(venv) self.assertLess( fin.stdout.index("DESTRUCTION"), fin.stdout.index("Create Virtual environment"), ) def test_un_venv_sans_pip_est_rebati(self): """Ce que laisse « python -m venv » quand ensurepip manque.""" venv = self.venv_factice("sans_pip", VOULUE) (venv / "bin" / "activate").unlink() fin = self.lance(venv) self.assertEqual(0, fin.returncode, fin.stdout + fin.stderr) self.assertFalse((venv / "TEMOIN").exists()) class TestLExigenceDePatch(BancInstallVenv): """Le patch ne borne que le venv d'Odoo, contraint par son pyproject. Sur le venv d'outillage, l'exiger écarterait le Python des distributions dès qu'il est d'un cran en retard — et ferait compiler CPython pour une différence que rien ne réclame.""" PATCH_PLUS_BAS = "9.99.0" DEMANDE = "9.99.4" def setUp(self): super().setUp() pose_bouchon( self.coin / "pyenv" / "versions" / self.DEMANDE / "bin" / "python", self.DEMANDE, ) def joue(self, contexte, venv): return subprocess.run( [str(SCRIPT), contexte, str(venv), self.DEMANDE], cwd=RACINE, capture_output=True, text=True, env=self.env(), ) def test_l_outillage_garde_un_patch_plus_ancien(self): venv = self.venv_factice("outils", self.PATCH_PLUS_BAS) fin = self.joue("ERPLibre", venv) self.assertEqual(0, fin.returncode, fin.stdout + fin.stderr) self.assertTrue((venv / "TEMOIN").exists(), fin.stdout) def test_odoo_refuse_un_patch_plus_ancien(self): """Poetry refuserait un patch inférieur à ce que borne le pyproject.""" venv = self.venv_factice("odoo", self.PATCH_PLUS_BAS) fin = self.joue("Odoo", venv) self.assertIn("conserve", fin.stdout + fin.stderr) def test_une_autre_mineure_ne_passe_jamais(self): venv = self.venv_factice("mineure", "9.98.0") fin = self.joue("ERPLibre", venv) self.assertIn("DESTRUCTION", fin.stdout) class TestCeQueLeContexteDecide(BancInstallVenv): """Rebâtir le venv d'outillage coûte une installation pip ; rebâtir celui d'Odoo en refait une de Poetry entière. L'écart de version ne vaut donc destruction que pour le premier.""" def test_le_venv_odoo_d_une_autre_version_est_conserve(self): venv = self.venv_factice("odoo", AUTRE) fin = self.lance(venv, contexte="Odoo") self.assertEqual(0, fin.returncode, fin.stdout + fin.stderr) self.assertIn("conserve", fin.stdout) self.assertTrue((venv / "TEMOIN").exists()) def test_le_venv_odoo_hors_service_est_rebati_quand_meme(self): """Une autre version se tolère ; un interpréteur mort, non.""" venv = self.venv_factice("odoo_casse", VOULUE) (venv / "bin" / "python").write_text("#!/bin/sh\nexit 1\n") fin = self.lance(venv, contexte="Odoo") self.assertEqual(0, fin.returncode, fin.stdout + fin.stderr) self.assertIn("DESTRUCTION", fin.stdout) self.assertFalse((venv / "TEMOIN").exists()) class TestCeQuIlRefuse(BancInstallVenv): def test_un_repertoire_sans_pyvenv_cfg_garde_son_contenu(self): pas_un_venv = self.coin / "donnees" (pas_un_venv / "sous").mkdir(parents=True) (pas_un_venv / "sous" / "PRECIEUX").write_text("ne pas effacer\n") fin = self.lance(pas_un_venv) self.assertEqual(1, fin.returncode) self.assertIn("Refus de detruire", fin.stdout) self.assertTrue((pas_un_venv / "sous" / "PRECIEUX").exists()) def test_un_venv_monte_a_distance_est_epargne(self): """Il appartient à une autre machine : l'effacer traverse le réseau.""" faux_findmnt = self.bin / "findmnt" faux_findmnt.write_text("#!/bin/sh\necho fuse.sshfs\n") faux_findmnt.chmod(0o755) venv = self.venv_factice("monte", AUTRE) fin = self.lance(venv) self.assertEqual(1, fin.returncode) self.assertIn("monte a distance", fin.stdout) self.assertTrue((venv / "TEMOIN").exists()) def test_un_repertoire_vide_ne_demande_pas_d_intervention(self): """Rien à perdre : rmdir suffit, et l'installation continue.""" vide = self.coin / "vide" vide.mkdir() fin = self.lance(vide) self.assertEqual(0, fin.returncode, fin.stdout + fin.stderr) self.assertNotIn("Refus", fin.stdout) def test_la_racine_est_refusee(self): fin = subprocess.run( [str(SCRIPT), "ERPLibre", "/", VOULUE], cwd=RACINE, capture_output=True, text=True, env=self.env(), ) self.assertEqual(1, fin.returncode) self.assertNotIn("DESTRUCTION", fin.stdout) class TestSansInterpreteur(BancInstallVenv): def test_aucune_destruction_quand_l_interpreteur_manque(self): """L'interpréteur est obtenu AVANT : sinon on reste sans venv.""" venv = self.venv_factice("perime", AUTRE) env = dict(self.env(), PYENV_ROOT=str(self.coin / "nulle_part")) fin = subprocess.run( [str(SCRIPT), "ERPLibre", str(venv), VOULUE], cwd=RACINE, capture_output=True, text=True, env=env, ) self.assertEqual(1, fin.returncode) self.assertTrue((venv / "TEMOIN").exists()) if __name__ == "__main__": unittest.main()