Skip to main content
🐍 ament_pythonROS 2 · June 2026

ament_python Guide 2026

Build pure Python ROS 2 packages: package.xml format 3, setup.py entry points for ros2 run, colcon build --symlink-install, pytest tests, and when to use ament_cmake instead.

The #1 Gotcha

After colcon build, always run source install/setup.bash before ros2 run. Forgetting this is the cause of 80% of "package not found" errors with ament_python packages. Use --symlink-install to avoid rebuilding after Python edits.

1

ament_python Package Structure

An ament_python package uses Python's native setuptools (setup.py / setup.cfg) for builds. The layout mirrors a standard Python package with ROS 2 additions.

text
my_py_pkg/
├── package.xml              # ROS 2 package manifest
├── setup.py                 # setuptools build script
├── setup.cfg                # package metadata (entry_points, description)
├── resource/
│   └── my_py_pkg            # ament index marker (empty file, required)
├── my_py_pkg/
│   ├── __init__.py
│   ├── talker.py
│   ├── listener.py
│   └── utils.py
└── test/
    ├── test_copyright.py    # ament_copyright check
    ├── test_flake8.py       # ament_flake8 linting
    └── test_my_pkg.py       # your unit tests

Key files:
  package.xml  →  declares ROS 2 dependencies (rclpy, std_msgs, etc.)
  setup.py     →  sets up entry_points for ros2 run executables
  setup.cfg    →  package name, version; avoids setup.py boilerplate
  resource/    →  ament resource index — colcon won't install without it

Generate from template:
  ros2 pkg create --build-type ament_python my_py_pkg
  ros2 pkg create --build-type ament_python my_py_pkg \
    --dependencies rclpy std_msgs

The resource/my_py_pkg marker file is required — colcon uses it to register the package in the ament resource index. Missing it causes `ros2 run my_py_pkg` to fail with 'Package not found'.

2

package.xml for Python Packages

ament_python packages use format 3 and declare rclpy dependencies with <depend> (build + exec). Use <exec_depend> for pure runtime deps, <test_depend> for test-only.

xml
<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd"
            schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
  <name>my_py_pkg</name>
  <version>0.1.0</version>
  <description>Example ament_python ROS 2 package</description>
  <maintainer email="dev@example.com">Your Name</maintainer>
  <license>Apache-2.0</license>

  <!-- Build system: ament_python (not ament_cmake) -->
  <buildtool_depend>ament_python</buildtool_depend>

  <!-- Runtime Python dependencies -->
  <depend>rclpy</depend>
  <depend>std_msgs</depend>
  <depend>sensor_msgs</depend>
  <depend>geometry_msgs</depend>

  <!-- Interface packages (used in type hints) -->
  <depend>example_interfaces</depend>

  <!-- For third-party Python packages available in rosdep -->
  <exec_depend>python3-numpy</exec_depend>
  <exec_depend>python3-opencv</exec_depend>

  <!-- Test-only dependencies -->
  <test_depend>ament_copyright</test_depend>
  <test_depend>ament_flake8</test_depend>
  <test_depend>ament_pep8</test_depend>
  <test_depend>python3-pytest</test_depend>

  <export>
    <build_type>ament_python</build_type>
  </export>
</package>

<depend> = both build and exec dependency (safest for rclpy and message packages). Use <build_depend> + <exec_depend> separately only when the build requirement differs from runtime (e.g., code-generation tools).

3

setup.py — Entry Points for ros2 run

The entry_points key declares executables that ros2 run will find. Each entry maps a command name to a Python function (module:function).

python
# setup.py
from setuptools import find_packages, setup

package_name = "my_py_pkg"

setup(
    name=package_name,
    version="0.1.0",
    packages=find_packages(exclude=["test"]),

    # ── ament index resource marker ──────────────────────
    data_files=[
        ("share/ament_index/resource_index/packages",
         ["resource/" + package_name]),
        ("share/" + package_name, ["package.xml"]),
        # Install launch files (optional)
        ("share/" + package_name + "/launch",
         ["launch/my_launch.py"]),
        # Install config files (optional)
        ("share/" + package_name + "/config",
         ["config/params.yaml"]),
    ],

    install_requires=["setuptools"],
    zip_safe=True,

    maintainer="Your Name",
    maintainer_email="dev@example.com",
    description="Example ament_python package",
    license="Apache-2.0",

    # ── Executables for ros2 run ──────────────────────────
    # Format: "command_name = module.path:function_name"
    entry_points={
        "console_scripts": [
            "talker = my_py_pkg.talker:main",
            "listener = my_py_pkg.listener:main",
            "image_processor = my_py_pkg.image_processor:main",
        ],
    },
)

# Usage after colcon build:
#   ros2 run my_py_pkg talker
#   ros2 run my_py_pkg listener

Every entry in console_scripts creates a wrapper script at install/my_py_pkg/lib/my_py_pkg/<name>. colcon puts this on PATH when you source install/setup.bash. Without source install/setup.bash, ros2 run won't find these.

4

setup.cfg — Metadata and test=pytest

setup.cfg complements setup.py with declarative metadata. The [tool:pytest] section is needed for colcon test to use pytest as the test runner.

ini
# setup.cfg
[metadata]
name = my_py_pkg
version = 0.1.0
description = Example ament_python package
author = Your Name
author_email = dev@example.com
license = Apache-2.0

[options]
packages = find:
install_requires =
    setuptools

# ── Test runner configuration ─────────────────────────────
[tool:pytest]
# Required: colcon test uses pytest by default for ament_python
junit_suite_name = my_py_pkg

# ── flake8 linting ────────────────────────────────────────
[flake8]
max-line-length = 99
# PEP 8 E501 (line too long) relaxed to 99 chars (ROS 2 convention)

# ── isort (optional, if using) ────────────────────────────
[isort]
known_ros = rclpy,rcl_interfaces,std_msgs,sensor_msgs,geometry_msgs
sections = FUTURE,STDLIB,THIRDPARTY,ROS,FIRSTPARTY,LOCALFOLDER
default_section = THIRDPARTY
5

Minimal rclpy Node Pattern

The standard ROS 2 Python node with a proper main() function that works as a colcon entry point.

python
# my_py_pkg/talker.py
import rclpy
from rclpy.node import Node
from std_msgs.msg import String


class Talker(Node):
    def __init__(self):
        super().__init__("talker")

        # Declare parameter with default
        self.declare_parameter("publish_rate", 1.0)
        rate = self.get_parameter("publish_rate").value

        self.pub = self.create_publisher(String, "chatter", 10)
        self.timer = self.create_timer(1.0 / rate, self._timer_cb)
        self.count = 0

    def _timer_cb(self):
        msg = String()
        msg.data = f"Hello {self.count}"
        self.pub.publish(msg)
        self.get_logger().info(f"Publishing: {msg.data}")
        self.count += 1


def main(args=None):
    rclpy.init(args=args)
    node = Talker()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.shutdown()


# ── Direct execution (python talker.py) ─────────────────────
if __name__ == "__main__":
    main()

Always call node.destroy_node() before rclpy.shutdown() — omitting it causes a warning and can leak resources if the node holds timers or subscriptions. The try/except/finally pattern handles both Ctrl-C and normal exits cleanly.

6

Building and Testing with colcon

colcon build compiles and installs the package. Always source the install overlay after building, or ros2 run won't find new packages.

bash
# Build a specific package
colcon build --packages-select my_py_pkg

# Build with symlink install (Python files update without rebuild)
colcon build --packages-select my_py_pkg --symlink-install

# Source the install overlay
source install/setup.bash          # Linux/macOS
# .installsetup.ps1              # Windows PowerShell

# Verify the package is found
ros2 pkg list | grep my_py_pkg
ros2 pkg executables my_py_pkg

# Run the node
ros2 run my_py_pkg talker
ros2 run my_py_pkg talker --ros-args -p publish_rate:=5.0

# ── Run tests ─────────────────────────────────────────────
colcon test --packages-select my_py_pkg
colcon test-result --verbose       # show pass/fail details

# ── Install Python deps via rosdep ────────────────────────
rosdep install --from-paths src --ignore-src -r -y

# ── Check linting manually ────────────────────────────────
ament_flake8 my_py_pkg/
ament_pep8 my_py_pkg/
ament_copyright my_py_pkg/

--symlink-install creates symlinks instead of copying files — Python changes take effect immediately without rebuilding. Do NOT use this for C++ extensions; only pure Python packages benefit.

7

ament_python vs ament_cmake — When to Use Which

Pure Python nodes use ament_python. If your package needs C++ extensions, custom messages, or composable nodes, use ament_cmake with Python bindings.

text
ament_python  ←  when your package is pure Python
──────────────────────────────────────────────────────────────
  ✅ Simple node implementations
  ✅ Scripts, utilities, launch files, config
  ✅ Fast iteration with --symlink-install
  ✅ No compilation step
  ❌ Cannot define custom messages/services
  ❌ Cannot build C extensions or shared libraries
  ❌ Cannot use rclcpp_components (composable nodes)

ament_cmake  ←  when you need C++ or custom interfaces
──────────────────────────────────────────────────────────────
  ✅ Custom .msg / .srv / .action files
  ✅ C++ nodes, shared libraries, composable nodes
  ✅ Python bindings with ament_cmake_python helpers
  ✅ Mixed C++/Python packages (most common for complex pkgs)
  ❌ More complex CMakeLists.txt required
  ❌ Slower build (compilation required)

Mixed package (ament_cmake + Python):
  CMakeLists.txt:
    find_package(ament_cmake_python REQUIRED)
    ament_python_install_package(${PROJECT_NAME})
    install(PROGRAMS scripts/my_script.py DESTINATION lib/${PROJECT_NAME})

  package.xml:
    <buildtool_depend>ament_cmake</buildtool_depend>
    <buildtool_depend>ament_cmake_python</buildtool_depend>
    <build_type>ament_cmake</build_type>   (in <export>)
8

Writing Tests with pytest + ament_pytest

ROS 2 Python tests use standard pytest with ament_pytest integration for colcon test compatibility. Tests can spin actual rclpy nodes.

python
# test/test_talker.py
import pytest
import rclpy
from rclpy.node import Node
from std_msgs.msg import String


@pytest.fixture(autouse=True)
def ros_context():
    """Initialize rclpy for each test and shut down after."""
    rclpy.init()
    yield
    rclpy.shutdown()


def test_talker_publishes():
    """Integration test: check the talker publishes a message."""
    from my_py_pkg.talker import Talker

    talker = Talker()
    received = []

    helper = rclpy.create_node("test_helper")
    helper.create_subscription(
        String, "chatter",
        lambda msg: received.append(msg.data),
        10,
    )

    import threading
    t = threading.Thread(
        target=lambda: rclpy.spin_until_future_complete(
            talker, rclpy.Future(), timeout_sec=1.0
        )
    )
    t.start()

    # Let it spin for a moment
    import time; time.sleep(0.5)
    rclpy.spin_once(helper, timeout_sec=0.5)

    talker.destroy_node()
    helper.destroy_node()

    assert len(received) > 0
    assert "Hello" in received[0]


# ── Unit test (no ROS 2 needed) ──────────────────────────
def test_message_format():
    """Pure Python unit test — no rclpy required."""
    msg = String()
    msg.data = "Hello 0"
    assert msg.data.startswith("Hello")

Quick Reference

ConceptDetails
Create packageros2 pkg create --build-type ament_python my_pkg --dependencies rclpy
resource/ markerresource/my_pkg (empty file) — required for ament index registration
Entry point format"cmd = module.path:function" in setup.py console_scripts
Buildcolcon build --packages-select my_pkg --symlink-install
Source overlaysource install/setup.bash after every build
Run noderos2 run my_pkg talker (must match entry_points key)
Testcolcon test --packages-select my_pkg
Pure Python deps<exec_depend>python3-numpy</exec_depend> in package.xml
vs ament_cmakeUse ament_cmake when you need custom msgs, C++, or composable nodes
No rebuild needed--symlink-install: Python edits take effect immediately