diff --git a/rosidl_cli/rosidl_cli/cli.py b/rosidl_cli/rosidl_cli/cli.py index 4fda533a1..446f531b9 100644 --- a/rosidl_cli/rosidl_cli/cli.py +++ b/rosidl_cli/rosidl_cli/cli.py @@ -16,6 +16,7 @@ import signal from rosidl_cli.command.generate import GenerateCommand +from rosidl_cli.command.translate import TranslateCommand from rosidl_cli.common import get_first_line_doc @@ -81,7 +82,7 @@ def main(*, script_name='rosidl', argv=None, description=None, commands=None): ) if commands is None: - commands = [GenerateCommand()] + commands = [GenerateCommand(), TranslateCommand()] # add arguments for command extension(s) add_subparsers( diff --git a/rosidl_cli/rosidl_cli/command/generate/helpers.py b/rosidl_cli/rosidl_cli/command/helpers.py similarity index 79% rename from rosidl_cli/rosidl_cli/command/generate/helpers.py rename to rosidl_cli/rosidl_cli/command/helpers.py index af5841dfb..51b63f469 100644 --- a/rosidl_cli/rosidl_cli/command/generate/helpers.py +++ b/rosidl_cli/rosidl_cli/command/helpers.py @@ -43,33 +43,45 @@ def dependencies_from_include_paths(include_paths): }) -def idl_tuples_from_interface_files(interface_files): +def interface_path_as_tuple(path): """ - Express ROS interface definition file paths as IDL tuples. + Express interface definition file path as an + (absolute prefix, relative path) tuple. - 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. + An interface definition file path is a relative path, optionally prefixed + by a path against which to resolve the former followed by a colon ':'. + Thus, this function applies following logic: + - If a given path follows this pattern, it is split at the colon ':' - 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 + - If a given path has no prefix, the current working directory is used as prefix. """ + path_as_string = str(path) + if ':' not in path_as_string: + prefix = pathlib.Path.cwd() + else: + prefix, _, path = path_as_string.rpartition(':') + prefix = pathlib.Path(prefix).resolve() + path = pathlib.Path(path) + if path.is_absolute(): + raise ValueError('Interface definition file path ' + f"'{path}' cannot be absolute") + return prefix, path + + +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 an absolute path against + which to resolve it followed by a colon ':'. This function then applies + the same logic as `interface_path_as_tuple`. + """ 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 = pathlib.Path(prefix).resolve() - 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()}') + prefix, path = interface_path_as_tuple(path) + idl_tuples.append(f'{prefix}:{path.as_posix()}') return idl_tuples diff --git a/rosidl_cli/rosidl_cli/command/translate/__init__.py b/rosidl_cli/rosidl_cli/command/translate/__init__.py new file mode 100644 index 000000000..b78828a59 --- /dev/null +++ b/rosidl_cli/rosidl_cli/command/translate/__init__.py @@ -0,0 +1,102 @@ +# 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 collections +import os +import pathlib + +from rosidl_cli.command import Command + +from .extensions import load_translate_extensions + + +class TranslateCommand(Command): + """Translate interface definition files from one format to another.""" + + name = 'translate' + + def add_arguments(self, parser, cli_name): + parser.add_argument( + '-o', '--output-path', metavar='PATH', + type=pathlib.Path, default=pathlib.Path.cwd(), + help=('Path to directory to hold translated interface definition' + "files. Defaults to '.'.")) + parser.add_argument( + '--use', '--translator', metavar='TRANSLATOR_SPEC', + dest='translator_specs', action='append', default=[], + help=('Target translators, backed by tool extensions. ' + 'Specified by name plus an optional PEP440 version ' + 'specifier. If none is given, suitable ones among ' + 'all available translators will be chosen.') + ) + parser.add_argument( + '--to', '--output-format', metavar='FORMAT', + dest='output_format', required=True, + help=('Output format for translate interface definition files. ' + 'Specified by name plus an optional PEP440 version.') + ) + parser.add_argument( + '--from', '--input-format', metavar='FORMAT', + dest='input_format', default=None, + help=('Input format for all source interface definition files. ' + 'Specified by name plus an optional PEP440 version. ' + 'If not given, file extensions will be used to deduce ' + 'the format of each interface definition file.') + ) + parser.add_argument( + '-I', '--include-path', metavar='PATH', type=pathlib.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 all interface files belong to') + parser.add_argument( + 'interface_files', metavar='interface_file', nargs='+', + help=('Normalized relative path to an interface definition file. ' + "If prefixed by another path followed by a colon ':', " + 'path resolution is performed against such path.') + ) + + def main(self, *, parser, args): + extensions = load_translate_extensions( + specs=args.translator_specs, + strict=any(args.translator_specs) + ) + if not extensions: + return 'No translate extensions found' + + if not args.input_format: + interface_files_per_format = collections.defaultdict(list) + for interface_file in args.interface_files: + input_format = os.path.splitext(interface_file)[-1][1:] + interface_files_per_format[input_format].append(interface_file) + else: + interface_files_per_format = { + args.input_format: args.interface_files} + + for input_format, interface_files in interface_files_per_format.items(): + extension = next(( + extension for extension in extensions + if extension.input_format == input_format and \ + extension.output_format == args.output_format + ), None) + + if not extension: + return (f"Translation from '{input_format}' to " + f"'{args.output_format}' is not supported") + + extension.translate( + args.package_name, interface_files, + args.include_paths, args.output_path) diff --git a/rosidl_cli/rosidl_cli/command/translate/extensions.py b/rosidl_cli/rosidl_cli/command/translate/extensions.py new file mode 100644 index 000000000..f8f311237 --- /dev/null +++ b/rosidl_cli/rosidl_cli/command/translate/extensions.py @@ -0,0 +1,63 @@ +# 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 TranslateCommandExtension(Extension): + """ + The extension point for interface definition translation. + + The following attributes must be defined + * `input_format` + * `output_format` + + The following methods must be defined: + * `translate` + """ + + def translate( + self, + package_name, + interface_file, + include_paths, + output_path + ): + """ + Translate interface definition file. + + The path to an interface definition file is a relative path optionally + prefixed by an absolute path followed by a colon ':', in which case + path resolution is to be performed against that absolute path. + + On output, the directory structure specified by this relative path + will be replicated e.g. an ``msg/Empty.foo`` file will result in a + ``msg/Empty.bar`` file under `output_path`. + + :param package_name: name of the package `interface_file` belongs to + :param interface_file: path to the interface definition file + :param include_paths: list of paths to include dependency interface + definition files from + :param output_path: path to directory to hold the translated interface + definition file + """ + raise NotImplementedError() + + +def load_translate_extensions(**kwargs): + """Load extensions for interface definition translation.""" + return load_extensions( + 'rosidl_cli.command.translate.extensions', **kwargs + )