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.
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.
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'.
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 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).
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).
# 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.
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.
# 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 = THIRDPARTYMinimal rclpy Node Pattern
The standard ROS 2 Python node with a proper main() function that works as a colcon entry point.
# 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.
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.
# 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.
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.
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>)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.
# 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
| Concept | Details |
|---|---|
| Create package | ros2 pkg create --build-type ament_python my_pkg --dependencies rclpy |
| resource/ marker | resource/my_pkg (empty file) — required for ament index registration |
| Entry point format | "cmd = module.path:function" in setup.py console_scripts |
| Build | colcon build --packages-select my_pkg --symlink-install |
| Source overlay | source install/setup.bash after every build |
| Run node | ros2 run my_pkg talker (must match entry_points key) |
| Test | colcon test --packages-select my_pkg |
| Pure Python deps | <exec_depend>python3-numpy</exec_depend> in package.xml |
| vs ament_cmake | Use ament_cmake when you need custom msgs, C++, or composable nodes |
| No rebuild needed | --symlink-install: Python edits take effect immediately |