From 90dccc76d2e19486467b28939fc68e2777563541 Mon Sep 17 00:00:00 2001 From: brinkflew Date: Sat, 21 Mar 2026 12:32:47 +0100 Subject: [PATCH] [FIX] Ensure modern setuptools for gevent installs with --no-build-isolation (#93) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Old setuptools (58–59) pins break `pip install --no-build-isolation` for gevent (and other sdists) with current pip: PEP 517 cannot load setuptools.build_meta reliably. - Raise the floor to setuptools>=69 in `odev/static/requirements.txt` for Python >= 3.8. - Bootstrap new Odoo venvs with setuptools>=69.0.0; before installing gevent, upgrade setuptools and wheel so existing venvs recover without manual steps. Add unit tests for the static requirements policy and prepare_venv call order. --- odev/common/odoobin.py | 4 +- odev/static/requirements.txt | 5 +- .../tests/common/test_odoobin_prepare_venv.py | 128 ++++++++++++++++++ 3 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 tests/tests/common/test_odoobin_prepare_venv.py diff --git a/odev/common/odoobin.py b/odev/common/odoobin.py index 591b8dfb..4e863a3d 100644 --- a/odev/common/odoobin.py +++ b/odev/common/odoobin.py @@ -663,7 +663,7 @@ def prepare_venv(self): if not self.venv.exists: self.venv.create() - self.venv.install_packages(["wheel", "setuptools", "pip", "cython<3.0.0"]) + self.venv.install_packages(["wheel", "setuptools>=69.0.0", "pip", "cython<3.0.0"]) self.venv.install_packages(["pyyaml==5.4.1"], ["--no-build-isolation"]) for path in self.addons_requirements: @@ -673,6 +673,8 @@ def prepare_venv(self): ) if missing_gevent: + # --no-build-isolation uses the venv's setuptools; ensure it is new enough for pip's PEP517 backend. + self.venv.install_packages(["setuptools>=69.0.0", "wheel"], []) self.venv.install_packages([missing_gevent.split(" ;")[0]], ["--no-build-isolation"]) if any(self.venv.missing_requirements(path)): diff --git a/odev/static/requirements.txt b/odev/static/requirements.txt index c5bbc4f7..8bb80141 100644 --- a/odev/static/requirements.txt +++ b/odev/static/requirements.txt @@ -2,7 +2,8 @@ ipdb phonenumbers pudb pydevd-odoo +# setuptools 58–59 breaks `pip install --no-build-isolation` for gevent (and other sdists) with current pip +# because the backend cannot load setuptools.build_meta reliably; use a modern floor on supported Pythons. +setuptools>=69.0.0; python_version >= '3.8' setuptools<58.0.0; python_version < '3.8' -setuptools>=58.0.0, <59.0.0; python_version >= '3.8' and python_version < '3.12' -setuptools>59.0.0; python_version >= '3.12' websocket-client diff --git a/tests/tests/common/test_odoobin_prepare_venv.py b/tests/tests/common/test_odoobin_prepare_venv.py new file mode 100644 index 00000000..34944c9e --- /dev/null +++ b/tests/tests/common/test_odoobin_prepare_venv.py @@ -0,0 +1,128 @@ +"""Tests for Odoo venv preparation (setuptools / gevent / --no-build-isolation).""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock, PropertyMock, patch + +import odev.common as odev_common +from odev.common.odoobin import OdoobinProcess +from odev.common.version import OdooVersion + + +def _repo_root() -> Path: + """Git root of the odev project (contains the inner `odev` package and `tests/`).""" + return Path(__file__).resolve().parent.parent.parent.parent + + +def _static_requirements_path() -> Path: + """Path to odev's static requirements.txt.""" + return _repo_root() / "odev" / "static" / "requirements.txt" + + +class TestStaticRequirementsSetuptools(unittest.TestCase): + """Guardrails for #93: setuptools must be new enough for pip + --no-build-isolation sdists.""" + + def test_static_requirements_setuptools_floor_for_python_3_8_plus(self): + """Python >= 3.8 must pin setuptools>=69 (see odev/static/requirements.txt).""" + text = _static_requirements_path().read_text(encoding="utf-8") + self.assertIn("setuptools>=69.0.0", text) + self.assertRegex( + text, + r"setuptools>=69\.0\.0;\s*python_version\s*>?=\s*['\"]3\.8['\"]", + ) + + @staticmethod + def _markers_target_python_3_8_or_later(text: str) -> bool: + return "python_version >= '3.8'" in text or 'python_version >= "3.8"' in text + + def test_static_requirements_no_old_setuptools_cap_for_modern_python(self): + """Ensure we do not reintroduce setuptools<59 for 3.8-3.11 (breaks gevent builds).""" + for line in _static_requirements_path().read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if "setuptools" not in stripped: + continue + if sys.version_info >= (3, 8) and self._markers_target_python_3_8_or_later(stripped): + self.assertNotRegex( + stripped, + r"setuptools\s*>=\s*58[^.]|setuptools\s*>=\s*58\.0\.0,\s*<59", + msg=f"Unexpected legacy setuptools cap on supported Python: {stripped!r}", + ) + + +class TestPrepareVenvBootstrap(unittest.TestCase): + """prepare_venv must install a modern setuptools before --no-build-isolation gevent.""" + + def setUp(self): + super().setUp() + self._req_path = Path("/tmp/odev-prepare-venv-req.txt") # noqa: S108 + self._prev_framework = odev_common.framework + self._mock_odev = MagicMock() + self._mock_odev.home_path = Path("/tmp/odev-prepare-venv-test-home") # noqa: S108 + odev_common.framework = self._mock_odev + + def tearDown(self): + odev_common.framework = self._prev_framework + super().tearDown() + + def _make_process(self, mock_venv: MagicMock) -> OdoobinProcess: + db = MagicMock() + db.exists = True + db.edition = "community" + db.repository = None + process = OdoobinProcess(db) + process.with_version(OdooVersion("16.0")) + process._venv = mock_venv + return process + + def test_new_venv_installs_setuptools_floor_before_pyyaml(self): + mock_venv = MagicMock() + mock_venv.exists = False + mock_venv.missing_requirements.return_value = iter([]) + + process = self._make_process(mock_venv) + + with patch.object( + OdoobinProcess, "addons_requirements", new_callable=PropertyMock, return_value=[self._req_path] + ): + process.prepare_venv() + + mock_venv.create.assert_called_once() + mock_venv.install_packages.assert_any_call( + ["wheel", "setuptools>=69.0.0", "pip", "cython<3.0.0"], + ) + mock_venv.install_packages.assert_any_call( + ["pyyaml==5.4.1"], + ["--no-build-isolation"], + ) + + def test_missing_gevent_upgrades_setuptools_before_no_build_isolation(self): + mock_venv = MagicMock() + mock_venv.exists = True + mock_venv.missing_requirements.side_effect = [ + iter(["gevent==21.8.0 ; python_version >= '3.7'"]), + iter([]), + ] + + process = self._make_process(mock_venv) + + with patch.object( + OdoobinProcess, "addons_requirements", new_callable=PropertyMock, return_value=[self._req_path] + ): + process.prepare_venv() + + mock_venv.create.assert_not_called() + calls = mock_venv.install_packages.call_args_list + self.assertGreaterEqual(len(calls), 2, msg="Expected setuptools/wheel then gevent installs") + self.assertEqual( + calls[0].args, + (["setuptools>=69.0.0", "wheel"], []), + ) + self.assertEqual( + calls[1].args, + (["gevent==21.8.0"], ["--no-build-isolation"]), + )