Skip to content

1.11 Step | InstallPythonRequirements

Omisen edited this page Aug 16, 2026 · 8 revisions

Installs the pip dependencies into the virtualenv. It lives in src/steps/install_python_requirements.rs. The third and last of the source sub-steps. Every install runs as the odoo user, through the venv's pip.

Its peculiarity: it has no undo of its own.


The conceptual heart: no undo of its own

The pip packages live inside <install_dir>/sandbox (the venv). If the venv is ours, the undo of 1.10 CreateVirtualenv (rm -rf sandbox) removes them all. Therefore:

this step's undo → a documented NO-OP. Uninstalling the packages one by one would be redundant and fragile: removing the venv is the correct undo.

One undo fewer to write, for the right reason. And if the venv was Preexisting, we do not touch somebody else's venv's packages either.


Life cycle

Phase Behaviour
snapshot lightweight: the state relevant to the rollback is the venv's (1.10), not a per-package PreState
run reads requirements.txt (missing → error), then the pip sequence below. dry_run → log
undo NO-OP (see above)

The Cython/gevent workaround (a real fix, to be preserved)

Cython 3 removed Python 2's long type; gevent (required by Odoo 18) still uses that code in its .pyx files. pip builds wheels in an isolated environment that ignores the venv's Cython. The solution (faithful to Bash) is a precise sequence:

1. pip install --upgrade pip wheel "setuptools<81"     # setuptools is NOT decorative, see below
2. pip install "Cython<3"                              # into the venv
3. pip install --no-build-isolation -r <gevent+greenlet lines>
4. pip install --prefer-binary -r <requirements without those lines>

Why setuptools at step 1. From Python 3.12 venv no longer seeds it, but step 3 uses --no-build-isolation, i.e. it asks pip to build with what it finds in the venv. Without it the build backend does not exist and pip dies with BackendUnavailable. The system python3-setuptools is irrelevant: the venv is isolated.

Why it is bounded, since 3.2.0. What this step owes the venv is that setuptools exists, never that it is the newest — and the difference stopped being academic when setuptools 82 removed pkg_resources, which Odoo 16 imports on its first line. An unbounded --upgrade walked into a virtualenv that already worked (Ubuntu 22.04 seeds a setuptools that has the module) and replaced it with one that did not, so initialize-odoo-database died on the first line of Odoo that ever runs.

The bound sits below 81, not 82: 81 keeps the module but warns on every import — twice per Odoo 16 start, in a stable branch that does not filter it — and setuptools' own message names the pin that stops it, so the number is read rather than picked.

It is unconditional on purpose. Applying it only where needed would mean either reading the cloned sources, which cannot tell importing pkg_resources from mentioning it (Odoo 17 names it inside a try/except, Odoo 19 only inside warning filters), or a table keyed on the Odoo version — a second source of truth of the kind that diverges in silence. With no discriminant there is nothing that can answer wrongly, and the ceiling costs nothing elsewhere: this setuptools serves our build without isolation and nothing else, since pip's isolated builds fetch their own.

  • The gevent and greenlet lines are handed to pip in a requirements file, verbatim: see below — that is the part that took two rounds of CI to get right.
  • Step 4 uses a filtered requirements file without those lines, otherwise pip would rebuild them from source in the isolated environment, ignoring Cython<3.

Which gevent: pip decides, not us (A-R6-3)

Odoo 18's requirements.txt does not pin one gevent version. It pins four, one per Python version, and as many greenlets — with environment markers, and annotated by Odoo itself with the Ubuntu release:

gevent==21.8.0  ; … python_version == '3.10'               # (Jammy)
gevent==24.2.1  ; … python_version >= '3.12' and < '3.13'  # (Noble)
greenlet==1.1.2 ; … python_version == '3.10'               # (Jammy)
greenlet==3.0.3 ; … python_version >= '3.12' and < '3.13'  # (Noble)

The first version of this step took the first line starting with gevent and threw its marker away. On Ubuntu 22.04 that is the right line by coincidence (Python 3.10 comes first); on 24.04 it still picked Jammy, and the CI died like this:

gevent   → _greenlet_primitives.c:273: fatal error: longintrepr.h: No such file
greenlet → PyThreadState has no member 'recursion_limit'

longintrepr.h was made private in Python 3.12 and PyThreadState's fields renamed: gevent 21.8.0 and greenlet 1.1.x predate 3.12 and cannot compile against it. It was not the build environment: it was the wrong version.

The marker was thrown away because of a self-inflicted constraint. It is true that --no-build-isolation does not tolerate a marker on argv — but passing a requirements file keeps the markers, and it is pip that evaluates them: the software whose job that is exactly. We stop choosing.

greenlet is in the group alongside gevent because installing gevent alone made pip resolve greenlet from gevent's metadata (greenlet>=1.1,<2) instead of from Odoo's pin — which is how greenlet 1.1.x ended up compiling against Python 3.12.

A welcome side effect: with the right version, Noble has a prebuilt wheel (gevent-24.2.1-cp312-manylinux…), so nothing is compiled at all and --no-build-isolation stays inert. The Cython<3 workaround does its work where it is genuinely needed — Jammy, where gevent 21.8.0 has no wheel.

This is not a leftover: it is a real fix. extract_gevent_spec and filter_out_gevent are pure functions, tested separately; the pip sequence is verified in order.


Pip's cache lives inside our perimeter (A-R5-3)

pip puts its cache in $HOME/.cache, and the odoo user's $HOME is /opt/odoo — the directory the installer marks Preexisting and never touches when it finds it already there. Measured result in CI: after a complete rollback, /opt/odoo/.cache was still there. Regenerable caches, but the promise is “the system goes back to exactly what it was”.

The correction is preventive, not a cleanup: every invocation passes --cache-dir <install_dir>/sandbox/.pip-cache, i.e. inside the venv, which 1.10 CreateVirtualenv's undo removes wholesale with an rm -rf. Nothing is born outside the perimeter, so nothing has to be chased with deletion heuristics inside the customer's home. And it remains a cache: a second run of the installer finds it and does not re-download the wheels.

Why not <install_dir>/.pip-cache, one level up: 1.9 CloneOdooRepo's undo removes install_dir only if empty. A cache there would block it — the leftover would move one level instead of disappearing.

It covers pip, not everything. The first installation to reach the end in CI showed that /opt/odoo/.cache comes back regardless: odoo-bin and the service write into the odoo user's home too (fontconfig, and pip's selfcheck up to version 23, which ignores --cache-dir). Chasing the producers is a losing battle; the general cover is 1.3b SetupCacheDir, which owns the directory. This --cache-dir stays because it still keeps hundreds of MB of wheels out of the home.


Where the temporary requirements files are born

The two files handed to pip --requirement (the gevent/greenlet lines, and the filtered remainder) are born inside <install_dir>/sandbox, not in /tmp.

The reason is precise: here root writes a file that pip reads as the odoo user — two distinct operations, with a window in between. In /tmp, which is world-writable, and with a fixed name written in the source, anyone controlling any local account can replace that file within the window and have pip install arbitrary packages into the venv: code execution as the owner of the filestore and the database. It is not the symlink case — which fs.protected_symlinks mitigates — but replacement of the contents, and there the kernel does not help.

sandbox removes the premise instead of defending against the attack: it belongs to odoo and is not writable by third parties, so no third party can create or replace anything inside it. On top of that the file is born and dies inside the reversible perimeter — CreateVirtualenv's undo does rm -rf sandbox — so an interrupted run leaves no residue outside.

The unpredictable name and the fail-closed creation (O_EXCL | O_NOFOLLOW) remain regardless. The final chown is not decorative: the file is born 0600 root and the one who has to read it is odoo.


When the build cannot succeed: say so, instead of letting gcc do the talking

Odoo pins gevent and greenlet per Python version. If the interpreter is newer than all its pins, there is no prebuilt wheel for that version: pip builds from source, and the C generated by Cython does not survive a newer CPython's headers. It is not a compiler problem nor a missing system package — it is the version, and no build flag gets around it.

Two things happen then, and neither is a refusal:

  1. The preflight warns, before the confirmation, saying which interpreter is there and what will break.
  2. If the build does fail, the error puts the cause in front of pip's output — which is kept in full, because explaining is not hiding the evidence — and shows the lines that Odoo version declares, so it is immediately visible that there is none for this interpreter.

Corrected in 3.2.0, and the correction is the interesting part. That message used to appear only on a Python newer than the newest one tested. The case it had been written for turned out to happen on exactly the newest tested one: Odoo 16 on Fedora builds on Python 3.13 — equal, not newer — and dies precisely this way. So the explanation was silent on the very failure it existed for, and the reader got three hundred lines of gcc and nothing else.

The question was wrong rather than the threshold. “Is this Python newer than the ones we test?” is about us; what breaks the build is “does this Odoo pin a wheel for this interpreter?”, which varies per Odoo version. Now there are two registers: above the tested Python the message asserts the cause, as before; otherwise it states the facts — the interpreter the venv is on, and the lines that Odoo version declares — and draws no conclusion. Facts cannot be wrong, which is what keeps the original objection satisfied: if the build failed for a missing compiler instead, nothing there has misled anyone.

The question “does Odoo have a pin for this Python?” cannot be answered from requirements.txt: the markers are open upwards, so the applicable line is applicable and pip picks it correctly. That the wheel is missing for that interpreter is a fact about PyPI, not about the file. That is why the installer does not pre-empt this with a check that would pretend to know: it explains at the moment the failure occurs.

The real way out is upstream — building the venv on an interpreter the pins cover, which is what the preflight does where the distribution packages one: see Multi-distribution support. Deviating from Odoo's pins is not among the options: it would be a combination nobody has tested.


Design notes

  • Every install goes through sudo -u odoo <venv>/bin/pip (least privilege), with --cache-dir inside the venv.
  • The snapshot serialises only an “installed” flag for the summary — it drives no undo.
  • Tests: no-op undo (no uninstall; removing pip's work belongs to 1.10), the gevent/Cython sequence in order, missing requirements.txt → error, pure gevent extraction and filtering.

Clone this wiki locally