#!/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())