diff --git a/.claude/skills/erplibre-deployment/SKILL.md b/.claude/skills/erplibre-deployment/SKILL.md index 552e18d..b2f6688 100644 --- a/.claude/skills/erplibre-deployment/SKILL.md +++ b/.claude/skills/erplibre-deployment/SKILL.md @@ -36,6 +36,10 @@ Un seul fichier décide : `script/install/lib_python_provider.sh`. mise n'est jamais installé automatiquement — `make install_mise` porte cette décision. Pas de binaire mise pour s390x à ce jour : cette architecture reste sur pyenv. +Le hook `pre-commit` relaie `script/analyse/check_python_version.py` : il +signale le source qui ne parse pas sous cette version, sans bloquer, et dit +quand aucun interpréteur de cette version n'était là pour vérifier. + ## Paquets Python `EL_PIP_PROVIDER` (dans `env_var.sh`) vaut `auto`, `uv` ou `pip`. `auto` prend diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index 3d12634..371aedb 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -81,6 +81,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The install log carries what the host decided before launching: the download cache authority placed or refused, the bypass, the mirror. Those lines were said on a console that scrolls away, while the file reopened after a failure held only the symptom — on a guest with no trust store, a refused certificate, hundreds of derivations to build and six hundred lines of errors, without a word on the cause - One entry of the download cache can be forgotten from the menu, under « Age and cleanup ». The binary could already do it; the menu offered only the two bulk purges, neither of which reaches a single object — « erase what has not served » never reaches one the service rejuvenates each time it serves it, and « erase everything » costs the whole cache for one file. `--detient` runs first and is the preview: same line, same key, nothing modified - `erplibre_go_qemu_cache --recle` stores a cache's objects again under the current key, without downloading anything, and merges the copies a mirror carried under several paths. Objects written under the former key rule stay on disk but become UNREACHABLE, so the service asks upstream for them again and the space they hold serves no one: on a store of 12 764 objects, 5 419 were in that case — 9.11 GiB — and merging the duplicates returned about 3.37 GiB. The service must be stopped, the body being renamed before its meta, and `--dry-run` only counts what would move. A status-only entry is left alone, its key carrying the host rather than the path +- The `pre-commit` hook runs `check_python_version.py` on staged files: it reports source that does not parse under the Python of `conf/python-erplibre-version`, without blocking the commit, and says when no such interpreter was there to check. Neither black nor flake8 sees that fault — black's target bounds what it writes, never what it accepts @@ -134,6 +135,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Le journal d'installation porte ce que l'hôte a décidé avant de lancer : l'autorité du cache de téléchargement posée ou refusée, l'exception, le miroir. Ces lignes se disaient sur une console qui défile, pendant que le fichier qu'on rouvre après un échec ne portait que le symptôme — sur un invité sans magasin de confiance, un certificat refusé, des centaines de dérivations à construire et six cents lignes d'erreurs, sans un mot sur la cause - Une entrée du cache de téléchargement s'oublie depuis le menu, sous « Âge et nettoyage ». Le binaire savait déjà le faire ; le menu n'offrait que les deux purges en gros, dont aucune ne vise un objet — « effacer ce qui n'a plus servi » n'atteint jamais celui que le service rajeunit chaque fois qu'il le rend, et « tout effacer » coûte le cache entier pour un fichier. `--detient` passe d'abord et fait l'aperçu : même ligne, même clé, sans rien modifier - `erplibre_go_qemu_cache --recle` range à nouveau les objets d'un cache sous la clé courante, sans rien retélécharger, et fond les copies qu'un miroir portait sous plusieurs chemins. Les objets écrits sous l'ancienne règle de clé restent sur le disque mais deviennent INTROUVABLES, si bien que le service les redemande à l'amont et que la place qu'ils tiennent ne sert plus personne : sur un magasin de 12 764 objets, 5 419 étaient dans ce cas — 9,11 Gio — et la fusion des doublons a rendu environ 3,37 Gio. Le service doit être arrêté, le corps étant renommé avant son méta, et `--dry-run` ne fait que compter ce qui bougerait. Un statut seul n'est pas touché, sa clé portant l'hôte et non le chemin +- Le hook `pre-commit` lance `check_python_version.py` sur les fichiers indexés : il signale le source qui ne parse pas sous le Python de `conf/python-erplibre-version`, sans bloquer le commit, et dit quand aucun interpréteur de cette version n'était là pour vérifier. Ni black ni flake8 ne voient ce défaut — la cible de black borne ce qu'il écrit, jamais ce qu'il accepte ## Changed diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 5729d9c..6bc6481 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -61,6 +61,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Le journal d'installation porte ce que l'hôte a décidé avant de lancer : l'autorité du cache de téléchargement posée ou refusée, l'exception, le miroir. Ces lignes se disaient sur une console qui défile, pendant que le fichier qu'on rouvre après un échec ne portait que le symptôme — sur un invité sans magasin de confiance, un certificat refusé, des centaines de dérivations à construire et six cents lignes d'erreurs, sans un mot sur la cause - Une entrée du cache de téléchargement s'oublie depuis le menu, sous « Âge et nettoyage ». Le binaire savait déjà le faire ; le menu n'offrait que les deux purges en gros, dont aucune ne vise un objet — « effacer ce qui n'a plus servi » n'atteint jamais celui que le service rajeunit chaque fois qu'il le rend, et « tout effacer » coûte le cache entier pour un fichier. `--detient` passe d'abord et fait l'aperçu : même ligne, même clé, sans rien modifier - `erplibre_go_qemu_cache --recle` range à nouveau les objets d'un cache sous la clé courante, sans rien retélécharger, et fond les copies qu'un miroir portait sous plusieurs chemins. Les objets écrits sous l'ancienne règle de clé restent sur le disque mais deviennent INTROUVABLES, si bien que le service les redemande à l'amont et que la place qu'ils tiennent ne sert plus personne : sur un magasin de 12 764 objets, 5 419 étaient dans ce cas — 9,11 Gio — et la fusion des doublons a rendu environ 3,37 Gio. Le service doit être arrêté, le corps étant renommé avant son méta, et `--dry-run` ne fait que compter ce qui bougerait. Un statut seul n'est pas touché, sa clé portant l'hôte et non le chemin +- Le hook `pre-commit` lance `check_python_version.py` sur les fichiers indexés : il signale le source qui ne parse pas sous le Python de `conf/python-erplibre-version`, sans bloquer le commit, et dit quand aucun interpréteur de cette version n'était là pour vérifier. Ni black ni flake8 ne voient ce défaut — la cible de black borne ce qu'il écrit, jamais ce qu'il accepte ## Modifié diff --git a/CHANGELOG.md b/CHANGELOG.md index 934c3a7..9fd79b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The install log carries what the host decided before launching: the download cache authority placed or refused, the bypass, the mirror. Those lines were said on a console that scrolls away, while the file reopened after a failure held only the symptom — on a guest with no trust store, a refused certificate, hundreds of derivations to build and six hundred lines of errors, without a word on the cause - One entry of the download cache can be forgotten from the menu, under « Age and cleanup ». The binary could already do it; the menu offered only the two bulk purges, neither of which reaches a single object — « erase what has not served » never reaches one the service rejuvenates each time it serves it, and « erase everything » costs the whole cache for one file. `--detient` runs first and is the preview: same line, same key, nothing modified - `erplibre_go_qemu_cache --recle` stores a cache's objects again under the current key, without downloading anything, and merges the copies a mirror carried under several paths. Objects written under the former key rule stay on disk but become UNREACHABLE, so the service asks upstream for them again and the space they hold serves no one: on a store of 12 764 objects, 5 419 were in that case — 9.11 GiB — and merging the duplicates returned about 3.37 GiB. The service must be stopped, the body being renamed before its meta, and `--dry-run` only counts what would move. A status-only entry is left alone, its key carrying the host rather than the path +- The `pre-commit` hook runs `check_python_version.py` on staged files: it reports source that does not parse under the Python of `conf/python-erplibre-version`, without blocking the commit, and says when no such interpreter was there to check. Neither black nor flake8 sees that fault — black's target bounds what it writes, never what it accepts ## Changed diff --git a/script/analyse/check_python_version.py b/script/analyse/check_python_version.py new file mode 100755 index 0000000..7ae509b --- /dev/null +++ b/script/analyse/check_python_version.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce source parse-t-il sous le Python de conf/python-erplibre-version ? + +Un fichier dont la syntaxe dépasse cet interpréteur ne casse qu'au chargement, +et black ne le voit pas : sa cible borne ce qu'il ÉCRIT, jamais ce qu'il +accepte. L'interpréteur courant compile les fichiers quand sa majeure.mineure +convient, sinon un Python déjà installé le fait ; l'outil n'installe rien et +sort en 0. +""" + +import argparse +import glob +import os +import re +import shutil +import subprocess +import sys +from subprocess import PIPE + +RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +VERSION = os.path.join(RACINE, "conf", "python-erplibre-version") +# Les dépôts que Google Repo rapatrie sous script/ portent leur propre norme et +# leur propre Python : le dépôt ne répond pas de leur source. Même liste que +# l'extend-exclude de .ruff.toml, pour que balayer un répertoire à la main +# juge exactement ce que le formatage touche. +EXCLU = re.compile( + r"(^|/)(\.git|\.venv[^/]*|node_modules|\.repo|__pycache__|addons" + r"|OCA_maintainer-tools|OCA_odoo-module-migrator)/" +) + +try: + sys.path.append(RACINE) + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + t = str + + +# Lu par l'interpréteur visé : n'emploie que de la syntaxe ancienne. +SONDE = r""" +import sys +for nom in sys.argv[1:]: + try: + with open(nom, "rb") as fh: + compile(fh.read(), nom, "exec", dont_inherit=True) + except Exception as exc: + ligne = getattr(exc, "lineno", None) or 1 + print("%s\t%s\t%s" % (nom, ligne, getattr(exc, "msg", exc))) +""" + + +def lance(cmd, **options): + """La sortie standard de cmd, vide sur échec ; l'erreur standard passe.""" + try: + fin = subprocess.run(cmd, stdout=PIPE, errors="replace", **options) + except (OSError, subprocess.SubprocessError): + return "" + return fin.stdout if fin.returncode == 0 else "" + + +def version_voulue(): + """La première ligne ni vide ni commentée du fichier version, ou None.""" + if not os.path.isfile(VERSION): + return None + with open(VERSION, encoding="utf-8") as fh: + lignes = [ligne.strip() for ligne in fh] + return next((x for x in lignes if x and not x.startswith("#")), None) + + +def interpreteur(version): + """Un python `version` déjà là : celui qui tourne, mise, pyenv, le PATH. + + La majeure.mineure suffit : elle seule décide de la grammaire acceptée. + Lancé depuis .venv.erplibre, l'outil se prend donc lui-même et ne cherche + nulle part ailleurs.""" + majeure_mineure = ".".join(version.split(".")[:2]) + if ".".join(map(str, sys.version_info[:2])) == majeure_mineure: + return sys.executable + if shutil.which("mise"): + # MISE_OFFLINE : résoudre la version sans interroger le réseau. + env = dict(os.environ, MISE_OFFLINE="1") + commande = ["mise", "where", "python@" + version] + prefixe = lance(commande, env=env, stderr=subprocess.DEVNULL) + exe = os.path.join(prefixe.strip(), "bin", "python3") + if prefixe and os.access(exe, os.X_OK): + return exe + pyenv = os.environ.get("PYENV_ROOT") or os.path.expanduser("~/.pyenv") + motif = os.path.join(pyenv, "versions", version + "*", "bin", "python") + trouves = sorted(p for p in glob.glob(motif) if os.access(p, os.X_OK)) + if trouves: + return trouves[-1] + return shutil.which("python" + majeure_mineure) + + +def est_python(chemin): + """Suffixe .py, ou fichier sans suffixe dont le hashbang nomme python.""" + if EXCLU.search(chemin) or not os.path.isfile(chemin): + return False + if os.path.splitext(chemin)[1]: + return chemin.endswith(".py") + with open(chemin, "rb") as fh: + premiere = fh.readline(200) + return premiere.startswith(b"#!") and b"python" in premiere + + +def fichiers(args): + """Les fichiers Python désignés : l'index git, ou les chemins parcourus.""" + if args.staged: + git = ["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"] + noms = lance(git, cwd=RACINE).splitlines() + chemins = [os.path.relpath(os.path.join(RACINE, n)) for n in noms] + return [c for c in chemins if est_python(c)] + trouves = [] + for chemin in args.paths: + trouves += [chemin] if est_python(chemin) else [] + for base, dossiers, noms in os.walk(chemin): + dossiers[:] = [d for d in dossiers if not EXCLU.search(d + "/")] + complets = (os.path.join(base, n) for n in sorted(noms)) + trouves += [c for c in complets if est_python(c)] + return trouves + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=t("does this source parse under the repository Python") + ) + parser.add_argument("paths", nargs="*") + parser.add_argument( + "--staged", + action="store_true", + help=t("only the files added to the git index"), + ) + args = parser.parse_args(argv) + if not args.staged and not args.paths: + parser.error(t("give a path, or --staged")) + version = version_voulue() + if not version: + avis = t("no conf/python-erplibre-version: nothing checked") + print(avis, file=sys.stderr) + return 0 + chemins = fichiers(args) + exe = chemins and interpreteur(version) + if chemins and not exe: + avis = t("not checked (no Python %s): mise install python@%s") + print(avis % (version, version), file=sys.stderr) + if not exe: + return 0 + fichier, refuses = None, 0 + for ligne in lance([exe, "-c", SONDE, *chemins]).splitlines(): + nom, numero, message = ligne.split("\t", 2) + if nom != fichier: + fichier, refuses = nom, refuses + 1 + print(nom) + print(f" 🔴 {numero:>5} {message}") + if refuses: + bilan = t("%s file(s) refused by Python %s") + print("\n" + bilan % (refuses, version)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/git/hooks/pre-commit b/script/git/hooks/pre-commit index 520e64f..d5ae56a 100755 --- a/script/git/hooks/pre-commit +++ b/script/git/hooks/pre-commit @@ -7,6 +7,9 @@ Il INFORME et ne refuse jamais — le nettoyage se fait au fur et à mesure, sur les fichiers qu'on touche déjà, et non en une passe qui réécrirait le dépôt. Un hook qui bloquerait sur du style se ferait désinstaller la même semaine. +Il relaie aussi check_python_version.py, sans bloquer davantage : le source +indexé qui ne parse pas sous le Python de conf/python-erplibre-version. + Installation : git config core.hooksPath script/git/hooks @@ -15,6 +18,7 @@ Il sort toujours en 0. Pour l'interroger à la main, sur un fichier ou un répertoire : python3 script/analyse/check_comment_hygiene.py script/todo/todo.py + python3 script/analyse/check_python_version.py script/todo/todo.py """ import subprocess import sys @@ -22,12 +26,13 @@ from pathlib import Path RACINE = Path(__file__).resolve().parents[3] OUTIL = RACINE / "script" / "analyse" / "check_comment_hygiene.py" +VERSION = RACINE / "script" / "analyse" / "check_python_version.py" # Au-delà, le rapport cesse d'être une invitation et devient un mur. PLAFOND = 12 -def main() -> int: +def commentaires() -> int: if not OUTIL.is_file(): return 0 @@ -76,5 +81,37 @@ def main() -> int: return 0 +def syntaxe() -> None: + """Relaie les refus de syntaxe, ou l'avis d'un contrôle absent.""" + if not VERSION.is_file(): + return + try: + sortie = subprocess.run( + [sys.executable, str(VERSION), "--staged"], + capture_output=True, + text=True, + cwd=str(RACINE), + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + return + lignes = (sortie.stdout + sortie.stderr).strip().split("\n") + if not lignes[0]: + return + trop = len(lignes) - PLAFOND + if trop > 0: + lignes = lignes[:PLAFOND] + [f" … et {trop} lignes de plus"] + print("\n ⓘ syntaxe Python du dépôt\n", file=sys.stderr) + for ligne in lignes: + print(f" {ligne}", file=sys.stderr) + print("\n Le commit n'est PAS bloqué.\n", file=sys.stderr) + + +def main() -> int: + commentaires() + syntaxe() + return 0 + + if __name__ == "__main__": sys.exit(main()) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 87db79b..8116e1c 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -9502,6 +9502,24 @@ TRANSLATIONS = { "fr": "donner un chemin, ou --staged", "en": "give a path, or --staged", }, + # check_python_version.py partage « only the files added to the git + # index » et « give a path, or --staged » avec check_comment_hygiene.py. + "does this source parse under the repository Python": { + "fr": "ce source parse-t-il sous le Python du dépôt", + "en": "does this source parse under the repository Python", + }, + "no conf/python-erplibre-version: nothing checked": { + "fr": "aucun conf/python-erplibre-version : rien vérifié", + "en": "no conf/python-erplibre-version: nothing checked", + }, + "not checked (no Python %s): mise install python@%s": { + "fr": "NON vérifié (aucun Python %s ici) : mise install python@%s", + "en": "not checked (no Python %s): mise install python@%s", + }, + "%s file(s) refused by Python %s": { + "fr": "%s fichier(s) refusé(s) par Python %s", + "en": "%s file(s) refused by Python %s", + }, "unknown result": { "fr": "résultat inconnu", "en": "unknown result", diff --git a/test/test_check_python_version.py b/test/test_check_python_version.py new file mode 100644 index 0000000..538af41 --- /dev/null +++ b/test/test_check_python_version.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le contrôle voit-il ce que le Python du dépôt refuse ? + +Ce contrôle existe parce qu'aucun autre outil ne voit ce défaut : la cible de +black borne ce qu'il ÉCRIT, jamais ce qu'il accepte, et flake8 analyse avec +l'interpréteur courant. Une syntaxe que la version déclarée ne connaît pas ne +casse donc qu'au chargement, loin du commit qui l'a introduite. + +Sa portée suit conf/python-erplibre-version : il ne signale que ce que CETTE +version refuse. Le dépôt déclarant aujourd'hui la plus récente des versions +publiées, seuls un source cassé et un source écrit pour plus récent encore lui +échappent — d'où les tests qui déclarent une version ancienne pour éprouver le +mécanisme lui-même. + +Deux exigences que ces tests gardent, et qu'il serait facile de perdre : + +- SANS interpréteur de la version voulue, le contrôle DIT qu'il n'a pas + vérifié. Un outil qui rend 0 en silence laisse croire qu'il a regardé. +- Les dépôts rapatriés sous script/ sont écartés même quand leur chemin est + NOMMÉ : sans « force-exclude », l'exclusion ne vaudrait qu'à la découverte, + et le contrôle irait juger l'historique d'autrui. +""" + +import contextlib +import io +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parents[1] +OUTIL = RACINE / "script/analyse/check_python_version.py" + +sys.path.insert(0, str(RACINE / "script" / "analyse")) + +import check_python_version as controle # noqa: E402 + +# Cas INVENTÉS, jamais copiés d'un fichier du dépôt : un exemple qui illustre +# un interdit ne se prend pas dans le parc. +# +# Une f-string PEP 701 — des guillemets doubles dans une f-string à guillemets +# doubles — que Python refuse avant 3.12 et accepte depuis. +PEP_701 = 'x = f"{"a"}"\n' +# Cassé sous toute version. +CASSE = "def f(:\n pass\n" + + +@contextlib.contextmanager +def version_declaree(valeur): + """Fait dire au dépôt qu'il vise `valeur`, le temps d'un test.""" + with tempfile.TemporaryDirectory() as coin: + fichier = Path(coin) / "python-erplibre-version" + fichier.write_text(valeur, encoding="utf-8") + ancien, controle.VERSION = controle.VERSION, str(fichier) + try: + yield + finally: + controle.VERSION = ancien + + +def juge(contenu, version): + """Ce que l'outil imprime sur un fichier portant `contenu`.""" + with tempfile.TemporaryDirectory() as coin: + cible = Path(coin) / "echantillon.py" + cible.write_text(contenu, encoding="utf-8") + sortie = io.StringIO() + with version_declaree(version), contextlib.redirect_stdout(sortie): + code = controle.main([str(cible)]) + return code, sortie.getvalue() + + +def interpreteur_absent(version): + return controle.interpreteur(version) is None + + +class TestCeQuIlVoit(unittest.TestCase): + def test_un_source_casse_est_signale(self): + version = controle.version_voulue() + if interpreteur_absent(version): + self.skipTest(f"aucun Python {version} ici") + code, sortie = juge(CASSE, version) + self.assertIn("echantillon.py", sortie) + self.assertIn("🔴", sortie) + self.assertEqual(0, code, "il informe, il ne bloque pas") + + def test_une_syntaxe_plus_recente_que_la_version_declaree(self): + """Le mécanisme même : PEP 701 refusé quand le dépôt vise 3.10.""" + if interpreteur_absent("3.10"): + self.skipTest("aucun Python 3.10 ici") + _, sortie = juge(PEP_701, "3.10") + self.assertIn("echantillon.py", sortie) + + def test_la_meme_syntaxe_passe_sous_une_version_qui_la_connait(self): + if interpreteur_absent("3.12"): + self.skipTest("aucun Python 3.12 ici") + _, sortie = juge(PEP_701, "3.12") + self.assertEqual("", sortie.strip()) + + def test_un_source_ordinaire_ne_dit_rien(self): + version = controle.version_voulue() + if interpreteur_absent(version): + self.skipTest(f"aucun Python {version} ici") + _, sortie = juge("import os\n\nprint(os.sep)\n", version) + self.assertEqual("", sortie.strip()) + + def test_le_depot_lui_meme_passe(self): + fin = subprocess.run( + [sys.executable, str(OUTIL), "script/"], + cwd=RACINE, + capture_output=True, + text=True, + ) + self.assertEqual("", fin.stdout.strip(), fin.stdout) + self.assertEqual(0, fin.returncode) + + +class TestCeQuIlReconnait(unittest.TestCase): + def test_un_executable_sans_suffixe_au_hashbang_python(self): + """Les deux hooks du dépôt n'ont pas de suffixe : ils comptent.""" + with tempfile.TemporaryDirectory() as coin: + hook = Path(coin) / "pre-commit" + hook.write_text( + "#!/usr/bin/env python3\nx = 1\n", encoding="utf-8" + ) + self.assertTrue(controle.est_python(str(hook))) + + def test_un_fichier_sans_suffixe_ni_hashbang_python_est_ignore(self): + with tempfile.TemporaryDirectory() as coin: + texte = Path(coin) / "LISEZMOI" + texte.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + self.assertFalse(controle.est_python(str(texte))) + + def test_un_depot_rapatrie_est_ecarte_meme_nomme(self): + self.assertFalse( + controle.est_python("script/OCA_maintainer-tools/tools/config.py") + ) + self.assertFalse(controle.est_python("addons/module/models/x.py")) + + +class TestQuandIlNePeutPasVerifier(unittest.TestCase): + def test_sans_interpreteur_il_dit_qu_il_n_a_pas_verifie(self): + """Le silence ferait croire à un contrôle qui n'a pas eu lieu.""" + aveugle = dict( + os.environ, + PATH="/nonexistent", + PYENV_ROOT="/nonexistent", + MISE_DATA_DIR="/nonexistent", + ) + with tempfile.TemporaryDirectory() as coin: + cible = Path(coin) / "echantillon.py" + cible.write_text(CASSE, encoding="utf-8") + fin = subprocess.run( + [sys.executable, str(OUTIL), str(cible)], + cwd=RACINE, + capture_output=True, + text=True, + env=aveugle, + ) + if fin.stdout.strip(): + self.skipTest("un interpréteur de la version reste joignable ici") + self.assertTrue(fin.stderr.strip(), "l'avertissement manque") + self.assertEqual(0, fin.returncode) + + def test_sans_fichier_de_version_rien_n_est_affirme(self): + with tempfile.TemporaryDirectory() as coin: + ancien = controle.VERSION + controle.VERSION = str(Path(coin) / "absent") + try: + self.assertIsNone(controle.version_voulue()) + finally: + controle.VERSION = ancien + + +class TestLaVersionLue(unittest.TestCase): + def test_la_premiere_ligne_utile_est_retenue(self): + with version_declaree("# un commentaire\n\n3.14.7\n"): + self.assertEqual("3.14.7", controle.version_voulue()) + + def test_le_depot_declare_bien_une_version(self): + self.assertRegex(controle.version_voulue(), r"^\d+\.\d+") + + +if __name__ == "__main__": + unittest.main()