Creating a ROS 2 CLI command and verb

Following our previous post on ROS 2 CLI (Command Line Interface), we will see here how one can extend the set of existing CLI tools by introducing a new command and its related verb(s).

As support for this tutorial, we will create a ‘Hello World’ example so that the new command will be hello and the new verb will be world. The material is readily available on github.

Compared to ROS 1, the ROS 2 CLI has been entirely re-designed in Python offering a clean API, a single entry-point (the keyword ros2) and more importantly for the topic at hand, a plugin-like interface using Python entry points.

This new interface allows one to easily extend the existing set of commands and verbs using a few boiler-plate classes and the actual implementation of our new tools.

Let’s get to it!

Setting up the package

First we will create a new ROS 2 python package and the necessary sub-folders:

$ cd ~/ros2_ws/src
$ mkdir ros2hellocli && cd ros2hellocli
$ mkdir command verb

While the ros2hellocli will be the root folder for this project, the command folder will contain a command extension point that allows ROS 2 CLI to discover the new command. Similarly, the verb folder will contain the verb extension point(s) which will hold the actual implementation of our new functionality.

But first, let us not forget to turn those sub-folders into Python packages:

$ touch command/__init__.py verb/__init__.py

Now that we have our project structure ready, we will set up the boiler-plate code mentioned earlier, starting with the classical package manifest and setup.py files.

$ touch package.xml

And copy the following,

<?xml version="1.0"?>
<package format="2">
  <name>ros2hellocli</name>
  <version>0.0.0</version>
  <description>
    The ROS 2 command line tools example.
  </description>
  <maintainer email="jeremie.deray@example.org">Jeremie Deray</maintainer>
  <license>Apache License 2.0</license>

  <depend>ros2cli</depend>

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

Then,

$ touch setup.py

And copy the following,

from setuptools import find_packages
from setuptools import setup

setup(
  name='ros2hellocli',
  version='0.0.0',
  packages=find_packages(exclude=['test']),
  install_requires=['ros2cli'],
  zip_safe=True,
  author='Jeremie Deray',
  author_email='jeremie.deray@example.org',
  maintainer='Jeremie Deray',
  maintainer_email='jeremie.deray@example.org',
  url='https://github.com/artivis/ros2hellocli',
  download_url='https://github.com/artivis/ros2hellocli/releases',
  keywords=[],
  classifiers=[
      'Environment :: Console',
      'Intended Audience :: Developers',
      'License :: OSI Approved :: Apache Software License',
      'Programming Language :: Python',
  ],
  description='A minimal plugin example for ROS 2 command line tools.',
  long_description="""The package provides the hello command as a plugin example of ROS 2 command line tools.""",
  license='Apache License, Version 2.0',
)

As those two files are fairly common in the ROS world, we skip detailing them and refer the reader to ROS documentation for further explanations (package manifest on ROS wiki).

When creating a new CLI tool, remember however to edit the appropriate entries such as name/authors/maintainer etc.
We will also notice that the package depend upon ros2cli since it is meant to extend it.

Creating a ROS 2 CLI command

Now we shall create the new command hello and its command entry-point. First we will create a hello.py file in the command folder,

$ touch command/hello.py

and populate it as follows,

from ros2cli.command import add_subparsers
from ros2cli.command import CommandExtension
from ros2cli.verb import get_verb_extensions


class HelloCommand(CommandExtension):
    """The 'hello' command extension."""

    def add_arguments(self, parser, cli_name):
        self._subparser = parser
        verb_extensions = get_verb_extensions('ros2hellocli.verb')
        add_subparsers(
            parser, cli_name, '_verb', verb_extensions, required=False)

    def main(self, *, parser, args):
        if not hasattr(args, '_verb'):
            self._subparser.print_help()
            return 0

        extension = getattr(args, '_verb')

        return extension.main(args=args)

The content of hello.py is fairly similar to any other command entry-point.

With the new command being defined, we will now edit the setup.py file to advertise this new entry-point so that the CLI framework can find it. Within the setup() function in setup.py, we append the following lines:

  ...
  tests_require=['pytest'],
  entry_points={
        'ros2cli.command': [
            'hello = ros2hellocli.command.hello:HelloCommand',
        ]
    }
)

From now on, ROS 2 CLI framework should be able to find the hello verb,

$ cd ~/ros2_ws
$ colcon build --symlink-install --packages-select ros2hellocli
$ source install/local_setup.bash
$ ros2 [tab][tab]

Hitting the [tab] key will trigger the CLI auto-completion which will display in the terminal the different options available. Among those options should appear hello,

$ ros2 [tab][tab]
action            extensions        msg               run             topic
bag      ------>  hello  <------    multicast         security
component         interface         node              service
daemon            launch            param             srv
extension_points  lifecycle         pkg               test

Since the CLI framework can find hello, we should also be able to call it,

$ ros2 hello
usage: ros2 hello [-h]
                  Call `ros2 hello <command> -h` for more detailed usage. ...

The 'hello' command extension

optional arguments:
  -h, --help            show this help message and exit

Commands:

  Call `ros2 hello <command> -h` for more detailed usage.

It works!

Fairly simple so far isn’t it? Notice that the output shown in the terminal is the same as calling ros2 hello --help.

Creating a ROS 2 CLI word

Alright, so now that we have successfully created the new command hello, we will now create its associated new verb world. Notice that the following is transposable to virtually any command.
Just like commands, verbs rely on the same ‘entry-point’ mechanism, we therefore create a world.py file in the verb folder,

$ cd ~/ros2_ws/src/ros2hellocli
$ touch verb/world.py

and populate it as follows,

from ros2cli.verb import VerbExtension


class WorldVerb(VerbExtension):
    """Prints Hello World on the terminal."""

    def main(self, *, args):
      print('Hello, ROS 2 World!')

As previously, we have to advertise this new entry-point in setup() as well by appending the following,

  ...
  tests_require=['pytest'],
  entry_points={
        'ros2cli.command': [
            'hello = ros2hellocli.command.hello:HelloCommand',
        ],
        'ros2hellocli.verb': [
            'world = ros2hellocli.verb.world:WorldVerb',
        ]
    }
)

The ROS 2 CLI framework should be able to find the world command,

$ cd ~/ros2_ws
$ colcon build --symlink-install --packages-select ros2hellocli
$ source install/local_setup.bash
$ ros2 hello [tab][tab]
world

and we should be able to call it,

$ ros2 hello world
Hello, ROS 2 World!

Et voilà! We successfully created a CLI command/verb duo.

Although this example is working fine, we will improve it a little in order to cover two more aspects of the CLI framework, the first one being handling user input arguments and the second being related to good practice.

We will start by creating an api Python package:

$ cd ~/ros2_ws/src/ros2hellocli
$ mkdir api
$ touch api/__init__.py

It will contain all of the factorized code, everything that one can turn into small and useful Python functions/classes for re-use and prevent code duplication. And that is precisely what we will do with our print call. In the api/__init__.py file, we will define the following functions,

def get_hello_world():
    return 'Hello, ROS 2 World!'

def get_hello_world_leet():
    return 'He110, R0S 2 W04ld!'

From there we will modify the WorldVerb class so that is calls one of the above function by default and the other if the user passes a given flag option to the CLI. To do so we modify the verb/world.py file as follows,

from ros2cli.verb import VerbExtension
from ros2hellocli.api import get_hello_world, get_hello_world_leet


class WorldVerb(VerbExtension):
    """Prints Hello World on the terminal."""

    def add_arguments(self, parser, cli_name):
      parser.add_argument(
                  '--leet', '-l', action='store_true',
                  help="Display the message in 'l33t' form.")

    def main(self, *, args):
      message = get_hello_world() if not args.leet else get_hello_world_leet()
      print(message)

Let us test this new option,

$ cd ~/ros2_ws
$ colcon build --symlink-install --packages-select ros2hellocli
$ ros2 hello world
Hello, ROS 2 World!
$ ros2 hello world --leet
He110, R0S 2 W04ld!

Isn’t it great?

So now that we have now covered the basics of adding both a new ROS 2 CLI command and verb, how would you expand the hello command with a new universe verb?

Notice that you may find many examples in the ros2cli github repository to help you creating powerful CLI tools. If you are not yet familiar with all existing tools, you can have a look at the ROS 2 CLI cheats sheet we put together to help you get up to date.

Come join the discussion and tell us what new CLI tool you have developed!

About: Blog