diff --git a/rosidl_cli/rosidl_cli/cli.py b/rosidl_cli/rosidl_cli/cli.py index 18eff8042..9c3f8ac28 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 @@ -73,7 +74,7 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter ) - 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 80% rename from rosidl_cli/rosidl_cli/command/generate/helpers.py rename to rosidl_cli/rosidl_cli/command/helpers.py index 643d86813..1dbf1c8eb 100644 --- a/rosidl_cli/rosidl_cli/command/generate/helpers.py +++ b/rosidl_cli/rosidl_cli/command/helpers.py @@ -45,33 +45,44 @@ 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(os.path.abspath(prefix)) + 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 = 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()}') + 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..20607d25b --- /dev/null +++ b/rosidl_cli/rosidl_cli/command/translate/__init__.py @@ -0,0 +1,98 @@ +# 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): + 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=('Translators to be used. If none is given, ' + 'suitable available ones will be used.') + ) + parser.add_argument( + '--to', '--output-format', required=True, + metavar='FORMAT', dest='output_format', + help='Output format for translate interface definition files.' + ) + parser.add_argument( + '--from', '--input-format', default=None, + metavar='FORMAT', dest='input_format', + help=('Input format for all source interface definition files. ' + '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, *, 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..1cb97b5d3 --- /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_files, + include_paths, + output_path + ): + """ + Translate interface definition files. + + 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_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 translated interface + definition files + """ + raise NotImplementedError() + + +def load_translate_extensions(**kwargs): + """Load extensions for interface definition translation.""" + return load_extensions( + 'rosidl_cli.command.translate.extensions', **kwargs + ) diff --git a/rosidl_cli/test/rosidl_cli/test_generate_helpers.py b/rosidl_cli/test/rosidl_cli/test_helpers.py similarity index 72% rename from rosidl_cli/test/rosidl_cli/test_generate_helpers.py rename to rosidl_cli/test/rosidl_cli/test_helpers.py index 52c390cc2..5a16f684f 100644 --- a/rosidl_cli/test/rosidl_cli/test_generate_helpers.py +++ b/rosidl_cli/test/rosidl_cli/test_helpers.py @@ -18,7 +18,22 @@ import pytest -from rosidl_cli.command.generate.helpers import legacy_generator_arguments_file +from rosidl_cli.command.helpers import interface_path_as_tuple +from rosidl_cli.command.helpers import legacy_generator_arguments_file + + +def test_interface_path_as_tuple(): + prefix, path = interface_path_as_tuple('/tmp:msg/Empty.idl') + assert pathlib.Path('msg/Empty.idl') == path + assert pathlib.Path(os.path.abspath('/tmp')) == prefix + + prefix, path = interface_path_as_tuple('tmp:msg/Empty.idl') + assert pathlib.Path('msg/Empty.idl') == path + assert pathlib.Path.cwd() / 'tmp' == prefix + + prefix, path = interface_path_as_tuple('msg/Empty.idl') + assert pathlib.Path('msg/Empty.idl') == path + assert pathlib.Path.cwd() == prefix @pytest.fixture