TL;DR: A workspace-level uv-managed venv works on stock apt-installed ROS 2 — including a PyTorch+CUDA node — but we hit five reproducible failure modes on the way (verified on Jazzy; none of the mechanisms are Jazzy-specific). Key measurement: the known shebang workaround ([build_scripts] executable = /usr/bin/env python3) does not cover --symlink-install, so when colcon is launched from the system Python there is currently no complete workaround. Below are four minimal change proposals for colcon/ament — all opt-in, none fixing the venv’s location or name, with no behavior change for workspaces that do not use a venv.
Background
PEP 668 disabled pip install into the system Python on Ubuntu 24.04, and deep-learning robotics often needs exact version pins and custom package indexes (e.g. torch==2.6.0+cu124) that package.xml/rosdep currently has no way to declare. A per-workspace virtual environment with pyproject.toml and a lockfile — managed here with uv — is one practical answer. In Letting Python Be Python, the idea that workspaces could become venvs was raised, along with the question of what it would take to get there; Status of Colcon building “standards-based” Python packages covers the related build-tool work. This post adds empirical data to that discussion: we migrated a real robot stack to uv while keeping colcon, ros2 run, and ros2 launch in use, and recorded what broke and why.
What works and what breaks
With a venv created by uv venv --system-site-packages from the distro interpreter, and python-preference = "only-system" set in the [tool.uv] section of pyproject.toml, everything builds and a torch+CUDA inference node runs on the venv’s Python, with lockfile reproducibility and custom wheel indexes.
Setup: Ubuntu 24.04 / apt Jazzy / Python 3.12.3 / setuptools 68.1.2 / uv 0.11.28, and pyproject.toml
Workspace: 3 × ament_cmake, 1 × ament_python, one inference node using torch==2.6.0+cu124. Disposable Docker containers; venv/lockfile/uv cache on bind mounts. Shells source the ROS environment first, then activate the venv.
[project]
requires-python = "==3.12.*" # pin to the distro interpreter
dependencies = [
"torch==2.6.0",
"numpy>=1.26,<2", # protect cv_bridge (numpy 1.x C ABI)
]
[tool.uv]
package = false # env definition only
python-preference = "only-system" # never substitute uv-managed CPython
[tool.uv.sources]
torch = [{ index = "pytorch-cu124" }]
[[tool.uv.index]]
name = "pytorch-cu124"
url = "https://download.pytorch.org/whl/cu124"
explicit = true
Along the way we hit five reproducible failure modes. All of them can be worked around, but the workarounds are not covered by official documentation, so they are easy to rediscover independently:
| # | Failure mode | Cause | Current workaround |
|---|---|---|---|
| 1 | Every shell needs two setup steps (source install/setup.bash and venv activation), in order |
The ROS environment and the venv have no knowledge of each other | Hand-written shell setup per project |
| 2 | colcon treats directories inside the venv as packages during discovery | Package discovery descends into every subdirectory | touch .venv/COLCON_IGNORE (documented) |
| 3 | ros2 run executes ament_python nodes with the system interpreter even while a venv is active |
colcon runs setup.py with its own sys.executable; setuptools writes that interpreter into console-script shebangs |
Incomplete — see next section (ros2/ros2#1094, open since 2021) |
| 4 | numpy 2.x in the venv breaks apt-built extensions (cv_bridge) at import | Jazzy binaries are built against numpy 1.26’s C ABI | Pin numpy<2 in the workspace |
| 5 | uv provisions its own standalone CPython, which mismatches distro-built C extensions | uv’s default python-preference |
python-preference = "only-system" in pyproject.toml ([tool.uv]) |
The remaining gap
Four of the five have complete workarounds; #3 does not. A known mitigation is [build_scripts] executable = /usr/bin/env python3 in setup.cfg (mechanism related to colcon-core#183, reported in ros2/ros2#1094). We measured it on Jazzy:
- Regular
colcon build: works — scripts get env shebangs and resolve to the active venv. colcon build --symlink-install: not applied — the develop/editable code path keeps#!/usr/bin/python3, so the mode commonly used during development is not covered.- Launching colcon from the venv itself —
.venv/bin/python -m colcon build— covers both modes (with--system-site-packages, the apt-installed colcon is importable from the venv, so nothing extra needs to be installed). The limitation: the venv’s absolute path is written into the generated shebangs, so the result does not survive venv recreation andinstall/is not relocatable.
Bottom line: when colcon is launched from the system Python — the common configuration in tutorials and CI — there is currently no complete workaround.
Proposed changes
One design principle for all four: opt-in, no fixed venv location or name, and no behavior change for workspaces that do not involve a venv.
- P1 — discovery: skip any directory containing
pyvenv.cfg(the PEP 405 marker every venv has) during package discovery — an automaticCOLCON_IGNOREfor venvs of any name, in any location. - P2 — shebangs: an option to emit
#!/usr/bin/env python3shebangs on both the install and the develop (--symlink-install) code paths. The setup.cfg mitigation covers only the install path and has to be repeated in every package; an option at the build-tool level would cover a whole workspace at once. Where no venv is active,env python3resolves to/usr/bin/python3as before. - P3 — activation: record the path of the interpreter colcon built with under
install/, and letsetup.bashread it and, if that interpreter belongs to a venv, activate it (with an opt-out environment variable). This is a minimal mechanism for the “workspaces as venvs” idea from the threads above, and it leaves the venv’s location entirely up to the user.
Relation to existing work
- colcon-uv manages Python dependencies per package, installed during
colcon build. This post focuses on one environment and one lockfile per workspace; the two granularities address different needs (per-package isolation vs. one shared environment for a whole launch graph) and can coexist. - Pixi as a co-official installation method concerns how ROS itself is installed. The scope here is different and does not compete with it: keeping the standard apt installation and making the Python layer of one workspace reproducible.
- ros-python-wheels distributes ROS client libraries as pip-installable wheels. The direction here is complementary: using pip/uv-managed dependencies inside a standard, apt-based ROS workspace.
- A similar uv setup (
--system-site-packages+ lockfile) has been shared in Status of Colcon building “standards-based” Python packages, with nodes started directly throughpython. The measurements above cover the case where colcon,ros2 run, andros2 launchstay in use.
Open questions
- For P1: would a package-identification extension in colcon-core, modeled on the existing
COLCON_IGNOREone, be an acceptable shape — or would this fit better as a separately distributed extension package? - For P2, which layer would be better suited to handle the develop-path shebang — colcon-core, or the setuptools develop machinery?
- For those running workspace-level venvs with colcon in CI or on production robots: which failure modes are missing from the list above (overlays, cross-compilation, non-Ubuntu platforms)?