Uv on ROS 2: a field report on workspace-level virtual environments — five failure modes and minimal colcon/ament proposals

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 and install/ 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 automatic COLCON_IGNORE for venvs of any name, in any location.
  • P2 — shebangs: an option to emit #!/usr/bin/env python3 shebangs 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 python3 resolves to /usr/bin/python3 as before.
  • P3 — activation: record the path of the interpreter colcon built with under install/, and let setup.bash read 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 through python. The measurements above cover the case where colcon, ros2 run, and ros2 launch stay in use.

Open questions

  1. For P1: would a package-identification extension in colcon-core, modeled on the existing COLCON_IGNORE one, be an acceptable shape — or would this fit better as a separately distributed extension package?
  2. For P2, which layer would be better suited to handle the develop-path shebang — colcon-core, or the setuptools develop machinery?
  3. 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)?
1 Like

Thanks for putting in the effort!

In ROS workspaces, numpy should be exclusively installed via the python3-numpy rosdep, shouldn’t it? That, in connection with system-site-packages shouldn’t lead to uv installing numpy 2, or does it? I know the Python “packaging” tools can be quite dumb and news-eager at times…

I’d expect the workflow to be first install all apt-installable deps via rosdep, and then either run rosdep for pip packages, or uv sync the rest of the Python deps. I guess the traditional rosdep workflow doesn’t work in this kind of batches, making it random in which order will the packaging tools be called, right?

1 Like

What about ignoring all hidden and dot-starting dirs? I don’t see a valid use-case with ROS packages in hidden dirs.

Is there an actual reason why does the system interpreter get hardcoded there? Or does it seem more like an omission?

1 Like