From 60d049dfec339e2c723e0d2aa2b5a1c304aebe05 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 22 Aug 2026 05:07:06 -0400 Subject: [PATCH] =?UTF-8?q?[FIX]=20install=20:=20compiler=20pykcs11=20avec?= =?UTF-8?q?=20SWIG=204.3=20et=20au-del=C3=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toute installation d'Odoo 14, 15 ou 17 échoue depuis que SWIG 4.5 est paru sur PyPI : « ‘PyInt_FromLong’ was not declared in this scope », 55 fois. pykcs11 — tiré par endesive — ne livre aucun wrapper pré-généré, et son « requires = ["swig"] » n'est pas borné : c'est la DERNIÈRE version publiée qui tourne, pas celle du système. Or SWIG 4.3 a retiré les alias Python 2 qu'il écrivait lui-même, et le typemap CK_RV de pykcs11 en utilise un. On rend l'alias au préprocesseur, identique token pour token à celui de SWIG 4.2. CPPFLAGS et non CFLAGS : un .cpp passe par compiler_so_cxx. Vérifié sur la VM et ici : poetry install rend 0, import PyKCS11 passe. --- EN --- Every Odoo 14, 15 and 17 install has been failing since SWIG 4.5 landed on PyPI: "'PyInt_FromLong' was not declared in this scope", 55 times. pykcs11 — pulled in by endesive — ships no pre-generated wrapper, and its `requires = ["swig"]` is unbounded: the LATEST published version runs, not the system one. SWIG 4.3 dropped the Python 2 aliases it used to emit itself, and pykcs11's CK_RV typemap uses one of them. We hand the alias back to the preprocessor, token for token identical to SWIG 4.2's. CPPFLAGS, not CFLAGS: a .cpp goes through compiler_so_cxx. Verified on the VM and here: poetry install returns 0, import PyKCS11 works. Assisted-by: Claude Opus 5 --- script/install/install_locally.sh | 23 ++++ script/test/run_unit_test.sh | 2 +- test/test_install_swig_compat.py | 190 ++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 test/test_install_swig_compat.py diff --git a/script/install/install_locally.sh b/script/install/install_locally.sh index d73d13d..0955fee 100755 --- a/script/install/install_locally.sh +++ b/script/install/install_locally.sh @@ -166,6 +166,29 @@ if [[ "${EL_PHASE}" != "setup" ]]; then "${POETRY_ODOO_PATH}" --version # To fix keyring problem when installation is blocked, use export PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring + # pykcs11 — tiré par endesive, donc présent dans les locks 14, 15 et 17 — + # ne livre AUCUN wrapper pré-généré dans son sdist : SWIG tourne à CHAQUE + # installation. Et ce n'est pas le SWIG du système qui tourne : le + # « requires = ["setuptools", "swig"] » de pykcs11 n'est pas borné, donc + # Poetry télécharge la DERNIÈRE version publiée sur PyPI (4.5.0 le 21 août + # 2026, vue dans le journal d'installation). + # + # Or SWIG a retiré en 4.3 les alias Python 2 que ses versions antérieures + # écrivaient dans le code généré (PyInt_FromLong, PyString_Check…), et le + # typemap CK_RV de pykcs11 en utilise un. D'où, sur une VM Ubuntu 26.04: + # « ‘PyInt_FromLong’ was not declared in this scope », 55 fois, et + # install_odoo_17 s'arrête. Aucune sonde locale ne peut le prévoir — le + # SWIG qui tourne est choisi au moment du build, pas ici. + # + # On redonne l'alias au préprocesseur, dans la forme EXACTE que SWIG 4.2 + # écrivait : définition identique token pour token, donc aucun + # avertissement de redéfinition là où SWIG la fournit encore (vérifié en + # -Werror contre un wrapper généré par SWIG 4.2). + # + # CPPFLAGS et non CFLAGS : un .cpp passe par « compiler_so_cxx », qui lit + # CXXFLAGS et CPPFLAGS — CFLAGS ne l'atteint JAMAIS. Mesuré sur setuptools + # 84, et c'est ce qui a fait échouer le premier correctif. + export CPPFLAGS="${CPPFLAGS:+${CPPFLAGS} }-DPyInt_FromLong(x)=PyLong_FromLong(x)" # « poetry install » reste à Poetry : uv ne lit pas poetry.lock # (astral-sh/uv#1804, « not planned ») et Poetry 2.1.3 n'a plus « export ». if [[ ${WITH_POETRY_INSTALLATION} -ne 0 ]]; then diff --git a/script/test/run_unit_test.sh b/script/test/run_unit_test.sh index fb227ea..f25c954 100755 --- a/script/test/run_unit_test.sh +++ b/script/test/run_unit_test.sh @@ -42,7 +42,7 @@ FILES=("$@") if [[ ${#FILES[@]} -eq 0 ]]; then # Aucun argument : tout ce que le dépôt sait tester sans base de données. mapfile -t FILES < <(ls test/test_qemu_*.py test/test_mobile_*.py \ - test/test_todo_*.py 2>/dev/null) + test/test_todo_*.py test/test_install_*.py 2>/dev/null) fi fail=0 diff --git a/test/test_install_swig_compat.py b/test/test_install_swig_compat.py new file mode 100644 index 0000000..a6eb4d0 --- /dev/null +++ b/test/test_install_swig_compat.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""L'installation Poetry survit à SWIG >= 4.3. + +pykcs11 — tiré par endesive, donc dans les locks Odoo 14, 15 et 17 — ne livre +aucun wrapper pré-généré : SWIG tourne à chaque installation. Son +« requires = ["setuptools", "swig"] » n'étant pas borné, Poetry prend la +DERNIÈRE version de PyPI. SWIG 4.3 a retiré les alias Python 2 que les +versions antérieures écrivaient dans le code généré, et le typemap CK_RV de +pykcs11 en utilise un : « ‘PyInt_FromLong’ was not declared in this scope », +55 fois, et install_odoo_17 s'arrête. + +Ces tests gardent les trois choses que l'enquête a coûté : + +- CPPFLAGS, pas CFLAGS. Un .cpp est compilé par « compiler_so_cxx », qui lit + CXXFLAGS et CPPFLAGS ; CFLAGS ne l'atteint JAMAIS. Le premier correctif a + échoué exactement là. +- La définition doit être identique TOKEN POUR TOKEN à celle de SWIG 4.2, + sinon les hôtes qui ont encore un vieux SWIG récoltent un avertissement de + redéfinition à chaque fichier. +- Le drapeau doit être posé AVANT « poetry install », et s'ajouter à un + CPPFLAGS existant au lieu de l'écraser. +""" + +import os +import re +import shutil +import subprocess +import sysconfig +import tempfile +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parents[1] +SCRIPT = RACINE / "script/install/install_locally.sh" +SOURCE = SCRIPT.read_text(encoding="utf-8") + +# La ligne que SWIG <= 4.2 plaçait lui-même dans le wrapper généré. +SWIG_42 = "#define PyInt_FromLong(x) PyLong_FromLong(x)" + +# Ce que produit SWIG >= 4.3 : l'appel, sans l'alias. +APPEL_SWIG_43 = """ +#include +PyObject *sonde(long v) { return PyInt_FromLong(v); } +""" + + +def _drapeau(): + """Le -D tel qu'il est écrit dans le script — source unique.""" + m = re.search(r"export CPPFLAGS=\"[^\"]*?(-DPyInt_FromLong[^\" ]*)\"", SOURCE) + return m.group(1) if m else "" + + +def _compilateur(): + return shutil.which("c++") or shutil.which("g++") + + +def _compile(source, drapeaux): + """(succès, sortie) d'une compilation de syntaxe seule.""" + inc = sysconfig.get_paths()["include"] + if not Path(inc, "Python.h").exists(): + return None, "Python.h absent" + with tempfile.TemporaryDirectory() as tmp: + fichier = Path(tmp, "sonde.cpp") + fichier.write_text(source, encoding="utf-8") + res = subprocess.run( + [_compilateur(), "-fsyntax-only", *drapeaux, "-I", inc, str(fichier)], + capture_output=True, + text=True, + timeout=120, + ) + return res.returncode == 0, res.stdout + res.stderr + + +class TestLeDrapeauDansLeScript(unittest.TestCase): + def test_it_is_exported(self): + self.assertTrue(_drapeau(), "aucun -DPyInt_FromLong dans le script") + + def test_it_is_set_before_poetry_install(self): + """Posé après, il n'atteindrait rien : c'est « poetry install » qui + lance les compilations.""" + export = SOURCE.index("export CPPFLAGS=") + install = SOURCE.index('install --no-root ${POETRY_VERBOSE}') + self.assertLess(export, install) + + def test_it_appends_instead_of_replacing(self): + """Écraser CPPFLAGS jetterait ce que l'appelant y avait mis — sur + s390x et openSUSE, des chemins d'en-têtes y passent.""" + self.assertIn('${CPPFLAGS:+${CPPFLAGS} }', SOURCE) + + def test_it_is_cppflags_and_not_cflags(self): + """Le piège qui a fait échouer le premier correctif : le fichier fautif + est un .cpp, et CFLAGS ne va qu'aux .c.""" + self.assertNotIn("export CFLAGS=", SOURCE) + + def test_the_shell_builds_the_value_it_claims(self): + """Les parenthèses du -D sont des métacaractères du shell : mal + protégées, la variable serait tronquée ou le script casserait.""" + for depart, attendu in ( + ("", "-DPyInt_FromLong(x)=PyLong_FromLong(x)"), + ("-DDEJA=1", "-DDEJA=1 -DPyInt_FromLong(x)=PyLong_FromLong(x)"), + ): + res = subprocess.run( + [ + "bash", + "-c", + f'export CPPFLAGS="{depart}"; ' + 'export CPPFLAGS="${CPPFLAGS:+${CPPFLAGS} }' + f'{_drapeau()}"; printf %s "$CPPFLAGS"', + ], + capture_output=True, + text=True, + timeout=30, + ) + self.assertEqual(attendu, res.stdout) + + def test_the_script_still_parses(self): + res = subprocess.run( + ["bash", "-n", str(SCRIPT)], capture_output=True, text=True + ) + self.assertEqual(0, res.returncode, res.stderr) + + +@unittest.skipUnless(_compilateur(), "aucun compilateur C++") +class TestLeDrapeauCompile(unittest.TestCase): + """Le drapeau fait-il ce qu'on lui prête ? Compilé, pas supposé.""" + + def test_swig_43_output_fails_without_it(self): + """Sans quoi le test suivant ne prouverait rien : c'est bien CE + symptôme que le drapeau soigne.""" + ok, sortie = _compile(APPEL_SWIG_43, []) + if ok is None: + self.skipTest(sortie) + self.assertFalse(ok) + self.assertIn("PyInt_FromLong", sortie) + + def test_the_same_source_compiles_with_it(self): + ok, sortie = _compile(APPEL_SWIG_43, [_drapeau()]) + if ok is None: + self.skipTest(sortie) + self.assertTrue(ok, sortie[-400:]) + + def test_it_does_not_clash_with_an_older_swig(self): + """Un hôte dont le SWIG écrit encore l'alias verrait, sinon, un + avertissement de redéfinition par fichier compilé. En -Werror, ce + serait un échec.""" + ok, sortie = _compile( + SWIG_42 + APPEL_SWIG_43, ["-Werror", _drapeau()] + ) + if ok is None: + self.skipTest(sortie) + self.assertTrue(ok, sortie[-400:]) + self.assertNotIn("redefined", sortie) + + +class TestHypotheseSetuptools(unittest.TestCase): + """L'hypothèse dont dépend le choix de CPPFLAGS, écrite noir sur blanc. + + Si un setuptools futur change de câblage, c'est ici qu'on l'apprend — pas + au milieu d'une installation de VM. + """ + + def test_cppflags_reaches_the_cxx_compiler_but_cflags_does_not(self): + try: + from setuptools._distutils.ccompiler import new_compiler + from setuptools._distutils.sysconfig import customize_compiler + except ImportError as exc: # pragma: no cover - setuptools trop vieux + self.skipTest(f"distutils vendu introuvable : {exc}") + garde = {v: os.environ.get(v) for v in ("CFLAGS", "CPPFLAGS")} + os.environ["CFLAGS"] = "-DVU_PAR_CFLAGS=1" + os.environ["CPPFLAGS"] = "-DVU_PAR_CPPFLAGS=1" + try: + compilateur = new_compiler() + customize_compiler(compilateur) + cxx = getattr(compilateur, "compiler_so_cxx", None) + if not cxx: + self.skipTest("pas de compiler_so_cxx dans ce setuptools") + self.assertIn("-DVU_PAR_CPPFLAGS=1", cxx) + self.assertNotIn("-DVU_PAR_CFLAGS=1", cxx) + finally: + for var, val in garde.items(): + if val is None: + os.environ.pop(var, None) + else: + os.environ[var] = val + + +if __name__ == "__main__": + unittest.main(verbosity=1)