erplibre/test/test_install_swig_compat.py
Mathieu Benoit 60d049dfec [FIX] install : compiler pykcs11 avec SWIG 4.3 et au-delà
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
2026-08-23 02:11:50 -04:00

190 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 <Python.h>
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)