Skip to content

Developer Guide

Pixi Workspace

This project uses pixi exclusively for package management. Pixi manages all C++ libraries, Python packages, build tools, and platform-specific dependencies through a single lockfile (pixi.lock). No pip, conda, poetry, or venv.

Available Tasks

Bash
pixi task list
Task Description What it does
build Full build C++ libs + nanobind bindings
build-cpp C++ only Fetches sources via vcstool, builds 28 packages with colcon
install Install bindings Editable pip install (depends on build-cpp)
test Run tests pytest with xdist parallel (depends on install)
typecheck Type check pyright on src/tesseract_robotics/
lint Lint ruff check
fmt Format ruff format
docs Live docs mkdocs serve with auto-reload
docs-build Build docs Static site to site/
check-changelog Check changelog CHANGELOG.md release structure (the Docs workflow runs it too)

Daily Workflow

Bash
# First time setup (fetches C++ deps, builds everything, ~15-30 min)
pixi run build

# Run tests
pixi run test

# Run a single test file
pixi run python -m pytest tests/trajopt_sqp/test_trajopt_sqp_bindings.py -v

# Run only tests affected by recent changes
pixi run python -m pytest --testmon

# Run an example
pixi run tesseract_freespace_ompl_example
# or
pixi run python -m tesseract_robotics.examples.freespace_ompl_example

# Interactive shell (all env vars set)
pixi shell
python -c "from tesseract_robotics.planning import Robot; print('ok')"

Rebuild After C++ Changes

If you modify a C++ binding file (src/*_bindings.cpp):

Bash
# Reinstall bindings only (fast, ~30s)
pixi run install

If you modify upstream C++ sources or dependencies.repos:

Bash
# Full C++ rebuild + reinstall
pixi run build

Task Dependency Chain

Tasks use depends-on for ordered execution:

Text Only
build
  └── install
        └── build-cpp

Running pixi run build executes build-cpp → install automatically. Running pixi run test also triggers the full chain since it depends on install.

Environments

The workspace defines multiple Python environments for CI testing:

Bash
# Default environment (Python from pixi.lock)
pixi run test

# Specific Python version
pixi run -e py39 test
pixi run -e py312 test

Environments defined in pyproject.toml:

Environment Python Usage
default from lockfile Local development
py39 3.9.x CI matrix
py310 3.10.x CI matrix
py311 3.11.x CI matrix
py312 3.12.x CI matrix

Dependency Management

All dependencies live in pyproject.toml under [tool.pixi.dependencies] (conda packages) and [tool.pixi.pypi-dependencies] (PyPI packages).

Bash
# Add a conda dependency
pixi add some-package

# Add a PyPI dependency
pixi add --pypi some-package

# Update lockfile after editing pyproject.toml manually
pixi install

# See what's installed
pixi list

Critical pins:

  • taskflow>=3.6,<3.8 — 3.8+ requires C++20, we use C++17
  • cereal>=1.3,<2 — serialization backend (0.34+)
  • llvm-openmp>=14,<20 — macOS OpenMP (not Homebrew's)

CONDA_PREFIX and Worktrees

When working in a git worktree, the CONDA_PREFIX env var may point to the main repo's .pixi/envs/default instead of the worktree's. This causes build failures (e.g. missing cereal). Override when building manually:

Bash
CONDA_PREFIX=$(pwd)/.pixi/envs/default pip install -e . --no-build-isolation

Using pixi run handles this automatically.


Architecture

graph TD
    subgraph "C++ Libraries"
        A[tesseract_environment]
        B[tesseract_kinematics]
        C[tesseract_collision]
        D[tesseract_planning]
    end

    subgraph "nanobind Modules"
        E[tesseract_environment]
        F[tesseract_kinematics]
        G[tesseract_collision]
        H[tesseract_motion_planners]
    end

    subgraph "Python API"
        I[tesseract_robotics.planning]
        J[Robot, MotionProgram, TaskComposer,<br/>plan_freespace / plan_ompl / plan_cartesian]
    end

    A --> E
    B --> F
    C --> G
    D --> H
    E --> I
    F --> I
    G --> I
    H --> I
    I --> J

Project Layout

Text Only
tesseract_nanobind/
├── pyproject.toml             # pixi workspace + package config
├── pixi.lock                  # locked deps (~200 packages)
├── CMakeLists.txt             # nanobind module build
├── dependencies.repos    # C++ source versions (vcstool)
├── src/
│   ├── tesseract_robotics/    # Python package
│   │   ├── planning/          # High-level API (pure Python)
│   │   ├── viewer/            # 3D visualization
│   │   ├── trajopt_ifopt/     # Low-level optimization
│   │   ├── trajopt_sqp/       # SQP solver
│   │   └── <module>/          # nanobind module + __init__.py + .pyi
│   ├── tesseract_nb.h         # Shared precompiled header
│   └── <module>/              # C++ binding source (*_bindings.cpp)
├── tests/                     # pytest tests
├── examples/                  # Usage examples
├── scripts/
│   ├── build_tesseract_cpp.sh # C++ build (colcon + vcstool)
│   ├── generate_stubs.py      # Regenerate / check .pyi stubs
│   ├── build_linux_wheel.sh   # Portable manylinux wheel (patchelf)
│   └── build_macos_wheel.sh   # Portable macOS wheel (delocate)
└── ws/                        # C++ workspace
    ├── src/                   # Fetched C++ sources
    └── install/               # Built C++ libraries

Cross-Module Type Resolution

nanobind maintains separate type registries per module. When a function returns a type from another module, that module must be imported first:

C++
NB_MODULE(_my_module, m) {
    // Import module that defines the type BEFORE using it
    nb::module_::import_("tesseract_robotics.tesseract_collision._tesseract_collision");

    // Now can return DiscreteContactManager from functions
    .def("getContactManager", [...] { return self.getDiscreteContactManager(); })
}

See Migration Notes for details.


Type Checking

Bash
pixi run typecheck

Uses pyright configured via pyrightconfig.json. Key design decisions:

  • Python code is type-checked — the planning/ module and other pure Python code
  • Auto-generated stubs are excluded — nanobind generates .pyi stubs with C++ type artifacts
  • Hand-written stub exception — ompl_base/_ompl_base.pyi is manually maintained

When adding new bindings, declare inheritance in nanobind to get correct stubs:

C++
// Good - generates: class Derived(Base)
nb::class_<Derived, Base>(m, "Derived")

// Bad - generates: class Derived (no inheritance)
nb::class_<Derived>(m, "Derived")

Stub Generation

After modifying C++ bindings, regenerate the .pyi stubs:

Bash
pixi run stubs

This finds every nanobind extension module in the installed package and writes each stub to src/tesseract_robotics/<module>/. Each module is rendered by nanobind.stubgen in a fresh interpreter, because which cross-module types resolve depends on what is already imported. Stubs are committed for IDE support and type checking.

Stubs are generated, never hand-edited. Edits made by hand are lost at the next regeneration. Put documentation in the binding's docstring instead.

Drift gate (gh-159). pixi run stubs-check exits 1 when a committed stub differs from what the build renders. CI runs it in every build job (all platforms, every Python except 3.9). Stubgen output depends on the nanobind version, so nanobind is pinned exactly in pyproject.toml. Bumping nanobind means regenerating the stubs in the same commit.

No std:: in stubs. stubgen writes a C++ type it can't map to Python as a quoted C++ name. Such a type has no binding or converter, so it also fails at runtime. Spellings of std:: types differ by platform (libc++ std::__1::, libstdc++ std::__cxx11::), so such a leak also makes the stub platform-dependent. tests/scripts/test_generate_stubs.py rejects them. Quoted tesseract::… names are the same kind of gap but platform-independent. Each one means the type isn't bound, or the module doesn't nb::module_::import_() the module that binds it.

nanobind's std::unordered_map caster matches default-allocator maps only. src/tesseract_nb.h adds a caster for Tesseract's Eigen::aligned_allocator maps (TransformMap and friends).


Pre-commit Hooks

Bash
pre-commit install
pre-commit install --hook-type pre-push
Hook Stage What it does
ruff check --fix pre-commit Auto-fix lint issues
ruff format pre-commit Format code
stage-formatted pre-commit Auto-stage ruff changes
pyright pre-push Type check
pytest --testmon pre-push Run affected tests

Skip when needed: git commit --no-verify / git push --no-verify


Changelog at Release Time

CHANGELOG.md follows Keep a Changelog and is published on the docs site. scripts/check_changelog.py checks its release structure; the Docs workflow runs it before building, so a mis-cut release fails CI instead of publishing a wrong changelog (0.35.0.7 and 0.35.0.8 were both cut by hand, and both broke it). To cut <version>:

  1. Rename ## [Unreleased] to ## [<version>] - <YYYY-MM-DD> and add a new, empty ## [Unreleased] above it.
  2. Add [<version>]: https://github.com/tesseract-robotics/tesseract_nanobind/compare/<previous>...<version> above the previous release's link.
  3. Point [Unreleased]: at compare/<version>...HEAD.
  4. Run pixi run check-changelog before committing.

Anything merged after the tag goes under ## [Unreleased], not into the release's section.


Contributing

  1. Fork the repository
  2. Create a feature branch (or use a git worktree)
  3. pixi run build (first time)
  4. Make changes
  5. pixi run test
  6. pixi run typecheck
  7. Submit a pull request