From 4a6e51ad45600ba041b0b31381d99deba9a91a0c Mon Sep 17 00:00:00 2001 From: brinkflew Date: Sun, 26 Jul 2026 23:36:44 +0200 Subject: [PATCH 1/2] [FIX] python: install a setuptools that can build Odoo's dependencies Installing Odoo's requirements failed on `gevent` with `BackendUnavailable: Cannot import 'setuptools.build_meta'`: odev pinned setuptools to 58-59 for python 3.8 to 3.11, and those versions cannot serve as a PEP 517 backend for current pip, which is what `--no-build-isolation` builds against. The floor is raised to 69. It is also capped below 82, which removed `pkg_resources`. Odoo 15.0 and 16.0 import it unconditionally in `odoo/modules/module.py` and odev runs both on python 3.10, where an uncapped requirement resolves to setuptools 83 and `odoo-bin` stops importing altogether. Users who upgraded setuptools by hand to work around this issue will see it downgraded. Neither bound had any effect before, because requirements were parsed with a regular expression capturing a single operator and version, and expecting the environment marker to follow the version immediately. Any requirement combining two bounds lost the second one *and* its marker: the existing `setuptools>=58.0.0, <59.0.0; python_version >= '3.8' and python_version < '3.12'` had always been read as an unconditional `setuptools>=58.0.0`. Requirements are now parsed with `packaging.requirements.Requirement`, which also removes the `eval()` of the comparison and of the marker. Markers are evaluated against the python of the virtual environment the requirements are being installed into, not against the interpreter odev runs under, so a requirement conditioned on the python version resolves for the Odoo installation it is meant for. Odoo builds several of its dependencies from source, and those builds fail with errors that do not name the system library they are missing. When creating a virtual environment, odev now lists the system packages Odoo declares in `setup/debinstall.sh` that are not installed, warns about them and offers to run the script. Detection uses the script's `--list` mode, which needs no privileges, and is skipped outside of Debian-based systems and for the versions of Odoo that predate the script. The prompt defaults to declining, since prompts return their default when running with `--force`, in headless mode and under tests, and the script is run through an explicit `sudo` because it silently downgrades to a dry run and exits successfully when not run as root. Closes #93 --- README.md | 10 + odev/_version.py | 2 +- odev/common/odoobin.py | 87 +++++++- odev/common/python.py | 81 ++++---- odev/static/requirements.txt | 6 +- .../tests/common/test_odoobin_prepare_venv.py | 192 ++++++++++++++++++ 6 files changed, 335 insertions(+), 43 deletions(-) create mode 100644 tests/tests/common/test_odoobin_prepare_venv.py diff --git a/README.md b/README.md index f5969a1e4..ae4ebc4c5 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,16 @@ Before you can run this tool, make sure the below requirements are set on your s source install requirements - [Other Odoo dependencies](https://www.odoo.com/documentation/19.0/administration/on_premise/source.html#dependencies) +Odoo builds part of its Python dependencies (`gevent`, `lxml`, `python-ldap`, …) from source, which needs the matching +system development packages. On Debian and Ubuntu, install them from the Odoo sources Odev has cloned: + +```sh +sudo ~/odoo/repositories/odoo/odoo/setup/debinstall.sh +``` + +Odev checks for those packages when it creates a virtual environment for a version of Odoo and offers to run the +script for you if any are missing. + Make sure `git` is properly setup with SSH key authentication before using commands, as Odev will try to connect to the Odoo [Community](https://github.com/odoo/odoo) and [Enterprise](https://github.com/odoo/enterprise) repositories to pull sources when required. diff --git a/odev/_version.py b/odev/_version.py index 7306664df..12aa2f183 100644 --- a/odev/_version.py +++ b/odev/_version.py @@ -22,4 +22,4 @@ # or merged change. # ------------------------------------------------------------------------------ -__version__ = "4.29.9" +__version__ = "4.29.10" diff --git a/odev/common/odoobin.py b/odev/common/odoobin.py index 8b0e881d0..832868a38 100644 --- a/odev/common/odoobin.py +++ b/odev/common/odoobin.py @@ -2,6 +2,8 @@ import re import shlex +import shutil +import sys from ast import literal_eval from collections.abc import Callable, Generator, Mapping, Sequence from contextlib import nullcontext @@ -51,6 +53,16 @@ ODOO_UPGRADE_REPOSITORY: str = "odoo/upgrade" +SETUPTOOLS_REQUIREMENT: str = "setuptools>=69.0.0,<82" +"""Version of setuptools to install in the virtual environments of Odoo installations. +At least 69 because setuptools 58 and 59 cannot serve as a PEP 517 backend for current pip, +which breaks installing gevent and other source distributions with `--no-build-isolation`. +Below 82 because it removed `pkg_resources`, which Odoo 15.0 and 16.0 import unconditionally. +""" + +DEBIAN_INSTALL_SCRIPT: str = "setup/debinstall.sh" +"""Path of the script listing and installing Odoo's system dependencies, relative to the Odoo sources.""" + ODOO_PYTHON_VERSIONS: Mapping[int, str] = { 19: "3.12", @@ -711,14 +723,81 @@ def missing_npm_packages(self, packages: Sequence[str]) -> Generator[str, None, if f" {package}" not in installed_packages: yield package + def missing_system_dependencies(self) -> list[str]: + """List the system packages Odoo needs to build its python dependencies and that are missing. + + Detection is only possible on Debian-based systems, where Odoo ships a script listing them; + anywhere else, and for versions of Odoo predating that script, an empty list is returned. + + :return: The names of the missing packages. + :rtype: List[str] + """ + script = self.odoo_path / DEBIAN_INSTALL_SCRIPT + + if sys.platform != "linux" or not script.is_file(): + return [] + + if not (shutil.which("apt-get") and shutil.which("dpkg-query")): + return [] + + # `--list` only prints the package names parsed out of `debian/control`, it needs no privileges + listed = bash.execute(f"sh {shlex.quote(script.as_posix())} --list", raise_on_error=False) + + if listed is None: + return [] + + packages = listed.stdout.decode().split() + installed = bash.execute( + "dpkg-query --show --showformat '${Package} ${Status}\\n' " + " ".join(map(shlex.quote, packages)), + raise_on_error=False, + ) + satisfied = { + line.split(" ", 1)[0] + for line in (installed.stdout.decode().splitlines() if installed else []) + if line.endswith("install ok installed") + } + return [package for package in packages if package not in satisfied] + + def check_system_dependencies(self) -> None: + """Warn about the system packages Odoo needs to build its python dependencies, and offer + to install them. + + Building gevent, python-ldap or lxml from source fails with errors that do not point at the + missing system library, so this is checked upfront, when a virtual environment is created. + """ + missing = self.missing_system_dependencies() + + if not missing: + return + + script = self.odoo_path / DEBIAN_INSTALL_SCRIPT + logger.warning( + f"{len(missing)} system packages required by Odoo are not installed, building its python " + "dependencies from source is likely to fail:\n" + string.join_bullet(missing) + ) + + # Never escalate privileges without a real answer: prompts return their default value when + # running with `--force`, in headless mode and during tests + if self.console.bypass_prompt or not sys.stdin.isatty(): + logger.info(f"Install them by running: sudo {script.as_posix()}") + return + + if not self.console.confirm(f"Run {script.as_posix()} now? This requires sudo.", default=False): + return + + # Not `bash.execute(sudo=True)`: the script silently downgrades to a dry run when it is not + # run as root and exits successfully, so it would never be retried with elevated privileges + bash.run(f"sudo {shlex.quote(script.as_posix())}") + def prepare_venv(self): """Prepare the virtual environment of the Odoo installation.""" if not self.database.exists: raise OdevError("Database does not exist") if not self.venv.exists: + self.check_system_dependencies() self.venv.create() - self.venv.install_packages(["wheel", "setuptools", "pip", "cython<3.0.0"]) + self.venv.install_packages(["wheel", SETUPTOOLS_REQUIREMENT, "pip", "cython<3.0.0"]) self.venv.install_packages(["pyyaml==5.4.1"], ["--no-build-isolation"]) for path in self.addons_requirements: @@ -728,6 +807,12 @@ def prepare_venv(self): ) if missing_gevent: + # `--no-build-isolation` builds against the setuptools of the virtual environment + # rather than a fresh one, so it has to be usable as a PEP 517 backend. Custom addons + # requirements are installed before odev's own, so this may still be the old one. + if not self.venv.satisfies(SETUPTOOLS_REQUIREMENT): + self.venv.install_packages([SETUPTOOLS_REQUIREMENT, "wheel"]) + self.venv.install_packages([missing_gevent.split(" ;")[0]], ["--no-build-isolation"]) if any(self.venv.missing_requirements(path)): diff --git a/odev/common/python.py b/odev/common/python.py index d67223f7b..1fc532882 100644 --- a/odev/common/python.py +++ b/odev/common/python.py @@ -11,6 +11,8 @@ from typing import ClassVar import virtualenv +from packaging.markers import default_environment +from packaging.requirements import InvalidRequirement, Requirement from packaging.version import InvalidVersion, Version, parse as parse_version from odev.common import bash, progress, string @@ -465,43 +467,32 @@ def missing_requirements(self, path: Path | str, raise_if_error: bool = True) -> continue - match = RE_PACKAGE.search(line) - - if match is None: + try: + requirement = Requirement(line) + except InvalidRequirement: + logger.debug(f"Ignoring unparsable requirement {line!r}") continue - if not self.__check_package_conditions(match.group("conditional")): + if requirement.marker is not None and not requirement.marker.evaluate(self.marker_environment): continue - installed_version = installed_packages.get(match.group("name").lower()) + installed_version = installed_packages.get(requirement.name.lower()) if installed_version is None: - logger.debug(f"Missing python package {match.group('name')}") + logger.debug(f"Missing python package {requirement.name}") yield line continue if not isinstance(installed_version, Version): - raise TypeError(f"Invalid version {installed_version!r} for python package {match.group('name')}") - - if match.group("version") is None and match.group("op") is None: - continue - - package_operator = match.group("op") + raise TypeError(f"Invalid version {installed_version!r} for python package {requirement.name}") - if package_operator is None: + if not requirement.specifier: continue - package_version = match.group("version").split("*", 1)[0].rstrip(".") - - version_locals = { - "installed_version": installed_version, - "package_version": parse_version(package_version), - } - - if not eval(f"installed_version {package_operator} package_version", version_locals): # noqa: S307 - known values + if not requirement.specifier.contains(installed_version, prereleases=True): logger.debug( - f"Incorrect python package version {match.group('name')} " - f"({installed_version} {package_operator} {package_version})" + f"Incorrect python package version {requirement.name} " + f"({installed_version} does not satisfy {requirement.specifier})" ) yield line @@ -516,24 +507,36 @@ def __check_requirements_path(self, path: Path | str) -> Path: return requirements_path - def __check_package_conditions(self, conditional: str | None) -> bool: - if conditional is None: - return True + def satisfies(self, specification: str) -> bool: + """Check whether a package installed in this environment satisfies a requirement. - if "python_version" in conditional: - conditional = re.sub( - r"(?:'|\")(3.\d+)(?:'|\")", - lambda m: m.group(1) and " {} ".format(int(m.group(1).replace(".", ""))), - conditional, - ) + :param specification: The requirement to check, e.g. `setuptools>=69.0.0,<82`. + :return: True if the package is installed and its version satisfies the requirement. + :rtype: bool + """ + requirement = Requirement(specification) + installed_version = self.installed_packages().get(requirement.name.lower()) - return eval( # noqa: S307 - known values and operations - conditional, - { - "sys_platform": sys.platform, - "python_version": int(self.version.replace(".", "")), - }, - ) + if not isinstance(installed_version, Version): + return False + + return requirement.specifier.contains(installed_version, prereleases=True) + + @property + def marker_environment(self) -> MutableMapping[str, str]: + """Environment against which the markers of a requirement are evaluated. + + Requirements are evaluated for the python of *this* environment, not for the one odev + itself runs under, so that a requirement conditioned on the python version is resolved + for the Odoo installation it is going to be installed in. + """ + version = self.version + return { + **default_environment(), + "python_version": version, + "python_full_version": version, + "implementation_version": version, + } def run_script( self, diff --git a/odev/static/requirements.txt b/odev/static/requirements.txt index c5bbc4f71..7c832434f 100644 --- a/odev/static/requirements.txt +++ b/odev/static/requirements.txt @@ -2,7 +2,9 @@ ipdb phonenumbers pudb pydevd-odoo +# setuptools 58-59 cannot serve as a PEP 517 backend for current pip, which breaks +# `pip install --no-build-isolation` for gevent and other source distributions. +# Capped below 82, which removed `pkg_resources`; Odoo 15.0 and 16.0 import it unconditionally. +setuptools>=69.0.0,<82; 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 000000000..2a12ad33d --- /dev/null +++ b/tests/tests/common/test_odoobin_prepare_venv.py @@ -0,0 +1,192 @@ +"""Tests for the python and system packages odev needs to prepare an Odoo installation.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from packaging.requirements import Requirement +from packaging.version import Version + +from odev.common.odoobin import DEBIAN_INSTALL_SCRIPT, SETUPTOOLS_REQUIREMENT, OdoobinProcess +from odev.common.python import PythonEnv + +from tests.fixtures import OdevTestCase + + +class TestSetuptoolsRequirement(OdevTestCase): + """Guardrails for #93 and for the Odoo versions that cannot run on a recent setuptools.""" + + def setuptools_requirement(self, python_version: str) -> Requirement: + """Return the setuptools requirement that applies to a given python version.""" + text = (self.odev.static_path / "requirements.txt").read_text(encoding="utf-8") + requirements = [ + Requirement(stripped) + for line in text.splitlines() + if (stripped := line.split("#", 1)[0].strip()) and Requirement(stripped).name == "setuptools" + ] + return next( + requirement + for requirement in requirements + if requirement.marker is None or requirement.marker.evaluate({"python_version": python_version}) + ) + + def test_modern_python_requires_a_usable_pep517_backend(self): + """Setuptools 58 and 59 cannot load `setuptools.build_meta` for current pip, which is what + breaks installing gevent with `--no-build-isolation`. + """ + specifier = self.setuptools_requirement("3.10").specifier + self.assertFalse(specifier.contains(Version("58.0.0")), "regression of #93") + self.assertFalse(specifier.contains(Version("59.0.0")), "regression of #93") + self.assertTrue(specifier.contains(Version("69.0.0"))) + + def test_modern_python_excludes_setuptools_without_pkg_resources(self): + """Setuptools 82 removed `pkg_resources`, which Odoo 15.0 and 16.0 import unconditionally + in `odoo/modules/module.py`; both run on python 3.10. + """ + specifier = self.setuptools_requirement("3.10").specifier + self.assertTrue(specifier.contains(Version("81.2.0"))) + self.assertFalse(specifier.contains(Version("82.0.0"))) + self.assertFalse(specifier.contains(Version("83.0.0"))) + + def test_legacy_python_keeps_the_legacy_cap(self): + """Odoo 13.0 and older run on python 3.7 or 2.7, where setuptools must stay old.""" + specifier = self.setuptools_requirement("3.7").specifier + self.assertTrue(specifier.contains(Version("57.5.0"))) + self.assertFalse(specifier.contains(Version("69.0.0"))) + + def test_constant_matches_the_static_requirements(self): + self.assertEqual( + Requirement(SETUPTOOLS_REQUIREMENT).specifier, + self.setuptools_requirement("3.10").specifier, + ) + + +class TestMissingRequirements(OdevTestCase): + """`missing_requirements` must honour the full specifier and the markers of a requirement.""" + + def write_requirements(self, *lines: str) -> Path: + path = self.run_path / "requirements.txt" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + def missing(self, requirement: str, installed: dict[str, Version], python_version: str = "3.10") -> list[str]: + venv = PythonEnv(str(self.run_path / "venv")) + path = self.write_requirements(requirement) + + with ( + self.patch(PythonEnv, "installed_packages", return_value=installed), + self.patch_property(PythonEnv, "version", python_version), + ): + return list(venv.missing_requirements(path)) + + def test_upper_bound_is_honoured(self): + """The requirement parser only kept the first operator of a specifier, so an upper bound + was silently dropped and an over-new package was considered satisfied. + """ + requirement = "setuptools>=69.0.0,<82; python_version >= '3.8'" + self.assertEqual(self.missing(requirement, {"setuptools": Version("83.0.0")}), [requirement]) + self.assertEqual(self.missing(requirement, {"setuptools": Version("75.0.0")}), []) + + def test_lower_bound_is_honoured(self): + requirement = "setuptools>=69.0.0,<82; python_version >= '3.8'" + self.assertEqual(self.missing(requirement, {"setuptools": Version("58.0.0")}), [requirement]) + + def test_marker_is_evaluated_for_the_environment_python(self): + """Markers apply to the python of the virtual environment, not to the one odev runs under.""" + requirement = "setuptools<58.0.0; python_version < '3.8'" + self.assertEqual(self.missing(requirement, {"setuptools": Version("69.0.0")}, python_version="3.10"), []) + self.assertEqual( + self.missing(requirement, {"setuptools": Version("69.0.0")}, python_version="3.7"), + [requirement], + ) + + def test_missing_package_is_reported(self): + self.assertEqual(self.missing("phonenumbers", {}), ["phonenumbers"]) + + def test_unpinned_installed_package_is_satisfied(self): + self.assertEqual(self.missing("phonenumbers", {"phonenumbers": Version("8.13.0")}), []) + + def test_comments_and_blank_lines_are_skipped(self): + self.assertEqual(self.missing("# just a comment", {}), []) + + +class TestSatisfies(OdevTestCase): + """`PythonEnv.satisfies` answers whether an installed package matches a specification.""" + + def satisfies(self, specification: str, installed: dict[str, Version]) -> bool: + venv = PythonEnv(str(self.run_path / "venv")) + + with self.patch(PythonEnv, "installed_packages", return_value=installed): + return venv.satisfies(specification) + + def test_within_bounds(self): + self.assertTrue(self.satisfies(SETUPTOOLS_REQUIREMENT, {"setuptools": Version("75.0.0")})) + + def test_below_lower_bound(self): + self.assertFalse(self.satisfies(SETUPTOOLS_REQUIREMENT, {"setuptools": Version("58.0.0")})) + + def test_above_upper_bound(self): + self.assertFalse(self.satisfies(SETUPTOOLS_REQUIREMENT, {"setuptools": Version("83.0.0")})) + + def test_not_installed(self): + self.assertFalse(self.satisfies(SETUPTOOLS_REQUIREMENT, {})) + + +class TestSystemDependencies(OdevTestCase): + """The system dependencies check must stay silent where it cannot apply, and never sudo alone.""" + + def setUp(self): + super().setUp() + self.odoo_path = self.run_path / "odoo" + (self.odoo_path / "setup").mkdir(parents=True, exist_ok=True) + self.process = OdoobinProcess.__new__(OdoobinProcess) + self.process._framework = self.odev + + def write_script(self): + (self.odoo_path / DEBIAN_INSTALL_SCRIPT).write_text("#!/bin/sh\n", encoding="utf-8") + + def missing(self, platform: str = "linux", which: bool = True) -> list[str]: + with ( + self.patch_property(OdoobinProcess, "odoo_path", self.odoo_path), + patch("odev.common.odoobin.sys.platform", platform), + patch("odev.common.odoobin.shutil.which", return_value="/usr/bin/apt-get" if which else None), + ): + return self.process.missing_system_dependencies() + + def test_no_check_outside_linux(self): + self.write_script() + self.assertEqual(self.missing(platform="darwin"), []) + + def test_no_check_without_the_script(self): + self.assertEqual(self.missing(), []) + + def test_no_check_without_apt(self): + self.write_script() + self.assertEqual(self.missing(which=False), []) + + def test_never_escalates_without_a_confirmation(self): + """Prompts return their default when running with `--force`, headless, or under tests, so + the check must not reach `sudo` on its own. + """ + self.write_script() + run = MagicMock() + + with ( + self.patch(OdoobinProcess, "missing_system_dependencies", return_value=["libpq-dev"]), + self.patch_property(OdoobinProcess, "odoo_path", self.odoo_path), + patch("odev.common.odoobin.bash.run", run), + ): + self.process.check_system_dependencies() + + run.assert_not_called() + + def test_nothing_happens_when_no_package_is_missing(self): + run = MagicMock() + + with ( + self.patch(OdoobinProcess, "missing_system_dependencies", return_value=[]), + patch("odev.common.odoobin.bash.run", run), + ): + self.process.check_system_dependencies() + + run.assert_not_called() From aeb7061a0587cb588dca5a67ef7d8573467d5986 Mon Sep 17 00:00:00 2001 From: brinkflew Date: Mon, 27 Jul 2026 01:53:26 +0200 Subject: [PATCH 2/2] [IMP] system: report missing dependencies on any operating system The system dependency check introduced with the setuptools fix only worked on Debian and Ubuntu: it listed the packages Odoo declares in `debian/control` and offered to run `setup/debinstall.sh` with `sudo`. On macOS, Fedora or Arch it reported nothing at all, and the user only found out something was missing when the build of gevent failed in the compiler. Odev does not control the machine it runs on, so it now describes what is missing in plain words and, when it recognizes the package manager, prints the command installing it. It no longer runs that command itself. Where Odoo lists its own packages they are still used, and anywhere else the check falls back to probing what an executable on the `PATH` proves present, so that nothing is reported as missing on a distribution that names it differently. The development headers of python are checked on every system: `debian/control` asks for `python3-dev`, which is not necessarily the version of python the Odoo installation is built against. `PythonEnv.install_system_packages` used to raise `Neither dnf or apt package managers found on the system` when it ran anywhere else, naming neither the packages to install nor a way to move forward. It now reports them and returns whether it installed anything, so that `create` gives up once instead of asking the same question again on a system where the answer cannot change. Claude-Session: https://claude.ai/code/session_01K8csZBrrBYp8oqH5paxTAm --- README.md | 10 +- odev/common/odoobin.py | 89 +++++---- odev/common/python.py | 86 ++++++--- odev/common/system.py | 174 ++++++++++++++++++ .../tests/common/test_odoobin_prepare_venv.py | 81 ++++++-- tests/tests/common/test_python_env.py | 73 ++++++++ tests/tests/common/test_system.py | 87 +++++++++ 7 files changed, 511 insertions(+), 89 deletions(-) create mode 100644 odev/common/system.py create mode 100644 tests/tests/common/test_system.py diff --git a/README.md b/README.md index ae4ebc4c5..112d932ad 100644 --- a/README.md +++ b/README.md @@ -23,15 +23,17 @@ Before you can run this tool, make sure the below requirements are set on your s source install requirements - [Other Odoo dependencies](https://www.odoo.com/documentation/19.0/administration/on_premise/source.html#dependencies) -Odoo builds part of its Python dependencies (`gevent`, `lxml`, `python-ldap`, …) from source, which needs the matching -system development packages. On Debian and Ubuntu, install them from the Odoo sources Odev has cloned: +Odoo builds part of its Python dependencies (`gevent`, `lxml`, `python-ldap`, …) from source, which needs a C compiler +and the matching development packages: the headers of the Python version Odoo runs on, the PostgreSQL client library +and the OpenLDAP and SASL headers. On Debian and Ubuntu, install them all from the Odoo sources Odev has cloned: ```sh sudo ~/odoo/repositories/odoo/odoo/setup/debinstall.sh ``` -Odev checks for those packages when it creates a virtual environment for a version of Odoo and offers to run the -script for you if any are missing. +On Fedora, Arch, openSUSE, Alpine or macOS, install the equivalents with your own package manager. Odev checks +whenever it creates a virtual environment for a version of Odoo and, whatever the system, tells you what is missing +along with the command that installs it. It never installs anything itself. Make sure `git` is properly setup with SSH key authentication before using commands, as Odev will try to connect to the Odoo [Community](https://github.com/odoo/odoo) and [Enterprise](https://github.com/odoo/enterprise) repositories diff --git a/odev/common/odoobin.py b/odev/common/odoobin.py index 832868a38..a41993b41 100644 --- a/odev/common/odoobin.py +++ b/odev/common/odoobin.py @@ -19,7 +19,7 @@ from packaging.version import Version -from odev.common import bash, string +from odev.common import bash, string, system from odev.common.cache import TTLCache from odev.common.connectors import GitConnector, GitWorktree from odev.common.databases import Branch, Repository @@ -31,6 +31,7 @@ from odev.common.progress import spinner from odev.common.python import PythonEnv from odev.common.signal_handling import capture_signals +from odev.common.system import SystemDependency from odev.common.version import OdooVersion @@ -63,6 +64,12 @@ DEBIAN_INSTALL_SCRIPT: str = "setup/debinstall.sh" """Path of the script listing and installing Odoo's system dependencies, relative to the Odoo sources.""" +PROBED_DEPENDENCIES: Sequence[SystemDependency] = (system.C_COMPILER, system.POSTGRESQL_HEADERS) +"""System dependencies looked for on systems where Odoo does not list its own packages. Restricted +to the ones an executable on the `PATH` proves present, so that nothing is ever reported as missing +on a distribution that just names it differently. +""" + ODOO_PYTHON_VERSIONS: Mapping[int, str] = { 19: "3.12", @@ -723,28 +730,23 @@ def missing_npm_packages(self, packages: Sequence[str]) -> Generator[str, None, if f" {package}" not in installed_packages: yield package - def missing_system_dependencies(self) -> list[str]: - """List the system packages Odoo needs to build its python dependencies and that are missing. + def missing_debian_packages(self) -> list[str] | None: + """List the packages Odoo declares in `debian/control` and that are not installed. - Detection is only possible on Debian-based systems, where Odoo ships a script listing them; - anywhere else, and for versions of Odoo predating that script, an empty list is returned. - - :return: The names of the missing packages. - :rtype: List[str] + :return: The names of the missing packages, or None on a system where Odoo does not + describe them or where they cannot be queried. + :rtype: Optional[List[str]] """ script = self.odoo_path / DEBIAN_INSTALL_SCRIPT - if sys.platform != "linux" or not script.is_file(): - return [] - - if not (shutil.which("apt-get") and shutil.which("dpkg-query")): - return [] + if not script.is_file() or not shutil.which("dpkg-query"): + return None # `--list` only prints the package names parsed out of `debian/control`, it needs no privileges listed = bash.execute(f"sh {shlex.quote(script.as_posix())} --list", raise_on_error=False) if listed is None: - return [] + return None packages = listed.stdout.decode().split() installed = bash.execute( @@ -758,45 +760,62 @@ def missing_system_dependencies(self) -> list[str]: } return [package for package in packages if package not in satisfied] + def missing_system_dependencies(self) -> list[SystemDependency]: + """List what Odoo needs to build its python dependencies from source and that is missing. + + Odev runs on systems it does not control, so this never assumes a distribution: where Odoo + describes its own packages they are used, and anywhere else only what can be proven missing + is reported. + + :return: The missing dependencies. + :rtype: List[SystemDependency] + """ + if sys.platform not in {"linux", "darwin"}: + return [] + + debian_packages = self.missing_debian_packages() + + if debian_packages is None: + missing = [dependency for dependency in PROBED_DEPENDENCIES if not dependency.found] + else: + missing = [SystemDependency(name=package, packages={"apt-get": package}) for package in debian_packages] + + # Checked everywhere: `debian/control` asks for `python3-dev`, which is not necessarily the + # version of python the Odoo installation is built against + if self.venv.exists and not self.venv.has_development_headers: + missing.append(system.PYTHON_HEADERS) + + return missing + def check_system_dependencies(self) -> None: - """Warn about the system packages Odoo needs to build its python dependencies, and offer - to install them. + """Warn about what Odoo needs to build its python dependencies from source and that is + missing from the system. - Building gevent, python-ldap or lxml from source fails with errors that do not point at the - missing system library, so this is checked upfront, when a virtual environment is created. + Building gevent, python-ldap or lxml from source fails with errors naming a missing header + rather than the package providing it, so this is checked when a virtual environment is + created. Nothing is installed: the machine odev runs on belongs to the user. """ missing = self.missing_system_dependencies() if not missing: return - script = self.odoo_path / DEBIAN_INSTALL_SCRIPT logger.warning( - f"{len(missing)} system packages required by Odoo are not installed, building its python " - "dependencies from source is likely to fail:\n" + string.join_bullet(missing) + f"{len(missing)} system dependencies required by Odoo are missing, building its python " + "dependencies from source is likely to fail:\n" + + system.install_instructions(missing, version=self.venv.version) ) - # Never escalate privileges without a real answer: prompts return their default value when - # running with `--force`, in headless mode and during tests - if self.console.bypass_prompt or not sys.stdin.isatty(): - logger.info(f"Install them by running: sudo {script.as_posix()}") - return - - if not self.console.confirm(f"Run {script.as_posix()} now? This requires sudo.", default=False): - return - - # Not `bash.execute(sudo=True)`: the script silently downgrades to a dry run when it is not - # run as root and exits successfully, so it would never be retried with elevated privileges - bash.run(f"sudo {shlex.quote(script.as_posix())}") - def prepare_venv(self): """Prepare the virtual environment of the Odoo installation.""" if not self.database.exists: raise OdevError("Database does not exist") if not self.venv.exists: - self.check_system_dependencies() self.venv.create() + # After creating the environment: the interpreter it was created from is the one whose + # development headers matter, and the warning still precedes the installs that need them + self.check_system_dependencies() self.venv.install_packages(["wheel", SETUPTOOLS_REQUIREMENT, "pip", "cython<3.0.0"]) self.venv.install_packages(["pyyaml==5.4.1"], ["--no-build-isolation"]) diff --git a/odev/common/python.py b/odev/common/python.py index 1fc532882..94be1ab78 100644 --- a/odev/common/python.py +++ b/odev/common/python.py @@ -15,7 +15,7 @@ from packaging.requirements import InvalidRequirement, Requirement from packaging.version import InvalidVersion, Version, parse as parse_version -from odev.common import bash, progress, string +from odev.common import bash, progress, string, system from odev.common.cache import TTLCache from odev.common.console import console from odev.common.errors import OdevError @@ -45,23 +45,11 @@ re.VERBOSE | re.IGNORECASE, ) -OS_PACKAGES = { - "dnf": [ - "gcc", - "libpq-devel", - "openldap-devel", - "python{version}-devel", - "python{version}", - ], - "apt": [ - "gcc", - "libldap2-dev", - "libpq-dev", - "libsasl2-dev", - "python{version}-dev", - "python{version}", - ], -} +INSTALLABLE_PACKAGE_MANAGERS: frozenset[str] = frozenset({"apt-get", "dnf"}) +"""Package managers odev installs packages with on its own. Anywhere else it only reports what is +missing and lets the user install it, as installing the wrong package under `sudo` on a system odev +has never been tested against is worse than saying nothing. +""" @lru_cache @@ -196,7 +184,11 @@ def create(self) -> None: ): raise OdevError("Failed to create virtual environment") from error - self.install_system_packages() + # Only retry if something was actually installed, otherwise the same question + # would be asked over and over on a system odev cannot install packages on + if not self.install_system_packages(): + raise OdevError("Failed to create virtual environment") from error + return self.create() raise OdevError("Failed to create virtual environment") from error @@ -215,21 +207,34 @@ def remove(self) -> None: return logger.info(f"Removed {venv_description}") - def install_system_packages(self) -> None: - """Install system packages for the current python version.""" + def install_system_packages(self) -> bool: + """Install the system packages needed to run and build against the current python version. + + Odev only installs packages on the systems it has been tested against; on any other one it + reports what is missing and how to install it, and leaves it to the user. + + :return: Whether packages were installed, so that callers know a retry is worth it. + :rtype: bool + """ if self._global: raise OdevError("Cannot install system packages for the global python interpreter") - with progress.spinner("Installing system packages"): - package_manager = next((pkg for pkg in OS_PACKAGES if shutil.which(pkg)), None) + package_manager = system.package_manager() - if not package_manager: - raise OdevError( - f"Neither {string.join_or(list(OS_PACKAGES.keys()))} package managers found on the system, " - "cannot install packages" - ) + if package_manager not in INSTALLABLE_PACKAGE_MANAGERS: + logger.warning( + f"Odev cannot install packages on this system, python {self.version} and the packages " + "needed to build Odoo's dependencies have to be installed manually:\n" + + system.install_instructions(system.ODOO_SYSTEM_DEPENDENCIES, version=self.version) + ) + return False - packages = " ".join([pkg.format(version=self.version) for pkg in OS_PACKAGES[package_manager]]) + with progress.spinner("Installing system packages"): + packages = " ".join( + package + for dependency in system.ODOO_SYSTEM_DEPENDENCIES + if (package := dependency.package(package_manager, version=self.version)) + ) logger.info( f"The following packages will be installed using {package_manager}:\n" + string.join_bullet(packages.split()) @@ -237,7 +242,7 @@ def install_system_packages(self) -> None: if not console.confirm("Continue?", default=True): logger.warning("Aborting system package installation") - return + return False try: bash.execute(f"{package_manager} install -y {packages}", sudo=True) @@ -257,6 +262,27 @@ def install_system_packages(self) -> None: bash.execute(f"ln -s {lldap} {lldap_r}", sudo=True) logger.info(f"Installed system packages for python {self.version}") + return True + + @property + def has_development_headers(self) -> bool: + """Whether the C headers needed to build python extensions are available for this interpreter. + + `INCLUDEPY` points to the headers of the interpreter this environment was created from, which + most distributions ship in a separate package (`python3.10-dev`, `python3.10-devel`). Building + `gevent` or `python-ldap` from source fails without them, with an error naming a missing + `Python.h` rather than the package that provides it. + """ + headers = bash.execute( + f"{self.python} -c 'import sysconfig; print(sysconfig.get_config_var(\"INCLUDEPY\"))'", + raise_on_error=False, + ) + + if headers is None: + # Never warn on a guess: an interpreter that cannot be questioned is assumed complete + return True + + return Path(headers.stdout.decode().strip(), "Python.h").is_file() def install_packages(self, packages: list[str], options: list[str] | None = None) -> None: """Install python packages. diff --git a/odev/common/system.py b/odev/common/system.py new file mode 100644 index 000000000..ea21de19b --- /dev/null +++ b/odev/common/system.py @@ -0,0 +1,174 @@ +"""Description of the operating system odev runs on and of the packages it can install.""" + +import shutil +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field + +from odev.common import string + + +__all__ = [ + "ODOO_SYSTEM_DEPENDENCIES", + "SystemDependency", + "install_instructions", + "package_manager", +] + + +PACKAGE_MANAGERS: Mapping[str, str] = { + "apt-get": "sudo apt-get install -y {packages}", + "dnf": "sudo dnf install -y {packages}", + "zypper": "sudo zypper install -y {packages}", + "pacman": "sudo pacman -S --needed {packages}", + "apk": "sudo apk add {packages}", + "brew": "brew install {packages}", +} +"""Command installing packages with each of the package managers odev knows about, keyed by the +executable to look for on the `PATH`. Package managers shipped by the distribution come first so +that a Linux machine on which Homebrew is also installed is told to use its own. +""" + + +@dataclass(frozen=True) +class SystemDependency: + """A part of the operating system Odoo needs to build its python dependencies from source.""" + + name: str + """Human-readable description, displayed as-is when no package can be named for the current + system. May contain placeholders, `{version}` being the version of python being installed. + """ + + packages: Mapping[str, str] = field(default_factory=dict) + """Package providing this dependency, per package manager. A package manager absent from this + mapping has nothing to install: Arch Linux ships the python headers within `python` itself and + the compiler of macOS comes from `xcode-select --install`, not from Homebrew. + """ + + commands: Sequence[str] = () + """Executables proving, if any of them is found on the `PATH`, that this dependency is + installed. Dependencies detected some other way leave this empty and are never found. + """ + + @property + def found(self) -> bool: + """Whether the dependency can be proven to be installed on the current system.""" + return any(shutil.which(command) for command in self.commands) + + def package(self, manager: str, **placeholders: str) -> str | None: + """Package to install to provide this dependency with a given package manager. + + :param manager: The package manager to install the dependency with. + :param placeholders: Values for the placeholders in the name of the package. + :return: The name of the package, or None if the package manager does not ship one. + :rtype: Optional[str] + """ + package = self.packages.get(manager) + return package.format(**placeholders) if package is not None else None + + +PYTHON_INTERPRETER = SystemDependency( + name="the python {version} interpreter", + packages={ + "apt-get": "python{version}", + "dnf": "python{version}", + "zypper": "python{version}", + "pacman": "python", + "apk": "python3", + "brew": "python@{version}", + }, +) + +PYTHON_HEADERS = SystemDependency( + name="the development headers of python {version} (Python.h)", + packages={ + "apt-get": "python{version}-dev", + "dnf": "python{version}-devel", + "zypper": "python{version}-devel", + "apk": "python3-dev", + }, +) + +C_COMPILER = SystemDependency( + name="a C compiler", + packages={ + "apt-get": "build-essential", + "dnf": "gcc", + "zypper": "gcc", + "pacman": "base-devel", + "apk": "build-base", + }, + commands=("cc", "gcc", "clang"), +) + +POSTGRESQL_HEADERS = SystemDependency( + name="the PostgreSQL client headers (pg_config)", + packages={ + "apt-get": "libpq-dev", + "dnf": "libpq-devel", + "zypper": "postgresql-devel", + "pacman": "postgresql-libs", + "apk": "postgresql-dev", + "brew": "libpq", + }, + commands=("pg_config",), +) + +LDAP_HEADERS = SystemDependency( + name="the OpenLDAP and SASL headers", + packages={ + "apt-get": "libldap2-dev libsasl2-dev", + "dnf": "openldap-devel", + "zypper": "openldap2-devel cyrus-sasl-devel", + "pacman": "libldap libsasl", + "apk": "openldap-dev", + "brew": "openldap", + }, +) + +ODOO_SYSTEM_DEPENDENCIES: Sequence[SystemDependency] = ( + PYTHON_INTERPRETER, + PYTHON_HEADERS, + C_COMPILER, + POSTGRESQL_HEADERS, + LDAP_HEADERS, +) +"""Everything Odoo needs to build its python dependencies from source, in the order in which it is +worth installing it. +""" + + +def package_manager() -> str | None: + """Find the package manager of the current system. + + :return: The name of the package manager, or None if odev does not know the one in use. + :rtype: Optional[str] + """ + return next((manager for manager in PACKAGE_MANAGERS if shutil.which(manager)), None) + + +def install_instructions(dependencies: Sequence[SystemDependency], **placeholders: str) -> str: + """Describe what is missing from the current system and how to install it. + + Odev runs on systems it does not control, and cannot name a package for all of them, so the + dependencies are always described in plain words; the command is only added on top when the + package manager in use is one odev knows. + + :param dependencies: The dependencies missing from the current system. + :param placeholders: Values for the placeholders in the names of the dependencies and of their + packages, `version` being the version of python being installed. + :return: A bullet list of the missing dependencies, followed by the command installing them. + :rtype: str + """ + instructions = string.join_bullet([dependency.name.format(**placeholders) for dependency in dependencies]) + manager = package_manager() + + if manager is None: + return instructions + + packages = [package for dependency in dependencies if (package := dependency.package(manager, **placeholders))] + + if not packages: + return instructions + + command = PACKAGE_MANAGERS[manager].format(packages=" ".join(packages)) + return f"{instructions}\n\nInstall them by running:\n{string.stylize(command, 'color.cyan')}" diff --git a/tests/tests/common/test_odoobin_prepare_venv.py b/tests/tests/common/test_odoobin_prepare_venv.py index 2a12ad33d..501b46330 100644 --- a/tests/tests/common/test_odoobin_prepare_venv.py +++ b/tests/tests/common/test_odoobin_prepare_venv.py @@ -1,11 +1,13 @@ """Tests for the python and system packages odev needs to prepare an Odoo installation.""" +from collections.abc import Sequence from pathlib import Path from unittest.mock import MagicMock, patch from packaging.requirements import Requirement from packaging.version import Version +from odev.common import system from odev.common.odoobin import DEBIAN_INSTALL_SCRIPT, SETUPTOOLS_REQUIREMENT, OdoobinProcess from odev.common.python import PythonEnv @@ -133,7 +135,7 @@ def test_not_installed(self): class TestSystemDependencies(OdevTestCase): - """The system dependencies check must stay silent where it cannot apply, and never sudo alone.""" + """The system dependencies check must work on any unix and never install anything itself.""" def setUp(self): super().setUp() @@ -145,42 +147,81 @@ def setUp(self): def write_script(self): (self.odoo_path / DEBIAN_INSTALL_SCRIPT).write_text("#!/bin/sh\n", encoding="utf-8") - def missing(self, platform: str = "linux", which: bool = True) -> list[str]: + def missing( + self, + platform: str = "linux", + on_path: Sequence[str] = (), + debian_packages: list[str] | None = None, + headers: bool = True, + ) -> list[str]: + """Return the names of the dependencies reported as missing on a simulated system. + + :param platform: The value of `sys.platform` to simulate. + :param on_path: The executables available on the `PATH`. + :param debian_packages: The packages Odoo declares and that are missing, None on a system + where they cannot be listed. + :param headers: Whether the interpreter ships its development headers. + """ + venv = MagicMock(exists=True, version="3.10", has_development_headers=headers) + with ( self.patch_property(OdoobinProcess, "odoo_path", self.odoo_path), + self.patch_property(OdoobinProcess, "venv", venv), + self.patch(OdoobinProcess, "missing_debian_packages", return_value=debian_packages), patch("odev.common.odoobin.sys.platform", platform), - patch("odev.common.odoobin.shutil.which", return_value="/usr/bin/apt-get" if which else None), + patch("odev.common.system.shutil.which", side_effect=lambda command: command in on_path or None), ): - return self.process.missing_system_dependencies() + return [dependency.name for dependency in self.process.missing_system_dependencies()] - def test_no_check_outside_linux(self): - self.write_script() - self.assertEqual(self.missing(platform="darwin"), []) + def test_probes_run_where_odoo_lists_no_package(self): + """Regression guard: a system that is not Debian-based used to be reported as complete, and + the user only found out when the build of gevent failed in the compiler. + """ + self.assertEqual( + self.missing(platform="darwin"), + [system.C_COMPILER.name, system.POSTGRESQL_HEADERS.name], + ) - def test_no_check_without_the_script(self): - self.assertEqual(self.missing(), []) + def test_probes_are_satisfied_by_any_of_their_commands(self): + self.assertEqual(self.missing(platform="darwin", on_path=["clang"]), [system.POSTGRESQL_HEADERS.name]) + self.assertEqual(self.missing(platform="darwin", on_path=["clang", "pg_config"]), []) - def test_no_check_without_apt(self): - self.write_script() - self.assertEqual(self.missing(which=False), []) + def test_packages_declared_by_odoo_take_over_the_probes(self): + self.assertEqual(self.missing(debian_packages=["libpq-dev", "libsasl2-dev"]), ["libpq-dev", "libsasl2-dev"]) - def test_never_escalates_without_a_confirmation(self): - """Prompts return their default when running with `--force`, headless, or under tests, so - the check must not reach `sudo` on its own. + def test_headers_are_checked_even_where_odoo_lists_its_packages(self): + """`debian/control` asks for `python3-dev`, not for the headers of the version of python the + Odoo installation is actually built against. """ - self.write_script() - run = MagicMock() + self.assertEqual(self.missing(debian_packages=[], headers=False), [system.PYTHON_HEADERS.name]) + + def test_no_check_outside_unix(self): + self.assertEqual(self.missing(platform="win32", headers=False), []) + + def test_nothing_reported_on_a_complete_system(self): + self.assertEqual(self.missing(debian_packages=[]), []) + + def test_never_installs_anything(self): + """The machine odev runs on belongs to the user: the check reports and never escalates.""" + run, execute = MagicMock(), MagicMock() + venv = MagicMock(exists=True, version="3.10", has_development_headers=False) with ( - self.patch(OdoobinProcess, "missing_system_dependencies", return_value=["libpq-dev"]), - self.patch_property(OdoobinProcess, "odoo_path", self.odoo_path), + self.patch_property(OdoobinProcess, "venv", venv), + self.patch( + OdoobinProcess, + "missing_system_dependencies", + return_value=[system.C_COMPILER, system.POSTGRESQL_HEADERS], + ), patch("odev.common.odoobin.bash.run", run), + patch("odev.common.odoobin.bash.execute", execute), ): self.process.check_system_dependencies() run.assert_not_called() + execute.assert_not_called() - def test_nothing_happens_when_no_package_is_missing(self): + def test_nothing_happens_when_no_dependency_is_missing(self): run = MagicMock() with ( diff --git a/tests/tests/common/test_python_env.py b/tests/tests/common/test_python_env.py index 0ca92fd12..f6a7d8348 100644 --- a/tests/tests/common/test_python_env.py +++ b/tests/tests/common/test_python_env.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch from odev.common import bash +from odev.common.errors import OdevError from odev.common.python import PythonEnv from tests.fixtures import OdevTestCase @@ -55,3 +56,75 @@ def streaming_failure(command, **_kwargs): self.assertIsInstance(result, CompletedProcess) self.assertEqual(result.returncode, 1) stream_filter_mock.assert_called_once_with("line 1") + + +class TestSystemPackages(OdevTestCase): + """Odev runs on systems whose package manager it does not know, and must say what is missing + rather than dead-end on them. + """ + + @classmethod + def setUpClass(cls): + with patch("odev.common.odev.Odev.start", return_value=None): + super().setUpClass() + + def setUp(self): + super().setUp() + self.env = PythonEnv(path=self.run_path / "venv", version="3.10") + + def on_path(self, *commands: str): + return patch("odev.common.system.shutil.which", side_effect=lambda command: command in commands or None) + + def test_reports_without_a_known_package_manager(self): + """Used to raise `Neither dnf or apt package managers found on the system`, which named + neither the packages to install nor a way to move forward. + """ + with self.on_path(): + self.assertFalse(self.env.install_system_packages()) + + def test_reports_on_a_package_manager_odev_does_not_install_with(self): + """Arch is understood well enough to name its packages, not well enough to run `sudo` on.""" + with self.on_path("pacman"): + self.assertFalse(self.env.install_system_packages()) + + def test_creation_gives_up_once_when_nothing_can_be_installed(self): + """`create` used to retry unconditionally, asking the same question again on a system where + the answer could not change anything. + """ + error = RuntimeError("failed to find interpreter for Builtin discover of python_spec='3.10'") + + with ( + patch("odev.common.python.virtualenv.cli_run", side_effect=error), + patch("odev.common.python.console.confirm", return_value=True), + self.patch(PythonEnv, "install_system_packages", return_value=False) as install, + self.assertRaises(OdevError), + ): + self.env.create() + + install.assert_called_once() + + +class TestDevelopmentHeaders(OdevTestCase): + """Missing python headers are the most common reason for a source build to fail.""" + + def has_headers(self, include_path: Path | None) -> bool: + env = PythonEnv(path=self.run_path / "venv", version="3.10") + result = None if include_path is None else CompletedProcess("", 0, stdout=f"{include_path}\n".encode()) + + with self.patch(bash, "execute", return_value=result): + return env.has_development_headers + + def test_headers_present(self): + include_path = self.run_path / "include" / "python3.10" + include_path.mkdir(parents=True, exist_ok=True) + (include_path / "Python.h").write_text("", encoding="utf-8") + self.assertTrue(self.has_headers(include_path)) + + def test_headers_missing(self): + self.assertFalse(self.has_headers(self.run_path / "include" / "python3.10")) + + def test_never_warns_on_a_guess(self): + """An interpreter that cannot be questioned is assumed complete: a false alarm on every + `odev run` would be worse than staying silent. + """ + self.assertTrue(self.has_headers(None)) diff --git a/tests/tests/common/test_system.py b/tests/tests/common/test_system.py new file mode 100644 index 000000000..6e85f2456 --- /dev/null +++ b/tests/tests/common/test_system.py @@ -0,0 +1,87 @@ +"""Tests for the description of the operating system odev runs on.""" + +from collections.abc import Sequence +from unittest.mock import patch + +from odev.common import system +from odev.common.system import SystemDependency + +from tests.fixtures import OdevTestCase + + +class TestPackageManager(OdevTestCase): + """Odev has to recognize the package manager of the system it runs on, whichever it is.""" + + def package_manager(self, on_path: Sequence[str]) -> str | None: + with patch("odev.common.system.shutil.which", side_effect=lambda command: command in on_path or None): + return system.package_manager() + + def test_every_known_package_manager_is_detected(self): + for manager in system.PACKAGE_MANAGERS: + self.assertEqual(self.package_manager([manager]), manager) + + def test_the_package_manager_of_the_distribution_wins_over_homebrew(self): + """Homebrew installs on Linux too, but a Fedora machine has to be told about `dnf`.""" + self.assertEqual(self.package_manager(["dnf", "brew"]), "dnf") + + def test_unknown_package_manager(self): + self.assertIsNone(self.package_manager([])) + + +class TestInstallInstructions(OdevTestCase): + """Whatever the system, the user must be told what is missing; the command is a bonus.""" + + def instructions(self, dependencies: Sequence[SystemDependency], on_path: Sequence[str] = ()) -> str: + with patch("odev.common.system.shutil.which", side_effect=lambda command: command in on_path or None): + return system.install_instructions(dependencies, version="3.10") + + def test_names_and_command(self): + instructions = self.instructions([system.C_COMPILER, system.POSTGRESQL_HEADERS], on_path=["dnf"]) + self.assertIn("• a C compiler", instructions) + self.assertIn("• the PostgreSQL client headers (pg_config)", instructions) + self.assertIn("sudo dnf install -y gcc libpq-devel", instructions) + + def test_placeholders_are_expanded(self): + instructions = self.instructions([system.PYTHON_HEADERS], on_path=["apt-get"]) + self.assertIn("• the development headers of python 3.10 (Python.h)", instructions) + self.assertIn("sudo apt-get install -y python3.10-dev", instructions) + + def test_names_only_without_a_known_package_manager(self): + """On a system odev knows nothing about, saying what is missing is still useful.""" + instructions = self.instructions([system.C_COMPILER]) + self.assertEqual(instructions, "• a C compiler") + + def test_dependencies_without_a_package_are_still_named(self): + """Arch Linux ships the python headers within `python` itself: there is nothing to install, + but the user still has to know they are what is missing. + """ + instructions = self.instructions([system.PYTHON_HEADERS, system.C_COMPILER], on_path=["pacman"]) + self.assertIn("• the development headers of python 3.10 (Python.h)", instructions) + self.assertIn("sudo pacman -S --needed base-devel", instructions) + self.assertNotIn("python3.10", instructions.rsplit("\n", 1)[-1]) + + def test_no_command_when_nothing_can_be_named(self): + instructions = self.instructions([system.PYTHON_HEADERS], on_path=["pacman"]) + self.assertEqual(instructions, "• the development headers of python 3.10 (Python.h)") + + +class TestSystemDependencyProbes(OdevTestCase): + """A dependency is only ever reported as missing when it can be proven to be.""" + + def found(self, dependency: SystemDependency, on_path: Sequence[str]) -> bool: + with patch("odev.common.system.shutil.which", side_effect=lambda command: command in on_path or None): + return dependency.found + + def test_any_command_proves_the_dependency(self): + self.assertTrue(self.found(system.C_COMPILER, ["clang"])) + self.assertTrue(self.found(system.C_COMPILER, ["gcc"])) + self.assertFalse(self.found(system.C_COMPILER, ["pg_config"])) + + def test_dependencies_without_a_command_are_never_found(self): + """Those are detected some other way and must not be probed on the `PATH` by mistake.""" + self.assertFalse(self.found(system.PYTHON_HEADERS, ["python3.10", "cc"])) + + def test_odoo_dependencies_name_a_package_for_every_manager_that_ships_one(self): + for dependency in system.ODOO_SYSTEM_DEPENDENCIES: + for manager in dependency.packages: + self.assertIn(manager, system.PACKAGE_MANAGERS, f"{dependency.name} names an unknown {manager!r}")