Exercise 2: Build ROS packages with Pixi¶
30 minutes, hands-on
Work in: exercises/02-ros-package/ · Solution: solutions/02-ros-package/ · After: Building ROS packages with Pixi
Goal: hand the build to Pixi.
The same C++ node from Exercise 1, plus a Python one, both built and installed from their package.xml: no colcon build, no source install/setup.bash, no build/ directory.
Exercise 1 built your node the ROS way: colcon in the workspace, an install/ overlay, and sourcing before every run.
Pixi can build the package itself and put it in the environment next to turtlesim, so none of that is needed anymore: no colcon, no overlay, no sourcing, not even the build tools in your workspace.
This exercise gets you there one piece at a time, until what is left is edit and run.
Same format as before: each step says what to do, the commands are folded away underneath, and the cheat sheet helps with the flags.
Work inside the workshop repository
This is where Exercise 1 left off, trimmed to Lyrical only: pixi.toml builds src/turtle_dancer/ with colcon and sources the overlay on activation.
The Kilted environment and the PyTorch node are left out so the diff in this exercise stays about the build.
The initial build selects only turtle_dancer, with its overlay in install/default/.
The Python package stays unbuilt until 2.6.
Run the colcon way once, so you have the before in front of you:
Run this initial build before pixi run dance: activation needs the overlay to exist before the command starts.
2.1 Turn on Pixi build¶
Building packages from source is a preview feature in Pixi, so a workspace opts in.
It also needs a build backend, the piece that knows the build system.
For ROS that is pixi-build-ros, and since every package in the workspace uses the same one, you declare it once, in [workspace.dependencies].
Your turn
- Enable the
pixi-buildpreview in the[workspace]table. - Add
pixi-build-ros = ">=0.7.5"to[workspace.dependencies].
Solution
# 1. add the preview
pixi workspace preview add pixi-build
# 2. add the backend to the pool
pixi workspace dependencies add "pixi-build-ros >=0.7.5"
[workspace]
name = "02-ros-package"
channels = ["https://prefix.dev/robostack-lyrical", "conda-forge"]
platforms = [
"linux-64",
{ platform = "osx-arm64", macos = "14.0" },
"win-64",
]
version = "0.1.0"
preview = ["pixi-build"]
[workspace.dependencies]
pixi-build-ros = ">=0.7.5"
[workspace.dependencies] is a pool of shared specs, not an install list: nothing lands in the environment because of it.
Packages pick entries out of the pool, which is the next step, and bumping the backend version later is one line.
2.2 Give the package a manifest¶
Until now every pixi.toml you wrote had a [workspace] table.
There is a second main table which describes a single package:
| Manifest role | Marked by | Describes | In this exercise |
|---|---|---|---|
| Workspace | [workspace] |
your environment: channels, platforms, dependencies, tasks | pixi.toml |
| Package | [package] |
how to build one package | src/turtle_dancer/pixi.toml |
A workspace is what you pixi run.
A package is what Pixi turns into a conda package, through the backend.
pixi-build-ros reads package.xml for the name, version, dependencies and build type, maps the dependencies to their RoboStack names, and runs the ament_cmake or ament_python build you already have.
So the package manifest has very little to say.
Your turn
- Create
src/turtle_dancer/pixi.tomlthat namespixi-build-rosas its build backend, taking the version from the workspace pool. Hint:workspace = true. - Open
package.xmlandCMakeLists.txt. Which lines does the backend need, and which one makesros2 runfind the executable? Change nothing.
Solution
[package.build.backend]
name = "pixi-build-ros"
workspace = true
Two lines: which backend, and "the version is in the workspace pool".
The ROS distro comes from the robostack-lyrical channel of the workspace, and everything else comes from package.xml.
From package.xml the backend takes <name>, <version>, <depend> and <build_type>.
From CMakeLists.txt the line that matters is install(TARGETS dance DESTINATION lib/${PROJECT_NAME}): ros2 run looks in lib/<package>/, and that is where the backend installs whatever your CMake installs.
Both files are the ones colcon used a minute ago, unchanged.
2.3 Depend on your own package¶
A workspace depends on a local package like on anything else, except the specifier is a path to the package directory.
The dependency name is the package.xml name with the distro prefix and hyphens: ros-lyrical-turtle-dancer, the name RoboStack would give it if it were published there.
Your turn
- Add
ros-lyrical-turtle-danceras a path dependency onsrc/turtle_dancer. - Install, and watch what happens.
- Find your package in
pixi list.
Solution
[dependencies]
ros-lyrical-turtle-dancer = { path = "src/turtle_dancer" }
ros-lyrical-ros-base = ">=0.11"
ros-lyrical-turtlesim = ">=1.8"
ros-dev-tools = ">=1.0"
A Running build for recipe: ros-lyrical-turtle-dancer-0.1.0-... block scrolls by.
The first install fetches the backend and a build environment (CMake, the compilers, the ROS libraries from package.xml) into .pixi/bld/, compiles the node and installs the result into the environment as a conda package.
On Windows, compiler activation uses the Visual Studio tools you installed in Before you start.
Later builds are incremental.
$ pixi list turtle # 3
ros-lyrical-turtle-dancer conda src/turtle_dancer
ros-lyrical-turtlesim 1.10.9 np2py314h1e5664e_22 417.69 KiB conda https://prefix.dev/robostack-lyrical
Where other packages show a channel, yours shows the path it was built from.
On Windows, findstr turtle instead of grep turtle.
2.4 Delete the colcon machinery¶
Right now the node is in the environment twice: once in colcon's install/, once built by Pixi.
Everything colcon needed can go.
Your turn
- Remove the
depends-onofdanceand bothactivationtables, then remove thebuildtask and theros-dev-toolsdependency. - Delete
build/,install/andlog/. - Run the node. In a second terminal, run the simulator and watch it go.
Solution
# 1. in your editor: drop `depends-on` from dance and delete both [target.*.activation] tables
pixi task remove build
pixi remove ros-dev-tools
# 2 (PowerShell: Remove-Item -Recurse build, install, log)
rm -rf build install log
# 3, in two terminals
pixi run sim
pixi run dance
Resulting pixi.toml:
[workspace]
name = "02-ros-package"
channels = ["https://prefix.dev/robostack-lyrical", "conda-forge"]
platforms = [
"linux-64",
{ platform = "osx-arm64", macos = "14.0" },
"win-64",
]
version = "0.1.0"
preview = ["pixi-build"]
[workspace.dependencies]
pixi-build-ros = ">=0.7.2"
[dependencies]
ros-lyrical-turtle-dancer = { path = "src/turtle_dancer" }
ros-lyrical-ros-base = ">=0.11"
ros-lyrical-turtlesim = ">=1.8"
[tasks]
sim = "ros2 run turtlesim turtlesim_node"
teleop = "ros2 run turtlesim turtle_teleop_key"
topics = "ros2 topic list"
dance = "ros2 run turtle_dancer dance"
pixi run ros2 run turtle_dancer dance works as well, from a fresh terminal, with nothing sourced.
The node is a package in the environment now, the same as turtlesim.
2.5 Live in the edit-run loop¶
In Exercise 1 a change to the node meant edit, pixi run build, run, with the sourcing hidden in an activation script.
See what it means now.
Your turn
- Open
src/turtle_dancer/src/dance.cppand change the default angular speed from0.8to3.0. - Run the node again, with the simulator still open.
Solution
pixi run checks the inputs of every source dependency before it runs anything: package.xml, CMakeLists.txt, the source files.
One of them changed, so the package was rebuilt and reinstalled first.
Change it back to 0.8 and run again: same thing, the other way.
What counts as an input
The backend watches C and C++ sources, package.xml, CMakeLists.txt, setup.py, launch files, messages and a few more by default.
Anything else goes in extra-input-globs under [package.build.config] in the package manifest.
2.6 Add the Python package¶
src/turtle_choreographer/ is a second node, in Python this time, an ament_python package provided pre-written.
It gets the same treatment: a package manifest, a path dependency, a task.
Your turn
- Create
src/turtle_choreographer/pixi.toml: the same two lines as the C++ one. - Add
ros-lyrical-turtle-choreographeras a path dependency. - Add a
choreographtask that runsros2 run turtle_choreographer choreograph. - Run it, with the simulator open.
Solution
[package.build.backend]
name = "pixi-build-ros"
workspace = true
[dependencies]
# Our own packages, built from source. The names are the package.xml names with
# the ROS distro prefix that RoboStack uses, hyphens instead of underscores.
ros-lyrical-turtle-dancer = { path = "src/turtle_dancer" }
ros-lyrical-turtle-choreographer = { path = "src/turtle_choreographer" }
# What we need to run them. The libraries the packages themselves need come
# from their package.xml.
ros-lyrical-ros-base = ">=0.11"
ros-lyrical-turtlesim = ">=1.8"
# 3
pixi task add choreograph "ros2 run turtle_choreographer choreograph"
# 4
pixi run choreograph # the turtle draws a figure of eight
One workspace, two languages, one lockfile.
setup.cfg is what makes ros2 run find a Python node
Have a look at src/turtle_choreographer/setup.cfg: install_scripts=$base/lib/turtle_choreographer.
That puts the entry point in lib/<package>/, where ros2 run looks, instead of bin/.
ros2 pkg create --build-type ament_python generates this file for you.
Hand-written packages sometimes lack it; the backend then fills in the same default, but it is better to have the file.
2.7 Publish the packages locally¶
So far the packages are source dependencies: every workspace that wants them needs the source tree.
Publishing turns the build result into normal .conda packages.
For the workshop we publish to a local folder, not to Prefix.dev, so you can see exactly what would be uploaded.
Pixi only publishes packages that opt in, to avoid accidentally publishing every package it finds in a workspace. That opt-in lives in each package manifest.
Your turn
- Add
publish = trueto the[package]table in both package manifests:src/turtle_dancer/pixi.tomlandsrc/turtle_choreographer/pixi.toml. - Publish the workspace packages to a local folder.
- Inspect the packages Pixi wrote.
Solution
[package]
publish = true
[package.build.backend]
name = "pixi-build-ros"
workspace = true
[package]
publish = true
[package.build.backend]
name = "pixi-build-ros"
workspace = true
# 2. build both opted-in packages and publish them to a local channel
pixi publish --target-channel output
# 3. inspect what was created
find output -name "*.conda" -print
You should see one package for each node, under a platform subdirectory such as linux-64/, osx-arm64/ or win-64/:
output/<platform>/ros-lyrical-turtle-dancer-0.1.0-<build>.conda
output/<platform>/ros-lyrical-turtle-choreographer-0.1.0-<build>.conda
That output/ directory is a real local conda channel, including the channel metadata Pixi needs.
Another workspace could install from it with a file channel.
By using ./output as the channel URL, you can install from the local channel without publishing to Prefix.dev.
The same command can publish to a Prefix.dev channel too:
You need to be logged in, and you need permission to publish to that channel.
The full pixi.toml
# Solution to Exercise 2: Build ROS packages with Pixi.
#
# The same C++ package Exercise 1 built with colcon, plus a Python one, now both
# built and installed by Pixi straight from their package.xml:
#
# pixi run sim # in one terminal
# pixi run dance # in another, C++
# pixi run choreograph # ...or Python
#
# Compare with Exercise 1: no `colcon build`, no `source install/setup.bash`, no
# build/ install/ log/ directories, no activation scripts. Edit a source file
# and `pixi run` again: Pixi rebuilds what changed before it runs.
[workspace]
channels = ["https://prefix.dev/robostack-lyrical", "conda-forge"]
platforms = ["linux-64", { platform = "osx-arm64", macos = "14.0" }, "win-64"]
# Building packages from source is a preview feature, so it is opt-in.
preview = ["pixi-build"]
# Share the same backend version for all packages in this workspace.
[workspace.dependencies]
pixi-build-ros = ">=0.7.2"
[dependencies]
# Our own packages, built from source. The names are the package.xml names with
# the ROS distro prefix that RoboStack uses, hyphens instead of underscores.
ros-lyrical-turtle-dancer = { path = "src/turtle_dancer" }
ros-lyrical-turtle-choreographer = { path = "src/turtle_choreographer" }
# What we need to run them. The libraries the packages themselves need come
# from their package.xml.
ros-lyrical-ros-base = ">=0.11"
ros-lyrical-turtlesim = ">=1.8"
[tasks.sim]
cmd = "ros2 run turtlesim turtlesim_node"
description = "Start the turtlesim window"
[tasks.teleop]
cmd = "ros2 run turtlesim turtle_teleop_key"
description = "Drive the turtle with the arrow keys"
[tasks.topics]
cmd = "ros2 topic list"
description = "List the ROS topics"
[tasks.dance]
cmd = "ros2 run turtle_dancer dance"
description = "Run the C++ node; Pixi builds it first if the source changed"
[tasks.choreograph]
cmd = "ros2 run turtle_choreographer choreograph"
description = "Run the Python node; same story"
[tasks.executables]
cmd = "ros2 pkg executables turtle_dancer && ros2 pkg executables turtle_choreographer"
description = "Show that the ROS tooling sees our nodes, exactly as after a colcon build"
[tasks.test]
cmd = "python -c \"import turtle_choreographer.choreograph as c; assert c.main; print('packages: ok')\""
description = "Prove both packages were built and installed: what CI runs"
# The package manifest: how to build this one package. There is no [workspace]
# table here on purpose, that role belongs to the pixi.toml two levels up.
#
# Name, version, description, maintainer and dependencies all come out of
# package.xml, which stays the single source of truth. CMakeLists.txt is
# untouched too: the backend runs the ament_cmake build you already have.
[package]
publish = true
[package.build.backend]
name = "pixi-build-ros"
workspace = true
# The package manifest for the Python package. Same backend, same shape as the
# C++ one: the build type (ament_python) is read from package.xml, and setup.py
# and setup.cfg are used as they are.
[package]
publish = true
[package.build.backend]
name = "pixi-build-ros"
workspace = true
Check your work¶
pixi run dance # C++ node, no sourcing
pixi run choreograph # Python node
pixi run executables # ros2 sees both nodes, exactly as after a colcon build
ls # src/, pixi.toml and output/, no build/ install/ log/
executables is one more task to add: ros2 pkg executables turtle_dancer && ros2 pkg executables turtle_choreographer.
It should print:
Going further¶
Finished early? Try these.
- Compare what you started with against what you have:
git diff --stat exercises/02-ros-package/pixi.toml. - Install one of your locally published packages from
output/in a fresh scratch workspace. That is the same kind of channel RoboStack serves, just on your disk. - Trim the workspace: replace
ros-lyrical-ros-basewithros-lyrical-ros2run. Everything the nodes need comes through theirpackage.xmlnow, sopixi listgets a lot shorter. - Add a third package with a custom
.msg, and use it from both nodes. Interface generation works through this backend.
With your own workspace
Pick one leaf package from your repository, one that does not depend on your other packages.
Put the two-table pixi.toml from 2.2 next to its package.xml, add the path dependency, pixi install.
Expect it to fail the first time, and read the error: usually a package.xml dependency that is not on RoboStack under that name, which extra-package-mappings in the backend config fixes, or one that is not packaged at all.
This is the ten minutes of the workshop we most want to spend with you, so raise a hand.