Three pinned Pythons on one device: per-app CPython isolation with uv
TL;DR - I ran three Python apps on one Avocado OS device, each on its own CPython (3.11.14 / 3.12.13 / 3.14.2) with its own native wheels, wired into a producer to processor to aggregator pipeline over a local NATS broker. A sample window generated on 3.11 is processed on 3.12 and aggregated on 3.14, and numpy resolves to different versions per app (2.4.6 vs 2.5.1) on the same image, so the isolation is real, not conventional.
The problem: a mixed-Python fleet
Avocado OS builds an immutable Linux image for embedded devices, and a "reference" is a worked example you clone to start from. python-multiversion-uv answers a messy real-world case: a fleet where independent Python apps each pin a different interpreter from their own dependency set and collaborate over a message bus, so you cannot standardize on one interpreter without rewriting someone's lockfile. Three app extensions each resolve independently with uv (Astral's Python package and interpreter manager); two ship a standalone CPython (python-build-standalone) inside the extension, and one rides the device's system Python.
Three interpreters, one image
Each app is packaged as a system extension (interpreter plus wheels) and a configuration extension (its systemd unit). The build is two commands:
avocado install -f
avocado build
app311 and app314 run uv python install 3.11 / 3.14 inside the SDK. uv detects the target architecture, downloads the matching python-build-standalone interpreter, and installs each app's wheels against it, so the interpreter and every wheel match the device. app312 skips the interpreter and resolves against the SDK's own Python 3.12, which is byte-for-byte the /usr/bin/python3 the device ships. Because each app resolves independently, versions diverge on purpose: numpy is 2.4.6 on 3.11 and 2.5.1 on 3.12 and 3.14, and scipy 1.18.0 resolves only for the 3.14 aggregator. No Yocto recipe per package, adding a dependency is a line in a compile script.
The pipeline comes up
Boot it under QEMU:
avocado provision dev
avocado sdk run -iE vm dev
Log in as root (empty password). The three apps form a data pipeline over NATS, each stage on its own interpreter: app311 (producer, 3.11) generates a sample window with numpy and publishes it; app312 (processor, 3.12) reduces each window to numpy statistics plus the FFT peak and republishes; app314 (aggregator, 3.14) fits a scipy trend across a rolling window. Every app logs one startup line reporting the interpreter it runs on and the native wheels it loaded, and app314 logs a pipeline_complete whose chain names all three interpreters that touched the data:
journalctl -u app314 -o cat | grep -m1 startup
{
"event": "startup",
"app": "app314",
"role": "aggregator",
"python": "3.14.2",
"executable": "/usr/lib/app314/python/bin/python3.14",
"deps": { "numpy": "2.5.1", "scipy": "1.18.0", "nats-py": "2.15.0" }
}
journalctl -u app314 -o cat | grep -m1 pipeline_complete
{
"event": "pipeline_complete",
"seq": 11,
"chain": {
"producer": { "app": "app311", "python": "3.11.14" },
"processor": { "app": "app312", "python": "3.12.13" },
"aggregator": { "app": "app314", "python": "3.14.2" }
},
"result": { "window": 3, "rms_mean": 0.763675, "rms_trend_slope": -0.007031, "peak_hz": 5.0 }
}
One
pipeline_completeline proves three independently pinned interpreters - 3.11 to 3.12 to 3.14 - collaborated on a single result over a local NATS bus.
The full CLI walkthrough, from avocado init to pipeline_complete:
The same fleet check from inside Avocado Desktop: build the reference, then open the embedded VM shell and watch the pipeline over the in-app terminal, no external terminal needed:
What broke, what's deferred
- The QEMU boot needs an SDK whose
vmhelper pads to a power of two. This reference's image is ~1.1 GiB. The2024/edgeSDK'svmhelper historically resized the disk to a fixed 1 GiB, soavocado sdk run -iE vm devaborted atqemu-img resize("Use the --shrink option") on any image over 1 GiB. The current helper pads to the next power of two instead, which fixes it; if your boot hits that abort, update the SDK (avocado sdk install). This affects only the local QEMU boot, not the build or a hardware deploy. - aarch64 is designed, not boot-verified here. The build is arch-aware (uv pulls aarch64 interpreters and wheels; the broker step fetches the arm64
nats-server), and the reference declaresraspberrypi5andraspberrypi4, but a Pi boot topipeline_completeis a hardware-in-the-loop slot not run for this note. - No systemd hardening. The apps run as root off read-only extension content;
DynamicUserandProtectSystem=strictare deferred, since a bundled interpreter and wheels under/usr/lib/app3NNneed a profile that still exposes those paths. - Footprint is the trade-off. Shipping two standalone CPython trees plus wheels is not free on image size; the system-Python app (3.12) is the cheaper model when the base image's version fits. The reference ships both models to show the contrast, not to recommend bundling by default.
- Not production posture. The generated
configextension sets an empty root password (NOT FOR PRODUCTION), and the broker binds127.0.0.1:4222with no TLS or auth, which is correct for a single-device local bus but not for off-box coordination.
Reproduce it
avocado init --reference python-multiversion-uv python-multiversion-uv
cd python-multiversion-uv
avocado install -f
avocado build
avocado provision dev
avocado sdk run -iE vm dev
Log in as root with an empty password. Read each app's startup line for three different python versions, then watch the data flow through the pipeline:
journalctl -u app311 -o cat | grep -m1 startup # producer 3.11
journalctl -u app312 -o cat | grep -m1 startup # processor 3.12
journalctl -u app314 -o cat | grep -m1 startup # aggregator 3.14
journalctl -u app311 -o cat | grep -m1 produced
journalctl -u app312 -o cat | grep -m1 processed
Then grep the aggregator for the whole-system signal:
journalctl -u app314 -o cat | grep pipeline_complete
Reference: python-multiversion-uv.