ROS2 runコマンドをいじってみた

せしまるです。

本日はros2 runコマンドをちょっとだけいじった話を書いていきたいと思います。

# いじった経緯

仕事内で、あるルールが決められました。
それは、実行ファイルをinstall/pkgname/に移動させるというもの

簡単に書くと、以下のように変更されます。

```console
// 今までのインストール先.
install/pkgname/lib/pkgname/実行ファイル
// install/pkgname下はこんな感じ.
$ ls install/pkgname
lib  share

// 変更後のインストール先.
install/pkgname/実行ファイル
// install/pkgname下はこんな感じ.
$ ls install/pkgname
実行ファイル  share
```

# 変更する点

まず、CMakeLists.txtをいじりました。
具体的に言うとinstall部分の変更です。

```text:CMakeLists.txt
// before.
install(TARGETS
  xxxx
  DESTINATION lib/xxxx
  )
```

↓に変更

```text:CMAkeLists.txt
// after.
install(TARGETS
  xxxx
  DESTINATION .
  )
```

これをビルドすると、install/pkgname/に実行ファイルが置かれます。
とりあえず実行してみるかと思い、local_setup.bashを読み込んでからros2 runコマンドを叩きます。

```console:console
$ source install/local_setup.bash
$ ros2 run xxxx xxxx
No executable found
```

あれ、実行ファイルが見つからないだと・・・？


# ROS2 runの仕様

なんでだろうと思い、とりあえず調べましたが納得のいくものは見つからず。
仕方ないのでros2 runの処理部分を見てみる。
（公式マニュアルの通りにROS2がインストールされていると仮定します）

```console:~/ros2_ws/install/ros2run/lib/python3.5/site-packages/ros2run/command/run.py
# Copyright 2017 Open Source Robotics Foundation, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from argparse import REMAINDER
import shlex

from ros2cli.command import CommandExtension
from ros2pkg.api import package_name_completer
from ros2pkg.api import PackageNotFound
from ros2run.api import ExecutableNameCompleter
from ros2run.api import get_executable_path
from ros2run.api import MultipleExecutables
from ros2run.api import run_executable


class RunCommand(CommandExtension):
    """Run a package specific executable."""

    def add_arguments(self, parser, cli_name):
        arg = parser.add_argument(
            '--prefix',
            help='Prefix command, which should go before the executable. '
                 'Command must be wrapped in quotes if it contains spaces '
                 "(e.g. --prefix 'gdb -ex run --args').")
        try:
            from argcomplete.completers import SuppressCompleter
        except ImportError:
            pass
        else:
            arg.completer = SuppressCompleter()
        arg = parser.add_argument(
            'package_name',
            help='Name of the ROS package')
        arg.completer = package_name_completer
        arg = parser.add_argument(
            'executable_name',
            help='Name of the executable')
        arg.completer = ExecutableNameCompleter(
            package_name_key='package_name')
        parser.add_argument(
            'argv', nargs=REMAINDER,
            help='Pass arbitrary arguments to the executable')

    def main(self, *, parser, args):
        try:
            path = get_executable_path(
                package_name=args.package_name,
                executable_name=args.executable_name)
        except PackageNotFound:
            raise RuntimeError(
                "Package '{args.package_name}' not found"
                .format_map(locals()))
        except MultipleExecutables as e:
            msg = 'Multiple executables found:'
            for p in e.paths:
                msg += '\n- {p}'.format_map(locals())
            raise RuntimeError(msg)
        if path is None:
            return 'No executable found'
        prefix = shlex.split(args.prefix) if args.prefix is not None else None
        return run_executable(path=path, argv=args.argv, prefix=prefix)
```

お、mainの最初のほうでget_executable_pathとかいうの呼んでるじゃん。
これはros2runのapiの方に定義されているみたいなのでそちらも見てみる。

```console:~/ros2_ws/install/ros2run/lib/python3.5/site-packages/ros2run/api/__init__.py
# Copyright 2017 Open Source Robotics Foundation, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import os
import subprocess
import sys

from ros2pkg.api import get_executable_paths
from ros2pkg.api import PackageNotFound


class MultipleExecutables(Exception):

    def __init__(self, paths):
        self.paths = paths


def get_executable_path(*, package_name, executable_name):
    paths = get_executable_paths(package_name=package_name)
    paths2base = {}
    for p in paths:
        basename = os.path.basename(p)
        if basename == executable_name:
            # pick exact match
            paths2base[p] = basename
        elif sys.platform == 'win32':
            # check extensions listed in PATHEXT for match without extension
            pathext = os.environ.get('PATHEXT', '').lower().split(os.pathsep)
            ext = os.path.splitext(basename)[1].lower()
            if ext in pathext and basename[:-len(ext)] == executable_name:
                # pick match because of known extension
                paths2base[p] = basename
    if not paths2base:
        return None
    if len(paths2base) > 1:
        raise MultipleExecutables(paths2base.keys())
    return list(paths2base.keys())[0]


def run_executable(*, path, argv, prefix=None):
    cmd = [path] + argv

    # on Windows Python scripts are invokable through the interpreter
    if os.name == 'nt' and path.endswith('.py'):
        cmd.insert(0, sys.executable)

    if prefix is not None:
        cmd = prefix + cmd

    process = subprocess.Popen(cmd)
    while process.returncode is None:
        try:
            process.communicate()
        except KeyboardInterrupt:
            # the subprocess will also receive the signal and should shut down
            # therefore we continue here until the process has finished
            pass
    return process.returncode


class ExecutableNameCompleter:
    """Callable returning a list of executable names within a package."""

    def __init__(self, *, package_name_key=None):
        self.package_name_key = package_name_key

    def __call__(self, prefix, parsed_args, **kwargs):
        package_name = getattr(parsed_args, self.package_name_key)
        try:
            paths = get_executable_paths(package_name=package_name)
        except PackageNotFound:
            return []
        return [os.path.basename(p) for p in paths]
```

get_executable_pathは見つけたけど、その中でさらにget_executable_pathsを呼んでいる。
get_executable_pathsはros2pkgの方にあるらしい。

```console:~/ros2_ws/install/ros2pkg/lib/python3.5/site-packages/ros2pkg/api/__init__.py
# Copyright 2017 Open Source Robotics Foundation, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import os

from ament_index_python import get_package_prefix
from ament_index_python import get_packages_with_prefixes
from ament_index_python import PackageNotFoundError


class PackageNotFound(Exception):

    def __init__(self, package_name):
        self.package_name = package_name


def get_package_names():
    return get_packages_with_prefixes().keys()


def get_prefix_path(package_name):
    try:
        prefix_path = get_package_prefix(package_name)
    except PackageNotFoundError:
        return None
    return prefix_path


def get_executable_paths(*, package_name):
    prefix_path = get_prefix_path(package_name)
    if prefix_path is None:
        raise PackageNotFound(package_name)
    base_path = os.path.join(prefix_path, 'lib', package_name)
    executable_paths = []
    for dirpath, dirnames, filenames in os.walk(base_path):
        # ignore folder starting with .
        dirnames[:] = [d for d in dirnames if d[0] not in ['.']]
        dirnames.sort()
        # select executable files
        for filename in sorted(filenames):
            path = os.path.join(dirpath, filename)
            if os.access(path, os.X_OK):
                executable_paths.append(path)
    return executable_paths


def package_name_completer(**kwargs):
    """Callable returning a list of packages names."""
    return get_package_names()
```

見てみると、44行目にそれらしきものが（os.path.joinのところ）。
というかべた書きでlibって書いてあるしw

ちなみにos.path.joinの引数になっている変数の中身はこんな感じ。
・prefix_path:install/pkgnameまでのパス
・'lib':libという名前のディレクトリ（べた）
・package_name:パッケージ名。これはlib下のパッケージ名を指す

なるほどなるほど。
今の実装だとlib/pkgnameはいらないから、こうすればいいかな。
編集には管理者権限がいるのでsudoしましょう。

```console:~/ros2_ws/install/ros2pkg/lib/python3.5/site-packages/ros2pkg/api/__init__.py
# 44行目
base_path = prefix_path
# 一応コメントアウト
#base_path = os.path.join(prefix_path, 'lib', package_name)
```

変更を保存したら、とりあえず再起動（怖いので）


# 実行してみる

再起動後、作業ディレクトリに移動し、local_setup.bashを読み込む。
そしてros2 runコマンドを実行・・・の前にちゃんとros2が動いてるか確認してみる。

```console:console
$ ros2 daemon status
The daemon is not running
```

動いてはいるな…。
よし、実行だ!

```console:console
$ ros2 run xxxx xxxx
xxxx start
```

うごいたー！やったぜ。


# 余談

怖くなったのでその他のros2関連コマンドを確認しましたが、どれも問題なく動いている感じはします。
ただ、この方法はルールを無視しようという考えのものやってみただけなので、実際にやる場合は自己責任でお願いします（遠い目）


というわけで今回はここまでです。
ありがとうございました。