diff --git a/rosidl_cli/completion/rosidl-argcomplete.bash b/rosidl_cli/completion/rosidl-argcomplete.bash
new file mode 100644
index 000000000..0262ec2a0
--- /dev/null
+++ b/rosidl_cli/completion/rosidl-argcomplete.bash
@@ -0,0 +1,19 @@
+# Copyright 2021 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.
+
+if type register-python-argcomplete3 > /dev/null 2>&1; then
+ eval "$(register-python-argcomplete3 rosidl)"
+elif type register-python-argcomplete > /dev/null 2>&1; then
+ eval "$(register-python-argcomplete rosidl)"
+fi
diff --git a/rosidl_cli/completion/rosidl-argcomplete.zsh b/rosidl_cli/completion/rosidl-argcomplete.zsh
new file mode 100644
index 000000000..9a86dbd23
--- /dev/null
+++ b/rosidl_cli/completion/rosidl-argcomplete.zsh
@@ -0,0 +1,23 @@
+# Copyright 2021 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.
+
+autoload -U +X compinit && compinit
+autoload -U +X bashcompinit && bashcompinit
+
+# Get this scripts directory
+__rosidl_cli_completion_dir=${0:a:h}
+# Just source the bash version, it works in zsh too
+source "$__rosidl_cli_completion_dir/rosidl-argcomplete.bash"
+# Cleanup
+unset __rosidl_cli_completion_dir
diff --git a/rosidl_cli/package.xml b/rosidl_cli/package.xml
new file mode 100644
index 000000000..d9a85f7e1
--- /dev/null
+++ b/rosidl_cli/package.xml
@@ -0,0 +1,27 @@
+
+
+
+ rosidl_cli
+ 0.1.0
+
+ Command line tools for ROS interface generation.
+
+ Chris Lalancette
+ Shane Loretz
+ Apache License 2.0
+
+ Michel Hidalgo
+
+ python3-argcomplete
+ python3-importlib-metadata
+
+ ament_copyright
+ ament_flake8
+ ament_pep257
+ ament_xmllint
+ python3-pytest
+
+
+ ament_python
+
+
diff --git a/rosidl_cli/pytest.ini b/rosidl_cli/pytest.ini
new file mode 100644
index 000000000..fe55d2ed6
--- /dev/null
+++ b/rosidl_cli/pytest.ini
@@ -0,0 +1,2 @@
+[pytest]
+junit_family=xunit2
diff --git a/rosidl_cli/resource/package.dsv b/rosidl_cli/resource/package.dsv
new file mode 100644
index 000000000..9293e2a38
--- /dev/null
+++ b/rosidl_cli/resource/package.dsv
@@ -0,0 +1,2 @@
+source;share/rosidl_cli/environment/rosidl-argcomplete.bash
+source;share/rosidl_cli/environment/rosidl-argcomplete.zsh
diff --git a/rosidl_cli/resource/rosidl_cli b/rosidl_cli/resource/rosidl_cli
new file mode 100644
index 000000000..e69de29bb
diff --git a/rosidl_cli/rosidl_cli/__init__.py b/rosidl_cli/rosidl_cli/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/rosidl_cli/rosidl_cli/cli.py b/rosidl_cli/rosidl_cli/cli.py
new file mode 100644
index 000000000..18eff8042
--- /dev/null
+++ b/rosidl_cli/rosidl_cli/cli.py
@@ -0,0 +1,103 @@
+# Copyright 2021 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 argparse
+import signal
+
+from rosidl_cli.command.generate import GenerateCommand
+from rosidl_cli.common import get_first_line_doc
+
+
+def add_subparsers(parser, cli_name, commands):
+ """
+ Create argparse subparser for each command.
+
+ The ``cli_name`` is used for the title and description of the
+ ``add_subparsers`` function call.
+
+ For each command a subparser is created.
+
+ :param parser: the parent argument parser
+ :type parser: :py:class:`argparse.ArgumentParser`
+ :param str cli_name: name of the command line command to which the
+ subparsers are being added
+ :param commands: each of the commands contributing specific arguments
+ :type commands: :py:class:`List[Command]`
+ """
+ # add subparser with description of available subparsers
+ description = ''
+
+ commands = sorted(commands, key=lambda command: command.name)
+ max_length = max(len(command.name) for command in commands)
+ for command in commands:
+ description += '%s %s\n' % (
+ command.name.ljust(max_length),
+ get_first_line_doc(command))
+ subparser = parser.add_subparsers(
+ title='Commands', description=description,
+ metavar=f'Call `{cli_name} -h` for more detailed usage.')
+ subparser.dest = '_command'
+ subparser.required = True
+
+ # add extension specific sub-sub-parser with its arguments
+ for command in commands:
+ command_parser = subparser.add_parser(
+ command.name,
+ description=get_first_line_doc(command),
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ command_parser.set_defaults(_command=command)
+ command.add_arguments(command_parser)
+
+ return subparser
+
+
+def main():
+ script_name = 'rosidl'
+ description = f'{script_name} is an extensible command-line tool ' \
+ 'for ROS interface generation.'
+
+ # top level parser
+ parser = argparse.ArgumentParser(
+ description=description,
+ formatter_class=argparse.RawDescriptionHelpFormatter
+ )
+
+ commands = [GenerateCommand()]
+
+ # add arguments for command extension(s)
+ add_subparsers(
+ parser,
+ script_name,
+ commands
+ )
+
+ # register argcomplete hook if available
+ try:
+ from argcomplete import autocomplete
+ except ImportError:
+ pass
+ else:
+ autocomplete(parser, exclude=['-h', '--help'])
+
+ # parse the command line arguments
+ args = parser.parse_args()
+
+ # call the main method of the command
+ try:
+ rc = args._command.main(args=args)
+ except KeyboardInterrupt:
+ rc = signal.SIGINT
+ except (ValueError, RuntimeError) as e:
+ rc = str(e)
+ return rc
diff --git a/rosidl_cli/rosidl_cli/command/__init__.py b/rosidl_cli/rosidl_cli/command/__init__.py
new file mode 100644
index 000000000..22d035bb5
--- /dev/null
+++ b/rosidl_cli/rosidl_cli/command/__init__.py
@@ -0,0 +1,29 @@
+# Copyright 2021 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.
+
+
+class Command:
+ """
+ The extension point for 'command' extensions.
+
+ The following methods must be defined:
+ * `main`
+ * `add_arguments`
+ """
+
+ def add_arguments(self, parser):
+ pass
+
+ def main(self, *, parser, args):
+ raise NotImplementedError()
diff --git a/rosidl_cli/rosidl_cli/command/generate/__init__.py b/rosidl_cli/rosidl_cli/command/generate/__init__.py
new file mode 100644
index 000000000..b65b95f57
--- /dev/null
+++ b/rosidl_cli/rosidl_cli/command/generate/__init__.py
@@ -0,0 +1,82 @@
+# Copyright 2021 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 pathlib
+
+from rosidl_cli.command import Command
+
+from .extensions import load_type_extensions
+from .extensions import load_typesupport_extensions
+
+
+class GenerateCommand(Command):
+ """Generate source code from interface definition files."""
+
+ name = 'generate'
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ '-o', '--output-path', type=pathlib.Path,
+ metavar='PATH', default=pathlib.Path.cwd(),
+ help=('Path to directory to hold generated source code files. '
+ "Defaults to '.'."))
+ parser.add_argument(
+ '-t', '--type', metavar='TYPE_SPEC',
+ dest='type_specs', action='append', default=[],
+ help='Target type representations for generation.')
+ parser.add_argument(
+ '-ts', '--type-support', metavar='TYPESUPPORT_SPEC',
+ dest='typesupport_specs', action='append', default=[],
+ help='Target type supports for generation.')
+ parser.add_argument(
+ '-I', '--include-path', type=pathlib.Path, metavar='PATH',
+ dest='include_paths', action='append', default=[],
+ help='Paths to include dependency interface definition files from.')
+ parser.add_argument(
+ 'package_name', help='Name of the package to generate code for')
+ parser.add_argument(
+ 'interface_files', metavar='interface_file', nargs='+',
+ help=('Normalized relative path to interface definition file. '
+ "If prefixed by another path followed by a colon ':', "
+ 'path resolution is performed against such path.'))
+
+ def main(self, *, args):
+ extensions = []
+
+ unspecific_generation = \
+ not args.type_specs and not args.typesupport_specs
+
+ if args.type_specs or unspecific_generation:
+ extensions.extend(load_type_extensions(
+ specs=args.type_specs,
+ strict=not unspecific_generation))
+
+ if args.typesupport_specs or unspecific_generation:
+ extensions.extend(load_typesupport_extensions(
+ specs=args.typesupport_specs,
+ strict=not unspecific_generation))
+
+ if unspecific_generation and not extensions:
+ return 'No type nor typesupport extensions were found'
+
+ if len(extensions) > 1:
+ for extension in extensions:
+ extension.generate(
+ args.package_name, args.interface_files, args.include_paths,
+ output_path=args.output_path / extension.name)
+ else:
+ extensions[0].generate(
+ args.package_name, args.interface_files,
+ args.include_paths, args.output_path
+ )
diff --git a/rosidl_cli/rosidl_cli/command/generate/extensions.py b/rosidl_cli/rosidl_cli/command/generate/extensions.py
new file mode 100644
index 000000000..4c976d7e7
--- /dev/null
+++ b/rosidl_cli/rosidl_cli/command/generate/extensions.py
@@ -0,0 +1,57 @@
+# Copyright 2021 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 rosidl_cli.extensions import Extension
+from rosidl_cli.extensions import load_extensions
+
+
+class GenerateCommandExtension(Extension):
+ """
+ The extension point for source code generation.
+
+ The following methods must be defined:
+ * `generate`
+ """
+
+ def generate(
+ self,
+ package_name,
+ interface_files,
+ include_paths,
+ output_path
+ ):
+ """
+ Generate source code.
+
+ Paths to interface definition files are relative paths optionally
+ prefixed by an absolute path followed by a colon ':', in which case
+ path resolution is to be performed against that absolute path.
+
+ :param package_name: name of the package to generate source code for
+ :param interface_files: list of paths to interface definition files
+ :param include_paths: list of paths to include dependency interface
+ definition files from.
+ :param output_path: path to directory to hold generated source code files
+ """
+ raise NotImplementedError()
+
+
+def load_type_extensions(**kwargs):
+ """Load extensions for type representation source code generation."""
+ return load_extensions('rosidl_cli.command.generate.type_extensions', **kwargs)
+
+
+def load_typesupport_extensions(**kwargs):
+ """Load extensions for type support source code generation."""
+ return load_extensions('rosidl_cli.command.generate.typesupport_extensions', **kwargs)
diff --git a/rosidl_cli/rosidl_cli/command/generate/helpers.py b/rosidl_cli/rosidl_cli/command/generate/helpers.py
new file mode 100644
index 000000000..643d86813
--- /dev/null
+++ b/rosidl_cli/rosidl_cli/command/generate/helpers.py
@@ -0,0 +1,152 @@
+# Copyright 2021 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 contextlib
+import json
+import os
+import pathlib
+import tempfile
+
+
+def package_name_from_interface_file_path(path):
+ """
+ Derive ROS package name from a ROS interface definition file path.
+
+ This function assumes ROS interface definition files follow the typical
+ ``rosidl`` install space layout i.e. 'package_name/subfolder/interface.idl'.
+ """
+ return pathlib.Path(os.path.abspath(path)).parents[1].name
+
+
+def dependencies_from_include_paths(include_paths):
+ """
+ Collect dependencies' ROS interface definition files from include paths.
+
+ Interface definition file paths from dependencies are absolute paths
+ prefixed by the name of package they belong to followed by a colon ':'.
+ """
+ return list({
+ f'{package_name_from_interface_file_path(path)}:{path}'
+ for include_path in include_paths
+ for path in pathlib.Path(
+ os.path.abspath(include_path)
+ ).glob('**/*.idl')
+ })
+
+
+def idl_tuples_from_interface_files(interface_files):
+ """
+ Express ROS interface definition file paths as IDL tuples.
+
+ An IDL tuple is a relative path prefixed by by an absolute path against
+ which to resolve it followed by a colon ':'. This function then applies
+ the following logic:
+ - If a given path follows this pattern, it is passed through.
+ - If a given path is prefixed by a relative path, it is resolved
+ relative to the current working directory.
+ - If a given path has no prefixes, the current working directory is
+ used as prefix.
+ """
+ idl_tuples = []
+ for path in interface_files:
+ path_as_string = str(path)
+ if ':' not in path_as_string:
+ prefix = pathlib.Path.cwd()
+ stem = path
+ else:
+ prefix, _, stem = path_as_string.rpartition(':')
+ prefix = os.path.abspath(prefix)
+ stem = pathlib.Path(stem)
+ if stem.is_absolute():
+ raise ValueError('Interface definition file path '
+ f'{stem} cannot be absolute')
+ idl_tuples.append(f'{prefix}:{stem.as_posix()}')
+ return idl_tuples
+
+
+@contextlib.contextmanager
+def legacy_generator_arguments_file(
+ *,
+ package_name,
+ interface_files,
+ include_paths,
+ templates_path,
+ output_path
+):
+ """
+ Generate a temporary rosidl generator arguments file.
+
+ :param package_name: Name of the ROS package for which to generate code
+ :param interface_files: Relative paths to ROS interface definition files,
+ optionally prefixed by another absolute or relative path followed by
+ a colon ':'. The former relative paths will be used as a prototype to
+ namespace generated code (if applicable).
+ :param include_paths: Paths where ROS package dependencies' interface
+ definition files may be found
+ :param templates_path: Path to the templates directory for the
+ generator script this arguments are for
+ :param output_path: Path to the output directory for generated code
+ """
+ idl_tuples = idl_tuples_from_interface_files(interface_files)
+ interface_dependencies = dependencies_from_include_paths(include_paths)
+ output_path = os.path.abspath(output_path)
+ templates_path = os.path.abspath(templates_path)
+ # NOTE(hidmic): named temporary files cannot be opened twice on Windows,
+ # so close it and manually remove it when leaving the context
+ with tempfile.NamedTemporaryFile(mode='w', delete=False) as tmp:
+ tmp.write(json.dumps({
+ 'package_name': package_name,
+ 'output_dir': output_path,
+ 'template_dir': templates_path,
+ 'idl_tuples': idl_tuples,
+ 'ros_interface_dependencies': interface_dependencies,
+ # TODO(hidmic): re-enable output file caching
+ 'target_dependencies': []
+ }))
+ path_to_file = os.path.abspath(tmp.name)
+ try:
+ yield path_to_file
+ finally:
+ try:
+ os.unlink(path_to_file)
+ except FileNotFoundError:
+ pass
+
+
+def generate_visibility_control_file(
+ *,
+ package_name,
+ template_path,
+ output_path
+):
+ """
+ Generate a visibility control file from a template.
+
+ :param package_name: Name of the ROS package for which
+ to generate the file.
+ :param template_path: Path to template visibility control file.
+ May contain @PROJECT_NAME@ and @PROJECT_NAME_UPPER@ placeholders,
+ to be substituted by the package name, accordingly.
+ :param output_path: Path to visibility control file after interpolation.
+ """
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
+
+ with open(template_path, 'r') as fd:
+ content = fd.read()
+
+ content = content.replace('@PROJECT_NAME@', package_name)
+ content = content.replace('@PROJECT_NAME_UPPER@', package_name.upper())
+
+ with open(output_path, 'w') as fd:
+ fd.write(content)
diff --git a/rosidl_cli/rosidl_cli/common.py b/rosidl_cli/rosidl_cli/common.py
new file mode 100644
index 000000000..1f94c2c63
--- /dev/null
+++ b/rosidl_cli/rosidl_cli/common.py
@@ -0,0 +1,22 @@
+# Copyright 2021 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.
+
+
+def get_first_line_doc(any_type):
+ if any_type.__doc__:
+ for line in any_type.__doc__.splitlines():
+ line = line.strip()
+ if line:
+ return line.rstrip('.')
+ return ''
diff --git a/rosidl_cli/rosidl_cli/entry_points.py b/rosidl_cli/rosidl_cli/entry_points.py
new file mode 100644
index 000000000..cc5ee6107
--- /dev/null
+++ b/rosidl_cli/rosidl_cli/entry_points.py
@@ -0,0 +1,85 @@
+# Copyright 2021 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 logging
+
+try:
+ import importlib.metadata as importlib_metadata
+except ModuleNotFoundError:
+ import importlib_metadata
+
+
+logger = logging.getLogger(__name__)
+
+
+def get_entry_points(group_name, *, specs=None, strict=False):
+ """
+ Get entry points from a specific group.
+
+ :param str group_name: the name of the entry point group
+ :param list specs: an optional collection of entry point names to retrieve
+ :param bool strict: whether to raise or warn on error
+ :returns: mapping from entry point names to ``EntryPoint`` instances
+ :rtype: dict
+ """
+ if specs is not None:
+ specs = set(specs)
+ entry_points = {}
+ for entry_point in importlib_metadata.entry_points().get(group_name, []):
+ name = entry_point.name
+ if specs and name not in specs:
+ continue
+ if name in entry_points:
+ msg = (f"Found duplicate entry point '{name}': "
+ 'got {entry_point} and {entry_points[name]}')
+ if strict:
+ raise RuntimeError(msg)
+ logger.warning(msg)
+ continue
+ entry_points[name] = entry_point
+ if specs:
+ pending = specs - set(entry_points)
+ if pending:
+ msg = 'Some specs could not be met: '
+ msg += ', '.join(map(str, pending))
+ if strict:
+ raise RuntimeError(msg)
+ logger.warning(msg)
+ return entry_points
+
+
+def load_entry_points(group_name, *, strict=False, **kwargs):
+ """
+ Load entry points for a specific group.
+
+ See :py:function:`get_entry_points` for further reference on
+ additional keyword arguments.
+
+ :param str group_name: the name of the entry point group
+ :param bool strict: whether to raise or warn on error
+ :returns: mapping from entry point name to loaded entry point
+ :rtype: dict
+ """
+ loaded_entry_points = {}
+ for name, entry_point in get_entry_points(
+ group_name, strict=strict, **kwargs
+ ).items():
+ try:
+ loaded_entry_points[name] = entry_point.load()
+ except Exception as e: # noqa: F841
+ msg = f"Failed to load entry point '{name}': {e}"
+ if strict:
+ raise RuntimeError(msg)
+ logger.warning(msg)
+ return loaded_entry_points
diff --git a/rosidl_cli/rosidl_cli/extensions.py b/rosidl_cli/rosidl_cli/extensions.py
new file mode 100644
index 000000000..14488df66
--- /dev/null
+++ b/rosidl_cli/rosidl_cli/extensions.py
@@ -0,0 +1,57 @@
+# Copyright 2021 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 logging
+
+from rosidl_cli.entry_points import load_entry_points
+
+
+logger = logging.getLogger(__name__)
+
+
+class Extension:
+ """A generic extension point."""
+
+ def __init__(self, name):
+ self.__name = name
+
+ @property
+ def name(self):
+ return self.__name
+
+
+def load_extensions(group_name, *, strict=False, **kwargs):
+ """
+ Load extensions for a specific group.
+
+ See :py:function:`load_entry_points` for further reference on
+ additional keyword arguments.
+
+ :param str group_name: the name of the extension group
+ :param bool strict: whether to raise or warn on error
+ :returns: a list of :py:class:`Extension` instances
+ :rtype: list
+ """
+ extensions = []
+ for name, factory in load_entry_points(
+ group_name, strict=strict, **kwargs
+ ).items():
+ try:
+ extensions.append(factory(name))
+ except Exception as e:
+ msg = f"Failed to instantiate extension '{name}': {e}"
+ if strict:
+ raise RuntimeError(msg)
+ logger.warning(msg)
+ return extensions
diff --git a/rosidl_cli/setup.py b/rosidl_cli/setup.py
new file mode 100644
index 000000000..81b501f9e
--- /dev/null
+++ b/rosidl_cli/setup.py
@@ -0,0 +1,48 @@
+from setuptools import find_packages
+from setuptools import setup
+
+setup(
+ name='rosidl_cli',
+ version='0.1.0',
+ packages=find_packages(exclude=['test']),
+ extras_require={
+ 'completion': ['argcomplete'],
+ },
+ data_files=[
+ ('share/ament_index/resource_index/packages', [
+ 'resource/rosidl_cli',
+ ]),
+ ('share/rosidl_cli', [
+ 'package.xml',
+ 'resource/package.dsv',
+ ]),
+ ('share/rosidl_cli/environment', [
+ 'completion/rosidl-argcomplete.bash',
+ 'completion/rosidl-argcomplete.zsh'
+ ]),
+ ],
+ zip_safe=False,
+ author='Michel Hidalgo',
+ author_email='michel@ekumenlabs.com',
+ maintainer='Chris Lalancette, Shane Loretz',
+ maintainer_email='clalancette@openrobotics.org, sloretz@openrobotics.org',
+ url='https://github.com/ros2/rosidl/tree/master/rosidl_cli',
+ download_url='https://github.com/ros2/rosidl/releases',
+ keywords=[],
+ classifiers=[
+ 'Environment :: Console',
+ 'Intended Audience :: Developers',
+ 'License :: OSI Approved :: Apache Software License',
+ 'Programming Language :: Python',
+ ],
+ description='Command line tools for ROS interface generation.',
+ long_description="""\
+The tooling provides a single command line script for ROS interface source code generation.""",
+ license='Apache License, Version 2.0',
+ tests_require=['pytest'],
+ entry_points={
+ 'console_scripts': [
+ 'rosidl = rosidl_cli.cli:main',
+ ],
+ }
+)
diff --git a/rosidl_cli/test/rosidl_cli/test_common.py b/rosidl_cli/test/rosidl_cli/test_common.py
new file mode 100644
index 000000000..166f8bf0f
--- /dev/null
+++ b/rosidl_cli/test/rosidl_cli/test_common.py
@@ -0,0 +1,39 @@
+# Copyright 2021 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 rosidl_cli.common import get_first_line_doc
+
+
+def test_getting_first_line_from_no_docstring():
+ func = test_getting_first_line_from_no_docstring
+ line = get_first_line_doc(func)
+ assert line == ''
+
+
+def test_getting_first_line_from_docstring():
+ """Check it gets the first line."""
+ func = test_getting_first_line_from_docstring
+ line = get_first_line_doc(func)
+ assert line == 'Check it gets the first line'
+
+
+def test_getting_first_line_from_multiline_docstring():
+ """
+ Check it really gets the first non-empty line.
+
+ Additional paragraph to please flake8.
+ """
+ func = test_getting_first_line_from_multiline_docstring
+ line = get_first_line_doc(func)
+ assert line == 'Check it really gets the first non-empty line'
diff --git a/rosidl_cli/test/rosidl_cli/test_files/bar/msg/Bar.idl b/rosidl_cli/test/rosidl_cli/test_files/bar/msg/Bar.idl
new file mode 100644
index 000000000..e69de29bb
diff --git a/rosidl_cli/test/rosidl_cli/test_generate_helpers.py b/rosidl_cli/test/rosidl_cli/test_generate_helpers.py
new file mode 100644
index 000000000..52c390cc2
--- /dev/null
+++ b/rosidl_cli/test/rosidl_cli/test_generate_helpers.py
@@ -0,0 +1,55 @@
+# Copyright 2021 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 json
+import os
+import pathlib
+
+import pytest
+
+from rosidl_cli.command.generate.helpers import legacy_generator_arguments_file
+
+
+@pytest.fixture
+def current_path(request):
+ path = pathlib.Path(request.module.__file__)
+ path = path.resolve()
+ path = path.parent
+ cwd = pathlib.Path.cwd()
+ try:
+ os.chdir(str(path))
+ yield path
+ finally:
+ os.chdir(str(cwd))
+
+
+def test_legacy_generator_arguments_file(current_path):
+ with legacy_generator_arguments_file(
+ package_name='foo',
+ interface_files=['msg/Foo.idl'],
+ include_paths=['test_files/bar'],
+ templates_path='templates',
+ output_path='tmp',
+ ) as path:
+ with open(path, 'r') as fd:
+ args = json.load(fd)
+ assert args['package_name'] == 'foo'
+ assert args['output_dir'] == str(current_path / 'tmp')
+ assert args['template_dir'] == str(current_path / 'templates')
+ assert args['idl_tuples'] == [f'{current_path}:msg/Foo.idl']
+ path_to_dep = pathlib.Path('test_files/bar/msg/Bar.idl')
+ assert args['ros_interface_dependencies'] == [
+ 'bar:' + str(current_path / path_to_dep)
+ ]
+ assert not pathlib.Path(path).exists()
diff --git a/rosidl_cli/test/test_copyright.py b/rosidl_cli/test/test_copyright.py
new file mode 100644
index 000000000..cf0fae31f
--- /dev/null
+++ b/rosidl_cli/test/test_copyright.py
@@ -0,0 +1,23 @@
+# 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 ament_copyright.main import main
+import pytest
+
+
+@pytest.mark.copyright
+@pytest.mark.linter
+def test_copyright():
+ rc = main(argv=['.', 'test'])
+ assert rc == 0, 'Found errors'
diff --git a/rosidl_cli/test/test_flake8.py b/rosidl_cli/test/test_flake8.py
new file mode 100644
index 000000000..27ee1078f
--- /dev/null
+++ b/rosidl_cli/test/test_flake8.py
@@ -0,0 +1,25 @@
+# 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 ament_flake8.main import main_with_errors
+import pytest
+
+
+@pytest.mark.flake8
+@pytest.mark.linter
+def test_flake8():
+ rc, errors = main_with_errors(argv=[])
+ assert rc == 0, \
+ 'Found %d code style errors / warnings:\n' % len(errors) + \
+ '\n'.join(errors)
diff --git a/rosidl_cli/test/test_pep257.py b/rosidl_cli/test/test_pep257.py
new file mode 100644
index 000000000..0e38a6c60
--- /dev/null
+++ b/rosidl_cli/test/test_pep257.py
@@ -0,0 +1,23 @@
+# 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 ament_pep257.main import main
+import pytest
+
+
+@pytest.mark.linter
+@pytest.mark.pep257
+def test_pep257():
+ rc = main(argv=[])
+ assert rc == 0, 'Found code style errors / warnings'
diff --git a/rosidl_cli/test/test_xmllint.py b/rosidl_cli/test/test_xmllint.py
new file mode 100644
index 000000000..f46285e71
--- /dev/null
+++ b/rosidl_cli/test/test_xmllint.py
@@ -0,0 +1,23 @@
+# Copyright 2019 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 ament_xmllint.main import main
+import pytest
+
+
+@pytest.mark.linter
+@pytest.mark.xmllint
+def test_xmllint():
+ rc = main(argv=[])
+ assert rc == 0, 'Found errors'