🤖

ROS 2 JazzyでPEP 668に付き合う(with uv)

に公開

概要

この記事で扱う問題は以下の2点です。

  1. ROS 2パッケージ作成におけるPEP 668への対応
  2. setup.pyからpyproject.tomlへの移行に対するROS 2の対応

これらの問題に対する解決策としては、以下の記事が参考になります。

本記事は、先行事例を踏まえた上でのROS 2 Jazzy環境での検証結果をまとめたものです。

結論

  • ROS 2パッケージ内でuvを利用した仮想環境を作成し、仮想環境内で必要なPythonパッケージをインストール
  • ament_cmakeでのビルドを基本とし、ament_cmake_pythonを利用してPythonで書かれたプログラムをパッケージ化

課題の整理 -PEP 668について-

PEP 668とは?

Ubuntu 24.04ではPython 3.12がデフォルトとなり、PEP 668による制約が導入されました。PEP 668による制約をざっくりまとめると、プロジェクトごとに仮想環境を作り、その中でpipを使いましょう、というものです。実際に、システムの環境にpipでパッケージをインストールしようとすると、以下のようなエラーが発生します。

$ pip install moviepy

error: externally-managed-environment
× This environment is externally managed
╰─> To install Python packages system-wide, try apt install
    python3-xyz, where xyz is the package you are trying to
    install.
    
    If you wish to install a non-Debian-packaged Python package,
    create a virtual environment using python3 -m venv path/to/venv.
    Then use path/to/venv/bin/python and path/to/venv/bin/pip. Make
    sure you have python3-full installed.
    
    If you wish to install a non-Debian packaged Python application,
    it may be easiest to use pipx install xyz, which will manage a
    virtual environment for you. Make sure you have pipx installed.
    
    See /usr/share/doc/python3.12/README.venv for more information.
note: If you believe this is a mistake, please contact your Python installation or OS distribution provider. You can override this, at the risk of breaking your Python installation or OS, by passing --break-system-packages.
hint: See PEP 668 for the detailed specification.

PEP 668がROS 2に与える影響

PEP 668によってpipによるパッケージインストールが一筋縄ではいかなくなりました。特にROS 2のパッケージ開発においては、rosdepコマンドによる依存関係のインストール時にpipを利用する場合があり、これはシステムのPython環境に直接インストールしようとするため、PEP 668の制約に抵触してしまいます。そのため、PEP 668に準拠しながらpipによるインストールを伴うROS 2パッケージの開発方法を検討する必要があります。

課題の整理 -setup.pyからpyproject.tomlへの移行-

Pythonコミュニティでは、従来のsetup.pyに代わり、pyproject.tomlを利用する流れが進んでいます。

ROS 2のPythonを利用したパッケージ開発において、ビルド時にはcolconとament_pythonを使用することが一般的です。ament_pythonではsetup.pyを利用していますが、先述のとおりsetup.pyからpyproject.tomlへの移行が進められていることから、setup.pyを使わずにpyproject.tomlのみでROS 2パッケージを作成する方法を検討する必要があります。

少し前の情報になりますが、ROS 2 Humbleの時点でsetuptools 59.6.0を利用しているとwarningが出ることが問題になっていました。

暫定的な対応として、setuptoolsのバージョンを意図的に下げる方法が紹介されていますが、根本的な解決には至っていません。またROS 2 Jazzyの時点でもsetup.pyの利用が続いており、検証はしていませんが、根本的な解決には至っていないと思われます。
将来的にament_pythonがpyproject.tomlに対応することを期待したいですが、現状できる対応策を検討する必要があるでしょう。

解決策の提案

PEP 668への対応策として、ROS 2パッケージ内でuvを利用して仮想環境を作成し、その中で必要なPythonパッケージをインストールする方法を検討しました。
また、pyproject.tomlのみでROS 2パッケージを作成する方法として、ament_cmakeを利用する方法を検討しました。ament_cmakeを利用するビルドはC++で書かれたパッケージでの利用が一般的ですが、CMakeLists.txtでPythonスクリプトをインストールする設定(ament_cmake_pythonを利用)を行うことで、Pythonで書かれたROS 2ノードをパッケージ化できます。

検証

検証内容

ROS 2のデモパッケージの作成を通して、以下の点を検証しました。

  • ament_cmake、ament_cmake_pythonを利用する場合に、CMakeLists.txtにはどのように記述するのか
  • パッケージ内でuvを利用して仮想環境を作成し、その中でPythonパッケージのインストールを行った場合に、ROS 2ノード起動時に反映されるか
  • システム環境下に存在するPythonパッケージと、仮想環境下に存在するPythonパッケージが競合した場合に、仮想環境下のパッケージが優先されるか
  • 仮想環境起動済みの状況で、rosdepコマンドによりpipを通じたパッケージインストールを行った場合に仮想環境下にインストールされるか

検証環境

  • Ubuntu 24.04
  • Python 3.12
  • ROS 2 Jazzy
  • uv 0.9.18

ROS 2パッケージを作成する流れ

以下の手順でROS 2デモパッケージを作成します。
完成したパッケージをGitHubで公開しているので、手順をスキップしたい場合はそちらを利用してください。

1. ROS 2パッケージのひな形作成

まずワークスペースを作成し、ROS 2パッケージのひな形を作成します。今回はuv_testpkgという名称のパッケージを作成します。

mkdir -p ~/dev_ws/src
cd ~/dev_ws/src
ros2 pkg create --build-type ament_cmake --license MIT uv_testpkg

この時点でのディレクトリ・ファイル構成は以下のようになります。

dev_ws/src/
└── uv_testpkg
    ├── CMakeLists.txt
    ├── LICENSE
    ├── include
    │   └── uv_testpkg
    ├── package.xml
    └── src

2. uvプロジェクトの初期化と仮想環境の作成

uv_testpkgディレクトリ内でuvプロジェクトを初期化します。

cd uv_testpkg
uv init . --lib -p 3.12

--libオプションによってライブラリ形式のプロジェクトとして初期化します。uv_testpkg/src内のPythonファイルがライブラリとしてインストールされる想定の形になります。
-p 3.12オプションによって、Python 3.12を利用するように指定します。

続いて仮想環境を作成します。--system-site-packagesオプションを付与することで、システム環境下のパッケージ(rclpyなど)を仮想環境内からも参照できるようにします。

uv venv --system-site-packages

この時点でのuv_testpkg以下のディレクトリ・ファイル構成は以下のようになります。(隠しファイルは除く)

uv_testpkg
├── CMakeLists.txt
├── LICENSE
├── README.md
├── include
│   └── uv_testpkg
├── package.xml
├── pyproject.toml
└── src
    └── uv_testpkg
        ├── __init__.py
        └── py.typed

3. ROS 2ノードの作成

dev_ws/src/uv_testpkgディレクトリ内にscriptsディレクトリを作成し、その中にROS 2ノードのPythonスクリプトを作成します。

mkdir -p scripts

今回はROS 2 docsのチュートリアルで紹介されているpublisherノードとsubscriberノードを作成します。各ファイルはscriptsディレクトリに配置します。

publisher.py
#!/usr/bin/env python3

import rclpy
from rclpy.node import Node
from std_msgs.msg import String

class MinimalPublisher(Node):
    def __init__(self):
        super().__init__('minimal_publisher')
        self.publisher_ = self.create_publisher(String, 'topic', 10)
        timer_period = 0.5  # seconds
        self.timer = self.create_timer(timer_period, self.timer_callback)
        self.i = 0

    def timer_callback(self):
        msg = String()
        msg.data = 'Hello World: %d' % self.i
        self.publisher_.publish(msg)
        self.get_logger().info('Publishing: "%s"' % msg.data)
        self.i += 1

def main(args=None):
    rclpy.init(args=args)
    minimal_publisher = MinimalPublisher()
    rclpy.spin(minimal_publisher)

    # Destroy the node explicitly
    # (optional - otherwise it will be done automatically
    # when the garbage collector destroys the node object)
    minimal_publisher.destroy_node()
    rclpy.shutdown()

if __name__ == '__main__':
    main()
subscriber.py
#!/usr/bin/env python3

import rclpy
from rclpy.node import Node
from std_msgs.msg import String

class MinimalSubscriber(Node):
    def __init__(self):
        super().__init__('minimal_subscriber')
        self.subscription = self.create_subscription(
            String,
            'topic',
            self.listener_callback,
            10)
        self.subscription  # prevent unused variable warning

    def listener_callback(self, msg):
        self.get_logger().info('I heard: "%s"' % msg.data)


def main(args=None):
    rclpy.init(args=args)
    minimal_subscriber = MinimalSubscriber()
    rclpy.spin(minimal_subscriber)

    # Destroy the node explicitly
    # (optional - otherwise it will be done automatically
    # when the garbage collector destroys the node object)
    minimal_subscriber.destroy_node()
    rclpy.shutdown()

if __name__ == '__main__':
    main()

4. packages.xmlとCMakeLists.txtの編集

package.xmlを以下のように記述します。maintainerタグの部分は適宜書き換えてください。
追記部分は

  • <buildtool_depend>ament_cmake_python</buildtool_depend>
  • <exec_depend>rclpy</exec_depend>
  • <exec_depend>std_msgs</exec_depend>

です。

package.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>uv_testpkg</name>
  <version>0.0.0</version>
  <description>TODO: Package description</description>
  <maintainer email="">unknown</maintainer>
  <license>MIT</license>

  <buildtool_depend>ament_cmake</buildtool_depend>
  <buildtool_depend>ament_cmake_python</buildtool_depend>

  <test_depend>ament_lint_auto</test_depend>
  <test_depend>ament_lint_common</test_depend>

  <exec_depend>rclpy</exec_depend>
  <exec_depend>std_msgs</exec_depend>

  <export>
    <build_type>ament_cmake</build_type>
  </export>
</package>

CMakeLists.txtを以下のように記述します。
追記部分はfind_packageとament_python_install_packageおよびinstallの箇所です。

find_package部分ではament_cmake_pythonとrclpyを追加しています。

ament_python_install_packageではパッケージ名とパッケージディレクトリを指定しています。
${PROJECT_NAME}は今回の場合uv_testpkgになります。$ ros2 run <pkg_name> <executable_name>の<pkg_name>に該当します。
PACKAGE_DIR src/${PROJECT_NAME}はパッケージ内のPythonモジュールが格納されているディレクトリを指定しています。今回の場合はuv_testpkg/src/uv_testpkgになります。ここで指定したディレクトリ内のPythonファイルがライブラリとしてインストールされます。

install部分では、先ほど作成したscripts/publisher.pyとscripts/subscriber.pyをそれぞれros2 runコマンドで実行可能な形でインストールしています。ファイルごとに記述する必要があります。

CMakeLists.txt
cmake_minimum_required(VERSION 3.8)
project(uv_testpkg)

if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
  add_compile_options(-Wall -Wextra -Wpedantic)
endif()

# find dependencies
find_package(ament_cmake REQUIRED)
find_package(ament_cmake_python REQUIRED)
find_package(rclpy REQUIRED)

# Install Python modules
ament_python_install_package(${PROJECT_NAME} PACKAGE_DIR src/${PROJECT_NAME})

# Install Python executables
install(
  PROGRAMS scripts/publisher.py
  DESTINATION lib/${PROJECT_NAME}
)
install(
  PROGRAMS scripts/subscriber.py
  DESTINATION lib/${PROJECT_NAME}
)

if(BUILD_TESTING)
  find_package(ament_lint_auto REQUIRED)
  # the following line skips the linter which checks for copyrights
  # comment the line when a copyright and license is added to all source files
  set(ament_cmake_copyright_FOUND TRUE)
  # the following line skips cpplint (only works in a git repo)
  # comment the line when this package is in a git repo and when
  # a copyright and license is added to all source files
  set(ament_cmake_cpplint_FOUND TRUE)
  ament_lint_auto_find_test_dependencies()
endif()

ament_package()

この時点でのuv_testpkg以下のディレクトリ・ファイル構成は以下のようになります。(隠しファイルは除く)

uv_testpkg
├── CMakeLists.txt
├── LICENSE
├── README.md
├── include
│   └── uv_testpkg
├── package.xml
├── pyproject.toml
├── scripts
│   ├── publisher.py
│   └── subscriber.py
└── src
    └── uv_testpkg
        ├── __init__.py
        └── py.typed

5. ビルドと実行

ワークスペースのルートディレクトリに移動してビルドします。今回は--symlink-installオプションを利用しない形でビルドします。
ビルドが成功したら、パッケージを読み込ませてノードを起動します。
通常のros2 runコマンドとは少し異なり、Pythonファイルの拡張子.pyも含めて指定する必要があります。

cd ~/dev_ws
colcon build
source ./install/setup.bash
ros2 run uv_testpkg publisher.py

別のターミナルで同様にセットアップを行い、subscriberノードを起動します。

cd ~/dev_ws
source ./install/setup.bash
ros2 run uv_testpkg subscriber.py

publisherノードがメッセージを発行し、subscriberノードがそのメッセージを受信して表示していることが確認できれば成功です。

uvによるPythonパッケージのインストール検証

続いて、仮想環境内でインストールしたPythonパッケージの振る舞いについて検証します。

1. 仮想環境の有効化とパッケージのインストール

uvで作成した仮想環境を有効化します。次にシステム環境下にはインストールされていないmoviepyパッケージを仮想環境内にインストールします。併せてnumpyパッケージが仮想環境内にインストールされますが、私の環境ではシステム環境下のnumpyパッケージのバージョンが1.26.4、仮想環境内のnumpyパッケージのバージョンが2.4.0となっており、バージョンが異なる状態になっています。これにより、システム環境下と仮想環境下で同一のパッケージが存在する場合に、どちらのパッケージが優先されるかを確認できます。

cd ~/dev_ws/src/uv_testpkg
source .venv/bin/activate
uv add moviepy

2. 仮想環境内のパッケージを読み込むノードの作成

scriptsディレクトリ内に、仮想環境内のmoviepyパッケージを読み込むROS 2ノードを作成します。moviepyとnumpyのバージョン情報を表示するノードにします。moviepyのバージョンが表示されれば、仮想環境内のパッケージが正しく読み込まれていることが確認できます。またnumpyのバージョンを表示することで、システム環境と仮想環境のパッケージの優先順位も確認できます。

uv_piptest.py
#!/usr/bin/env python3

import rclpy
from rclpy.node import Node
from std_msgs.msg import String

import moviepy
import numpy

class UVPipTestNode(Node):
    def __init__(self):
        super().__init__('uv_pip_test_node')
        self.publisher_ = self.create_publisher(String, 'uv_pip_test_topic', 10)
        timer_period = 1.0  # seconds
        self.timer = self.create_timer(timer_period, self.timer_callback)
        self.i = 0

    def timer_callback(self):
        msg = String()
        msg.data = f'moviepy version: {moviepy.__version__}, numpy version: {numpy.__version__}, count: {self.i}'
        self.publisher_.publish(msg)
        self.get_logger().info(f'Publishing: "{msg.data}"')
        self.i += 1

def main(args=None):
    rclpy.init(args=args)
    uv_pip_test_node = UVPipTestNode()
    try:
        rclpy.spin(uv_pip_test_node)
    except KeyboardInterrupt:
        pass
    finally:
        uv_pip_test_node.destroy_node()
        rclpy.shutdown()
    
if __name__ == '__main__':
    main()

3. ノードのインストールと実行

CMakeLists.txtのinstall記述部分に以下を追記して、先ほど作成したノードをインストール、実行します。

CMakeLists.txt
+ install(
+   PROGRAMS scripts/uv_piptest.py
+   DESTINATION lib/${PROJECT_NAME}
+ )
cd ~/dev_ws
colcon build
source ./install/setup.bash
ros2 run uv_testpkg uv_piptest.py

4. 実行結果

仮想環境下

[INFO] [1766487529.482756495] [uv_pip_test_node]: Publishing: "moviepy version: 2.1.2, numpy version: 2.4.0, count: 0"
[INFO] [1766487530.459914040] [uv_pip_test_node]: Publishing: "moviepy version: 2.1.2, numpy version: 2.4.0, count: 1"
[INFO] [1766487531.459868972] [uv_pip_test_node]: Publishing: "moviepy version: 2.1.2, numpy version: 2.4.0, count: 2"

moviepyのバージョンが表示されていることから、仮想環境内のパッケージが正しく読み込まれていることが確認できます。またnumpyのバージョンから仮想環境内のパッケージが優先されていることも確認できます。

システム環境下

import moviepyの部分でImportErrorが発生します。システム環境下にはmoviepyパッケージが存在しないためです。
一旦moviepyのimportをコメントアウトし、numpyのバージョンのみを表示するように変更して実行します。

[INFO] [1766486856.265054331] [uv_pip_test_node]: Publishing: "numpy version: 1.26.4, count: 0"
[INFO] [1766486857.250076879] [uv_pip_test_node]: Publishing: "numpy version: 1.26.4, count: 1"
[INFO] [1766486858.250171210] [uv_pip_test_node]: Publishing: "numpy version: 1.26.4, count: 2"

numpyのバージョンが1.26.4であることから、システム環境下のパッケージがimportされていることが確認できます。(当然ですね)

rosdepコマンドによるpipインストールの検証

1. package.xmlへのpipでインストールされるパッケージの追記

rosdepコマンドでpipを用いてPythonパッケージをインストールしようとした場合の挙動を確認します。

rosdepコマンドでインストールできるパッケージは以下のレポジトリに書かれています。

今回はrosdep/python.yamlを参照し、pipでインストールされるcanopen-pipをpackage.xmlに追記します。

package.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>uv_testpkg</name>
  <version>0.0.0</version>
  <description>TODO: Package description</description>
  <maintainer email="47575691+Kamorei@users.noreply.github.com">Kamorei</maintainer>
  <license>MIT</license>

  <buildtool_depend>ament_cmake</buildtool_depend>
  <buildtool_depend>ament_cmake_python</buildtool_depend>

  <test_depend>ament_lint_auto</test_depend>
  <test_depend>ament_lint_common</test_depend>

  <exec_depend>rclpy</exec_depend>
  <exec_depend>std_msgs</exec_depend>

  <depend>canopen-pip</depend>

  <export>
    <build_type>ament_cmake</build_type>
  </export>
</package>

2. rosdepコマンドの実行

仮想環境下とシステム環境下のそれぞれでrosdepコマンドを実行し、pipを使ってインストールを試みるcanopen-pipパッケージがインストールされるのかを確認します。

仮想環境下

cd ~/dev_ws
source src/uv_testpkg/.venv/bin/activate
rosdep install -i --from-path src

/usr/bin/rosdep:6: DeprecationWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html
 from pkg_resources import load_entry_point
ERROR: the following rosdeps failed to install
 pip:
rosdep installation of pip packages requires installing packages globally as root.
When using Python >= 3.11, PEP 668 compliance requires you to allow pip to install alongside
externally managed packages using the 'break-system-packages' option.
The recommeded way to set this option when using rosdep is to set the environment variable
PIP_BREAK_SYSTEM_PACKAGES=1
in your environment.

For more information refer to http://docs.ros.org/en/independent/api/rosdep/html/pip_and_pep_668.html

エラーが発生します。内容からPEP 668に抵触していることがわかります。やはりシステム環境下にpipでパッケージをインストールしようとしているようです。

システム環境下

cd ~/dev_ws
rosdep install -i --from-path src

/usr/bin/rosdep:6: DeprecationWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html
 from pkg_resources import load_entry_point
ERROR: the following rosdeps failed to install
 pip:
rosdep installation of pip packages requires installing packages globally as root.
When using Python >= 3.11, PEP 668 compliance requires you to allow pip to install alongside
externally managed packages using the 'break-system-packages' option.
The recommeded way to set this option when using rosdep is to set the environment variable
PIP_BREAK_SYSTEM_PACKAGES=1
in your environment.

For more information refer to http://docs.ros.org/en/independent/api/rosdep/html/pip_and_pep_668.html

仮想環境下と同様のエラーです。

まとめ

本記事では、ROS 2 Jazzy環境下でPEP 668に対応しつつ、setup.pyからpyproject.tomlへの移行を見据えたROS 2パッケージの作成方法を検証しました。

PEP 668への対応策として、uvを用いた仮想環境の作成と、その中でのPythonパッケージのインストール方法を紹介しました。仮想環境内でインストールしたPythonパッケージが正しく読み込まれること、システム環境下と仮想環境下で同一のパッケージが存在する場合に仮想環境下のパッケージが優先されることを確認しました。

また、setup.pyからpyproject.tomlへの移行を見据え、ament_cmakeおよびament_cmake_pythonを利用したROS 2パッケージの作成方法を紹介しました。CMakeLists.txtでPythonスクリプトをインストールする設定を行うことで、Pythonで書かれたROS 2ノードをパッケージ化できることを確認しました。

なお、rosdepコマンドによるpipインストールに関しては、仮想環境下であってもシステム環境下であってもPEP 668の制約に抵触し、エラーが発生することを確認しました。現状ではrosdepコマンドを利用したpipインストールは困難であるため、Pythonパッケージについてはuv等を用いた管理が推奨されます。

Discussion