From 3d0bae138be5f810baad7fe791132b1a3ab9b030 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Sat, 21 Feb 2026 22:54:20 +0000 Subject: [PATCH 01/39] Add first shot --- repositories/deps.bzl | 2 + repositories/repositories.bzl | 8 ++ requirements.txt | 1 + requirements_lock.txt | 12 ++ ros2/BUILD.bazel | 9 ++ ros2/proto_to_ros2_msg.py | 165 +++++++++++++++++++++++++++ ros2/protobuf.bzl | 143 +++++++++++++++++++++++ ros2/test/protobuf/BUILD.bazel | 34 ++++++ ros2/test/protobuf/Point.proto | 12 ++ ros2/test/protobuf/test_proto_msg.py | 18 +++ 10 files changed, 404 insertions(+) create mode 100644 ros2/proto_to_ros2_msg.py create mode 100644 ros2/protobuf.bzl create mode 100644 ros2/test/protobuf/BUILD.bazel create mode 100644 ros2/test/protobuf/Point.proto create mode 100644 ros2/test/protobuf/test_proto_msg.py diff --git a/repositories/deps.bzl b/repositories/deps.bzl index bdc684cb..ecf4ee1f 100644 --- a/repositories/deps.bzl +++ b/repositories/deps.bzl @@ -5,6 +5,7 @@ load("@bazel_features//:deps.bzl", "bazel_features_deps") load("@bazel_skylib//:workspace.bzl", "bazel_skylib_workspace") load("@googletest//:googletest_deps.bzl", "googletest_deps") load("@rules_foreign_cc//foreign_cc:repositories.bzl", "rules_foreign_cc_dependencies") +load("@rules_proto//proto:repositories.bzl", "rules_proto_dependencies") load("@rules_shell//shell:repositories.bzl", "rules_shell_dependencies", "rules_shell_toolchains") def ros2_deps(): @@ -16,3 +17,4 @@ def ros2_deps(): rules_shell_toolchains() rules_foreign_cc_dependencies() googletest_deps() + rules_proto_dependencies() diff --git a/repositories/repositories.bzl b/repositories/repositories.bzl index 60efc10e..8b4141d4 100644 --- a/repositories/repositories.bzl +++ b/repositories/repositories.bzl @@ -234,6 +234,14 @@ def ros2_workspace_repositories(): ], ) + maybe( + http_archive, + name = "rules_proto", + sha256 = "14a225870ab4e91869652cfd69ef2028277fc1dc4910d65d353b62d6e0ae21f4", + strip_prefix = "rules_proto-7.1.0", + url = "https://github.com/bazelbuild/rules_proto/releases/download/7.1.0/rules_proto-7.1.0.tar.gz", + ) + def ros2_repositories(): """Import ROS 2 repositories.""" diff --git a/requirements.txt b/requirements.txt index fad99e39..d811fa58 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ empy==3.3.* lark-parser numpy~=1.23 packaging +protobuf psutil pytest pytest-cov diff --git a/requirements_lock.txt b/requirements_lock.txt index 84095de9..43906706 100644 --- a/requirements_lock.txt +++ b/requirements_lock.txt @@ -134,6 +134,18 @@ pluggy==1.0.0 \ --hash=sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159 \ --hash=sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3 # via pytest +protobuf==6.33.5 \ + --hash=sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c \ + --hash=sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02 \ + --hash=sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c \ + --hash=sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd \ + --hash=sha256:8f04fa32763dcdb4973d537d6b54e615cc61108c7cb38fe59310c3192d29510a \ + --hash=sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190 \ + --hash=sha256:a3157e62729aafb8df6da2c03aa5c0937c7266c626ce11a278b6eb7963c4e37c \ + --hash=sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5 \ + --hash=sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0 \ + --hash=sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b + # via -r requirements.txt psutil==5.9.7 \ --hash=sha256:032f4f2c909818c86cea4fe2cc407f1c0f0cde8e6c6d702b28b8ce0c0d143340 \ --hash=sha256:0bd41bf2d1463dfa535942b2a8f0e958acf6607ac0be52265ab31f7923bcd5e6 \ diff --git a/ros2/BUILD.bazel b/ros2/BUILD.bazel index a16fe2e7..5fe39a4a 100644 --- a/ros2/BUILD.bazel +++ b/ros2/BUILD.bazel @@ -5,6 +5,7 @@ load("@bazel_skylib//rules:common_settings.bzl", "string_flag") load("@rules_cc//cc:defs.bzl", "cc_library") load("@rules_python//python:defs.bzl", "py_binary", "py_library") load("@rules_python//python:pip.bzl", "whl_filegroup") +load("@rules_ros2_pip_deps//:requirements.bzl", "requirement") exports_files([ "action.bzl", @@ -19,6 +20,7 @@ exports_files([ "launch.py.tpl", "launch.sh.tpl", "plugin.bzl", + "protobuf.bzl", "py_defs.bzl", "pytest_wrapper.py.tpl", "ros2_action.py", @@ -93,6 +95,13 @@ py_binary( ], ) +py_binary( + name = "proto_to_ros2_msg", + srcs = ["proto_to_ros2_msg.py"], + visibility = ["//visibility:public"], + deps = [requirement("protobuf")], +) + whl_filegroup( name = "numpy_includes", pattern = "numpy/core/include/numpy", diff --git a/ros2/proto_to_ros2_msg.py b/ros2/proto_to_ros2_msg.py new file mode 100644 index 00000000..70c96d69 --- /dev/null +++ b/ros2/proto_to_ros2_msg.py @@ -0,0 +1,165 @@ +# Copyright 2024 Milan Vukov +# +# 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. +"""Converts a proto file to a ROS2 .msg file. + +Limitations: +- Exactly one message definition per proto file is required. +- Service definitions are not supported. +- Only proto3 scalar field types are supported (no message-type fields, no enum + fields). +- Repeated scalar fields are supported and map to dynamic arrays (e.g. + `int32[] values`). +- proto `bytes` fields map to `uint8[]` in ROS2. +""" +import argparse +import sys + +from google.protobuf import descriptor_pb2 +from google.protobuf.descriptor_pb2 import FieldDescriptorProto + +# Mapping from proto3 scalar FieldDescriptorProto.Type to ROS2 .msg type. +# Types that map to arrays (like bytes) use a special sentinel handled below. +_PROTO_TO_ROS2_TYPE = { + FieldDescriptorProto.TYPE_DOUBLE: + 'float64', + FieldDescriptorProto.TYPE_FLOAT: + 'float32', + FieldDescriptorProto.TYPE_INT32: + 'int32', + FieldDescriptorProto.TYPE_INT64: + 'int64', + FieldDescriptorProto.TYPE_UINT32: + 'uint32', + FieldDescriptorProto.TYPE_UINT64: + 'uint64', + FieldDescriptorProto.TYPE_SINT32: + 'int32', + FieldDescriptorProto.TYPE_SINT64: + 'int64', + FieldDescriptorProto.TYPE_FIXED32: + 'uint32', + FieldDescriptorProto.TYPE_FIXED64: + 'uint64', + FieldDescriptorProto.TYPE_SFIXED32: + 'int32', + FieldDescriptorProto.TYPE_SFIXED64: + 'int64', + FieldDescriptorProto.TYPE_BOOL: + 'bool', + FieldDescriptorProto.TYPE_STRING: + 'string', + # bytes in proto3 → dynamic byte array in ROS2 + FieldDescriptorProto.TYPE_BYTES: + 'uint8[]', +} + +# Non-scalar types that are explicitly rejected. +_UNSUPPORTED_TYPES = { + FieldDescriptorProto.TYPE_MESSAGE: 'message', + FieldDescriptorProto.TYPE_GROUP: 'group', + FieldDescriptorProto.TYPE_ENUM: 'enum', +} + + +def _find_file_descriptor(proto_set, proto_source): + """Find a FileDescriptorProto by name, with fallback to basename matching. + """ + for file_proto in proto_set.file: + if file_proto.name == proto_source: + return file_proto + # Fallback: match by basename in case paths differ slightly. + source_basename = proto_source.split('/')[-1] + for file_proto in proto_set.file: + if file_proto.name.split('/')[-1] == source_basename: + return file_proto + return None + + +def _convert(file_proto, output_path, proto_source): + """Validate and convert a FileDescriptorProto to a ROS2 .msg file.""" + if file_proto.service: + sys.exit(f'Error: {proto_source}: services are not supported ' + f'(found {len(file_proto.service)} service(s)).') + + num_messages = len(file_proto.message_type) + if num_messages != 1: + sys.exit( + f'Error: {proto_source}: expected exactly 1 message definition, ' + f'got {num_messages}.') + + message = file_proto.message_type[0] + lines = [f'# Generated from proto source: {proto_source}', ''] + + for field in message.field: + field_type_value = field.type + + if field_type_value in _UNSUPPORTED_TYPES: + type_name = _UNSUPPORTED_TYPES[field_type_value] + sys.exit( + f'Error: {proto_source}: field "{field.name}" has unsupported ' + f'type "{type_name}". Only scalar types are supported.') + + if field_type_value not in _PROTO_TO_ROS2_TYPE: + sys.exit(f'Error: {proto_source}: field "{field.name}" has unknown ' + f'type value {field_type_value}.') + + ros2_type = _PROTO_TO_ROS2_TYPE[field_type_value] + is_repeated = (field.label == FieldDescriptorProto.LABEL_REPEATED) + + # proto `bytes` already becomes `uint8[]`; avoid double `[]`. + if is_repeated and field_type_value != FieldDescriptorProto.TYPE_BYTES: + ros2_type = ros2_type + '[]' + + lines.append(f'{ros2_type} {field.name}') + + with open(output_path, 'w') as f: + f.write('\n'.join(lines) + '\n') + + +def main(): + parser = argparse.ArgumentParser( + description='Convert a proto file to a ROS2 .msg file.') + parser.add_argument( + '--descriptor_set', + required=True, + help='Path to the binary FileDescriptorSet file.', + ) + parser.add_argument( + '--proto_source', + required=True, + help='Relative path of the proto source as stored in the descriptor.', + ) + parser.add_argument( + '--output', + required=True, + help='Path of the output .msg file to write.', + ) + args = parser.parse_args() + + with open(args.descriptor_set, 'rb') as f: + data = f.read() + + proto_set = descriptor_pb2.FileDescriptorSet() + proto_set.ParseFromString(data) + + file_proto = _find_file_descriptor(proto_set, args.proto_source) + if file_proto is None: + sys.exit(f'Error: could not find proto source "{args.proto_source}" in ' + f'descriptor set "{args.descriptor_set}".') + + _convert(file_proto, args.output, args.proto_source) + + +if __name__ == '__main__': + main() diff --git a/ros2/protobuf.bzl b/ros2/protobuf.bzl new file mode 100644 index 00000000..79504b54 --- /dev/null +++ b/ros2/protobuf.bzl @@ -0,0 +1,143 @@ +# Copyright 2026 Milan Vukov +# +# 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. +"""Converts proto_library targets to ros2_interface_library-compatible targets. + +Limitations: +- One proto file must correspond to exactly one message definition. +- Service definitions in a proto file cause a build error. +- Only proto3 scalar field types are supported (no message-type or enum fields). +- Repeated scalar fields map to dynamic ROS2 arrays (e.g. `int32[] values`). +- Proto `bytes` fields map to `uint8[]` in ROS2. + +Example usage: + proto_library( + name = "my_proto", + srcs = ["my.proto"], + ) + + proto_ros2_interface_library( + name = "my_msgs", + proto_deps = [":my_proto"], + ) + + cpp_ros2_interface_library( + name = "cpp_my_msgs", + deps = [":my_msgs"], + ) +""" + +load("@com_github_mvukov_rules_ros2//ros2:interfaces.bzl", "Ros2InterfaceInfo") +load("@rules_proto//proto:defs.bzl", "ProtoInfo") + +# Provider carrying the generated .msg files from a proto_library target. +ProtoToRos2MsgInfo = provider( + "Provides generated .msg files derived from a proto_library target.", + fields = { + "msg_files": "A depset of generated .msg Files.", + }, +) + +def _proto_to_ros2_msg_aspect_impl(target, ctx): + proto_info = target[ProtoInfo] + msg_files = [] + + for src in proto_info.direct_sources: + if not src.basename.endswith(".proto"): + fail("Expected a .proto source file, got: {}".format(src.basename)) + stem = src.basename[:-len(".proto")] + msg_file = ctx.actions.declare_file( + "{}/{}.msg".format(target.label.name, stem), + ) + msg_files.append(msg_file) + + ctx.actions.run( + executable = ctx.executable._proto_to_ros2_msg, + inputs = [proto_info.direct_descriptor_set], + outputs = [msg_file], + arguments = [ + "--descriptor_set", + proto_info.direct_descriptor_set.path, + "--proto_source", + src.short_path, + "--output", + msg_file.path, + ], + mnemonic = "ProtoToRos2Msg", + progress_message = "Converting proto to ROS2 msg for %{label}", + ) + + return [ProtoToRos2MsgInfo(msg_files = depset(msg_files))] + +proto_to_ros2_msg_aspect = aspect( + implementation = _proto_to_ros2_msg_aspect_impl, + attr_aspects = ["deps"], + attrs = { + "_proto_to_ros2_msg": attr.label( + default = Label("@com_github_mvukov_rules_ros2//ros2:proto_to_ros2_msg"), + executable = True, + cfg = "exec", + ), + }, + required_providers = [ProtoInfo], + provides = [ProtoToRos2MsgInfo], +) + +def _proto_ros2_interface_library_impl(ctx): + msg_files = [] + for dep in ctx.attr.proto_deps: + msg_files.extend(dep[ProtoToRos2MsgInfo].msg_files.to_list()) + + return [ + DefaultInfo(files = depset(msg_files)), + Ros2InterfaceInfo( + info = struct(srcs = msg_files), + deps = depset( + direct = [dep[Ros2InterfaceInfo].info for dep in ctx.attr.deps], + transitive = [ + dep[Ros2InterfaceInfo].deps + for dep in ctx.attr.deps + ], + ), + ), + ] + +proto_ros2_interface_library = rule( + implementation = _proto_ros2_interface_library_impl, + attrs = { + # Named `proto_deps` rather than `deps` so that aspects propagating + # along `deps` (e.g. idl_adapter_aspect) do not follow this edge into + # proto_library targets, which do not carry Ros2InterfaceInfo. + "proto_deps": attr.label_list( + providers = [ProtoInfo], + aspects = [proto_to_ros2_msg_aspect], + mandatory = True, + doc = "List of proto_library targets to convert to ROS2 interfaces.", + ), + # Standard interface deps (other ros2_interface_library / + # proto_ros2_interface_library targets). Enables dependency on other + # message packages and satisfies ctx.rule.attr.deps accesses in the + # language-generator aspects (idl_adapter_aspect, c_generator_aspect, + # etc.). + "deps": attr.label_list( + providers = [Ros2InterfaceInfo], + doc = "ROS2 interface libraries this target depends on.", + ), + }, + provides = [Ros2InterfaceInfo], + doc = """Converts proto_library targets to a ROS2 interface library. + +Generates one .msg file per proto source file and exposes Ros2InterfaceInfo +so that downstream rules such as cpp_ros2_interface_library and +py_ros2_interface_library can consume the result.""", +) diff --git a/ros2/test/protobuf/BUILD.bazel b/ros2/test/protobuf/BUILD.bazel new file mode 100644 index 00000000..1a71b8a5 --- /dev/null +++ b/ros2/test/protobuf/BUILD.bazel @@ -0,0 +1,34 @@ +"""Tests for proto -> ROS2 interface conversion.""" + +load( + "@com_github_mvukov_rules_ros2//ros2:interfaces.bzl", + "py_ros2_interface_library", +) +load( + "@com_github_mvukov_rules_ros2//ros2:protobuf.bzl", + "proto_ros2_interface_library", +) +load("@rules_proto//proto:defs.bzl", "proto_library") +load("@rules_python//python:defs.bzl", "py_test") + +proto_library( + name = "point_proto", + srcs = ["Point.proto"], +) + +proto_ros2_interface_library( + name = "point_msgs", + proto_deps = [":point_proto"], +) + +py_ros2_interface_library( + name = "py_point_msgs", + deps = [":point_msgs"], +) + +py_test( + name = "test_proto_msg", + size = "small", + srcs = ["test_proto_msg.py"], + deps = [":py_point_msgs"], +) diff --git a/ros2/test/protobuf/Point.proto b/ros2/test/protobuf/Point.proto new file mode 100644 index 00000000..0a7f679b --- /dev/null +++ b/ros2/test/protobuf/Point.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +// A simple 3D point with some extra scalar fields to exercise type conversion. +message Point { + double x = 1; + double y = 2; + double z = 3; + string label = 4; + int32 id = 5; + bool valid = 6; + repeated float values = 7; +} diff --git a/ros2/test/protobuf/test_proto_msg.py b/ros2/test/protobuf/test_proto_msg.py new file mode 100644 index 00000000..0430dc7f --- /dev/null +++ b/ros2/test/protobuf/test_proto_msg.py @@ -0,0 +1,18 @@ +"""Verifies that proto-derived ROS2 message types are importable and usable.""" +from point_msgs import msg + + +def test_point_message_instantiation(): + p = msg.Point() + p.x = 1.0 + p.y = 2.0 + p.z = 3.0 + p.label = 'hello' + p.id = 42 + p.valid = True + assert p.x == 1.0 + assert p.y == 2.0 + assert p.z == 3.0 + assert p.label == 'hello' + assert p.id == 42 + assert p.valid is True From 96ec63b3a958691903318302fb2429f50f3f2fc6 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Sun, 22 Feb 2026 15:21:10 +0000 Subject: [PATCH 02/39] Make proto_to_ros2_msg_aspect return Ros2InterfaceInfo --- ros2/interfaces.bzl | 14 +++--- ros2/protobuf.bzl | 47 +++++-------------- ros2/test/protobuf/BUILD.bazel | 4 +- .../protobuf/{Point.proto => point.proto} | 2 + 4 files changed, 25 insertions(+), 42 deletions(-) rename ros2/test/protobuf/{Point.proto => point.proto} (89%) diff --git a/ros2/interfaces.bzl b/ros2/interfaces.bzl index fed2762e..0e5fccc4 100644 --- a/ros2/interfaces.bzl +++ b/ros2/interfaces.bzl @@ -160,6 +160,7 @@ idl_adapter_aspect = aspect( cfg = "exec", ), }, + required_providers = [Ros2InterfaceInfo], provides = [IdlAdapterAspectInfo], ) @@ -289,13 +290,13 @@ def _get_compilation_contexts_from_deps(deps): return [dep[CcInfo].compilation_context for dep in deps] def _get_compilation_contexts_from_aspect_info_deps(deps, aspect_info): - return [dep[aspect_info].cc_info.compilation_context for dep in deps] + return [dep[aspect_info].cc_info.compilation_context for dep in deps if aspect_info in dep] def _get_linking_contexts_from_deps(deps): return [dep[CcInfo].linking_context for dep in deps] def _get_linking_contexts_from_aspect_info_deps(deps, aspect_info): - return [dep[aspect_info].cc_info.linking_context for dep in deps] + return [dep[aspect_info].cc_info.linking_context for dep in deps if aspect_info in dep] def _compile_cc_generated_code( ctx, @@ -777,32 +778,33 @@ def _py_generator_aspect_impl(target, ctx): *relative_path_parts[0:] ) + py_deps = [dep for dep in ctx.rule.attr.deps if PyGeneratorAspectInfo in dep] py_info = PyGeneratorAspectInfo( cc_info = cc_common.merge_cc_infos( direct_cc_infos = [compilation_info.cc_info] + [ dep[PyGeneratorAspectInfo].cc_info - for dep in ctx.rule.attr.deps + for dep in py_deps ], ), dynamic_libraries = depset( direct = [dynamic_library], transitive = [ dep[PyGeneratorAspectInfo].dynamic_libraries - for dep in ctx.rule.attr.deps + for dep in py_deps ], ), transitive_sources = depset( direct = _get_py_srcs(all_outputs), transitive = [ dep[PyGeneratorAspectInfo].transitive_sources - for dep in ctx.rule.attr.deps + for dep in py_deps ], ), imports = depset( direct = [py_import_path], transitive = [ dep[PyGeneratorAspectInfo].imports - for dep in ctx.rule.attr.deps + for dep in py_deps ], ), linker_inputs = compilation_info.cc_info.linking_context.linker_inputs, diff --git a/ros2/protobuf.bzl b/ros2/protobuf.bzl index 79504b54..3e8d06a1 100644 --- a/ros2/protobuf.bzl +++ b/ros2/protobuf.bzl @@ -28,7 +28,7 @@ Example usage: proto_ros2_interface_library( name = "my_msgs", - proto_deps = [":my_proto"], + deps = [":my_proto"], ) cpp_ros2_interface_library( @@ -40,14 +40,6 @@ Example usage: load("@com_github_mvukov_rules_ros2//ros2:interfaces.bzl", "Ros2InterfaceInfo") load("@rules_proto//proto:defs.bzl", "ProtoInfo") -# Provider carrying the generated .msg files from a proto_library target. -ProtoToRos2MsgInfo = provider( - "Provides generated .msg files derived from a proto_library target.", - fields = { - "msg_files": "A depset of generated .msg Files.", - }, -) - def _proto_to_ros2_msg_aspect_impl(target, ctx): proto_info = target[ProtoInfo] msg_files = [] @@ -55,7 +47,7 @@ def _proto_to_ros2_msg_aspect_impl(target, ctx): for src in proto_info.direct_sources: if not src.basename.endswith(".proto"): fail("Expected a .proto source file, got: {}".format(src.basename)) - stem = src.basename[:-len(".proto")] + stem = src.basename[:-len(".proto")].capitalize() msg_file = ctx.actions.declare_file( "{}/{}.msg".format(target.label.name, stem), ) @@ -77,7 +69,12 @@ def _proto_to_ros2_msg_aspect_impl(target, ctx): progress_message = "Converting proto to ROS2 msg for %{label}", ) - return [ProtoToRos2MsgInfo(msg_files = depset(msg_files))] + return [ + Ros2InterfaceInfo( + info = struct(srcs = msg_files), + deps = depset([]), + ), + ] proto_to_ros2_msg_aspect = aspect( implementation = _proto_to_ros2_msg_aspect_impl, @@ -90,49 +87,31 @@ proto_to_ros2_msg_aspect = aspect( ), }, required_providers = [ProtoInfo], - provides = [ProtoToRos2MsgInfo], + provides = [Ros2InterfaceInfo], ) def _proto_ros2_interface_library_impl(ctx): msg_files = [] - for dep in ctx.attr.proto_deps: - msg_files.extend(dep[ProtoToRos2MsgInfo].msg_files.to_list()) + for dep in ctx.attr.deps: + msg_files.extend(dep[Ros2InterfaceInfo].info.srcs) return [ DefaultInfo(files = depset(msg_files)), Ros2InterfaceInfo( info = struct(srcs = msg_files), - deps = depset( - direct = [dep[Ros2InterfaceInfo].info for dep in ctx.attr.deps], - transitive = [ - dep[Ros2InterfaceInfo].deps - for dep in ctx.attr.deps - ], - ), + deps = depset([]), ), ] proto_ros2_interface_library = rule( implementation = _proto_ros2_interface_library_impl, attrs = { - # Named `proto_deps` rather than `deps` so that aspects propagating - # along `deps` (e.g. idl_adapter_aspect) do not follow this edge into - # proto_library targets, which do not carry Ros2InterfaceInfo. - "proto_deps": attr.label_list( + "deps": attr.label_list( providers = [ProtoInfo], aspects = [proto_to_ros2_msg_aspect], mandatory = True, doc = "List of proto_library targets to convert to ROS2 interfaces.", ), - # Standard interface deps (other ros2_interface_library / - # proto_ros2_interface_library targets). Enables dependency on other - # message packages and satisfies ctx.rule.attr.deps accesses in the - # language-generator aspects (idl_adapter_aspect, c_generator_aspect, - # etc.). - "deps": attr.label_list( - providers = [Ros2InterfaceInfo], - doc = "ROS2 interface libraries this target depends on.", - ), }, provides = [Ros2InterfaceInfo], doc = """Converts proto_library targets to a ROS2 interface library. diff --git a/ros2/test/protobuf/BUILD.bazel b/ros2/test/protobuf/BUILD.bazel index 1a71b8a5..04611782 100644 --- a/ros2/test/protobuf/BUILD.bazel +++ b/ros2/test/protobuf/BUILD.bazel @@ -13,12 +13,12 @@ load("@rules_python//python:defs.bzl", "py_test") proto_library( name = "point_proto", - srcs = ["Point.proto"], + srcs = ["point.proto"], ) proto_ros2_interface_library( name = "point_msgs", - proto_deps = [":point_proto"], + deps = [":point_proto"], ) py_ros2_interface_library( diff --git a/ros2/test/protobuf/Point.proto b/ros2/test/protobuf/point.proto similarity index 89% rename from ros2/test/protobuf/Point.proto rename to ros2/test/protobuf/point.proto index 0a7f679b..0eaa5a1e 100644 --- a/ros2/test/protobuf/Point.proto +++ b/ros2/test/protobuf/point.proto @@ -1,5 +1,7 @@ syntax = "proto3"; +package ros2.test.protobuf; + // A simple 3D point with some extra scalar fields to exercise type conversion. message Point { double x = 1; From a5b1229006a319ca0904c47a3870ca1b7b15f2e9 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Sun, 22 Feb 2026 16:41:41 +0000 Subject: [PATCH 03/39] Add ros_package_name to Ros2InterfaceInfo --- ros2/interfaces.bzl | 10 ++++++---- ros2/plugin_aspects.bzl | 4 ++-- ros2/protobuf.bzl | 6 +++++- ros2/rust_interfaces.bzl | 2 +- ros2/test/protobuf/test_proto_msg.py | 3 +++ 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/ros2/interfaces.bzl b/ros2/interfaces.bzl index 0e5fccc4..0f314543 100644 --- a/ros2/interfaces.bzl +++ b/ros2/interfaces.bzl @@ -25,6 +25,7 @@ Ros2InterfaceInfo = provider( fields = [ "info", "deps", + "ros_package_name", ], ) @@ -42,6 +43,7 @@ def _ros2_interface_library_impl(ctx): for dep in ctx.attr.deps ], ), + ros_package_name = ctx.label.name, ), ] @@ -140,7 +142,7 @@ IdlAdapterAspectInfo = provider("TBD", fields = [ ]) def _idl_adapter_aspect_impl(target, ctx): - package_name = target.label.name + package_name = target[Ros2InterfaceInfo].ros_package_name srcs = target[Ros2InterfaceInfo].info.srcs idl_files, idl_tuples = _run_adapter(ctx, package_name, srcs) return [ @@ -366,7 +368,7 @@ def _compile_cc_generated_code( ) def _c_generator_aspect_impl(target, ctx): - package_name = target.label.name + package_name = target[Ros2InterfaceInfo].ros_package_name srcs = target[Ros2InterfaceInfo].info.srcs adapter = target[IdlAdapterAspectInfo] @@ -534,7 +536,7 @@ _TYPESUPPORT_INTROSPECION_GENERATOR_CPP_OUTPUT_MAPPING = [ ] def _cpp_generator_aspect_impl(target, ctx): - package_name = target.label.name + package_name = target[Ros2InterfaceInfo].ros_package_name srcs = target[Ros2InterfaceInfo].info.srcs adapter = target[IdlAdapterAspectInfo] @@ -685,7 +687,7 @@ def _get_py_srcs(files): return [f for f in files if f.path.endswith(".py")] def _py_generator_aspect_impl(target, ctx): - package_name = target.label.name + package_name = target[Ros2InterfaceInfo].ros_package_name srcs = target[Ros2InterfaceInfo].info.srcs adapter = target[IdlAdapterAspectInfo] diff --git a/ros2/plugin_aspects.bzl b/ros2/plugin_aspects.bzl index e29f2795..c1ba57dc 100644 --- a/ros2/plugin_aspects.bzl +++ b/ros2/plugin_aspects.bzl @@ -97,7 +97,7 @@ Ros2InterfaceCollectorAspectInfo = provider( def create_interface_struct(target): return struct( - package_name = target.label.name, + package_name = target[Ros2InterfaceInfo].ros_package_name, srcs = target[Ros2InterfaceInfo].info.srcs, ) @@ -154,7 +154,7 @@ def create_dynamic_library(ctx, **kwargs): return dynamic_library def _ros2_idl_plugin_aspect_impl(target, ctx): - package_name = target.label.name + package_name = target[Ros2InterfaceInfo].ros_package_name cc_info = target[CppGeneratorAspectInfo].cc_info dynamic_library = create_dynamic_library( ctx, diff --git a/ros2/protobuf.bzl b/ros2/protobuf.bzl index 3e8d06a1..6a70c85b 100644 --- a/ros2/protobuf.bzl +++ b/ros2/protobuf.bzl @@ -44,12 +44,14 @@ def _proto_to_ros2_msg_aspect_impl(target, ctx): proto_info = target[ProtoInfo] msg_files = [] + print(target.label.name) + ros_package_name = target.label.name for src in proto_info.direct_sources: if not src.basename.endswith(".proto"): fail("Expected a .proto source file, got: {}".format(src.basename)) stem = src.basename[:-len(".proto")].capitalize() msg_file = ctx.actions.declare_file( - "{}/{}.msg".format(target.label.name, stem), + "{}/{}.msg".format(ros_package_name, stem), ) msg_files.append(msg_file) @@ -73,6 +75,7 @@ def _proto_to_ros2_msg_aspect_impl(target, ctx): Ros2InterfaceInfo( info = struct(srcs = msg_files), deps = depset([]), + ros_package_name = ros_package_name, ), ] @@ -100,6 +103,7 @@ def _proto_ros2_interface_library_impl(ctx): Ros2InterfaceInfo( info = struct(srcs = msg_files), deps = depset([]), + ros_package_name = ctx.label.name, ), ] diff --git a/ros2/rust_interfaces.bzl b/ros2/rust_interfaces.bzl index 8143b80a..baab81a5 100644 --- a/ros2/rust_interfaces.bzl +++ b/ros2/rust_interfaces.bzl @@ -130,7 +130,7 @@ def _compile_rust_code(ctx, label, crate_name, srcs, deps): ) def _rust_generator_aspect_impl(target, ctx): - package_name = target.label.name + package_name = target[Ros2InterfaceInfo].ros_package_name srcs = target[Ros2InterfaceInfo].info.srcs adapter = target[IdlAdapterAspectInfo] diff --git a/ros2/test/protobuf/test_proto_msg.py b/ros2/test/protobuf/test_proto_msg.py index 0430dc7f..7169eecf 100644 --- a/ros2/test/protobuf/test_proto_msg.py +++ b/ros2/test/protobuf/test_proto_msg.py @@ -16,3 +16,6 @@ def test_point_message_instantiation(): assert p.label == 'hello' assert p.id == 42 assert p.valid is True + + +test_point_message_instantiation() From bb2858b1b3f3b7f920b120349d182e3d511a0faa Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Sun, 22 Feb 2026 19:55:19 +0000 Subject: [PATCH 04/39] Work out cpp_proto_ros2_interface_library --- ros2/interfaces.bzl | 16 +++--- ros2/proto_to_ros2_msg.py | 73 ++++++++++++++++++++++---- ros2/protobuf.bzl | 77 ++++++++++++++++------------ ros2/test/protobuf/BUILD.bazel | 34 ++++++------ ros2/test/protobuf/test_proto_msg.py | 21 -------- ros2/test/protobuf/tests.cc | 15 ++++++ ros2/test/protobuf/transform.proto | 9 ++++ 7 files changed, 157 insertions(+), 88 deletions(-) delete mode 100644 ros2/test/protobuf/test_proto_msg.py create mode 100644 ros2/test/protobuf/tests.cc create mode 100644 ros2/test/protobuf/transform.proto diff --git a/ros2/interfaces.bzl b/ros2/interfaces.bzl index 0f314543..1637a800 100644 --- a/ros2/interfaces.bzl +++ b/ros2/interfaces.bzl @@ -162,7 +162,8 @@ idl_adapter_aspect = aspect( cfg = "exec", ), }, - required_providers = [Ros2InterfaceInfo], + required_aspect_providers = [Ros2InterfaceInfo], + # required_providers = [Ros2InterfaceInfo], provides = [IdlAdapterAspectInfo], ) @@ -493,14 +494,14 @@ c_generator_aspect = aspect( fragments = ["cpp"], ) -def _cc_generator_impl(ctx, aspect_info): +def cc_generator_impl(ctx, aspect_info): cc_info = cc_common.merge_cc_infos( direct_cc_infos = [dep[aspect_info].cc_info for dep in ctx.attr.deps], ) return [cc_info] def _c_ros2_interface_library_impl(ctx): - return _cc_generator_impl(ctx, CGeneratorAspectInfo) + return cc_generator_impl(ctx, CGeneratorAspectInfo) c_ros2_interface_library = rule( attrs = { @@ -649,15 +650,18 @@ cpp_generator_aspect = aspect( default = Label("@bazel_tools//tools/cpp:current_cc_toolchain"), ), }, - required_providers = [Ros2InterfaceInfo], - required_aspect_providers = [IdlAdapterAspectInfo], + # required_providers = [Ros2InterfaceInfo], + required_aspect_providers = [ + [Ros2InterfaceInfo], + [IdlAdapterAspectInfo], + ], provides = [CppGeneratorAspectInfo], toolchains = ["@bazel_tools//tools/cpp:toolchain_type"], fragments = ["cpp"], ) def _cpp_ros2_interface_library_impl(ctx): - return _cc_generator_impl(ctx, CppGeneratorAspectInfo) + return cc_generator_impl(ctx, CppGeneratorAspectInfo) cpp_ros2_interface_library = rule( attrs = { diff --git a/ros2/proto_to_ros2_msg.py b/ros2/proto_to_ros2_msg.py index 70c96d69..0191fc08 100644 --- a/ros2/proto_to_ros2_msg.py +++ b/ros2/proto_to_ros2_msg.py @@ -16,10 +16,11 @@ Limitations: - Exactly one message definition per proto file is required. - Service definitions are not supported. -- Only proto3 scalar field types are supported (no message-type fields, no enum - fields). -- Repeated scalar fields are supported and map to dynamic arrays (e.g. - `int32[] values`). +- Message-type fields are supported as cross-package ROS2 references (e.g. + `pkg/Type`). The caller must supply --dep_mapping for each imported proto. +- Enum and group fields are not supported. +- Repeated scalar and message fields are supported and map to dynamic arrays + (e.g. `int32[] values`, `pkg/msg/Point[] points`). - proto `bytes` fields map to `uint8[]` in ROS2. """ import argparse @@ -66,12 +67,36 @@ # Non-scalar types that are explicitly rejected. _UNSUPPORTED_TYPES = { - FieldDescriptorProto.TYPE_MESSAGE: 'message', FieldDescriptorProto.TYPE_GROUP: 'group', FieldDescriptorProto.TYPE_ENUM: 'enum', } +def _build_msg_type_map(dep_descriptor_set_paths, dep_mapping): + """Build {'.pkg.MsgName': 'ros2_package/MsgName'} from dep descriptor sets. + """ + path_to_pkg = {} + for entry in dep_mapping: + proto_path, ros2_pkg = entry.split(':', 1) + path_to_pkg[proto_path] = ros2_pkg + + msg_type_map = {} + for ds_path in dep_descriptor_set_paths: + with open(ds_path, 'rb') as f: + data = f.read() + dep_set = descriptor_pb2.FileDescriptorSet() + dep_set.ParseFromString(data) + for file_proto in dep_set.file: + ros2_pkg = path_to_pkg.get(file_proto.name) + if ros2_pkg is None: + continue + pkg_prefix = '.' + file_proto.package if file_proto.package else '' + for msg in file_proto.message_type: + fq = f'{pkg_prefix}.{msg.name}' + msg_type_map[fq] = f'{ros2_pkg}/{msg.name}' + return msg_type_map + + def _find_file_descriptor(proto_set, proto_source): """Find a FileDescriptorProto by name, with fallback to basename matching. """ @@ -86,7 +111,7 @@ def _find_file_descriptor(proto_set, proto_source): return None -def _convert(file_proto, output_path, proto_source): +def _convert(file_proto, output_path, proto_source, msg_type_map): """Validate and convert a FileDescriptorProto to a ROS2 .msg file.""" if file_proto.service: sys.exit(f'Error: {proto_source}: services are not supported ' @@ -103,19 +128,32 @@ def _convert(file_proto, output_path, proto_source): for field in message.field: field_type_value = field.type + is_repeated = (field.label == FieldDescriptorProto.LABEL_REPEATED) + + if field_type_value == FieldDescriptorProto.TYPE_MESSAGE: + ros2_type = msg_type_map.get(field.type_name) + if ros2_type is None: + sys.exit( + f'Error: {proto_source}: field "{field.name}" references ' + f'message type "{field.type_name}" with no dep_mapping ' + f'entry. Add a --dep_mapping for the proto file that ' + f'defines it.') + if is_repeated: + ros2_type = ros2_type + '[]' + lines.append(f'{ros2_type} {field.name}') + continue if field_type_value in _UNSUPPORTED_TYPES: type_name = _UNSUPPORTED_TYPES[field_type_value] sys.exit( f'Error: {proto_source}: field "{field.name}" has unsupported ' - f'type "{type_name}". Only scalar types are supported.') + f'type "{type_name}".') if field_type_value not in _PROTO_TO_ROS2_TYPE: sys.exit(f'Error: {proto_source}: field "{field.name}" has unknown ' f'type value {field_type_value}.') ros2_type = _PROTO_TO_ROS2_TYPE[field_type_value] - is_repeated = (field.label == FieldDescriptorProto.LABEL_REPEATED) # proto `bytes` already becomes `uint8[]`; avoid double `[]`. if is_repeated and field_type_value != FieldDescriptorProto.TYPE_BYTES: @@ -145,6 +183,21 @@ def main(): required=True, help='Path of the output .msg file to write.', ) + parser.add_argument( + '--dep_mapping', + action='append', + default=[], + metavar='PROTO_PATH:ROS2_PACKAGE', + help='Mapping from a dep proto file path to its ROS2 package name. ' + 'May be repeated.', + ) + parser.add_argument( + '--dep_descriptor_set', + action='append', + default=[], + metavar='PATH', + help='Path to a dep binary FileDescriptorSet file. May be repeated.', + ) args = parser.parse_args() with open(args.descriptor_set, 'rb') as f: @@ -158,7 +211,9 @@ def main(): sys.exit(f'Error: could not find proto source "{args.proto_source}" in ' f'descriptor set "{args.descriptor_set}".') - _convert(file_proto, args.output, args.proto_source) + msg_type_map = _build_msg_type_map(args.dep_descriptor_set, + args.dep_mapping) + _convert(file_proto, args.output, args.proto_source, msg_type_map) if __name__ == '__main__': diff --git a/ros2/protobuf.bzl b/ros2/protobuf.bzl index 6a70c85b..af89dd9c 100644 --- a/ros2/protobuf.bzl +++ b/ros2/protobuf.bzl @@ -16,8 +16,11 @@ Limitations: - One proto file must correspond to exactly one message definition. - Service definitions in a proto file cause a build error. -- Only proto3 scalar field types are supported (no message-type or enum fields). -- Repeated scalar fields map to dynamic ROS2 arrays (e.g. `int32[] values`). +- Message-type fields are supported as cross-package ROS2 references (e.g. + `pkg/Type`). Each proto dep must have a corresponding + proto_ros2_interface_library so the package name can be resolved. +- Enum and group fields are not supported. +- Repeated scalar and message fields map to dynamic ROS2 arrays. - Proto `bytes` fields map to `uint8[]` in ROS2. Example usage: @@ -37,15 +40,35 @@ Example usage: ) """ -load("@com_github_mvukov_rules_ros2//ros2:interfaces.bzl", "Ros2InterfaceInfo") +load( + "@com_github_mvukov_rules_ros2//ros2:interfaces.bzl", + "CppGeneratorAspectInfo", + "Ros2InterfaceInfo", + "cc_generator_impl", + "cpp_generator_aspect", + "idl_adapter_aspect", +) load("@rules_proto//proto:defs.bzl", "ProtoInfo") def _proto_to_ros2_msg_aspect_impl(target, ctx): proto_info = target[ProtoInfo] msg_files = [] - print(target.label.name) - ros_package_name = target.label.name + ros_package_name = target.label.name + "_ros_msgs" + + dep_extra_args = [] + dep_descriptor_sets = [] + for dep in ctx.rule.attr.deps: + dep_ds = dep[ProtoInfo].direct_descriptor_set + dep_descriptor_sets.append(dep_ds) + dep_extra_args += ["--dep_descriptor_set", dep_ds.path] + dep_ros2_package = dep[Ros2InterfaceInfo].ros_package_name + for src in dep[ProtoInfo].direct_sources: + dep_extra_args += [ + "--dep_mapping", + "{}:{}".format(src.short_path, dep_ros2_package), + ] + for src in proto_info.direct_sources: if not src.basename.endswith(".proto"): fail("Expected a .proto source file, got: {}".format(src.basename)) @@ -57,7 +80,7 @@ def _proto_to_ros2_msg_aspect_impl(target, ctx): ctx.actions.run( executable = ctx.executable._proto_to_ros2_msg, - inputs = [proto_info.direct_descriptor_set], + inputs = [proto_info.direct_descriptor_set] + dep_descriptor_sets, outputs = [msg_file], arguments = [ "--descriptor_set", @@ -66,15 +89,21 @@ def _proto_to_ros2_msg_aspect_impl(target, ctx): src.short_path, "--output", msg_file.path, - ], + ] + dep_extra_args, mnemonic = "ProtoToRos2Msg", - progress_message = "Converting proto to ROS2 msg for %{label}", + progress_message = "Converting proto to ROS 2 messages for %{label}", ) return [ Ros2InterfaceInfo( info = struct(srcs = msg_files), - deps = depset([]), + deps = depset( + direct = [dep[Ros2InterfaceInfo].info for dep in ctx.rule.attr.deps], + transitive = [ + dep[Ros2InterfaceInfo].deps + for dep in ctx.rule.attr.deps + ], + ), ros_package_name = ros_package_name, ), ] @@ -93,34 +122,16 @@ proto_to_ros2_msg_aspect = aspect( provides = [Ros2InterfaceInfo], ) -def _proto_ros2_interface_library_impl(ctx): - msg_files = [] - for dep in ctx.attr.deps: - msg_files.extend(dep[Ros2InterfaceInfo].info.srcs) - - return [ - DefaultInfo(files = depset(msg_files)), - Ros2InterfaceInfo( - info = struct(srcs = msg_files), - deps = depset([]), - ros_package_name = ctx.label.name, - ), - ] +def _cpp_proto_ros2_interface_library_impl(ctx): + return cc_generator_impl(ctx, CppGeneratorAspectInfo) -proto_ros2_interface_library = rule( - implementation = _proto_ros2_interface_library_impl, +cpp_proto_ros2_interface_library = rule( attrs = { "deps": attr.label_list( - providers = [ProtoInfo], - aspects = [proto_to_ros2_msg_aspect], mandatory = True, - doc = "List of proto_library targets to convert to ROS2 interfaces.", + aspects = [proto_to_ros2_msg_aspect, idl_adapter_aspect, cpp_generator_aspect], + providers = [ProtoInfo], ), }, - provides = [Ros2InterfaceInfo], - doc = """Converts proto_library targets to a ROS2 interface library. - -Generates one .msg file per proto source file and exposes Ros2InterfaceInfo -so that downstream rules such as cpp_ros2_interface_library and -py_ros2_interface_library can consume the result.""", + implementation = _cpp_proto_ros2_interface_library_impl, ) diff --git a/ros2/test/protobuf/BUILD.bazel b/ros2/test/protobuf/BUILD.bazel index 04611782..36e425d3 100644 --- a/ros2/test/protobuf/BUILD.bazel +++ b/ros2/test/protobuf/BUILD.bazel @@ -1,34 +1,30 @@ """Tests for proto -> ROS2 interface conversion.""" -load( - "@com_github_mvukov_rules_ros2//ros2:interfaces.bzl", - "py_ros2_interface_library", -) -load( - "@com_github_mvukov_rules_ros2//ros2:protobuf.bzl", - "proto_ros2_interface_library", -) load("@rules_proto//proto:defs.bzl", "proto_library") -load("@rules_python//python:defs.bzl", "py_test") +load("//ros2:cc_defs.bzl", "ros2_cpp_test") +load("//ros2:protobuf.bzl", "cpp_proto_ros2_interface_library") proto_library( name = "point_proto", srcs = ["point.proto"], ) -proto_ros2_interface_library( - name = "point_msgs", +proto_library( + name = "transform_proto", + srcs = ["transform.proto"], deps = [":point_proto"], ) -py_ros2_interface_library( - name = "py_point_msgs", - deps = [":point_msgs"], +cpp_proto_ros2_interface_library( + name = "cpp_proto_ros_msgs", + deps = [":transform_proto"], ) -py_test( - name = "test_proto_msg", - size = "small", - srcs = ["test_proto_msg.py"], - deps = [":py_point_msgs"], +ros2_cpp_test( + name = "tests", + srcs = ["tests.cc"], + deps = [ + ":cpp_proto_ros_msgs", + "@googletest//:gtest_main", + ], ) diff --git a/ros2/test/protobuf/test_proto_msg.py b/ros2/test/protobuf/test_proto_msg.py deleted file mode 100644 index 7169eecf..00000000 --- a/ros2/test/protobuf/test_proto_msg.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Verifies that proto-derived ROS2 message types are importable and usable.""" -from point_msgs import msg - - -def test_point_message_instantiation(): - p = msg.Point() - p.x = 1.0 - p.y = 2.0 - p.z = 3.0 - p.label = 'hello' - p.id = 42 - p.valid = True - assert p.x == 1.0 - assert p.y == 2.0 - assert p.z == 3.0 - assert p.label == 'hello' - assert p.id == 42 - assert p.valid is True - - -test_point_message_instantiation() diff --git a/ros2/test/protobuf/tests.cc b/ros2/test/protobuf/tests.cc new file mode 100644 index 00000000..f85b52b9 --- /dev/null +++ b/ros2/test/protobuf/tests.cc @@ -0,0 +1,15 @@ +// Copyright 2026 Milan Vukov +// +// 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. +#include "point_proto_ros_msgs/msg/point.hpp" +#include "transform_proto_ros_msgs/msg/transform.hpp" diff --git a/ros2/test/protobuf/transform.proto b/ros2/test/protobuf/transform.proto new file mode 100644 index 00000000..cc9ef5dc --- /dev/null +++ b/ros2/test/protobuf/transform.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +import "ros2/test/protobuf/point.proto"; + +message Transform { + Point point = 1; +} From ac40a3121068f809583be5e42539a41a19aaacce Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Wed, 25 Feb 2026 22:41:22 +0000 Subject: [PATCH 05/39] use com_google_protobuf i.s.o. rules_proto --- repositories/deps.bzl | 4 ++-- repositories/repositories.bzl | 8 ++++---- ros2/protobuf.bzl | 2 +- ros2/test/protobuf/BUILD.bazel | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/repositories/deps.bzl b/repositories/deps.bzl index ecf4ee1f..c696dd05 100644 --- a/repositories/deps.bzl +++ b/repositories/deps.bzl @@ -3,9 +3,9 @@ load("@bazel_features//:deps.bzl", "bazel_features_deps") load("@bazel_skylib//:workspace.bzl", "bazel_skylib_workspace") +load("@com_google_protobuf//:protobuf_deps.bzl", "protobuf_deps") load("@googletest//:googletest_deps.bzl", "googletest_deps") load("@rules_foreign_cc//foreign_cc:repositories.bzl", "rules_foreign_cc_dependencies") -load("@rules_proto//proto:repositories.bzl", "rules_proto_dependencies") load("@rules_shell//shell:repositories.bzl", "rules_shell_dependencies", "rules_shell_toolchains") def ros2_deps(): @@ -17,4 +17,4 @@ def ros2_deps(): rules_shell_toolchains() rules_foreign_cc_dependencies() googletest_deps() - rules_proto_dependencies() + protobuf_deps() diff --git a/repositories/repositories.bzl b/repositories/repositories.bzl index 8b4141d4..8740e8e9 100644 --- a/repositories/repositories.bzl +++ b/repositories/repositories.bzl @@ -236,10 +236,10 @@ def ros2_workspace_repositories(): maybe( http_archive, - name = "rules_proto", - sha256 = "14a225870ab4e91869652cfd69ef2028277fc1dc4910d65d353b62d6e0ae21f4", - strip_prefix = "rules_proto-7.1.0", - url = "https://github.com/bazelbuild/rules_proto/releases/download/7.1.0/rules_proto-7.1.0.tar.gz", + name = "com_google_protobuf", + sha256 = "440848dffa209beb8a04e41cc352762e44f8e91342b2a43aab6af9b30713c2f6", + strip_prefix = "protobuf-33.5", + urls = ["https://github.com/protocolbuffers/protobuf/archive/refs/tags/v33.5.tar.gz"], ) def ros2_repositories(): diff --git a/ros2/protobuf.bzl b/ros2/protobuf.bzl index af89dd9c..2aefec9d 100644 --- a/ros2/protobuf.bzl +++ b/ros2/protobuf.bzl @@ -48,7 +48,7 @@ load( "cpp_generator_aspect", "idl_adapter_aspect", ) -load("@rules_proto//proto:defs.bzl", "ProtoInfo") +load("@com_google_protobuf//bazel/common:proto_info.bzl", "ProtoInfo") def _proto_to_ros2_msg_aspect_impl(target, ctx): proto_info = target[ProtoInfo] diff --git a/ros2/test/protobuf/BUILD.bazel b/ros2/test/protobuf/BUILD.bazel index 36e425d3..6a35d55a 100644 --- a/ros2/test/protobuf/BUILD.bazel +++ b/ros2/test/protobuf/BUILD.bazel @@ -1,6 +1,6 @@ """Tests for proto -> ROS2 interface conversion.""" -load("@rules_proto//proto:defs.bzl", "proto_library") +load("@com_google_protobuf//bazel:proto_library.bzl", "proto_library") load("//ros2:cc_defs.bzl", "ros2_cpp_test") load("//ros2:protobuf.bzl", "cpp_proto_ros2_interface_library") From 0fa359ae9ea57dbfcec806a54119eb6011d60bb0 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Sat, 28 Feb 2026 18:31:18 +0000 Subject: [PATCH 06/39] Add proto-ros conversion --- ros2/BUILD.bazel | 10 + ros2/proto_to_ros2_converter.py | 428 ++++++++++++++++++++++++++ ros2/protobuf.bzl | 155 ++++++++++ ros2/test/protobuf/BUILD.bazel | 16 +- ros2/test/protobuf/converter_tests.cc | 130 ++++++++ 5 files changed, 738 insertions(+), 1 deletion(-) create mode 100644 ros2/proto_to_ros2_converter.py create mode 100644 ros2/test/protobuf/converter_tests.cc diff --git a/ros2/BUILD.bazel b/ros2/BUILD.bazel index 5fe39a4a..e8710ffa 100644 --- a/ros2/BUILD.bazel +++ b/ros2/BUILD.bazel @@ -102,6 +102,16 @@ py_binary( deps = [requirement("protobuf")], ) +py_binary( + name = "proto_to_ros2_converter", + srcs = ["proto_to_ros2_converter.py"], + visibility = ["//visibility:public"], + deps = [ + requirement("empy"), + requirement("protobuf"), + ], +) + whl_filegroup( name = "numpy_includes", pattern = "numpy/core/include/numpy", diff --git a/ros2/proto_to_ros2_converter.py b/ros2/proto_to_ros2_converter.py new file mode 100644 index 00000000..ce55764e --- /dev/null +++ b/ros2/proto_to_ros2_converter.py @@ -0,0 +1,428 @@ +# Copyright 2026 Milan Vukov +# +# 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. +"""Generates C++ proto<->ROS2 converter header and source from a proto file. + +For each proto message the tool emits two free functions in namespace +``::proto_converters``: + + ToRos(const & proto); + FromRos(const & ros); + +Limitations mirror those of proto_to_ros2_msg.py: +- Exactly one message definition per proto file. +- Service definitions are not supported. +- Message-type fields require a --dep_mapping entry so the ROS2 package can + be resolved. +- Enum and group fields are not supported. +- Repeated bytes fields are not supported. +""" +import argparse +import io +import re +import sys + +import em +from google.protobuf import descriptor_pb2 +from google.protobuf.descriptor_pb2 import FieldDescriptorProto + +# --------------------------------------------------------------------------- +# Field-type tables +# --------------------------------------------------------------------------- + +# Proto scalar types → C++ type used in ROS2 structs. +_SCALAR_CPP_TYPE = { + FieldDescriptorProto.TYPE_DOUBLE: 'double', + FieldDescriptorProto.TYPE_FLOAT: 'float', + FieldDescriptorProto.TYPE_INT32: 'int32_t', + FieldDescriptorProto.TYPE_INT64: 'int64_t', + FieldDescriptorProto.TYPE_UINT32: 'uint32_t', + FieldDescriptorProto.TYPE_UINT64: 'uint64_t', + FieldDescriptorProto.TYPE_SINT32: 'int32_t', + FieldDescriptorProto.TYPE_SINT64: 'int64_t', + FieldDescriptorProto.TYPE_FIXED32: 'uint32_t', + FieldDescriptorProto.TYPE_FIXED64: 'uint64_t', + FieldDescriptorProto.TYPE_SFIXED32: 'int32_t', + FieldDescriptorProto.TYPE_SFIXED64: 'int64_t', + FieldDescriptorProto.TYPE_BOOL: 'bool', +} + +_UNSUPPORTED_TYPES = { + FieldDescriptorProto.TYPE_GROUP: 'group', + FieldDescriptorProto.TYPE_ENUM: 'enum', +} + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _to_snake_case(name): + """Converts CamelCase to snake_case (mirrors rosidl_cmake convention).""" + s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name) + s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1) + return s2.lower() + + +def _proto_package_to_ns(package): + """Converts 'a.b.c' → 'a::b::c'.""" + return '::'.join(package.split('.')) if package else '' + + +def _build_fqn_to_dep_pkg_map(dep_descriptor_set_paths, dep_mapping): + """Build {'.pkg.MsgName': 'dep_ros_package_name'} from dep descriptor sets. + """ + path_to_ros_pkg = {} + for entry in dep_mapping: + proto_path, ros2_pkg = entry.split(':', 1) + path_to_ros_pkg[proto_path] = ros2_pkg + + fqn_map = {} + for ds_path in dep_descriptor_set_paths: + with open(ds_path, 'rb') as f: + dep_set = descriptor_pb2.FileDescriptorSet() + dep_set.ParseFromString(f.read()) + for file_proto in dep_set.file: + ros2_pkg = path_to_ros_pkg.get(file_proto.name) + if ros2_pkg is None: + continue + pkg_prefix = '.' + file_proto.package if file_proto.package else '' + for msg in file_proto.message_type: + fqn = f'{pkg_prefix}.{msg.name}' + fqn_map[fqn] = ros2_pkg + return fqn_map + + +def _find_file_descriptor(proto_set, proto_source): + """Find a FileDescriptorProto by name, with basename fallback.""" + for fp in proto_set.file: + if fp.name == proto_source: + return fp + source_base = proto_source.split('/')[-1] + for fp in proto_set.file: + if fp.name.split('/')[-1] == source_base: + return fp + return None + + +# --------------------------------------------------------------------------- +# Per-field conversion code generation +# --------------------------------------------------------------------------- + + +def _field_conversions(message, proto_source, fqn_map): + """Return (to_ros_lines, from_ros_lines, dep_pkgs_used) for one message. + + Each entry in to_ros_lines / from_ros_lines is a C++ statement string + (already indented with two spaces). dep_pkgs_used is the set of dep + ros_package_names whose converters are called. + """ + to_ros = [] + from_ros = [] + dep_pkgs = set() + + for field in message.field: + name = field.name + is_repeated = field.label == FieldDescriptorProto.LABEL_REPEATED + ftype = field.type + + if ftype in _UNSUPPORTED_TYPES: + sys.exit(f'Error: {proto_source}: field "{name}": ' + f'{_UNSUPPORTED_TYPES[ftype]} fields are not supported.') + + # ---- bytes ---------------------------------------------------------- + if ftype == FieldDescriptorProto.TYPE_BYTES: + if is_repeated: + sys.exit(f'Error: {proto_source}: field "{name}": ' + f'repeated bytes is not supported.') + to_ros.append(f' ros.{name} = std::vector' + f'(proto.{name}().begin(), proto.{name}().end());') + from_ros.append( + f' proto.set_{name}' + f'(std::string(ros.{name}.begin(), ros.{name}.end()));') + continue + + # ---- string --------------------------------------------------------- + if ftype == FieldDescriptorProto.TYPE_STRING: + if is_repeated: + to_ros.append( + f' ros.{name} = std::vector' + f'(proto.{name}().begin(), proto.{name}().end());') + from_ros.append(f' for (const auto& s : ros.{name}) {{') + from_ros.append(f' proto.add_{name}(s);') + from_ros.append(' }') + else: + to_ros.append(f' ros.{name} = proto.{name}();') + from_ros.append(f' proto.set_{name}(ros.{name});') + continue + + # ---- message -------------------------------------------------------- + if ftype == FieldDescriptorProto.TYPE_MESSAGE: + dep_pkg = fqn_map.get(field.type_name) + if dep_pkg is None: + sys.exit( + f'Error: {proto_source}: field "{name}" references ' + f'message type "{field.type_name}" with no dep_mapping ' + f'entry. Add a --dep_mapping for the proto file that ' + f'defines it.') + dep_pkgs.add(dep_pkg) + conv = f'{dep_pkg}::proto_converters' + + if is_repeated: + to_ros.append(f' for (const auto& item : proto.{name}()) {{') + to_ros.append(f' ros.{name}.push_back({conv}::ToRos(item));') + to_ros.append(' }') + from_ros.append(f' for (const auto& item : ros.{name}) {{') + from_ros.append( + f' *proto.add_{name}() = {conv}::FromRos(item);') + from_ros.append(' }') + else: + to_ros.append(f' ros.{name} = {conv}::ToRos(proto.{name}());') + from_ros.append(f' *proto.mutable_{name}() = ' + f'{conv}::FromRos(ros.{name});') + continue + + # ---- scalar (all remaining types) ----------------------------------- + if ftype not in _SCALAR_CPP_TYPE: + sys.exit(f'Error: {proto_source}: field "{name}" has unknown ' + f'field type value {ftype}.') + + cpp_type = _SCALAR_CPP_TYPE[ftype] + + if is_repeated: + to_ros.append(f' ros.{name} = std::vector<{cpp_type}>' + f'(proto.{name}().begin(), proto.{name}().end());') + from_ros.append(f' proto.mutable_{name}()->Assign' + f'(ros.{name}.begin(), ros.{name}.end());') + else: + to_ros.append(f' ros.{name} = proto.{name}();') + from_ros.append(f' proto.set_{name}(ros.{name});') + + return to_ros, from_ros, dep_pkgs + + +# --------------------------------------------------------------------------- +# Empy templates +# --------------------------------------------------------------------------- + +_HEADER_TEMPLATE = """\ +// Generated by proto_to_ros2_converter. Do not edit. +#pragma once + +@(ctx['includes_section']) + +namespace @(ctx['ros_package_name'])::proto_converters { + +@[for msg in ctx['messages']] +@(msg['ros_type']) ToRos(const @(msg['proto_type'])& proto); + +@(msg['proto_type']) FromRos(const @(msg['ros_type'])& ros); +@[end for] + +} // namespace @(ctx['ros_package_name'])::proto_converters +""" + +_SOURCE_TEMPLATE = """\ +// Generated by proto_to_ros2_converter. Do not edit. +#include "@(ctx['ros_package_name'])/proto_converters.h" +@[for inc in ctx['dep_converter_includes']] +#include "@(inc)" +@[end for] + +namespace @(ctx['ros_package_name'])::proto_converters { + +@[for msg in ctx['messages']] +@(msg['ros_type']) ToRos(const @(msg['proto_type'])& proto) { + @(msg['ros_type']) ros; +@(msg['to_ros_body']) + return ros; +} + +@(msg['proto_type']) FromRos(const @(msg['ros_type'])& ros) { + @(msg['proto_type']) proto; +@(msg['from_ros_body']) + return proto; +} +@[end for] + +} // namespace @(ctx['ros_package_name'])::proto_converters +""" + + +def _render(template, context): + """Render an empy 3.3 template string with a 'ctx' variable.""" + output = io.StringIO() + interp = em.Interpreter(output=output, globals={'ctx': context}) + try: + interp.string(template) + return output.getvalue() + finally: + interp.shutdown() + + +# --------------------------------------------------------------------------- +# Main conversion logic +# --------------------------------------------------------------------------- + + +def _convert(descriptor_set, proto_sources, ros_package_name, fqn_map, + output_header, output_source): + """Generate converter .h and .cc for all proto sources in the descriptor.""" + messages = [] + dep_pkgs_all = set() + + proto_includes = [] + ros2_includes = [] + + for proto_source in proto_sources: + file_proto = _find_file_descriptor(descriptor_set, proto_source) + if file_proto is None: + sys.exit(f'Error: could not find proto source "{proto_source}" in ' + f'the descriptor set.') + + if file_proto.service: + sys.exit(f'Error: {proto_source}: services are not supported.') + + num_msgs = len(file_proto.message_type) + if num_msgs != 1: + sys.exit(f'Error: {proto_source}: expected exactly 1 message ' + f'definition, got {num_msgs}.') + + message = file_proto.message_type[0] + msg_name = message.name + + proto_ns = _proto_package_to_ns(file_proto.package) + proto_type = f'{proto_ns}::{msg_name}' if proto_ns else msg_name + ros_type = f'{ros_package_name}::msg::{msg_name}' + + # Proto C++ include: replace .proto suffix with .pb.h + proto_include = file_proto.name[:-len('.proto')] + '.pb.h' + # ROS2 msg include: snake_case name with .hpp extension + ros2_include = (f'{ros_package_name}/msg/' + f'{_to_snake_case(msg_name)}.hpp') + + to_ros_lines, from_ros_lines, dep_pkgs = _field_conversions( + message, proto_source, fqn_map) + dep_pkgs_all.update(dep_pkgs) + + to_ros_body = '\n'.join(to_ros_lines) + from_ros_body = '\n'.join(from_ros_lines) + + messages.append({ + 'msg_name': msg_name, + 'proto_type': proto_type, + 'ros_type': ros_type, + 'to_ros_body': to_ros_body, + 'from_ros_body': from_ros_body, + }) + + proto_includes.append(proto_include) + ros2_includes.append(ros2_include) + + # Build a sorted, deduplicated dep-converter include list. + dep_converter_includes = sorted( + f'{pkg}/proto_converters.h' for pkg in dep_pkgs_all) + + # Header includes: only the current package's proto and ROS2 msg types. + # Dep converter includes go into the .cc file, not the header. + include_lines = [] + for inc in proto_includes: + include_lines.append(f'#include "{inc}"') + include_lines.append('') + for inc in ros2_includes: + include_lines.append(f'#include "{inc}"') + includes_section = '\n'.join(include_lines) + + header_context = { + 'ros_package_name': ros_package_name, + 'includes_section': includes_section, + 'messages': messages, + } + source_context = { + 'ros_package_name': ros_package_name, + 'dep_converter_includes': dep_converter_includes, + 'messages': messages, + } + + with open(output_header, 'w') as f: + f.write(_render(_HEADER_TEMPLATE, header_context)) + with open(output_source, 'w') as f: + f.write(_render(_SOURCE_TEMPLATE, source_context)) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main(): + parser = argparse.ArgumentParser( + description='Generate C++ proto<->ROS2 converter code.') + parser.add_argument( + '--descriptor_set', + required=True, + help='Path to the binary FileDescriptorSet file.', + ) + parser.add_argument( + '--ros_package_name', + required=True, + help='ROS2 package name (e.g. point_proto_ros_msgs).', + ) + parser.add_argument( + '--output_header', + required=True, + help='Path of the output .h file to write.', + ) + parser.add_argument( + '--output_source', + required=True, + help='Path of the output .cc file to write.', + ) + parser.add_argument( + '--dep_mapping', + action='append', + default=[], + metavar='PROTO_PATH:ROS2_PACKAGE', + help='Mapping from a dep proto file path to its ROS2 package name. ' + 'May be repeated.', + ) + parser.add_argument( + '--dep_descriptor_set', + action='append', + default=[], + metavar='PATH', + help='Path to a dep binary FileDescriptorSet file. May be repeated.', + ) + args = parser.parse_args() + + with open(args.descriptor_set, 'rb') as f: + proto_set = descriptor_pb2.FileDescriptorSet() + proto_set.ParseFromString(f.read()) + + fqn_map = _build_fqn_to_dep_pkg_map(args.dep_descriptor_set, + args.dep_mapping) + + proto_sources = [fp.name for fp in proto_set.file] + + _convert( + descriptor_set=proto_set, + proto_sources=proto_sources, + ros_package_name=args.ros_package_name, + fqn_map=fqn_map, + output_header=args.output_header, + output_source=args.output_source, + ) + + +if __name__ == '__main__': + main() diff --git a/ros2/protobuf.bzl b/ros2/protobuf.bzl index 2aefec9d..76bedd31 100644 --- a/ros2/protobuf.bzl +++ b/ros2/protobuf.bzl @@ -49,6 +49,10 @@ load( "idl_adapter_aspect", ) load("@com_google_protobuf//bazel/common:proto_info.bzl", "ProtoInfo") +load("@com_google_protobuf//bazel/private:cc_proto_aspect.bzl", "cc_proto_aspect") +load("@rules_cc//cc:toolchain_utils.bzl", "find_cpp_toolchain") + +CppProtoConverterAspectInfo = provider("TBD", fields = ["cc_info"]) def _proto_to_ros2_msg_aspect_impl(target, ctx): proto_info = target[ProtoInfo] @@ -135,3 +139,154 @@ cpp_proto_ros2_interface_library = rule( }, implementation = _cpp_proto_ros2_interface_library_impl, ) + +def _cpp_proto_ros2_converter_aspect_impl(target, ctx): + proto_info = target[ProtoInfo] + ros_package_name = target[Ros2InterfaceInfo].ros_package_name + + # Collect dep information: descriptor sets and proto→ros_package mappings. + dep_extra_args = [] + dep_descriptor_sets = [] + dep_converter_cc_infos = [] + for dep in ctx.rule.attr.deps: + dep_ds = dep[ProtoInfo].direct_descriptor_set + dep_descriptor_sets.append(dep_ds) + dep_extra_args += ["--dep_descriptor_set", dep_ds.path] + dep_ros2_package = dep[Ros2InterfaceInfo].ros_package_name + for src in dep[ProtoInfo].direct_sources: + dep_extra_args += [ + "--dep_mapping", + "{}:{}".format(src.short_path, dep_ros2_package), + ] + if CppProtoConverterAspectInfo in dep: + dep_converter_cc_infos.append(dep[CppProtoConverterAspectInfo].cc_info) + + # Declare output files. + header = ctx.actions.declare_file( + "{}/proto_converters.h".format(ros_package_name), + ) + source = ctx.actions.declare_file( + "{}/proto_converters.cc".format(ros_package_name), + ) + + ctx.actions.run( + executable = ctx.executable._proto_to_ros2_converter, + inputs = [proto_info.direct_descriptor_set] + dep_descriptor_sets, + outputs = [header, source], + arguments = [ + "--descriptor_set", + proto_info.direct_descriptor_set.path, + "--ros_package_name", + ros_package_name, + "--output_header", + header.path, + "--output_source", + source.path, + ] + dep_extra_args, + mnemonic = "ProtoToRos2Converter", + progress_message = "Generating proto/ROS2 converters for %{label}", + ) + + # The include root is the parent directory of the ros_package_name folder. + cc_include_dir = "/".join(header.dirname.split("/")[:-1]) + + cc_toolchain = find_cpp_toolchain(ctx) + feature_configuration = cc_common.configure_features( + ctx = ctx, + cc_toolchain = cc_toolchain, + requested_features = ctx.features, + unsupported_features = ctx.disabled_features, + ) + + compilation_contexts = ( + [ + target[CcInfo].compilation_context, + target[CppGeneratorAspectInfo].cc_info.compilation_context, + ] + + [ci.compilation_context for ci in dep_converter_cc_infos] + ) + compilation_context, compilation_outputs = cc_common.compile( + actions = ctx.actions, + name = ros_package_name + "_converter", + cc_toolchain = cc_toolchain, + feature_configuration = feature_configuration, + system_includes = [cc_include_dir], + srcs = [source], + public_hdrs = [header], + compilation_contexts = compilation_contexts, + ) + + linking_contexts = ( + [ + target[CcInfo].linking_context, + target[CppGeneratorAspectInfo].cc_info.linking_context, + ] + + [ci.linking_context for ci in dep_converter_cc_infos] + ) + linking_context, _ = cc_common.create_linking_context_from_compilation_outputs( + actions = ctx.actions, + name = ros_package_name + "_converter", + compilation_outputs = compilation_outputs, + cc_toolchain = cc_toolchain, + feature_configuration = feature_configuration, + linking_contexts = linking_contexts, + ) + + return [ + CppProtoConverterAspectInfo( + cc_info = CcInfo( + compilation_context = compilation_context, + linking_context = linking_context, + ), + ), + ] + +cpp_proto_ros2_converter_aspect = aspect( + implementation = _cpp_proto_ros2_converter_aspect_impl, + attr_aspects = ["deps"], + attrs = { + "_proto_to_ros2_converter": attr.label( + default = Label("@com_github_mvukov_rules_ros2//ros2:proto_to_ros2_converter"), + executable = True, + cfg = "exec", + ), + "_cc_toolchain": attr.label( + default = Label("@bazel_tools//tools/cpp:current_cc_toolchain"), + ), + }, + required_providers = [ProtoInfo], + required_aspect_providers = [ + [Ros2InterfaceInfo], + [CppGeneratorAspectInfo], + [CcInfo], + ], + provides = [CppProtoConverterAspectInfo], + toolchains = ["@bazel_tools//tools/cpp:toolchain_type"], + fragments = ["cpp"], +) + +def _cpp_proto_ros2_converter_library_impl(ctx): + cc_info = cc_common.merge_cc_infos( + direct_cc_infos = [ + dep[CppProtoConverterAspectInfo].cc_info + for dep in ctx.attr.deps + ], + ) + return [cc_info] + +cpp_proto_ros2_converter_library = rule( + attrs = { + "deps": attr.label_list( + mandatory = True, + aspects = [ + proto_to_ros2_msg_aspect, + idl_adapter_aspect, + cpp_generator_aspect, + cc_proto_aspect, + cpp_proto_ros2_converter_aspect, + ], + providers = [ProtoInfo], + ), + }, + implementation = _cpp_proto_ros2_converter_library_impl, +) diff --git a/ros2/test/protobuf/BUILD.bazel b/ros2/test/protobuf/BUILD.bazel index 6a35d55a..dc21617b 100644 --- a/ros2/test/protobuf/BUILD.bazel +++ b/ros2/test/protobuf/BUILD.bazel @@ -2,7 +2,7 @@ load("@com_google_protobuf//bazel:proto_library.bzl", "proto_library") load("//ros2:cc_defs.bzl", "ros2_cpp_test") -load("//ros2:protobuf.bzl", "cpp_proto_ros2_interface_library") +load("//ros2:protobuf.bzl", "cpp_proto_ros2_converter_library", "cpp_proto_ros2_interface_library") proto_library( name = "point_proto", @@ -28,3 +28,17 @@ ros2_cpp_test( "@googletest//:gtest_main", ], ) + +cpp_proto_ros2_converter_library( + name = "cpp_proto_ros2_converters", + deps = [":transform_proto"], +) + +ros2_cpp_test( + name = "converter_tests", + srcs = ["converter_tests.cc"], + deps = [ + ":cpp_proto_ros2_converters", + "@googletest//:gtest_main", + ], +) diff --git a/ros2/test/protobuf/converter_tests.cc b/ros2/test/protobuf/converter_tests.cc new file mode 100644 index 00000000..494e4835 --- /dev/null +++ b/ros2/test/protobuf/converter_tests.cc @@ -0,0 +1,130 @@ +// Copyright 2026 Milan Vukov +// +// 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. +#include "gtest/gtest.h" + +#include "point_proto_ros_msgs/proto_converters.h" +#include "transform_proto_ros_msgs/proto_converters.h" + +namespace { + +// --------------------------------------------------------------------------- +// Point converter tests +// --------------------------------------------------------------------------- + +TEST(PointConverterTest, ToRos) { + ros2::test::protobuf::Point proto; + proto.set_x(1.0); + proto.set_y(2.0); + proto.set_z(3.0); + proto.set_label("hello"); + proto.set_id(42); + proto.set_valid(true); + proto.add_values(1.5f); + proto.add_values(2.5f); + + const auto ros = point_proto_ros_msgs::proto_converters::ToRos(proto); + + EXPECT_DOUBLE_EQ(ros.x, 1.0); + EXPECT_DOUBLE_EQ(ros.y, 2.0); + EXPECT_DOUBLE_EQ(ros.z, 3.0); + EXPECT_EQ(ros.label, "hello"); + EXPECT_EQ(ros.id, 42); + EXPECT_EQ(ros.valid, true); + ASSERT_EQ(ros.values.size(), 2u); + EXPECT_FLOAT_EQ(ros.values[0], 1.5f); + EXPECT_FLOAT_EQ(ros.values[1], 2.5f); +} + +TEST(PointConverterTest, FromRos) { + point_proto_ros_msgs::msg::Point ros; + ros.x = 4.0; + ros.y = 5.0; + ros.z = 6.0; + ros.label = "world"; + ros.id = -7; + ros.valid = false; + ros.values = {3.0f, 4.0f, 5.0f}; + + const auto proto = point_proto_ros_msgs::proto_converters::FromRos(ros); + + EXPECT_DOUBLE_EQ(proto.x(), 4.0); + EXPECT_DOUBLE_EQ(proto.y(), 5.0); + EXPECT_DOUBLE_EQ(proto.z(), 6.0); + EXPECT_EQ(proto.label(), "world"); + EXPECT_EQ(proto.id(), -7); + EXPECT_EQ(proto.valid(), false); + ASSERT_EQ(proto.values_size(), 3); + EXPECT_FLOAT_EQ(proto.values(0), 3.0f); + EXPECT_FLOAT_EQ(proto.values(1), 4.0f); + EXPECT_FLOAT_EQ(proto.values(2), 5.0f); +} + +TEST(PointConverterTest, RoundTrip) { + ros2::test::protobuf::Point original; + original.set_x(10.0); + original.set_y(20.0); + original.set_z(30.0); + original.set_label("round"); + original.set_id(99); + original.set_valid(true); + original.add_values(0.1f); + original.add_values(0.2f); + + const auto ros = point_proto_ros_msgs::proto_converters::ToRos(original); + const auto recovered = point_proto_ros_msgs::proto_converters::FromRos(ros); + + EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); +} + +// --------------------------------------------------------------------------- +// Transform converter tests +// --------------------------------------------------------------------------- + +TEST(TransformConverterTest, ToRos) { + ros2::test::protobuf::Transform proto; + proto.mutable_point()->set_x(7.0); + proto.mutable_point()->set_y(8.0); + proto.mutable_point()->set_z(9.0); + proto.mutable_point()->set_label("pt"); + proto.mutable_point()->set_id(1); + proto.mutable_point()->set_valid(true); + + const auto ros = transform_proto_ros_msgs::proto_converters::ToRos(proto); + + EXPECT_DOUBLE_EQ(ros.point.x, 7.0); + EXPECT_DOUBLE_EQ(ros.point.y, 8.0); + EXPECT_DOUBLE_EQ(ros.point.z, 9.0); + EXPECT_EQ(ros.point.label, "pt"); + EXPECT_EQ(ros.point.id, 1); + EXPECT_EQ(ros.point.valid, true); +} + +TEST(TransformConverterTest, RoundTrip) { + ros2::test::protobuf::Transform original; + original.mutable_point()->set_x(1.1); + original.mutable_point()->set_y(2.2); + original.mutable_point()->set_z(3.3); + original.mutable_point()->set_label("trip"); + original.mutable_point()->set_id(5); + original.mutable_point()->set_valid(false); + original.mutable_point()->add_values(0.5f); + + const auto ros = transform_proto_ros_msgs::proto_converters::ToRos(original); + const auto recovered = + transform_proto_ros_msgs::proto_converters::FromRos(ros); + + EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); +} + +} // namespace From 23b6c622af4c41d67d8b98cdb7653cb66824f890 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Sat, 28 Feb 2026 18:36:10 +0000 Subject: [PATCH 07/39] Fix nits --- ros2/proto_to_ros2_msg.py | 2 +- ros2/protobuf.bzl | 18 +----------------- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/ros2/proto_to_ros2_msg.py b/ros2/proto_to_ros2_msg.py index 0191fc08..af5fa21a 100644 --- a/ros2/proto_to_ros2_msg.py +++ b/ros2/proto_to_ros2_msg.py @@ -1,4 +1,4 @@ -# Copyright 2024 Milan Vukov +# Copyright 2026 Milan Vukov # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/ros2/protobuf.bzl b/ros2/protobuf.bzl index 76bedd31..5a58a4dd 100644 --- a/ros2/protobuf.bzl +++ b/ros2/protobuf.bzl @@ -11,7 +11,7 @@ # 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. -"""Converts proto_library targets to ros2_interface_library-compatible targets. +"""Protobuf ROS 2 utilities. Limitations: - One proto file must correspond to exactly one message definition. @@ -22,22 +22,6 @@ Limitations: - Enum and group fields are not supported. - Repeated scalar and message fields map to dynamic ROS2 arrays. - Proto `bytes` fields map to `uint8[]` in ROS2. - -Example usage: - proto_library( - name = "my_proto", - srcs = ["my.proto"], - ) - - proto_ros2_interface_library( - name = "my_msgs", - deps = [":my_proto"], - ) - - cpp_ros2_interface_library( - name = "cpp_my_msgs", - deps = [":my_msgs"], - ) """ load( From f5baae952da338eef232e4e2119732ec4fbbec40 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Sat, 28 Feb 2026 20:36:08 +0000 Subject: [PATCH 08/39] Fix protobuf generators - when multiple files in the same proto target - when proto file name has underscores --- ros2/proto_to_ros2_converter.py | 25 +++++++++++++++++++++---- ros2/proto_to_ros2_msg.py | 29 +++++++++++++++++++++++++++-- ros2/protobuf.bzl | 2 +- ros2/test/protobuf/BUILD.bazel | 5 ++++- ros2/test/protobuf/dummy_one.proto | 9 +++++++++ 5 files changed, 62 insertions(+), 8 deletions(-) create mode 100644 ros2/test/protobuf/dummy_one.proto diff --git a/ros2/proto_to_ros2_converter.py b/ros2/proto_to_ros2_converter.py index ce55764e..7e97d211 100644 --- a/ros2/proto_to_ros2_converter.py +++ b/ros2/proto_to_ros2_converter.py @@ -79,8 +79,12 @@ def _proto_package_to_ns(package): return '::'.join(package.split('.')) if package else '' -def _build_fqn_to_dep_pkg_map(dep_descriptor_set_paths, dep_mapping): +def _build_fqn_to_dep_pkg_map(dep_descriptor_set_paths, dep_mapping, + main_proto_set, ros_package_name): """Build {'.pkg.MsgName': 'dep_ros_package_name'} from dep descriptor sets. + + Also scans the main descriptor set for sibling files (other sources in the + same proto_library target) and maps their message types to ros_package_name. """ path_to_ros_pkg = {} for entry in dep_mapping: @@ -88,6 +92,17 @@ def _build_fqn_to_dep_pkg_map(dep_descriptor_set_paths, dep_mapping): path_to_ros_pkg[proto_path] = ros2_pkg fqn_map = {} + + # Scan sibling files in the main descriptor set (same proto_library target). + for file_proto in main_proto_set.file: + if file_proto.name in path_to_ros_pkg: + continue # Already covered by a dep_mapping. + # This is a sibling file; it belongs to the same ROS2 package. + pkg_prefix = '.' + file_proto.package if file_proto.package else '' + for msg in file_proto.message_type: + fqn = f'{pkg_prefix}.{msg.name}' + fqn_map[fqn] = ros_package_name + for ds_path in dep_descriptor_set_paths: with open(ds_path, 'rb') as f: dep_set = descriptor_pb2.FileDescriptorSet() @@ -329,9 +344,10 @@ def _convert(descriptor_set, proto_sources, ros_package_name, fqn_map, proto_includes.append(proto_include) ros2_includes.append(ros2_include) - # Build a sorted, deduplicated dep-converter include list. + # Build a sorted, deduplicated dep-converter include list, excluding self. dep_converter_includes = sorted( - f'{pkg}/proto_converters.h' for pkg in dep_pkgs_all) + f'{pkg}/proto_converters.h' for pkg in dep_pkgs_all + if pkg != ros_package_name) # Header includes: only the current package's proto and ROS2 msg types. # Dep converter includes go into the .cc file, not the header. @@ -410,7 +426,8 @@ def main(): proto_set.ParseFromString(f.read()) fqn_map = _build_fqn_to_dep_pkg_map(args.dep_descriptor_set, - args.dep_mapping) + args.dep_mapping, proto_set, + args.ros_package_name) proto_sources = [fp.name for fp in proto_set.file] diff --git a/ros2/proto_to_ros2_msg.py b/ros2/proto_to_ros2_msg.py index af5fa21a..8d6849b5 100644 --- a/ros2/proto_to_ros2_msg.py +++ b/ros2/proto_to_ros2_msg.py @@ -24,6 +24,7 @@ - proto `bytes` fields map to `uint8[]` in ROS2. """ import argparse +import os import sys from google.protobuf import descriptor_pb2 @@ -72,8 +73,13 @@ } -def _build_msg_type_map(dep_descriptor_set_paths, dep_mapping): +def _build_msg_type_map(dep_descriptor_set_paths, dep_mapping, + main_descriptor_set_path, proto_source, + self_ros_package): """Build {'.pkg.MsgName': 'ros2_package/MsgName'} from dep descriptor sets. + + Also scans the main descriptor set for sibling files (other sources in the + same proto_library target) and maps their message types to self_ros_package. """ path_to_pkg = {} for entry in dep_mapping: @@ -81,6 +87,23 @@ def _build_msg_type_map(dep_descriptor_set_paths, dep_mapping): path_to_pkg[proto_path] = ros2_pkg msg_type_map = {} + + # Scan sibling files in the main descriptor set (same proto_library target). + with open(main_descriptor_set_path, 'rb') as f: + main_data = f.read() + main_set = descriptor_pb2.FileDescriptorSet() + main_set.ParseFromString(main_data) + for file_proto in main_set.file: + if file_proto.name == proto_source: + continue # Skip the file being converted. + if file_proto.name in path_to_pkg: + continue # Already covered by a dep_mapping. + # This is a sibling file; it belongs to the same ROS2 package. + pkg_prefix = '.' + file_proto.package if file_proto.package else '' + for msg in file_proto.message_type: + fq = f'{pkg_prefix}.{msg.name}' + msg_type_map[fq] = f'{self_ros_package}/{msg.name}' + for ds_path in dep_descriptor_set_paths: with open(ds_path, 'rb') as f: data = f.read() @@ -211,8 +234,10 @@ def main(): sys.exit(f'Error: could not find proto source "{args.proto_source}" in ' f'descriptor set "{args.descriptor_set}".') + self_ros_package = os.path.basename(os.path.dirname(args.output)) msg_type_map = _build_msg_type_map(args.dep_descriptor_set, - args.dep_mapping) + args.dep_mapping, args.descriptor_set, + args.proto_source, self_ros_package) _convert(file_proto, args.output, args.proto_source, msg_type_map) diff --git a/ros2/protobuf.bzl b/ros2/protobuf.bzl index 5a58a4dd..0478732d 100644 --- a/ros2/protobuf.bzl +++ b/ros2/protobuf.bzl @@ -60,7 +60,7 @@ def _proto_to_ros2_msg_aspect_impl(target, ctx): for src in proto_info.direct_sources: if not src.basename.endswith(".proto"): fail("Expected a .proto source file, got: {}".format(src.basename)) - stem = src.basename[:-len(".proto")].capitalize() + stem = "".join([w.capitalize() for w in src.basename[:-len(".proto")].split("_")]) msg_file = ctx.actions.declare_file( "{}/{}.msg".format(ros_package_name, stem), ) diff --git a/ros2/test/protobuf/BUILD.bazel b/ros2/test/protobuf/BUILD.bazel index dc21617b..eacc9b21 100644 --- a/ros2/test/protobuf/BUILD.bazel +++ b/ros2/test/protobuf/BUILD.bazel @@ -6,7 +6,10 @@ load("//ros2:protobuf.bzl", "cpp_proto_ros2_converter_library", "cpp_proto_ros2_ proto_library( name = "point_proto", - srcs = ["point.proto"], + srcs = [ + "dummy_one.proto", + "point.proto", + ], ) proto_library( diff --git a/ros2/test/protobuf/dummy_one.proto b/ros2/test/protobuf/dummy_one.proto new file mode 100644 index 00000000..3767da93 --- /dev/null +++ b/ros2/test/protobuf/dummy_one.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +import "ros2/test/protobuf/point.proto"; + +message DummyOne { + repeated Point points = 1; +} From 59c0e07370a8e384d5abc0ef23a1809d3029e365 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Sat, 28 Feb 2026 21:42:27 +0000 Subject: [PATCH 09/39] Use protobuf-python from Bazel --- requirements.txt | 1 - requirements_lock.txt | 12 ------------ ros2/BUILD.bazel | 6 ++++-- 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/requirements.txt b/requirements.txt index d811fa58..fad99e39 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,6 @@ empy==3.3.* lark-parser numpy~=1.23 packaging -protobuf psutil pytest pytest-cov diff --git a/requirements_lock.txt b/requirements_lock.txt index 43906706..84095de9 100644 --- a/requirements_lock.txt +++ b/requirements_lock.txt @@ -134,18 +134,6 @@ pluggy==1.0.0 \ --hash=sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159 \ --hash=sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3 # via pytest -protobuf==6.33.5 \ - --hash=sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c \ - --hash=sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02 \ - --hash=sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c \ - --hash=sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd \ - --hash=sha256:8f04fa32763dcdb4973d537d6b54e615cc61108c7cb38fe59310c3192d29510a \ - --hash=sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190 \ - --hash=sha256:a3157e62729aafb8df6da2c03aa5c0937c7266c626ce11a278b6eb7963c4e37c \ - --hash=sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5 \ - --hash=sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0 \ - --hash=sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b - # via -r requirements.txt psutil==5.9.7 \ --hash=sha256:032f4f2c909818c86cea4fe2cc407f1c0f0cde8e6c6d702b28b8ce0c0d143340 \ --hash=sha256:0bd41bf2d1463dfa535942b2a8f0e958acf6607ac0be52265ab31f7923bcd5e6 \ diff --git a/ros2/BUILD.bazel b/ros2/BUILD.bazel index e8710ffa..06274d13 100644 --- a/ros2/BUILD.bazel +++ b/ros2/BUILD.bazel @@ -99,7 +99,9 @@ py_binary( name = "proto_to_ros2_msg", srcs = ["proto_to_ros2_msg.py"], visibility = ["//visibility:public"], - deps = [requirement("protobuf")], + deps = [ + "@com_google_protobuf//:protobuf_python", + ], ) py_binary( @@ -107,8 +109,8 @@ py_binary( srcs = ["proto_to_ros2_converter.py"], visibility = ["//visibility:public"], deps = [ + "@com_google_protobuf//:protobuf_python", requirement("empy"), - requirement("protobuf"), ], ) From 75bcd02912086f821984a5663c61358b522bdd77 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Sat, 28 Feb 2026 21:43:49 +0000 Subject: [PATCH 10/39] Add protobuf bzl dep --- MODULE.bazel | 1 + 1 file changed, 1 insertion(+) diff --git a/MODULE.bazel b/MODULE.bazel index 644d27db..c50e1d40 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -10,6 +10,7 @@ bazel_dep(name = "libyaml", version = "0.2.5") bazel_dep(name = "lz4", version = "1.10.0.bcr.1") bazel_dep(name = "nlohmann_json", version = "3.12.0.bcr.1") bazel_dep(name = "platforms", version = "1.0.0") +bazel_dep(name = "protobuf", version = "33.5", repo_name = "com_google_protobuf") bazel_dep(name = "pybind11_bazel", version = "3.0.0") bazel_dep(name = "readerwriterqueue", version = "1.0.6") bazel_dep(name = "rules_cc", version = "0.2.16") From 58472b42420c494aa73b2c4e0b9f65257c07b99f Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Sun, 1 Mar 2026 19:04:37 +0000 Subject: [PATCH 11/39] Clean up code duplication in protobuf.bzl --- ros2/proto_to_ros2_converter.py | 20 +++++++------- ros2/proto_to_ros2_msg.py | 26 +++++++++--------- ros2/protobuf.bzl | 48 +++++++++++++++------------------ 3 files changed, 45 insertions(+), 49 deletions(-) diff --git a/ros2/proto_to_ros2_converter.py b/ros2/proto_to_ros2_converter.py index 7e97d211..6b757519 100644 --- a/ros2/proto_to_ros2_converter.py +++ b/ros2/proto_to_ros2_converter.py @@ -11,7 +11,7 @@ # 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. -"""Generates C++ proto<->ROS2 converter header and source from a proto file. +"""Generates C++ proto<->ROS converter header and source from a proto file. For each proto message the tool emits two free functions in namespace ``::proto_converters``: @@ -22,7 +22,7 @@ Limitations mirror those of proto_to_ros2_msg.py: - Exactly one message definition per proto file. - Service definitions are not supported. -- Message-type fields require a --dep_mapping entry so the ROS2 package can +- Message-type fields require a --dep_mapping entry so the ROS package can be resolved. - Enum and group fields are not supported. - Repeated bytes fields are not supported. @@ -40,7 +40,7 @@ # Field-type tables # --------------------------------------------------------------------------- -# Proto scalar types → C++ type used in ROS2 structs. +# Proto scalar types → C++ type used in ROS structs. _SCALAR_CPP_TYPE = { FieldDescriptorProto.TYPE_DOUBLE: 'double', FieldDescriptorProto.TYPE_FLOAT: 'float', @@ -97,7 +97,7 @@ def _build_fqn_to_dep_pkg_map(dep_descriptor_set_paths, dep_mapping, for file_proto in main_proto_set.file: if file_proto.name in path_to_ros_pkg: continue # Already covered by a dep_mapping. - # This is a sibling file; it belongs to the same ROS2 package. + # This is a sibling file; it belongs to the same ROS package. pkg_prefix = '.' + file_proto.package if file_proto.package else '' for msg in file_proto.message_type: fqn = f'{pkg_prefix}.{msg.name}' @@ -322,7 +322,7 @@ def _convert(descriptor_set, proto_sources, ros_package_name, fqn_map, # Proto C++ include: replace .proto suffix with .pb.h proto_include = file_proto.name[:-len('.proto')] + '.pb.h' - # ROS2 msg include: snake_case name with .hpp extension + # ROS msg include: snake_case name with .hpp extension ros2_include = (f'{ros_package_name}/msg/' f'{_to_snake_case(msg_name)}.hpp') @@ -349,7 +349,7 @@ def _convert(descriptor_set, proto_sources, ros_package_name, fqn_map, f'{pkg}/proto_converters.h' for pkg in dep_pkgs_all if pkg != ros_package_name) - # Header includes: only the current package's proto and ROS2 msg types. + # Header includes: only the current package's proto and ROS msg types. # Dep converter includes go into the .cc file, not the header. include_lines = [] for inc in proto_includes: @@ -383,7 +383,7 @@ def _convert(descriptor_set, proto_sources, ros_package_name, fqn_map, def main(): parser = argparse.ArgumentParser( - description='Generate C++ proto<->ROS2 converter code.') + description='Generate C++ proto<->ROS converter code.') parser.add_argument( '--descriptor_set', required=True, @@ -392,7 +392,7 @@ def main(): parser.add_argument( '--ros_package_name', required=True, - help='ROS2 package name (e.g. point_proto_ros_msgs).', + help='ROS package name (e.g. point_proto_ros_msgs).', ) parser.add_argument( '--output_header', @@ -408,8 +408,8 @@ def main(): '--dep_mapping', action='append', default=[], - metavar='PROTO_PATH:ROS2_PACKAGE', - help='Mapping from a dep proto file path to its ROS2 package name. ' + metavar='PROTO_PATH:ROS_PACKAGE', + help='Mapping from a dep proto file path to its ROS package name. ' 'May be repeated.', ) parser.add_argument( diff --git a/ros2/proto_to_ros2_msg.py b/ros2/proto_to_ros2_msg.py index 8d6849b5..32add2d0 100644 --- a/ros2/proto_to_ros2_msg.py +++ b/ros2/proto_to_ros2_msg.py @@ -11,17 +11,17 @@ # 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. -"""Converts a proto file to a ROS2 .msg file. +"""Converts a proto file to a ROS .msg file. Limitations: - Exactly one message definition per proto file is required. - Service definitions are not supported. -- Message-type fields are supported as cross-package ROS2 references (e.g. +- Message-type fields are supported as cross-package ROS references (e.g. `pkg/Type`). The caller must supply --dep_mapping for each imported proto. - Enum and group fields are not supported. - Repeated scalar and message fields are supported and map to dynamic arrays (e.g. `int32[] values`, `pkg/msg/Point[] points`). -- proto `bytes` fields map to `uint8[]` in ROS2. +- proto `bytes` fields map to `uint8[]` in ROS. """ import argparse import os @@ -30,9 +30,9 @@ from google.protobuf import descriptor_pb2 from google.protobuf.descriptor_pb2 import FieldDescriptorProto -# Mapping from proto3 scalar FieldDescriptorProto.Type to ROS2 .msg type. +# Mapping from proto3 scalar FieldDescriptorProto.Type to ROS .msg type. # Types that map to arrays (like bytes) use a special sentinel handled below. -_PROTO_TO_ROS2_TYPE = { +_PROTO_TO_ROS_TYPE = { FieldDescriptorProto.TYPE_DOUBLE: 'float64', FieldDescriptorProto.TYPE_FLOAT: @@ -61,7 +61,7 @@ 'bool', FieldDescriptorProto.TYPE_STRING: 'string', - # bytes in proto3 → dynamic byte array in ROS2 + # bytes in proto3 → dynamic byte array in ROS FieldDescriptorProto.TYPE_BYTES: 'uint8[]', } @@ -98,7 +98,7 @@ def _build_msg_type_map(dep_descriptor_set_paths, dep_mapping, continue # Skip the file being converted. if file_proto.name in path_to_pkg: continue # Already covered by a dep_mapping. - # This is a sibling file; it belongs to the same ROS2 package. + # This is a sibling file; it belongs to the same ROS 2 package. pkg_prefix = '.' + file_proto.package if file_proto.package else '' for msg in file_proto.message_type: fq = f'{pkg_prefix}.{msg.name}' @@ -135,7 +135,7 @@ def _find_file_descriptor(proto_set, proto_source): def _convert(file_proto, output_path, proto_source, msg_type_map): - """Validate and convert a FileDescriptorProto to a ROS2 .msg file.""" + """Validate and convert a FileDescriptorProto to a ROS .msg file.""" if file_proto.service: sys.exit(f'Error: {proto_source}: services are not supported ' f'(found {len(file_proto.service)} service(s)).') @@ -172,11 +172,11 @@ def _convert(file_proto, output_path, proto_source, msg_type_map): f'Error: {proto_source}: field "{field.name}" has unsupported ' f'type "{type_name}".') - if field_type_value not in _PROTO_TO_ROS2_TYPE: + if field_type_value not in _PROTO_TO_ROS_TYPE: sys.exit(f'Error: {proto_source}: field "{field.name}" has unknown ' f'type value {field_type_value}.') - ros2_type = _PROTO_TO_ROS2_TYPE[field_type_value] + ros2_type = _PROTO_TO_ROS_TYPE[field_type_value] # proto `bytes` already becomes `uint8[]`; avoid double `[]`. if is_repeated and field_type_value != FieldDescriptorProto.TYPE_BYTES: @@ -190,7 +190,7 @@ def _convert(file_proto, output_path, proto_source, msg_type_map): def main(): parser = argparse.ArgumentParser( - description='Convert a proto file to a ROS2 .msg file.') + description='Convert a proto file to a ROS .msg file.') parser.add_argument( '--descriptor_set', required=True, @@ -210,8 +210,8 @@ def main(): '--dep_mapping', action='append', default=[], - metavar='PROTO_PATH:ROS2_PACKAGE', - help='Mapping from a dep proto file path to its ROS2 package name. ' + metavar='PROTO_PATH:ROS_PACKAGE', + help='Mapping from a dep proto file path to its ROS package name. ' 'May be repeated.', ) parser.add_argument( diff --git a/ros2/protobuf.bzl b/ros2/protobuf.bzl index 0478732d..28743b87 100644 --- a/ros2/protobuf.bzl +++ b/ros2/protobuf.bzl @@ -38,24 +38,29 @@ load("@rules_cc//cc:toolchain_utils.bzl", "find_cpp_toolchain") CppProtoConverterAspectInfo = provider("TBD", fields = ["cc_info"]) -def _proto_to_ros2_msg_aspect_impl(target, ctx): - proto_info = target[ProtoInfo] - msg_files = [] - - ros_package_name = target.label.name + "_ros_msgs" - +def _collect_dep_proto_args(deps): + """Returns (dep_extra_args, dep_descriptor_sets) for proto deps.""" dep_extra_args = [] dep_descriptor_sets = [] - for dep in ctx.rule.attr.deps: - dep_ds = dep[ProtoInfo].direct_descriptor_set - dep_descriptor_sets.append(dep_ds) - dep_extra_args += ["--dep_descriptor_set", dep_ds.path] + for dep in deps: + dep_descriptor_set = dep[ProtoInfo].direct_descriptor_set + dep_descriptor_sets.append(dep_descriptor_set) + dep_extra_args += ["--dep_descriptor_set", dep_descriptor_set.path] dep_ros2_package = dep[Ros2InterfaceInfo].ros_package_name for src in dep[ProtoInfo].direct_sources: dep_extra_args += [ "--dep_mapping", "{}:{}".format(src.short_path, dep_ros2_package), ] + return dep_extra_args, dep_descriptor_sets + +def _proto_to_ros2_msg_aspect_impl(target, ctx): + proto_info = target[ProtoInfo] + msg_files = [] + + ros_package_name = target.label.name + "_ros_msgs" + + dep_extra_args, dep_descriptor_sets = _collect_dep_proto_args(ctx.rule.attr.deps) for src in proto_info.direct_sources: if not src.basename.endswith(".proto"): @@ -129,21 +134,12 @@ def _cpp_proto_ros2_converter_aspect_impl(target, ctx): ros_package_name = target[Ros2InterfaceInfo].ros_package_name # Collect dep information: descriptor sets and proto→ros_package mappings. - dep_extra_args = [] - dep_descriptor_sets = [] - dep_converter_cc_infos = [] - for dep in ctx.rule.attr.deps: - dep_ds = dep[ProtoInfo].direct_descriptor_set - dep_descriptor_sets.append(dep_ds) - dep_extra_args += ["--dep_descriptor_set", dep_ds.path] - dep_ros2_package = dep[Ros2InterfaceInfo].ros_package_name - for src in dep[ProtoInfo].direct_sources: - dep_extra_args += [ - "--dep_mapping", - "{}:{}".format(src.short_path, dep_ros2_package), - ] - if CppProtoConverterAspectInfo in dep: - dep_converter_cc_infos.append(dep[CppProtoConverterAspectInfo].cc_info) + dep_extra_args, dep_descriptor_sets = _collect_dep_proto_args(ctx.rule.attr.deps) + dep_converter_cc_infos = [ + dep[CppProtoConverterAspectInfo].cc_info + for dep in ctx.rule.attr.deps + if CppProtoConverterAspectInfo in dep + ] # Declare output files. header = ctx.actions.declare_file( @@ -168,7 +164,7 @@ def _cpp_proto_ros2_converter_aspect_impl(target, ctx): source.path, ] + dep_extra_args, mnemonic = "ProtoToRos2Converter", - progress_message = "Generating proto/ROS2 converters for %{label}", + progress_message = "Generating proto/ROS 2 converters for %{label}", ) # The include root is the parent directory of the ros_package_name folder. From f416ab898ad3e33f9bfdac9e41f3975fd90acf1b Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Sun, 1 Mar 2026 19:42:09 +0000 Subject: [PATCH 12/39] Factor out common parts in generators --- ros2/BUILD.bazel | 10 +++++++ ros2/proto_to_ros2.py | 49 +++++++++++++++++++++++++++++++ ros2/proto_to_ros2_converter.py | 42 +++++++------------------- ros2/proto_to_ros2_msg.py | 52 +++++++-------------------------- 4 files changed, 79 insertions(+), 74 deletions(-) create mode 100644 ros2/proto_to_ros2.py diff --git a/ros2/BUILD.bazel b/ros2/BUILD.bazel index 06274d13..9eb917d0 100644 --- a/ros2/BUILD.bazel +++ b/ros2/BUILD.bazel @@ -95,11 +95,20 @@ py_binary( ], ) +py_library( + name = "proto_to_ros2", + srcs = ["proto_to_ros2.py"], + deps = [ + "@com_google_protobuf//:protobuf_python", + ], +) + py_binary( name = "proto_to_ros2_msg", srcs = ["proto_to_ros2_msg.py"], visibility = ["//visibility:public"], deps = [ + ":proto_to_ros2", "@com_google_protobuf//:protobuf_python", ], ) @@ -109,6 +118,7 @@ py_binary( srcs = ["proto_to_ros2_converter.py"], visibility = ["//visibility:public"], deps = [ + ":proto_to_ros2", "@com_google_protobuf//:protobuf_python", requirement("empy"), ], diff --git a/ros2/proto_to_ros2.py b/ros2/proto_to_ros2.py new file mode 100644 index 00000000..53b8aaa4 --- /dev/null +++ b/ros2/proto_to_ros2.py @@ -0,0 +1,49 @@ +# Copyright 2026 Milan Vukov +# +# 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. +"""Shared utilities for proto-to-ROS 2 code generation tools.""" +from google.protobuf import descriptor_pb2 +from google.protobuf.descriptor_pb2 import FieldDescriptorProto + +# Non-scalar proto field types that are explicitly unsupported. +UNSUPPORTED_TYPES = { + FieldDescriptorProto.TYPE_GROUP: 'group', + FieldDescriptorProto.TYPE_ENUM: 'enum', +} + + +def load_descriptor_set(path): + """Loads and parses a binary FileDescriptorSet from a file path.""" + with open(path, 'rb') as f: + proto_set = descriptor_pb2.FileDescriptorSet() + proto_set.ParseFromString(f.read()) + return proto_set + + +def find_file_descriptor(proto_set, proto_source): + """Finds a FileDescriptorProto by name, with fallback to basename + matching. + """ + for file_proto in proto_set.file: + if file_proto.name == proto_source: + return file_proto + source_basename = proto_source.split('/')[-1] + for file_proto in proto_set.file: + if file_proto.name.split('/')[-1] == source_basename: + return file_proto + return None + + +def parse_dep_mapping(dep_mapping): + """Parses a list of 'proto_path:ros_package' strings into a dict.""" + return dict(entry.split(':', 1) for entry in dep_mapping) diff --git a/ros2/proto_to_ros2_converter.py b/ros2/proto_to_ros2_converter.py index 6b757519..50ecf91e 100644 --- a/ros2/proto_to_ros2_converter.py +++ b/ros2/proto_to_ros2_converter.py @@ -33,9 +33,10 @@ import sys import em -from google.protobuf import descriptor_pb2 from google.protobuf.descriptor_pb2 import FieldDescriptorProto +from ros2 import proto_to_ros2 + # --------------------------------------------------------------------------- # Field-type tables # --------------------------------------------------------------------------- @@ -57,11 +58,6 @@ FieldDescriptorProto.TYPE_BOOL: 'bool', } -_UNSUPPORTED_TYPES = { - FieldDescriptorProto.TYPE_GROUP: 'group', - FieldDescriptorProto.TYPE_ENUM: 'enum', -} - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -86,11 +82,7 @@ def _build_fqn_to_dep_pkg_map(dep_descriptor_set_paths, dep_mapping, Also scans the main descriptor set for sibling files (other sources in the same proto_library target) and maps their message types to ros_package_name. """ - path_to_ros_pkg = {} - for entry in dep_mapping: - proto_path, ros2_pkg = entry.split(':', 1) - path_to_ros_pkg[proto_path] = ros2_pkg - + path_to_ros_pkg = proto_to_ros2.parse_dep_mapping(dep_mapping) fqn_map = {} # Scan sibling files in the main descriptor set (same proto_library target). @@ -104,9 +96,7 @@ def _build_fqn_to_dep_pkg_map(dep_descriptor_set_paths, dep_mapping, fqn_map[fqn] = ros_package_name for ds_path in dep_descriptor_set_paths: - with open(ds_path, 'rb') as f: - dep_set = descriptor_pb2.FileDescriptorSet() - dep_set.ParseFromString(f.read()) + dep_set = proto_to_ros2.load_descriptor_set(ds_path) for file_proto in dep_set.file: ros2_pkg = path_to_ros_pkg.get(file_proto.name) if ros2_pkg is None: @@ -118,18 +108,6 @@ def _build_fqn_to_dep_pkg_map(dep_descriptor_set_paths, dep_mapping, return fqn_map -def _find_file_descriptor(proto_set, proto_source): - """Find a FileDescriptorProto by name, with basename fallback.""" - for fp in proto_set.file: - if fp.name == proto_source: - return fp - source_base = proto_source.split('/')[-1] - for fp in proto_set.file: - if fp.name.split('/')[-1] == source_base: - return fp - return None - - # --------------------------------------------------------------------------- # Per-field conversion code generation # --------------------------------------------------------------------------- @@ -151,9 +129,10 @@ def _field_conversions(message, proto_source, fqn_map): is_repeated = field.label == FieldDescriptorProto.LABEL_REPEATED ftype = field.type - if ftype in _UNSUPPORTED_TYPES: + if ftype in proto_to_ros2.UNSUPPORTED_TYPES: sys.exit(f'Error: {proto_source}: field "{name}": ' - f'{_UNSUPPORTED_TYPES[ftype]} fields are not supported.') + f'{proto_to_ros2.UNSUPPORTED_TYPES[ftype]} ' + 'fields are not supported.') # ---- bytes ---------------------------------------------------------- if ftype == FieldDescriptorProto.TYPE_BYTES: @@ -300,7 +279,8 @@ def _convert(descriptor_set, proto_sources, ros_package_name, fqn_map, ros2_includes = [] for proto_source in proto_sources: - file_proto = _find_file_descriptor(descriptor_set, proto_source) + file_proto = proto_to_ros2.find_file_descriptor(descriptor_set, + proto_source) if file_proto is None: sys.exit(f'Error: could not find proto source "{proto_source}" in ' f'the descriptor set.') @@ -421,9 +401,7 @@ def main(): ) args = parser.parse_args() - with open(args.descriptor_set, 'rb') as f: - proto_set = descriptor_pb2.FileDescriptorSet() - proto_set.ParseFromString(f.read()) + proto_set = proto_to_ros2.load_descriptor_set(args.descriptor_set) fqn_map = _build_fqn_to_dep_pkg_map(args.dep_descriptor_set, args.dep_mapping, proto_set, diff --git a/ros2/proto_to_ros2_msg.py b/ros2/proto_to_ros2_msg.py index 32add2d0..74bf39fc 100644 --- a/ros2/proto_to_ros2_msg.py +++ b/ros2/proto_to_ros2_msg.py @@ -27,9 +27,10 @@ import os import sys -from google.protobuf import descriptor_pb2 from google.protobuf.descriptor_pb2 import FieldDescriptorProto +from ros2 import proto_to_ros2 + # Mapping from proto3 scalar FieldDescriptorProto.Type to ROS .msg type. # Types that map to arrays (like bytes) use a special sentinel handled below. _PROTO_TO_ROS_TYPE = { @@ -66,12 +67,6 @@ 'uint8[]', } -# Non-scalar types that are explicitly rejected. -_UNSUPPORTED_TYPES = { - FieldDescriptorProto.TYPE_GROUP: 'group', - FieldDescriptorProto.TYPE_ENUM: 'enum', -} - def _build_msg_type_map(dep_descriptor_set_paths, dep_mapping, main_descriptor_set_path, proto_source, @@ -81,18 +76,11 @@ def _build_msg_type_map(dep_descriptor_set_paths, dep_mapping, Also scans the main descriptor set for sibling files (other sources in the same proto_library target) and maps their message types to self_ros_package. """ - path_to_pkg = {} - for entry in dep_mapping: - proto_path, ros2_pkg = entry.split(':', 1) - path_to_pkg[proto_path] = ros2_pkg - + path_to_pkg = proto_to_ros2.parse_dep_mapping(dep_mapping) msg_type_map = {} # Scan sibling files in the main descriptor set (same proto_library target). - with open(main_descriptor_set_path, 'rb') as f: - main_data = f.read() - main_set = descriptor_pb2.FileDescriptorSet() - main_set.ParseFromString(main_data) + main_set = proto_to_ros2.load_descriptor_set(main_descriptor_set_path) for file_proto in main_set.file: if file_proto.name == proto_source: continue # Skip the file being converted. @@ -105,10 +93,7 @@ def _build_msg_type_map(dep_descriptor_set_paths, dep_mapping, msg_type_map[fq] = f'{self_ros_package}/{msg.name}' for ds_path in dep_descriptor_set_paths: - with open(ds_path, 'rb') as f: - data = f.read() - dep_set = descriptor_pb2.FileDescriptorSet() - dep_set.ParseFromString(data) + dep_set = proto_to_ros2.load_descriptor_set(ds_path) for file_proto in dep_set.file: ros2_pkg = path_to_pkg.get(file_proto.name) if ros2_pkg is None: @@ -120,20 +105,6 @@ def _build_msg_type_map(dep_descriptor_set_paths, dep_mapping, return msg_type_map -def _find_file_descriptor(proto_set, proto_source): - """Find a FileDescriptorProto by name, with fallback to basename matching. - """ - for file_proto in proto_set.file: - if file_proto.name == proto_source: - return file_proto - # Fallback: match by basename in case paths differ slightly. - source_basename = proto_source.split('/')[-1] - for file_proto in proto_set.file: - if file_proto.name.split('/')[-1] == source_basename: - return file_proto - return None - - def _convert(file_proto, output_path, proto_source, msg_type_map): """Validate and convert a FileDescriptorProto to a ROS .msg file.""" if file_proto.service: @@ -166,8 +137,8 @@ def _convert(file_proto, output_path, proto_source, msg_type_map): lines.append(f'{ros2_type} {field.name}') continue - if field_type_value in _UNSUPPORTED_TYPES: - type_name = _UNSUPPORTED_TYPES[field_type_value] + if field_type_value in proto_to_ros2.UNSUPPORTED_TYPES: + type_name = proto_to_ros2.UNSUPPORTED_TYPES[field_type_value] sys.exit( f'Error: {proto_source}: field "{field.name}" has unsupported ' f'type "{type_name}".') @@ -223,13 +194,10 @@ def main(): ) args = parser.parse_args() - with open(args.descriptor_set, 'rb') as f: - data = f.read() - - proto_set = descriptor_pb2.FileDescriptorSet() - proto_set.ParseFromString(data) + proto_set = proto_to_ros2.load_descriptor_set(args.descriptor_set) - file_proto = _find_file_descriptor(proto_set, args.proto_source) + file_proto = proto_to_ros2.find_file_descriptor(proto_set, + args.proto_source) if file_proto is None: sys.exit(f'Error: could not find proto source "{args.proto_source}" in ' f'descriptor set "{args.descriptor_set}".') From 79f244cff8439cdfc2ce4fa6036b42c86ca354b9 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Sun, 1 Mar 2026 19:52:09 +0000 Subject: [PATCH 13/39] Clean up naming --- ros2/proto_to_ros2_converter.py | 49 ++++++++++++++++----------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/ros2/proto_to_ros2_converter.py b/ros2/proto_to_ros2_converter.py index 50ecf91e..d3c2f396 100644 --- a/ros2/proto_to_ros2_converter.py +++ b/ros2/proto_to_ros2_converter.py @@ -75,15 +75,15 @@ def _proto_package_to_ns(package): return '::'.join(package.split('.')) if package else '' -def _build_fqn_to_dep_pkg_map(dep_descriptor_set_paths, dep_mapping, - main_proto_set, ros_package_name): +def _build_proto_types_to_ros_pkgs(dep_descriptor_set_paths, dep_mapping, + main_proto_set, ros_package_name): """Build {'.pkg.MsgName': 'dep_ros_package_name'} from dep descriptor sets. Also scans the main descriptor set for sibling files (other sources in the same proto_library target) and maps their message types to ros_package_name. """ path_to_ros_pkg = proto_to_ros2.parse_dep_mapping(dep_mapping) - fqn_map = {} + proto_types_to_ros_pkgs = {} # Scan sibling files in the main descriptor set (same proto_library target). for file_proto in main_proto_set.file: @@ -92,20 +92,17 @@ def _build_fqn_to_dep_pkg_map(dep_descriptor_set_paths, dep_mapping, # This is a sibling file; it belongs to the same ROS package. pkg_prefix = '.' + file_proto.package if file_proto.package else '' for msg in file_proto.message_type: - fqn = f'{pkg_prefix}.{msg.name}' - fqn_map[fqn] = ros_package_name + proto_types_to_ros_pkgs[ + f'{pkg_prefix}.{msg.name}'] = ros_package_name for ds_path in dep_descriptor_set_paths: dep_set = proto_to_ros2.load_descriptor_set(ds_path) for file_proto in dep_set.file: - ros2_pkg = path_to_ros_pkg.get(file_proto.name) - if ros2_pkg is None: - continue + ros_pkg = path_to_ros_pkg[file_proto.name] pkg_prefix = '.' + file_proto.package if file_proto.package else '' for msg in file_proto.message_type: - fqn = f'{pkg_prefix}.{msg.name}' - fqn_map[fqn] = ros2_pkg - return fqn_map + proto_types_to_ros_pkgs[f'{pkg_prefix}.{msg.name}'] = ros_pkg + return proto_types_to_ros_pkgs # --------------------------------------------------------------------------- @@ -113,11 +110,11 @@ def _build_fqn_to_dep_pkg_map(dep_descriptor_set_paths, dep_mapping, # --------------------------------------------------------------------------- -def _field_conversions(message, proto_source, fqn_map): +def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): """Return (to_ros_lines, from_ros_lines, dep_pkgs_used) for one message. Each entry in to_ros_lines / from_ros_lines is a C++ statement string - (already indented with two spaces). dep_pkgs_used is the set of dep + (already indented with two spaces). dep_pkgs_used is the set of dep ros_package_names whose converters are called. """ to_ros = [] @@ -162,7 +159,7 @@ def _field_conversions(message, proto_source, fqn_map): # ---- message -------------------------------------------------------- if ftype == FieldDescriptorProto.TYPE_MESSAGE: - dep_pkg = fqn_map.get(field.type_name) + dep_pkg = proto_types_to_ros_pkgs.get(field.type_name) if dep_pkg is None: sys.exit( f'Error: {proto_source}: field "{name}" references ' @@ -269,14 +266,14 @@ def _render(template, context): # --------------------------------------------------------------------------- -def _convert(descriptor_set, proto_sources, ros_package_name, fqn_map, - output_header, output_source): +def _convert(descriptor_set, proto_sources, ros_package_name, + proto_types_to_ros_pkgs, output_header, output_source): """Generate converter .h and .cc for all proto sources in the descriptor.""" messages = [] dep_pkgs_all = set() proto_includes = [] - ros2_includes = [] + ros_includes = [] for proto_source in proto_sources: file_proto = proto_to_ros2.find_file_descriptor(descriptor_set, @@ -303,11 +300,11 @@ def _convert(descriptor_set, proto_sources, ros_package_name, fqn_map, # Proto C++ include: replace .proto suffix with .pb.h proto_include = file_proto.name[:-len('.proto')] + '.pb.h' # ROS msg include: snake_case name with .hpp extension - ros2_include = (f'{ros_package_name}/msg/' - f'{_to_snake_case(msg_name)}.hpp') + ros_include = (f'{ros_package_name}/msg/' + f'{_to_snake_case(msg_name)}.hpp') to_ros_lines, from_ros_lines, dep_pkgs = _field_conversions( - message, proto_source, fqn_map) + message, proto_source, proto_types_to_ros_pkgs) dep_pkgs_all.update(dep_pkgs) to_ros_body = '\n'.join(to_ros_lines) @@ -322,7 +319,7 @@ def _convert(descriptor_set, proto_sources, ros_package_name, fqn_map, }) proto_includes.append(proto_include) - ros2_includes.append(ros2_include) + ros_includes.append(ros_include) # Build a sorted, deduplicated dep-converter include list, excluding self. dep_converter_includes = sorted( @@ -335,7 +332,7 @@ def _convert(descriptor_set, proto_sources, ros_package_name, fqn_map, for inc in proto_includes: include_lines.append(f'#include "{inc}"') include_lines.append('') - for inc in ros2_includes: + for inc in ros_includes: include_lines.append(f'#include "{inc}"') includes_section = '\n'.join(include_lines) @@ -403,9 +400,9 @@ def main(): proto_set = proto_to_ros2.load_descriptor_set(args.descriptor_set) - fqn_map = _build_fqn_to_dep_pkg_map(args.dep_descriptor_set, - args.dep_mapping, proto_set, - args.ros_package_name) + proto_types_to_ros_pkgs = _build_proto_types_to_ros_pkgs( + args.dep_descriptor_set, args.dep_mapping, proto_set, + args.ros_package_name) proto_sources = [fp.name for fp in proto_set.file] @@ -413,7 +410,7 @@ def main(): descriptor_set=proto_set, proto_sources=proto_sources, ros_package_name=args.ros_package_name, - fqn_map=fqn_map, + proto_types_to_ros_pkgs=proto_types_to_ros_pkgs, output_header=args.output_header, output_source=args.output_source, ) From fce594aa453a018c5885d3408b3b7ff0bf2898a6 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Sun, 1 Mar 2026 20:25:06 +0000 Subject: [PATCH 14/39] Ensure the message name is correct --- ros2/proto_to_ros2_msg.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ros2/proto_to_ros2_msg.py b/ros2/proto_to_ros2_msg.py index 74bf39fc..a66a616f 100644 --- a/ros2/proto_to_ros2_msg.py +++ b/ros2/proto_to_ros2_msg.py @@ -25,6 +25,7 @@ """ import argparse import os +import pathlib import sys from google.protobuf.descriptor_pb2 import FieldDescriptorProto @@ -118,6 +119,13 @@ def _convert(file_proto, output_path, proto_source, msg_type_map): f'got {num_messages}.') message = file_proto.message_type[0] + stem = pathlib.Path(proto_source).stem + expected_name = ''.join(w.capitalize() for w in stem.split('_')) + if message.name != expected_name: + sys.exit( + f'Error: {proto_source}: message must be named "{expected_name}" ' + f'to match the proto filename, got "{message.name}".') + lines = [f'# Generated from proto source: {proto_source}', ''] for field in message.field: From 50b7da31d6d075baa8c653c021de0a94085d7a97 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Sun, 1 Mar 2026 20:38:41 +0000 Subject: [PATCH 15/39] Update docs --- ros2/protobuf.bzl | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ros2/protobuf.bzl b/ros2/protobuf.bzl index 28743b87..af348289 100644 --- a/ros2/protobuf.bzl +++ b/ros2/protobuf.bzl @@ -119,6 +119,12 @@ def _cpp_proto_ros2_interface_library_impl(ctx): return cc_generator_impl(ctx, CppGeneratorAspectInfo) cpp_proto_ros2_interface_library = rule( + doc = """Generates a C++ ROS 2 interface library from proto_library deps. + +Each proto file in deps must be named in snake_case (e.g. my_message.proto) +or PascalCase (e.g. MyMessage.proto), and must define exactly one message +whose name matches the PascalCase form of the filename stem. +""", attrs = { "deps": attr.label_list( mandatory = True, @@ -255,6 +261,12 @@ def _cpp_proto_ros2_converter_library_impl(ctx): return [cc_info] cpp_proto_ros2_converter_library = rule( + doc = """Generates a C++ proto<->ROS 2 converter library from proto_library deps. + +Each proto file in deps must be named in snake_case (e.g. my_message.proto) +or PascalCase (e.g. MyMessage.proto), and must define exactly one message +whose name matches the PascalCase form of the filename stem. +""", attrs = { "deps": attr.label_list( mandatory = True, From 5fb24b549624cc514ac49183fd5ba55a51ca28d0 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Sun, 1 Mar 2026 20:57:30 +0000 Subject: [PATCH 16/39] Add support for enums --- ros2/proto_to_ros2.py | 1 - ros2/proto_to_ros2_converter.py | 24 +++++++++++++++- ros2/proto_to_ros2_msg.py | 41 ++++++++++++++++++++++++++- ros2/protobuf.bzl | 4 ++- ros2/test/protobuf/converter_tests.cc | 32 +++++++++++++++++++++ ros2/test/protobuf/dummy_one.proto | 8 ++++++ ros2/test/protobuf/tests.cc | 15 ++++++++++ 7 files changed, 121 insertions(+), 4 deletions(-) diff --git a/ros2/proto_to_ros2.py b/ros2/proto_to_ros2.py index 53b8aaa4..b6c8c0af 100644 --- a/ros2/proto_to_ros2.py +++ b/ros2/proto_to_ros2.py @@ -18,7 +18,6 @@ # Non-scalar proto field types that are explicitly unsupported. UNSUPPORTED_TYPES = { FieldDescriptorProto.TYPE_GROUP: 'group', - FieldDescriptorProto.TYPE_ENUM: 'enum', } diff --git a/ros2/proto_to_ros2_converter.py b/ros2/proto_to_ros2_converter.py index d3c2f396..a1beee6b 100644 --- a/ros2/proto_to_ros2_converter.py +++ b/ros2/proto_to_ros2_converter.py @@ -24,7 +24,9 @@ - Service definitions are not supported. - Message-type fields require a --dep_mapping entry so the ROS package can be resolved. -- Enum and group fields are not supported. +- Enum fields are supported (cast to/from int32_t). Only enums defined in + the same proto file are supported. +- Group fields are not supported. - Repeated bytes fields are not supported. """ import argparse @@ -131,6 +133,26 @@ def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): f'{proto_to_ros2.UNSUPPORTED_TYPES[ftype]} ' 'fields are not supported.') + # ---- enum ----------------------------------------------------------- + if ftype == FieldDescriptorProto.TYPE_ENUM: + # Proto3 enums are backed by int32; cast explicitly. + enum_cpp_type = field.type_name.lstrip('.').replace('.', '::') + if is_repeated: + to_ros.append(f' for (const auto v : proto.{name}()) {{') + to_ros.append( + f' ros.{name}.push_back(static_cast(v));') + to_ros.append(' }') + from_ros.append(f' for (const auto v : ros.{name}) {{') + from_ros.append(f' proto.add_{name}' + f'(static_cast<{enum_cpp_type}>(v));') + from_ros.append(' }') + else: + to_ros.append( + f' ros.{name} = static_cast(proto.{name}());') + from_ros.append(f' proto.set_{name}' + f'(static_cast<{enum_cpp_type}>(ros.{name}));') + continue + # ---- bytes ---------------------------------------------------------- if ftype == FieldDescriptorProto.TYPE_BYTES: if is_repeated: diff --git a/ros2/proto_to_ros2_msg.py b/ros2/proto_to_ros2_msg.py index a66a616f..f6042302 100644 --- a/ros2/proto_to_ros2_msg.py +++ b/ros2/proto_to_ros2_msg.py @@ -18,7 +18,9 @@ - Service definitions are not supported. - Message-type fields are supported as cross-package ROS references (e.g. `pkg/Type`). The caller must supply --dep_mapping for each imported proto. -- Enum and group fields are not supported. +- Enum fields are supported (mapped to int32 with named constants). + Only enums defined in the same proto file are supported. +- Group fields are not supported. - Repeated scalar and message fields are supported and map to dynamic arrays (e.g. `int32[] values`, `pkg/msg/Point[] points`). - proto `bytes` fields map to `uint8[]` in ROS. @@ -69,6 +71,21 @@ } +def _build_enum_map(file_proto, message): + """Build {'.pkg.EnumName': EnumDescriptorProto} for enums in this file. + + Covers top-level enums and enums nested directly inside the message. + """ + enum_map = {} + pkg_prefix = '.' + file_proto.package if file_proto.package else '' + for enum_type in file_proto.enum_type: # top-level enums + enum_map[f'{pkg_prefix}.{enum_type.name}'] = enum_type + msg_prefix = f'{pkg_prefix}.{message.name}' + for enum_type in message.enum_type: # nested enums + enum_map[f'{msg_prefix}.{enum_type.name}'] = enum_type + return enum_map + + def _build_msg_type_map(dep_descriptor_set_paths, dep_mapping, main_descriptor_set_path, proto_source, self_ros_package): @@ -128,6 +145,23 @@ def _convert(file_proto, output_path, proto_source, msg_type_map): lines = [f'# Generated from proto source: {proto_source}', ''] + # Emit enum constants before fields (deduplicated per enum type). + enum_map = _build_enum_map(file_proto, message) + emitted_enums = set() + for field in message.field: + if field.type == FieldDescriptorProto.TYPE_ENUM: + if field.type_name not in emitted_enums: + enum_desc = enum_map.get(field.type_name) + if enum_desc is None: + sys.exit(f'Error: {proto_source}: enum "{field.type_name}" ' + f'not found in the current proto file. Cross-file ' + f'enums are not yet supported.') + lines.append(f'# {enum_desc.name} constants') + for ev in enum_desc.value: + lines.append(f'int32 {ev.name}={ev.number}') + lines.append('') + emitted_enums.add(field.type_name) + for field in message.field: field_type_value = field.type is_repeated = (field.label == FieldDescriptorProto.LABEL_REPEATED) @@ -145,6 +179,11 @@ def _convert(file_proto, output_path, proto_source, msg_type_map): lines.append(f'{ros2_type} {field.name}') continue + if field_type_value == FieldDescriptorProto.TYPE_ENUM: + ros2_type = 'int32[]' if is_repeated else 'int32' + lines.append(f'{ros2_type} {field.name}') + continue + if field_type_value in proto_to_ros2.UNSUPPORTED_TYPES: type_name = proto_to_ros2.UNSUPPORTED_TYPES[field_type_value] sys.exit( diff --git a/ros2/protobuf.bzl b/ros2/protobuf.bzl index af348289..2121715b 100644 --- a/ros2/protobuf.bzl +++ b/ros2/protobuf.bzl @@ -19,7 +19,9 @@ Limitations: - Message-type fields are supported as cross-package ROS2 references (e.g. `pkg/Type`). Each proto dep must have a corresponding proto_ros2_interface_library so the package name can be resolved. -- Enum and group fields are not supported. +- Enum fields are supported (mapped to int32 with named constants). + Only enums defined in the same proto file are supported. +- Group fields are not supported. - Repeated scalar and message fields map to dynamic ROS2 arrays. - Proto `bytes` fields map to `uint8[]` in ROS2. """ diff --git a/ros2/test/protobuf/converter_tests.cc b/ros2/test/protobuf/converter_tests.cc index 494e4835..6efa3dcf 100644 --- a/ros2/test/protobuf/converter_tests.cc +++ b/ros2/test/protobuf/converter_tests.cc @@ -127,4 +127,36 @@ TEST(TransformConverterTest, RoundTrip) { EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); } +// --------------------------------------------------------------------------- +// DummyOne converter tests (exercises enum field conversion) +// --------------------------------------------------------------------------- + +TEST(DummyOneConverterTest, ToRos) { + ros2::test::protobuf::DummyOne proto; + proto.set_color(ros2::test::protobuf::COLOR_RED); + + const auto ros = point_proto_ros_msgs::proto_converters::ToRos(proto); + + EXPECT_EQ(ros.color, point_proto_ros_msgs::msg::DummyOne::COLOR_RED); +} + +TEST(DummyOneConverterTest, FromRos) { + point_proto_ros_msgs::msg::DummyOne ros; + ros.color = point_proto_ros_msgs::msg::DummyOne::COLOR_GREEN; + + const auto proto = point_proto_ros_msgs::proto_converters::FromRos(ros); + + EXPECT_EQ(proto.color(), ros2::test::protobuf::COLOR_GREEN); +} + +TEST(DummyOneConverterTest, RoundTrip) { + ros2::test::protobuf::DummyOne original; + original.set_color(ros2::test::protobuf::COLOR_BLUE); + + const auto ros = point_proto_ros_msgs::proto_converters::ToRos(original); + const auto recovered = point_proto_ros_msgs::proto_converters::FromRos(ros); + + EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); +} + } // namespace diff --git a/ros2/test/protobuf/dummy_one.proto b/ros2/test/protobuf/dummy_one.proto index 3767da93..6f39a9fb 100644 --- a/ros2/test/protobuf/dummy_one.proto +++ b/ros2/test/protobuf/dummy_one.proto @@ -4,6 +4,14 @@ package ros2.test.protobuf; import "ros2/test/protobuf/point.proto"; +enum Color { + COLOR_UNKNOWN = 0; + COLOR_RED = 1; + COLOR_GREEN = 2; + COLOR_BLUE = 3; +} + message DummyOne { repeated Point points = 1; + Color color = 2; } diff --git a/ros2/test/protobuf/tests.cc b/ros2/test/protobuf/tests.cc index f85b52b9..472368a7 100644 --- a/ros2/test/protobuf/tests.cc +++ b/ros2/test/protobuf/tests.cc @@ -11,5 +11,20 @@ // 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. +#include "gtest/gtest.h" + +#include "point_proto_ros_msgs/msg/dummy_one.hpp" #include "point_proto_ros_msgs/msg/point.hpp" #include "transform_proto_ros_msgs/msg/transform.hpp" + +namespace { + +TEST(DummyOneTest, EnumConstants) { + using DummyOne = point_proto_ros_msgs::msg::DummyOne; + EXPECT_EQ(DummyOne::COLOR_UNKNOWN, 0); + EXPECT_EQ(DummyOne::COLOR_RED, 1); + EXPECT_EQ(DummyOne::COLOR_GREEN, 2); + EXPECT_EQ(DummyOne::COLOR_BLUE, 3); +} + +} // namespace From f95604b0b0548931c43ff6301d4a11bf35102eae Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Thu, 5 Mar 2026 19:45:15 +0000 Subject: [PATCH 17/39] Disable oneof and maps --- ros2/proto_to_ros2_msg.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/ros2/proto_to_ros2_msg.py b/ros2/proto_to_ros2_msg.py index f6042302..430e679d 100644 --- a/ros2/proto_to_ros2_msg.py +++ b/ros2/proto_to_ros2_msg.py @@ -20,6 +20,7 @@ `pkg/Type`). The caller must supply --dep_mapping for each imported proto. - Enum fields are supported (mapped to int32 with named constants). Only enums defined in the same proto file are supported. +- oneof and map fields are not supported. - Group fields are not supported. - Repeated scalar and message fields are supported and map to dynamic arrays (e.g. `int32[] values`, `pkg/msg/Point[] points`). @@ -162,10 +163,29 @@ def _convert(file_proto, output_path, proto_source, msg_type_map): lines.append('') emitted_enums.add(field.type_name) + # Build the set of fully-qualified names of map-entry nested message types + # so that map fields can be detected and rejected below. + pkg_prefix = '.' + file_proto.package if file_proto.package else '' + map_entry_fq_names = { + f'{pkg_prefix}.{message.name}.{nested.name}' + for nested in message.nested_type + if nested.options.map_entry + } + for field in message.field: field_type_value = field.type is_repeated = (field.label == FieldDescriptorProto.LABEL_REPEATED) + if field.HasField('oneof_index'): + sys.exit( + f'Error: {proto_source}: field "{field.name}" is part of a ' + f'oneof, which is not supported.') + + if field.type_name in map_entry_fq_names: + sys.exit( + f'Error: {proto_source}: field "{field.name}" is a map field, ' + f'which is not supported.') + if field_type_value == FieldDescriptorProto.TYPE_MESSAGE: ros2_type = msg_type_map.get(field.type_name) if ros2_type is None: From 8b52ab2f41518793f3437bdb6b89108878553ab8 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Sat, 7 Mar 2026 16:27:03 +0000 Subject: [PATCH 18/39] Move proto related stuff to ros2/protobuf --- ros2/BUILD.bazel | 30 ---------------- ros2/protobuf/BUILD.bazel | 35 +++++++++++++++++++ ros2/{protobuf.bzl => protobuf/defs.bzl} | 4 +-- ros2/{ => protobuf}/proto_to_ros2.py | 0 .../{ => protobuf}/proto_to_ros2_converter.py | 2 +- ros2/{ => protobuf}/proto_to_ros2_msg.py | 2 +- ros2/test/protobuf/BUILD.bazel | 2 +- 7 files changed, 40 insertions(+), 35 deletions(-) create mode 100644 ros2/protobuf/BUILD.bazel rename ros2/{protobuf.bzl => protobuf/defs.bzl} (99%) rename ros2/{ => protobuf}/proto_to_ros2.py (100%) rename ros2/{ => protobuf}/proto_to_ros2_converter.py (99%) rename ros2/{ => protobuf}/proto_to_ros2_msg.py (99%) diff --git a/ros2/BUILD.bazel b/ros2/BUILD.bazel index 9eb917d0..88e53ffb 100644 --- a/ros2/BUILD.bazel +++ b/ros2/BUILD.bazel @@ -20,7 +20,6 @@ exports_files([ "launch.py.tpl", "launch.sh.tpl", "plugin.bzl", - "protobuf.bzl", "py_defs.bzl", "pytest_wrapper.py.tpl", "ros2_action.py", @@ -95,35 +94,6 @@ py_binary( ], ) -py_library( - name = "proto_to_ros2", - srcs = ["proto_to_ros2.py"], - deps = [ - "@com_google_protobuf//:protobuf_python", - ], -) - -py_binary( - name = "proto_to_ros2_msg", - srcs = ["proto_to_ros2_msg.py"], - visibility = ["//visibility:public"], - deps = [ - ":proto_to_ros2", - "@com_google_protobuf//:protobuf_python", - ], -) - -py_binary( - name = "proto_to_ros2_converter", - srcs = ["proto_to_ros2_converter.py"], - visibility = ["//visibility:public"], - deps = [ - ":proto_to_ros2", - "@com_google_protobuf//:protobuf_python", - requirement("empy"), - ], -) - whl_filegroup( name = "numpy_includes", pattern = "numpy/core/include/numpy", diff --git a/ros2/protobuf/BUILD.bazel b/ros2/protobuf/BUILD.bazel new file mode 100644 index 00000000..f4f030da --- /dev/null +++ b/ros2/protobuf/BUILD.bazel @@ -0,0 +1,35 @@ +load("@rules_python//python:defs.bzl", "py_binary", "py_library") +load("@rules_ros2_pip_deps//:requirements.bzl", "requirement") + +exports_files([ + "protobuf.bzl", +]) + +py_library( + name = "proto_to_ros2", + srcs = ["proto_to_ros2.py"], + deps = [ + "@com_google_protobuf//:protobuf_python", + ], +) + +py_binary( + name = "proto_to_ros2_msg", + srcs = ["proto_to_ros2_msg.py"], + visibility = ["//visibility:public"], + deps = [ + ":proto_to_ros2", + "@com_google_protobuf//:protobuf_python", + ], +) + +py_binary( + name = "proto_to_ros2_converter", + srcs = ["proto_to_ros2_converter.py"], + visibility = ["//visibility:public"], + deps = [ + ":proto_to_ros2", + "@com_google_protobuf//:protobuf_python", + requirement("empy"), + ], +) diff --git a/ros2/protobuf.bzl b/ros2/protobuf/defs.bzl similarity index 99% rename from ros2/protobuf.bzl rename to ros2/protobuf/defs.bzl index 2121715b..321931fb 100644 --- a/ros2/protobuf.bzl +++ b/ros2/protobuf/defs.bzl @@ -108,7 +108,7 @@ proto_to_ros2_msg_aspect = aspect( attr_aspects = ["deps"], attrs = { "_proto_to_ros2_msg": attr.label( - default = Label("@com_github_mvukov_rules_ros2//ros2:proto_to_ros2_msg"), + default = Label("@com_github_mvukov_rules_ros2//ros2/protobuf:proto_to_ros2_msg"), executable = True, cfg = "exec", ), @@ -234,7 +234,7 @@ cpp_proto_ros2_converter_aspect = aspect( attr_aspects = ["deps"], attrs = { "_proto_to_ros2_converter": attr.label( - default = Label("@com_github_mvukov_rules_ros2//ros2:proto_to_ros2_converter"), + default = Label("@com_github_mvukov_rules_ros2//ros2/protobuf:proto_to_ros2_converter"), executable = True, cfg = "exec", ), diff --git a/ros2/proto_to_ros2.py b/ros2/protobuf/proto_to_ros2.py similarity index 100% rename from ros2/proto_to_ros2.py rename to ros2/protobuf/proto_to_ros2.py diff --git a/ros2/proto_to_ros2_converter.py b/ros2/protobuf/proto_to_ros2_converter.py similarity index 99% rename from ros2/proto_to_ros2_converter.py rename to ros2/protobuf/proto_to_ros2_converter.py index a1beee6b..8273dd27 100644 --- a/ros2/proto_to_ros2_converter.py +++ b/ros2/protobuf/proto_to_ros2_converter.py @@ -37,7 +37,7 @@ import em from google.protobuf.descriptor_pb2 import FieldDescriptorProto -from ros2 import proto_to_ros2 +from ros2.protobuf import proto_to_ros2 # --------------------------------------------------------------------------- # Field-type tables diff --git a/ros2/proto_to_ros2_msg.py b/ros2/protobuf/proto_to_ros2_msg.py similarity index 99% rename from ros2/proto_to_ros2_msg.py rename to ros2/protobuf/proto_to_ros2_msg.py index 430e679d..01f042fb 100644 --- a/ros2/proto_to_ros2_msg.py +++ b/ros2/protobuf/proto_to_ros2_msg.py @@ -33,7 +33,7 @@ from google.protobuf.descriptor_pb2 import FieldDescriptorProto -from ros2 import proto_to_ros2 +from ros2.protobuf import proto_to_ros2 # Mapping from proto3 scalar FieldDescriptorProto.Type to ROS .msg type. # Types that map to arrays (like bytes) use a special sentinel handled below. diff --git a/ros2/test/protobuf/BUILD.bazel b/ros2/test/protobuf/BUILD.bazel index eacc9b21..6bf89554 100644 --- a/ros2/test/protobuf/BUILD.bazel +++ b/ros2/test/protobuf/BUILD.bazel @@ -2,7 +2,7 @@ load("@com_google_protobuf//bazel:proto_library.bzl", "proto_library") load("//ros2:cc_defs.bzl", "ros2_cpp_test") -load("//ros2:protobuf.bzl", "cpp_proto_ros2_converter_library", "cpp_proto_ros2_interface_library") +load("//ros2/protobuf:defs.bzl", "cpp_proto_ros2_converter_library", "cpp_proto_ros2_interface_library") proto_library( name = "point_proto", From b1f5a6208b2f9aff95b7e7e88ebfbdf0bfa4db6a Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Sat, 7 Mar 2026 16:49:11 +0000 Subject: [PATCH 19/39] Generated converters: void (const In&, out* out) --- ros2/protobuf/proto_to_ros2_converter.py | 51 +++++++++++------------- ros2/test/protobuf/converter_tests.cc | 34 ++++++++++------ 2 files changed, 46 insertions(+), 39 deletions(-) diff --git a/ros2/protobuf/proto_to_ros2_converter.py b/ros2/protobuf/proto_to_ros2_converter.py index 8273dd27..98c813e6 100644 --- a/ros2/protobuf/proto_to_ros2_converter.py +++ b/ros2/protobuf/proto_to_ros2_converter.py @@ -140,16 +140,16 @@ def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): if is_repeated: to_ros.append(f' for (const auto v : proto.{name}()) {{') to_ros.append( - f' ros.{name}.push_back(static_cast(v));') + f' ros->{name}.push_back(static_cast(v));') to_ros.append(' }') from_ros.append(f' for (const auto v : ros.{name}) {{') - from_ros.append(f' proto.add_{name}' + from_ros.append(f' proto->add_{name}' f'(static_cast<{enum_cpp_type}>(v));') from_ros.append(' }') else: to_ros.append( - f' ros.{name} = static_cast(proto.{name}());') - from_ros.append(f' proto.set_{name}' + f' ros->{name} = static_cast(proto.{name}());') + from_ros.append(f' proto->set_{name}' f'(static_cast<{enum_cpp_type}>(ros.{name}));') continue @@ -158,10 +158,10 @@ def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): if is_repeated: sys.exit(f'Error: {proto_source}: field "{name}": ' f'repeated bytes is not supported.') - to_ros.append(f' ros.{name} = std::vector' + to_ros.append(f' ros->{name} = std::vector' f'(proto.{name}().begin(), proto.{name}().end());') from_ros.append( - f' proto.set_{name}' + f' proto->set_{name}' f'(std::string(ros.{name}.begin(), ros.{name}.end()));') continue @@ -169,14 +169,14 @@ def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): if ftype == FieldDescriptorProto.TYPE_STRING: if is_repeated: to_ros.append( - f' ros.{name} = std::vector' + f' ros->{name} = std::vector' f'(proto.{name}().begin(), proto.{name}().end());') from_ros.append(f' for (const auto& s : ros.{name}) {{') - from_ros.append(f' proto.add_{name}(s);') + from_ros.append(f' proto->add_{name}(s);') from_ros.append(' }') else: - to_ros.append(f' ros.{name} = proto.{name}();') - from_ros.append(f' proto.set_{name}(ros.{name});') + to_ros.append(f' ros->{name} = proto.{name}();') + from_ros.append(f' proto->set_{name}(ros.{name});') continue # ---- message -------------------------------------------------------- @@ -193,16 +193,17 @@ def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): if is_repeated: to_ros.append(f' for (const auto& item : proto.{name}()) {{') - to_ros.append(f' ros.{name}.push_back({conv}::ToRos(item));') + to_ros.append( + f' {conv}::ToRos(item, &ros->{name}.emplace_back());') to_ros.append(' }') from_ros.append(f' for (const auto& item : ros.{name}) {{') from_ros.append( - f' *proto.add_{name}() = {conv}::FromRos(item);') + f' {conv}::FromRos(item, proto->add_{name}());') from_ros.append(' }') else: - to_ros.append(f' ros.{name} = {conv}::ToRos(proto.{name}());') - from_ros.append(f' *proto.mutable_{name}() = ' - f'{conv}::FromRos(ros.{name});') + to_ros.append(f' {conv}::ToRos(proto.{name}(), &ros->{name});') + from_ros.append( + f' {conv}::FromRos(ros.{name}, proto->mutable_{name}());') continue # ---- scalar (all remaining types) ----------------------------------- @@ -213,13 +214,13 @@ def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): cpp_type = _SCALAR_CPP_TYPE[ftype] if is_repeated: - to_ros.append(f' ros.{name} = std::vector<{cpp_type}>' + to_ros.append(f' ros->{name} = std::vector<{cpp_type}>' f'(proto.{name}().begin(), proto.{name}().end());') - from_ros.append(f' proto.mutable_{name}()->Assign' + from_ros.append(f' proto->mutable_{name}()->Assign' f'(ros.{name}.begin(), ros.{name}.end());') else: - to_ros.append(f' ros.{name} = proto.{name}();') - from_ros.append(f' proto.set_{name}(ros.{name});') + to_ros.append(f' ros->{name} = proto.{name}();') + from_ros.append(f' proto->set_{name}(ros.{name});') return to_ros, from_ros, dep_pkgs @@ -237,9 +238,9 @@ def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): namespace @(ctx['ros_package_name'])::proto_converters { @[for msg in ctx['messages']] -@(msg['ros_type']) ToRos(const @(msg['proto_type'])& proto); +void ToRos(const @(msg['proto_type'])& proto, @(msg['ros_type'])* ros); -@(msg['proto_type']) FromRos(const @(msg['ros_type'])& ros); +void FromRos(const @(msg['ros_type'])& ros, @(msg['proto_type'])* proto); @[end for] } // namespace @(ctx['ros_package_name'])::proto_converters @@ -255,16 +256,12 @@ def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): namespace @(ctx['ros_package_name'])::proto_converters { @[for msg in ctx['messages']] -@(msg['ros_type']) ToRos(const @(msg['proto_type'])& proto) { - @(msg['ros_type']) ros; +void ToRos(const @(msg['proto_type'])& proto, @(msg['ros_type'])* ros) { @(msg['to_ros_body']) - return ros; } -@(msg['proto_type']) FromRos(const @(msg['ros_type'])& ros) { - @(msg['proto_type']) proto; +void FromRos(const @(msg['ros_type'])& ros, @(msg['proto_type'])* proto) { @(msg['from_ros_body']) - return proto; } @[end for] diff --git a/ros2/test/protobuf/converter_tests.cc b/ros2/test/protobuf/converter_tests.cc index 6efa3dcf..07e155ed 100644 --- a/ros2/test/protobuf/converter_tests.cc +++ b/ros2/test/protobuf/converter_tests.cc @@ -33,7 +33,8 @@ TEST(PointConverterTest, ToRos) { proto.add_values(1.5f); proto.add_values(2.5f); - const auto ros = point_proto_ros_msgs::proto_converters::ToRos(proto); + point_proto_ros_msgs::msg::Point ros; + point_proto_ros_msgs::proto_converters::ToRos(proto, &ros); EXPECT_DOUBLE_EQ(ros.x, 1.0); EXPECT_DOUBLE_EQ(ros.y, 2.0); @@ -56,7 +57,8 @@ TEST(PointConverterTest, FromRos) { ros.valid = false; ros.values = {3.0f, 4.0f, 5.0f}; - const auto proto = point_proto_ros_msgs::proto_converters::FromRos(ros); + ros2::test::protobuf::Point proto; + point_proto_ros_msgs::proto_converters::FromRos(ros, &proto); EXPECT_DOUBLE_EQ(proto.x(), 4.0); EXPECT_DOUBLE_EQ(proto.y(), 5.0); @@ -81,8 +83,10 @@ TEST(PointConverterTest, RoundTrip) { original.add_values(0.1f); original.add_values(0.2f); - const auto ros = point_proto_ros_msgs::proto_converters::ToRos(original); - const auto recovered = point_proto_ros_msgs::proto_converters::FromRos(ros); + point_proto_ros_msgs::msg::Point ros; + point_proto_ros_msgs::proto_converters::ToRos(original, &ros); + ros2::test::protobuf::Point recovered; + point_proto_ros_msgs::proto_converters::FromRos(ros, &recovered); EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); } @@ -100,7 +104,8 @@ TEST(TransformConverterTest, ToRos) { proto.mutable_point()->set_id(1); proto.mutable_point()->set_valid(true); - const auto ros = transform_proto_ros_msgs::proto_converters::ToRos(proto); + transform_proto_ros_msgs::msg::Transform ros; + transform_proto_ros_msgs::proto_converters::ToRos(proto, &ros); EXPECT_DOUBLE_EQ(ros.point.x, 7.0); EXPECT_DOUBLE_EQ(ros.point.y, 8.0); @@ -120,9 +125,10 @@ TEST(TransformConverterTest, RoundTrip) { original.mutable_point()->set_valid(false); original.mutable_point()->add_values(0.5f); - const auto ros = transform_proto_ros_msgs::proto_converters::ToRos(original); - const auto recovered = - transform_proto_ros_msgs::proto_converters::FromRos(ros); + transform_proto_ros_msgs::msg::Transform ros; + transform_proto_ros_msgs::proto_converters::ToRos(original, &ros); + ros2::test::protobuf::Transform recovered; + transform_proto_ros_msgs::proto_converters::FromRos(ros, &recovered); EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); } @@ -135,7 +141,8 @@ TEST(DummyOneConverterTest, ToRos) { ros2::test::protobuf::DummyOne proto; proto.set_color(ros2::test::protobuf::COLOR_RED); - const auto ros = point_proto_ros_msgs::proto_converters::ToRos(proto); + point_proto_ros_msgs::msg::DummyOne ros; + point_proto_ros_msgs::proto_converters::ToRos(proto, &ros); EXPECT_EQ(ros.color, point_proto_ros_msgs::msg::DummyOne::COLOR_RED); } @@ -144,7 +151,8 @@ TEST(DummyOneConverterTest, FromRos) { point_proto_ros_msgs::msg::DummyOne ros; ros.color = point_proto_ros_msgs::msg::DummyOne::COLOR_GREEN; - const auto proto = point_proto_ros_msgs::proto_converters::FromRos(ros); + ros2::test::protobuf::DummyOne proto; + point_proto_ros_msgs::proto_converters::FromRos(ros, &proto); EXPECT_EQ(proto.color(), ros2::test::protobuf::COLOR_GREEN); } @@ -153,8 +161,10 @@ TEST(DummyOneConverterTest, RoundTrip) { ros2::test::protobuf::DummyOne original; original.set_color(ros2::test::protobuf::COLOR_BLUE); - const auto ros = point_proto_ros_msgs::proto_converters::ToRos(original); - const auto recovered = point_proto_ros_msgs::proto_converters::FromRos(ros); + point_proto_ros_msgs::msg::DummyOne ros; + point_proto_ros_msgs::proto_converters::ToRos(original, &ros); + ros2::test::protobuf::DummyOne recovered; + point_proto_ros_msgs::proto_converters::FromRos(ros, &recovered); EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); } From d9cbcbe43280a86925b4f205ffbe452ed4eb1d67 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Sun, 8 Mar 2026 17:07:19 +0000 Subject: [PATCH 20/39] Add automatic proto timestamp <> ros time conversion --- ros2/protobuf/BUILD.bazel | 11 +++++ ros2/protobuf/common_runtime.cc | 30 ++++++++++++ ros2/protobuf/common_runtime.h | 27 +++++++++++ ros2/protobuf/defs.bzl | 34 ++++++++++++- ros2/protobuf/proto_to_ros2_converter.py | 61 +++++++++++++++++++++--- ros2/protobuf/proto_to_ros2_msg.py | 5 ++ ros2/test/protobuf/BUILD.bazel | 2 + ros2/test/protobuf/converter_tests.cc | 46 ++++++++++++++++++ ros2/test/protobuf/event.proto | 23 +++++++++ 9 files changed, 231 insertions(+), 8 deletions(-) create mode 100644 ros2/protobuf/common_runtime.cc create mode 100644 ros2/protobuf/common_runtime.h create mode 100644 ros2/test/protobuf/event.proto diff --git a/ros2/protobuf/BUILD.bazel b/ros2/protobuf/BUILD.bazel index f4f030da..6fef59db 100644 --- a/ros2/protobuf/BUILD.bazel +++ b/ros2/protobuf/BUILD.bazel @@ -1,3 +1,4 @@ +load("@com_github_mvukov_rules_ros2//ros2:cc_defs.bzl", "ros2_cpp_library") load("@rules_python//python:defs.bzl", "py_binary", "py_library") load("@rules_ros2_pip_deps//:requirements.bzl", "requirement") @@ -33,3 +34,13 @@ py_binary( requirement("empy"), ], ) + +ros2_cpp_library( + name = "common_runtime", + srcs = ["common_runtime.cc"], + hdrs = ["common_runtime.h"], + deps = [ + "@com_google_protobuf//:timestamp_cc_proto", + "@ros2_rcl_interfaces//:cpp_builtin_interfaces", + ], +) diff --git a/ros2/protobuf/common_runtime.cc b/ros2/protobuf/common_runtime.cc new file mode 100644 index 00000000..bf08eb0f --- /dev/null +++ b/ros2/protobuf/common_runtime.cc @@ -0,0 +1,30 @@ +// Copyright 2026 Milan Vukov +// +// 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. +#include "ros2/protobuf/common_runtime.h" + +namespace rules_ros2_protobuf_common_runtime { + +void ToRos(const google::protobuf::Timestamp& proto, + builtin_interfaces::msg::Time* ros) { + ros->sec = proto.seconds(); + ros->nanosec = proto.nanos(); +} + +void FromRos(const builtin_interfaces::msg::Time& ros, + google::protobuf::Timestamp* proto) { + proto->set_seconds(ros.sec); + proto->set_nanos(ros.nanosec); +} + +} // namespace rules_ros2_protobuf_common_runtime diff --git a/ros2/protobuf/common_runtime.h b/ros2/protobuf/common_runtime.h new file mode 100644 index 00000000..7b4ab633 --- /dev/null +++ b/ros2/protobuf/common_runtime.h @@ -0,0 +1,27 @@ +// Copyright 2026 Milan Vukov +// +// 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. +#pragma once + +#include "builtin_interfaces/msg/time.hpp" +#include "google/protobuf/timestamp.pb.h" + +namespace rules_ros2_protobuf_common_runtime { + +void ToRos(const google::protobuf::Timestamp& proto, + builtin_interfaces::msg::Time* ros); + +void FromRos(const builtin_interfaces::msg::Time& ros, + google::protobuf::Timestamp* proto); + +} // namespace rules_ros2_protobuf_common_runtime diff --git a/ros2/protobuf/defs.bzl b/ros2/protobuf/defs.bzl index 321931fb..22c6433c 100644 --- a/ros2/protobuf/defs.bzl +++ b/ros2/protobuf/defs.bzl @@ -40,6 +40,8 @@ load("@rules_cc//cc:toolchain_utils.bzl", "find_cpp_toolchain") CppProtoConverterAspectInfo = provider("TBD", fields = ["cc_info"]) +_GOOGLE_PROTOBUF_TIMESTAMP_PROTO = "google/protobuf/timestamp.proto" + def _collect_dep_proto_args(deps): """Returns (dep_extra_args, dep_descriptor_sets) for proto deps.""" dep_extra_args = [] @@ -58,6 +60,15 @@ def _collect_dep_proto_args(deps): def _proto_to_ros2_msg_aspect_impl(target, ctx): proto_info = target[ProtoInfo] + + # Special case: google.protobuf.Timestamp → alias to builtin_interfaces so + # that downstream aspects (idl_adapter, cpp_generator) produce + # builtin_interfaces C++ headers that generated ROS msgs with Timestamp + # fields can include. + for src in proto_info.direct_sources: + if src.short_path.endswith(_GOOGLE_PROTOBUF_TIMESTAMP_PROTO): + return [ctx.attr._builtin_interfaces[Ros2InterfaceInfo]] + msg_files = [] ros_package_name = target.label.name + "_ros_msgs" @@ -112,6 +123,10 @@ proto_to_ros2_msg_aspect = aspect( executable = True, cfg = "exec", ), + "_builtin_interfaces": attr.label( + default = Label("@ros2_rcl_interfaces//:builtin_interfaces"), + providers = [Ros2InterfaceInfo], + ), }, required_providers = [ProtoInfo], provides = [Ros2InterfaceInfo], @@ -139,6 +154,15 @@ whose name matches the PascalCase form of the filename stem. def _cpp_proto_ros2_converter_aspect_impl(target, ctx): proto_info = target[ProtoInfo] + + # Skip converter generation for google.protobuf.Timestamp – conversion is + # handled by common_runtime; the generated converter would be wrong anyway + # because the Ros2InterfaceInfo for this target is aliased to + # builtin_interfaces (not a one-to-one Timestamp ↔ Time mapping). + for src in proto_info.direct_sources: + if src.short_path.endswith(_GOOGLE_PROTOBUF_TIMESTAMP_PROTO): + return [CppProtoConverterAspectInfo(cc_info = ctx.attr._common_runtime[CcInfo])] + ros_package_name = target[Ros2InterfaceInfo].ros_package_name # Collect dep information: descriptor sets and proto→ros_package mappings. @@ -157,6 +181,10 @@ def _cpp_proto_ros2_converter_aspect_impl(target, ctx): "{}/proto_converters.cc".format(ros_package_name), ) + proto_source_args = [] + for src in proto_info.direct_sources: + proto_source_args += ["--proto_source", src.short_path] + ctx.actions.run( executable = ctx.executable._proto_to_ros2_converter, inputs = [proto_info.direct_descriptor_set] + dep_descriptor_sets, @@ -170,7 +198,7 @@ def _cpp_proto_ros2_converter_aspect_impl(target, ctx): header.path, "--output_source", source.path, - ] + dep_extra_args, + ] + proto_source_args + dep_extra_args, mnemonic = "ProtoToRos2Converter", progress_message = "Generating proto/ROS 2 converters for %{label}", ) @@ -241,6 +269,10 @@ cpp_proto_ros2_converter_aspect = aspect( "_cc_toolchain": attr.label( default = Label("@bazel_tools//tools/cpp:current_cc_toolchain"), ), + "_common_runtime": attr.label( + default = Label("@com_github_mvukov_rules_ros2//ros2/protobuf:common_runtime"), + providers = [CcInfo], + ), }, required_providers = [ProtoInfo], required_aspect_providers = [ diff --git a/ros2/protobuf/proto_to_ros2_converter.py b/ros2/protobuf/proto_to_ros2_converter.py index 98c813e6..767c8525 100644 --- a/ros2/protobuf/proto_to_ros2_converter.py +++ b/ros2/protobuf/proto_to_ros2_converter.py @@ -100,7 +100,10 @@ def _build_proto_types_to_ros_pkgs(dep_descriptor_set_paths, dep_mapping, for ds_path in dep_descriptor_set_paths: dep_set = proto_to_ros2.load_descriptor_set(ds_path) for file_proto in dep_set.file: - ros_pkg = path_to_ros_pkg[file_proto.name] + ros_pkg = path_to_ros_pkg.get(file_proto.name) + if ros_pkg is None: + # TODO(mvukov) Investigate this in more details. + continue # Skip transitive well-known types not in dep_mapping. pkg_prefix = '.' + file_proto.package if file_proto.package else '' for msg in file_proto.message_type: proto_types_to_ros_pkgs[f'{pkg_prefix}.{msg.name}'] = ros_pkg @@ -111,17 +114,22 @@ def _build_proto_types_to_ros_pkgs(dep_descriptor_set_paths, dep_mapping, # Per-field conversion code generation # --------------------------------------------------------------------------- +_GOOGLE_PROTOBUF_TIMESTAMP = '.google.protobuf.Timestamp' +_COMMON_RUNTIME_NS = 'rules_ros2_protobuf_common_runtime' + def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): - """Return (to_ros_lines, from_ros_lines, dep_pkgs_used) for one message. + """Return (to_ros_lines, from_ros_lines, dep_pkgs, needs_common_runtime). Each entry in to_ros_lines / from_ros_lines is a C++ statement string - (already indented with two spaces). dep_pkgs_used is the set of dep - ros_package_names whose converters are called. + (already indented with two spaces). dep_pkgs is the set of dep + ros_package_names whose converters are called. needs_common_runtime is + True if any google.protobuf.Timestamp field was encountered. """ to_ros = [] from_ros = [] dep_pkgs = set() + needs_common_runtime = False for field in message.field: name = field.name @@ -181,6 +189,28 @@ def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): # ---- message -------------------------------------------------------- if ftype == FieldDescriptorProto.TYPE_MESSAGE: + # Special-case: + # google.protobuf.Timestamp → builtin_interfaces::msg::Time + if field.type_name == _GOOGLE_PROTOBUF_TIMESTAMP: + needs_common_runtime = True + ns = _COMMON_RUNTIME_NS + if is_repeated: + to_ros.append( + f' for (const auto& item : proto.{name}()) {{') + to_ros.append( + f' {ns}::ToRos(item, &ros->{name}.emplace_back());') + to_ros.append(' }') + from_ros.append(f' for (const auto& item : ros.{name}) {{') + from_ros.append( + f' {ns}::FromRos(item, proto->add_{name}());') + from_ros.append(' }') + else: + to_ros.append( + f' {ns}::ToRos(proto.{name}(), &ros->{name});') + from_ros.append( + f' {ns}::FromRos(ros.{name}, proto->mutable_{name}());' + ) + continue dep_pkg = proto_types_to_ros_pkgs.get(field.type_name) if dep_pkg is None: sys.exit( @@ -222,7 +252,7 @@ def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): to_ros.append(f' ros->{name} = proto.{name}();') from_ros.append(f' proto->set_{name}(ros.{name});') - return to_ros, from_ros, dep_pkgs + return to_ros, from_ros, dep_pkgs, needs_common_runtime # --------------------------------------------------------------------------- @@ -249,6 +279,9 @@ def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): _SOURCE_TEMPLATE = """\ // Generated by proto_to_ros2_converter. Do not edit. #include "@(ctx['ros_package_name'])/proto_converters.h" +@[if ctx['needs_common_runtime']] +#include "ros2/protobuf/common_runtime.h" +@[end if] @[for inc in ctx['dep_converter_includes']] #include "@(inc)" @[end for] @@ -290,6 +323,7 @@ def _convert(descriptor_set, proto_sources, ros_package_name, """Generate converter .h and .cc for all proto sources in the descriptor.""" messages = [] dep_pkgs_all = set() + needs_common_runtime_any = False proto_includes = [] ros_includes = [] @@ -322,9 +356,10 @@ def _convert(descriptor_set, proto_sources, ros_package_name, ros_include = (f'{ros_package_name}/msg/' f'{_to_snake_case(msg_name)}.hpp') - to_ros_lines, from_ros_lines, dep_pkgs = _field_conversions( + to_ros_lines, from_ros_lines, dep_pkgs, needs_cr = _field_conversions( message, proto_source, proto_types_to_ros_pkgs) dep_pkgs_all.update(dep_pkgs) + needs_common_runtime_any = needs_common_runtime_any or needs_cr to_ros_body = '\n'.join(to_ros_lines) from_ros_body = '\n'.join(from_ros_lines) @@ -363,6 +398,7 @@ def _convert(descriptor_set, proto_sources, ros_package_name, source_context = { 'ros_package_name': ros_package_name, 'dep_converter_includes': dep_converter_includes, + 'needs_common_runtime': needs_common_runtime_any, 'messages': messages, } @@ -415,6 +451,13 @@ def main(): metavar='PATH', help='Path to a dep binary FileDescriptorSet file. May be repeated.', ) + parser.add_argument( + '--proto_source', + action='append', + default=[], + metavar='PROTO_PATH', + help='Relative path of a direct proto source to convert. ', + ) args = parser.parse_args() proto_set = proto_to_ros2.load_descriptor_set(args.descriptor_set) @@ -423,7 +466,11 @@ def main(): args.dep_descriptor_set, args.dep_mapping, proto_set, args.ros_package_name) - proto_sources = [fp.name for fp in proto_set.file] + proto_sources = args.proto_source + if not proto_sources: + open(args.output_header, 'w').close() + open(args.output_source, 'w').close() + return _convert( descriptor_set=proto_set, diff --git a/ros2/protobuf/proto_to_ros2_msg.py b/ros2/protobuf/proto_to_ros2_msg.py index 01f042fb..ca409fd7 100644 --- a/ros2/protobuf/proto_to_ros2_msg.py +++ b/ros2/protobuf/proto_to_ros2_msg.py @@ -187,6 +187,11 @@ def _convert(file_proto, output_path, proto_source, msg_type_map): f'which is not supported.') if field_type_value == FieldDescriptorProto.TYPE_MESSAGE: + if field.type_name == '.google.protobuf.Timestamp': + ros2_type = ('builtin_interfaces/Time[]' + if is_repeated else 'builtin_interfaces/Time') + lines.append(f'{ros2_type} {field.name}') + continue ros2_type = msg_type_map.get(field.type_name) if ros2_type is None: sys.exit( diff --git a/ros2/test/protobuf/BUILD.bazel b/ros2/test/protobuf/BUILD.bazel index 6bf89554..e98edaeb 100644 --- a/ros2/test/protobuf/BUILD.bazel +++ b/ros2/test/protobuf/BUILD.bazel @@ -8,8 +8,10 @@ proto_library( name = "point_proto", srcs = [ "dummy_one.proto", + "event.proto", "point.proto", ], + deps = ["@com_google_protobuf//:timestamp_proto"], ) proto_library( diff --git a/ros2/test/protobuf/converter_tests.cc b/ros2/test/protobuf/converter_tests.cc index 07e155ed..1ced635f 100644 --- a/ros2/test/protobuf/converter_tests.cc +++ b/ros2/test/protobuf/converter_tests.cc @@ -169,4 +169,50 @@ TEST(DummyOneConverterTest, RoundTrip) { EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); } +// --------------------------------------------------------------------------- +// Event converter tests (exercises google.protobuf.Timestamp conversion) +// --------------------------------------------------------------------------- + +TEST(EventConverterTest, ToRos) { + ros2::test::protobuf::Event proto; + proto.mutable_stamp()->set_seconds(1234567890); + proto.mutable_stamp()->set_nanos(500000000); + proto.set_name("test_event"); + + point_proto_ros_msgs::msg::Event ros; + point_proto_ros_msgs::proto_converters::ToRos(proto, &ros); + + EXPECT_EQ(ros.stamp.sec, 1234567890); + EXPECT_EQ(ros.stamp.nanosec, 500000000u); + EXPECT_EQ(ros.name, "test_event"); +} + +TEST(EventConverterTest, FromRos) { + point_proto_ros_msgs::msg::Event ros; + ros.stamp.sec = 987654321; + ros.stamp.nanosec = 123456789u; + ros.name = "from_ros_event"; + + ros2::test::protobuf::Event proto; + point_proto_ros_msgs::proto_converters::FromRos(ros, &proto); + + EXPECT_EQ(proto.stamp().seconds(), 987654321); + EXPECT_EQ(proto.stamp().nanos(), 123456789); + EXPECT_EQ(proto.name(), "from_ros_event"); +} + +TEST(EventConverterTest, RoundTrip) { + ros2::test::protobuf::Event original; + original.mutable_stamp()->set_seconds(1000000000); + original.mutable_stamp()->set_nanos(999999999); + original.set_name("round_trip"); + + point_proto_ros_msgs::msg::Event ros; + point_proto_ros_msgs::proto_converters::ToRos(original, &ros); + ros2::test::protobuf::Event recovered; + point_proto_ros_msgs::proto_converters::FromRos(ros, &recovered); + + EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); +} + } // namespace diff --git a/ros2/test/protobuf/event.proto b/ros2/test/protobuf/event.proto new file mode 100644 index 00000000..eaf9c904 --- /dev/null +++ b/ros2/test/protobuf/event.proto @@ -0,0 +1,23 @@ +// Copyright 2026 Milan Vukov +// +// 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. +syntax = "proto3"; + +import "google/protobuf/timestamp.proto"; + +package ros2.test.protobuf; + +message Event { + google.protobuf.Timestamp stamp = 1; + string name = 2; +} From 1c1b1f0871f29c117d4a1a210ecc277846541ef5 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Sun, 8 Mar 2026 21:28:17 +0000 Subject: [PATCH 21/39] Fix mappings --- ros2/protobuf/proto_to_ros2_converter.py | 12 ++++++++---- ros2/protobuf/proto_to_ros2_msg.py | 12 +++++++++--- ros2/test/protobuf/BUILD.bazel | 5 ++++- ros2/test/protobuf/converter_tests.cc | 12 +++++++++++- ros2/test/protobuf/event.proto | 2 ++ 5 files changed, 34 insertions(+), 9 deletions(-) diff --git a/ros2/protobuf/proto_to_ros2_converter.py b/ros2/protobuf/proto_to_ros2_converter.py index 767c8525..e1629351 100644 --- a/ros2/protobuf/proto_to_ros2_converter.py +++ b/ros2/protobuf/proto_to_ros2_converter.py @@ -77,6 +77,13 @@ def _proto_package_to_ns(package): return '::'.join(package.split('.')) if package else '' +def _get_ros2_pkg(path_to_pkg, path): + for actual_path, ros2_pkg in path_to_pkg.items(): + if actual_path.endswith(path): + return ros2_pkg + raise ValueError(f'Failed to find ROS package for {path} proto file') + + def _build_proto_types_to_ros_pkgs(dep_descriptor_set_paths, dep_mapping, main_proto_set, ros_package_name): """Build {'.pkg.MsgName': 'dep_ros_package_name'} from dep descriptor sets. @@ -100,10 +107,7 @@ def _build_proto_types_to_ros_pkgs(dep_descriptor_set_paths, dep_mapping, for ds_path in dep_descriptor_set_paths: dep_set = proto_to_ros2.load_descriptor_set(ds_path) for file_proto in dep_set.file: - ros_pkg = path_to_ros_pkg.get(file_proto.name) - if ros_pkg is None: - # TODO(mvukov) Investigate this in more details. - continue # Skip transitive well-known types not in dep_mapping. + ros_pkg = _get_ros2_pkg(path_to_ros_pkg, file_proto.name) pkg_prefix = '.' + file_proto.package if file_proto.package else '' for msg in file_proto.message_type: proto_types_to_ros_pkgs[f'{pkg_prefix}.{msg.name}'] = ros_pkg diff --git a/ros2/protobuf/proto_to_ros2_msg.py b/ros2/protobuf/proto_to_ros2_msg.py index ca409fd7..16cb96ad 100644 --- a/ros2/protobuf/proto_to_ros2_msg.py +++ b/ros2/protobuf/proto_to_ros2_msg.py @@ -87,6 +87,13 @@ def _build_enum_map(file_proto, message): return enum_map +def _get_ros2_pkg(path_to_pkg, path): + for actual_path, ros2_pkg in path_to_pkg.items(): + if actual_path.endswith(path): + return ros2_pkg + raise ValueError(f'Failed to find ROS package for {path} proto file') + + def _build_msg_type_map(dep_descriptor_set_paths, dep_mapping, main_descriptor_set_path, proto_source, self_ros_package): @@ -106,6 +113,7 @@ def _build_msg_type_map(dep_descriptor_set_paths, dep_mapping, if file_proto.name in path_to_pkg: continue # Already covered by a dep_mapping. # This is a sibling file; it belongs to the same ROS 2 package. + # TODO(mvukov) Make package mandatory! pkg_prefix = '.' + file_proto.package if file_proto.package else '' for msg in file_proto.message_type: fq = f'{pkg_prefix}.{msg.name}' @@ -114,9 +122,7 @@ def _build_msg_type_map(dep_descriptor_set_paths, dep_mapping, for ds_path in dep_descriptor_set_paths: dep_set = proto_to_ros2.load_descriptor_set(ds_path) for file_proto in dep_set.file: - ros2_pkg = path_to_pkg.get(file_proto.name) - if ros2_pkg is None: - continue + ros2_pkg = _get_ros2_pkg(path_to_pkg, file_proto.name) pkg_prefix = '.' + file_proto.package if file_proto.package else '' for msg in file_proto.message_type: fq = f'{pkg_prefix}.{msg.name}' diff --git a/ros2/test/protobuf/BUILD.bazel b/ros2/test/protobuf/BUILD.bazel index e98edaeb..1efbf1a9 100644 --- a/ros2/test/protobuf/BUILD.bazel +++ b/ros2/test/protobuf/BUILD.bazel @@ -11,7 +11,10 @@ proto_library( "event.proto", "point.proto", ], - deps = ["@com_google_protobuf//:timestamp_proto"], + deps = [ + "@com_google_protobuf//:duration_proto", + "@com_google_protobuf//:timestamp_proto", + ], ) proto_library( diff --git a/ros2/test/protobuf/converter_tests.cc b/ros2/test/protobuf/converter_tests.cc index 1ced635f..2d2c6090 100644 --- a/ros2/test/protobuf/converter_tests.cc +++ b/ros2/test/protobuf/converter_tests.cc @@ -170,7 +170,7 @@ TEST(DummyOneConverterTest, RoundTrip) { } // --------------------------------------------------------------------------- -// Event converter tests (exercises google.protobuf.Timestamp conversion) +// Event converter tests (exercises Timestamp and Duration conversion) // --------------------------------------------------------------------------- TEST(EventConverterTest, ToRos) { @@ -178,6 +178,8 @@ TEST(EventConverterTest, ToRos) { proto.mutable_stamp()->set_seconds(1234567890); proto.mutable_stamp()->set_nanos(500000000); proto.set_name("test_event"); + proto.mutable_duration()->set_seconds(60); + proto.mutable_duration()->set_nanos(250000000); point_proto_ros_msgs::msg::Event ros; point_proto_ros_msgs::proto_converters::ToRos(proto, &ros); @@ -185,6 +187,8 @@ TEST(EventConverterTest, ToRos) { EXPECT_EQ(ros.stamp.sec, 1234567890); EXPECT_EQ(ros.stamp.nanosec, 500000000u); EXPECT_EQ(ros.name, "test_event"); + EXPECT_EQ(ros.duration.seconds, 60); + EXPECT_EQ(ros.duration.nanos, 250000000u); } TEST(EventConverterTest, FromRos) { @@ -192,6 +196,8 @@ TEST(EventConverterTest, FromRos) { ros.stamp.sec = 987654321; ros.stamp.nanosec = 123456789u; ros.name = "from_ros_event"; + ros.duration.seconds = 5; + ros.duration.nanos = 0u; ros2::test::protobuf::Event proto; point_proto_ros_msgs::proto_converters::FromRos(ros, &proto); @@ -199,6 +205,8 @@ TEST(EventConverterTest, FromRos) { EXPECT_EQ(proto.stamp().seconds(), 987654321); EXPECT_EQ(proto.stamp().nanos(), 123456789); EXPECT_EQ(proto.name(), "from_ros_event"); + EXPECT_EQ(proto.duration().seconds(), 5); + EXPECT_EQ(proto.duration().nanos(), 0); } TEST(EventConverterTest, RoundTrip) { @@ -206,6 +214,8 @@ TEST(EventConverterTest, RoundTrip) { original.mutable_stamp()->set_seconds(1000000000); original.mutable_stamp()->set_nanos(999999999); original.set_name("round_trip"); + original.mutable_duration()->set_seconds(3600); + original.mutable_duration()->set_nanos(1); point_proto_ros_msgs::msg::Event ros; point_proto_ros_msgs::proto_converters::ToRos(original, &ros); diff --git a/ros2/test/protobuf/event.proto b/ros2/test/protobuf/event.proto index eaf9c904..c8a15afa 100644 --- a/ros2/test/protobuf/event.proto +++ b/ros2/test/protobuf/event.proto @@ -13,6 +13,7 @@ // limitations under the License. syntax = "proto3"; +import "google/protobuf/duration.proto"; import "google/protobuf/timestamp.proto"; package ros2.test.protobuf; @@ -20,4 +21,5 @@ package ros2.test.protobuf; message Event { google.protobuf.Timestamp stamp = 1; string name = 2; + google.protobuf.Duration duration = 3; } From cf8006d7f6c2707295c1ec21dd8de40ff1353175 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Mon, 25 May 2026 19:18:33 +0000 Subject: [PATCH 22/39] Optimize converter --- ros2/protobuf/proto_to_ros2_converter.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ros2/protobuf/proto_to_ros2_converter.py b/ros2/protobuf/proto_to_ros2_converter.py index e1629351..05f13050 100644 --- a/ros2/protobuf/proto_to_ros2_converter.py +++ b/ros2/protobuf/proto_to_ros2_converter.py @@ -245,10 +245,8 @@ def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): sys.exit(f'Error: {proto_source}: field "{name}" has unknown ' f'field type value {ftype}.') - cpp_type = _SCALAR_CPP_TYPE[ftype] - if is_repeated: - to_ros.append(f' ros->{name} = std::vector<{cpp_type}>' + to_ros.append(f' ros->{name}.assign' f'(proto.{name}().begin(), proto.{name}().end());') from_ros.append(f' proto->mutable_{name}()->Assign' f'(ros.{name}.begin(), ros.{name}.end());') From c95d669416972cd18ee70c749128a3d4db5fd673 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Fri, 29 May 2026 07:57:57 +0000 Subject: [PATCH 23/39] rm common runtime and fix dependency propagation --- ros2/interfaces.bzl | 11 +++---- ros2/protobuf/BUILD.bazel | 10 ------ ros2/protobuf/common_runtime.cc | 30 ----------------- ros2/protobuf/common_runtime.h | 27 ---------------- ros2/protobuf/defs.bzl | 26 --------------- ros2/protobuf/proto_to_ros2_converter.py | 41 +++--------------------- ros2/protobuf/proto_to_ros2_msg.py | 5 --- ros2/test/protobuf/converter_tests.cc | 8 ++--- 8 files changed, 12 insertions(+), 146 deletions(-) delete mode 100644 ros2/protobuf/common_runtime.cc delete mode 100644 ros2/protobuf/common_runtime.h diff --git a/ros2/interfaces.bzl b/ros2/interfaces.bzl index e6c45c43..e11edbd6 100644 --- a/ros2/interfaces.bzl +++ b/ros2/interfaces.bzl @@ -16,6 +16,7 @@ load("@bazel_skylib//lib:paths.bzl", "paths") load("@com_github_mvukov_rules_ros2//ros2:cc_opts.bzl", "C_COPTS") +load("@com_google_protobuf//bazel/common:proto_info.bzl", "ProtoInfo") load("@rules_cc//cc:defs.bzl", "CcInfo", "cc_common") load("@rules_cc//cc:toolchain_utils.bzl", "find_cpp_toolchain") load("@rules_python//python:defs.bzl", "PyInfo", "py_library") @@ -156,7 +157,6 @@ def _idl_adapter_aspect_impl(target, ctx): idl_adapter_aspect = aspect( implementation = _idl_adapter_aspect_impl, attr_aspects = ["deps"], - required_providers = [Ros2InterfaceInfo], attrs = { "_adapter": attr.label( default = Label("@ros2_rosidl//:rosidl_adapter_app"), @@ -164,8 +164,8 @@ idl_adapter_aspect = aspect( cfg = "exec", ), }, + required_providers = [[ProtoInfo], [Ros2InterfaceInfo]], required_aspect_providers = [Ros2InterfaceInfo], - # required_providers = [Ros2InterfaceInfo], provides = [IdlAdapterAspectInfo], ) @@ -652,11 +652,8 @@ cpp_generator_aspect = aspect( default = Label("@bazel_tools//tools/cpp:current_cc_toolchain"), ), }, - # required_providers = [Ros2InterfaceInfo], - required_aspect_providers = [ - [Ros2InterfaceInfo], - [IdlAdapterAspectInfo], - ], + required_providers = [[ProtoInfo], [Ros2InterfaceInfo]], + required_aspect_providers = [[Ros2InterfaceInfo], [IdlAdapterAspectInfo]], provides = [CppGeneratorAspectInfo], toolchains = ["@bazel_tools//tools/cpp:toolchain_type"], fragments = ["cpp"], diff --git a/ros2/protobuf/BUILD.bazel b/ros2/protobuf/BUILD.bazel index 6fef59db..9df4ddee 100644 --- a/ros2/protobuf/BUILD.bazel +++ b/ros2/protobuf/BUILD.bazel @@ -34,13 +34,3 @@ py_binary( requirement("empy"), ], ) - -ros2_cpp_library( - name = "common_runtime", - srcs = ["common_runtime.cc"], - hdrs = ["common_runtime.h"], - deps = [ - "@com_google_protobuf//:timestamp_cc_proto", - "@ros2_rcl_interfaces//:cpp_builtin_interfaces", - ], -) diff --git a/ros2/protobuf/common_runtime.cc b/ros2/protobuf/common_runtime.cc deleted file mode 100644 index bf08eb0f..00000000 --- a/ros2/protobuf/common_runtime.cc +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2026 Milan Vukov -// -// 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. -#include "ros2/protobuf/common_runtime.h" - -namespace rules_ros2_protobuf_common_runtime { - -void ToRos(const google::protobuf::Timestamp& proto, - builtin_interfaces::msg::Time* ros) { - ros->sec = proto.seconds(); - ros->nanosec = proto.nanos(); -} - -void FromRos(const builtin_interfaces::msg::Time& ros, - google::protobuf::Timestamp* proto) { - proto->set_seconds(ros.sec); - proto->set_nanos(ros.nanosec); -} - -} // namespace rules_ros2_protobuf_common_runtime diff --git a/ros2/protobuf/common_runtime.h b/ros2/protobuf/common_runtime.h deleted file mode 100644 index 7b4ab633..00000000 --- a/ros2/protobuf/common_runtime.h +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2026 Milan Vukov -// -// 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. -#pragma once - -#include "builtin_interfaces/msg/time.hpp" -#include "google/protobuf/timestamp.pb.h" - -namespace rules_ros2_protobuf_common_runtime { - -void ToRos(const google::protobuf::Timestamp& proto, - builtin_interfaces::msg::Time* ros); - -void FromRos(const builtin_interfaces::msg::Time& ros, - google::protobuf::Timestamp* proto); - -} // namespace rules_ros2_protobuf_common_runtime diff --git a/ros2/protobuf/defs.bzl b/ros2/protobuf/defs.bzl index 22c6433c..ed1b316d 100644 --- a/ros2/protobuf/defs.bzl +++ b/ros2/protobuf/defs.bzl @@ -40,8 +40,6 @@ load("@rules_cc//cc:toolchain_utils.bzl", "find_cpp_toolchain") CppProtoConverterAspectInfo = provider("TBD", fields = ["cc_info"]) -_GOOGLE_PROTOBUF_TIMESTAMP_PROTO = "google/protobuf/timestamp.proto" - def _collect_dep_proto_args(deps): """Returns (dep_extra_args, dep_descriptor_sets) for proto deps.""" dep_extra_args = [] @@ -61,14 +59,6 @@ def _collect_dep_proto_args(deps): def _proto_to_ros2_msg_aspect_impl(target, ctx): proto_info = target[ProtoInfo] - # Special case: google.protobuf.Timestamp → alias to builtin_interfaces so - # that downstream aspects (idl_adapter, cpp_generator) produce - # builtin_interfaces C++ headers that generated ROS msgs with Timestamp - # fields can include. - for src in proto_info.direct_sources: - if src.short_path.endswith(_GOOGLE_PROTOBUF_TIMESTAMP_PROTO): - return [ctx.attr._builtin_interfaces[Ros2InterfaceInfo]] - msg_files = [] ros_package_name = target.label.name + "_ros_msgs" @@ -123,10 +113,6 @@ proto_to_ros2_msg_aspect = aspect( executable = True, cfg = "exec", ), - "_builtin_interfaces": attr.label( - default = Label("@ros2_rcl_interfaces//:builtin_interfaces"), - providers = [Ros2InterfaceInfo], - ), }, required_providers = [ProtoInfo], provides = [Ros2InterfaceInfo], @@ -155,14 +141,6 @@ whose name matches the PascalCase form of the filename stem. def _cpp_proto_ros2_converter_aspect_impl(target, ctx): proto_info = target[ProtoInfo] - # Skip converter generation for google.protobuf.Timestamp – conversion is - # handled by common_runtime; the generated converter would be wrong anyway - # because the Ros2InterfaceInfo for this target is aliased to - # builtin_interfaces (not a one-to-one Timestamp ↔ Time mapping). - for src in proto_info.direct_sources: - if src.short_path.endswith(_GOOGLE_PROTOBUF_TIMESTAMP_PROTO): - return [CppProtoConverterAspectInfo(cc_info = ctx.attr._common_runtime[CcInfo])] - ros_package_name = target[Ros2InterfaceInfo].ros_package_name # Collect dep information: descriptor sets and proto→ros_package mappings. @@ -269,10 +247,6 @@ cpp_proto_ros2_converter_aspect = aspect( "_cc_toolchain": attr.label( default = Label("@bazel_tools//tools/cpp:current_cc_toolchain"), ), - "_common_runtime": attr.label( - default = Label("@com_github_mvukov_rules_ros2//ros2/protobuf:common_runtime"), - providers = [CcInfo], - ), }, required_providers = [ProtoInfo], required_aspect_providers = [ diff --git a/ros2/protobuf/proto_to_ros2_converter.py b/ros2/protobuf/proto_to_ros2_converter.py index 05f13050..729993b0 100644 --- a/ros2/protobuf/proto_to_ros2_converter.py +++ b/ros2/protobuf/proto_to_ros2_converter.py @@ -118,22 +118,17 @@ def _build_proto_types_to_ros_pkgs(dep_descriptor_set_paths, dep_mapping, # Per-field conversion code generation # --------------------------------------------------------------------------- -_GOOGLE_PROTOBUF_TIMESTAMP = '.google.protobuf.Timestamp' -_COMMON_RUNTIME_NS = 'rules_ros2_protobuf_common_runtime' - def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): - """Return (to_ros_lines, from_ros_lines, dep_pkgs, needs_common_runtime). + """Return (to_ros_lines, from_ros_lines, dep_pkgs). Each entry in to_ros_lines / from_ros_lines is a C++ statement string (already indented with two spaces). dep_pkgs is the set of dep - ros_package_names whose converters are called. needs_common_runtime is - True if any google.protobuf.Timestamp field was encountered. + ros_package_names whose converters are called. """ to_ros = [] from_ros = [] dep_pkgs = set() - needs_common_runtime = False for field in message.field: name = field.name @@ -193,28 +188,6 @@ def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): # ---- message -------------------------------------------------------- if ftype == FieldDescriptorProto.TYPE_MESSAGE: - # Special-case: - # google.protobuf.Timestamp → builtin_interfaces::msg::Time - if field.type_name == _GOOGLE_PROTOBUF_TIMESTAMP: - needs_common_runtime = True - ns = _COMMON_RUNTIME_NS - if is_repeated: - to_ros.append( - f' for (const auto& item : proto.{name}()) {{') - to_ros.append( - f' {ns}::ToRos(item, &ros->{name}.emplace_back());') - to_ros.append(' }') - from_ros.append(f' for (const auto& item : ros.{name}) {{') - from_ros.append( - f' {ns}::FromRos(item, proto->add_{name}());') - from_ros.append(' }') - else: - to_ros.append( - f' {ns}::ToRos(proto.{name}(), &ros->{name});') - from_ros.append( - f' {ns}::FromRos(ros.{name}, proto->mutable_{name}());' - ) - continue dep_pkg = proto_types_to_ros_pkgs.get(field.type_name) if dep_pkg is None: sys.exit( @@ -254,7 +227,7 @@ def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): to_ros.append(f' ros->{name} = proto.{name}();') from_ros.append(f' proto->set_{name}(ros.{name});') - return to_ros, from_ros, dep_pkgs, needs_common_runtime + return to_ros, from_ros, dep_pkgs # --------------------------------------------------------------------------- @@ -281,9 +254,6 @@ def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): _SOURCE_TEMPLATE = """\ // Generated by proto_to_ros2_converter. Do not edit. #include "@(ctx['ros_package_name'])/proto_converters.h" -@[if ctx['needs_common_runtime']] -#include "ros2/protobuf/common_runtime.h" -@[end if] @[for inc in ctx['dep_converter_includes']] #include "@(inc)" @[end for] @@ -325,7 +295,6 @@ def _convert(descriptor_set, proto_sources, ros_package_name, """Generate converter .h and .cc for all proto sources in the descriptor.""" messages = [] dep_pkgs_all = set() - needs_common_runtime_any = False proto_includes = [] ros_includes = [] @@ -358,10 +327,9 @@ def _convert(descriptor_set, proto_sources, ros_package_name, ros_include = (f'{ros_package_name}/msg/' f'{_to_snake_case(msg_name)}.hpp') - to_ros_lines, from_ros_lines, dep_pkgs, needs_cr = _field_conversions( + to_ros_lines, from_ros_lines, dep_pkgs = _field_conversions( message, proto_source, proto_types_to_ros_pkgs) dep_pkgs_all.update(dep_pkgs) - needs_common_runtime_any = needs_common_runtime_any or needs_cr to_ros_body = '\n'.join(to_ros_lines) from_ros_body = '\n'.join(from_ros_lines) @@ -400,7 +368,6 @@ def _convert(descriptor_set, proto_sources, ros_package_name, source_context = { 'ros_package_name': ros_package_name, 'dep_converter_includes': dep_converter_includes, - 'needs_common_runtime': needs_common_runtime_any, 'messages': messages, } diff --git a/ros2/protobuf/proto_to_ros2_msg.py b/ros2/protobuf/proto_to_ros2_msg.py index 16cb96ad..31316a1d 100644 --- a/ros2/protobuf/proto_to_ros2_msg.py +++ b/ros2/protobuf/proto_to_ros2_msg.py @@ -193,11 +193,6 @@ def _convert(file_proto, output_path, proto_source, msg_type_map): f'which is not supported.') if field_type_value == FieldDescriptorProto.TYPE_MESSAGE: - if field.type_name == '.google.protobuf.Timestamp': - ros2_type = ('builtin_interfaces/Time[]' - if is_repeated else 'builtin_interfaces/Time') - lines.append(f'{ros2_type} {field.name}') - continue ros2_type = msg_type_map.get(field.type_name) if ros2_type is None: sys.exit( diff --git a/ros2/test/protobuf/converter_tests.cc b/ros2/test/protobuf/converter_tests.cc index 2d2c6090..3e7f13ef 100644 --- a/ros2/test/protobuf/converter_tests.cc +++ b/ros2/test/protobuf/converter_tests.cc @@ -184,8 +184,8 @@ TEST(EventConverterTest, ToRos) { point_proto_ros_msgs::msg::Event ros; point_proto_ros_msgs::proto_converters::ToRos(proto, &ros); - EXPECT_EQ(ros.stamp.sec, 1234567890); - EXPECT_EQ(ros.stamp.nanosec, 500000000u); + EXPECT_EQ(ros.stamp.seconds, 1234567890); + EXPECT_EQ(ros.stamp.nanos, 500000000); EXPECT_EQ(ros.name, "test_event"); EXPECT_EQ(ros.duration.seconds, 60); EXPECT_EQ(ros.duration.nanos, 250000000u); @@ -193,8 +193,8 @@ TEST(EventConverterTest, ToRos) { TEST(EventConverterTest, FromRos) { point_proto_ros_msgs::msg::Event ros; - ros.stamp.sec = 987654321; - ros.stamp.nanosec = 123456789u; + ros.stamp.seconds = 987654321; + ros.stamp.nanos = 123456789; ros.name = "from_ros_event"; ros.duration.seconds = 5; ros.duration.nanos = 0u; From b2ad254a72640ec7dbce83fa7d36a45a2f28dcd4 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Fri, 29 May 2026 08:08:43 +0000 Subject: [PATCH 24/39] Rename FromRos to ToProto --- ros2/protobuf/defs.bzl | 1 + ros2/protobuf/proto_to_ros2_converter.py | 10 +++++----- ros2/test/protobuf/converter_tests.cc | 20 ++++++++++---------- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/ros2/protobuf/defs.bzl b/ros2/protobuf/defs.bzl index ed1b316d..cbc97991 100644 --- a/ros2/protobuf/defs.bzl +++ b/ros2/protobuf/defs.bzl @@ -252,6 +252,7 @@ cpp_proto_ros2_converter_aspect = aspect( required_aspect_providers = [ [Ros2InterfaceInfo], [CppGeneratorAspectInfo], + # cc_proto_aspect returns CcInfo. [CcInfo], ], provides = [CppProtoConverterAspectInfo], diff --git a/ros2/protobuf/proto_to_ros2_converter.py b/ros2/protobuf/proto_to_ros2_converter.py index 729993b0..1470f873 100644 --- a/ros2/protobuf/proto_to_ros2_converter.py +++ b/ros2/protobuf/proto_to_ros2_converter.py @@ -17,7 +17,7 @@ ``::proto_converters``: ToRos(const & proto); - FromRos(const & ros); + ToProto(const & ros); Limitations mirror those of proto_to_ros2_msg.py: - Exactly one message definition per proto file. @@ -205,12 +205,12 @@ def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): to_ros.append(' }') from_ros.append(f' for (const auto& item : ros.{name}) {{') from_ros.append( - f' {conv}::FromRos(item, proto->add_{name}());') + f' {conv}::ToProto(item, proto->add_{name}());') from_ros.append(' }') else: to_ros.append(f' {conv}::ToRos(proto.{name}(), &ros->{name});') from_ros.append( - f' {conv}::FromRos(ros.{name}, proto->mutable_{name}());') + f' {conv}::ToProto(ros.{name}, proto->mutable_{name}());') continue # ---- scalar (all remaining types) ----------------------------------- @@ -245,7 +245,7 @@ def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): @[for msg in ctx['messages']] void ToRos(const @(msg['proto_type'])& proto, @(msg['ros_type'])* ros); -void FromRos(const @(msg['ros_type'])& ros, @(msg['proto_type'])* proto); +void ToProto(const @(msg['ros_type'])& ros, @(msg['proto_type'])* proto); @[end for] } // namespace @(ctx['ros_package_name'])::proto_converters @@ -265,7 +265,7 @@ def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): @(msg['to_ros_body']) } -void FromRos(const @(msg['ros_type'])& ros, @(msg['proto_type'])* proto) { +void ToProto(const @(msg['ros_type'])& ros, @(msg['proto_type'])* proto) { @(msg['from_ros_body']) } @[end for] diff --git a/ros2/test/protobuf/converter_tests.cc b/ros2/test/protobuf/converter_tests.cc index 3e7f13ef..bb3b5efd 100644 --- a/ros2/test/protobuf/converter_tests.cc +++ b/ros2/test/protobuf/converter_tests.cc @@ -47,7 +47,7 @@ TEST(PointConverterTest, ToRos) { EXPECT_FLOAT_EQ(ros.values[1], 2.5f); } -TEST(PointConverterTest, FromRos) { +TEST(PointConverterTest, ToProto) { point_proto_ros_msgs::msg::Point ros; ros.x = 4.0; ros.y = 5.0; @@ -58,7 +58,7 @@ TEST(PointConverterTest, FromRos) { ros.values = {3.0f, 4.0f, 5.0f}; ros2::test::protobuf::Point proto; - point_proto_ros_msgs::proto_converters::FromRos(ros, &proto); + point_proto_ros_msgs::proto_converters::ToProto(ros, &proto); EXPECT_DOUBLE_EQ(proto.x(), 4.0); EXPECT_DOUBLE_EQ(proto.y(), 5.0); @@ -86,7 +86,7 @@ TEST(PointConverterTest, RoundTrip) { point_proto_ros_msgs::msg::Point ros; point_proto_ros_msgs::proto_converters::ToRos(original, &ros); ros2::test::protobuf::Point recovered; - point_proto_ros_msgs::proto_converters::FromRos(ros, &recovered); + point_proto_ros_msgs::proto_converters::ToProto(ros, &recovered); EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); } @@ -128,7 +128,7 @@ TEST(TransformConverterTest, RoundTrip) { transform_proto_ros_msgs::msg::Transform ros; transform_proto_ros_msgs::proto_converters::ToRos(original, &ros); ros2::test::protobuf::Transform recovered; - transform_proto_ros_msgs::proto_converters::FromRos(ros, &recovered); + transform_proto_ros_msgs::proto_converters::ToProto(ros, &recovered); EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); } @@ -147,12 +147,12 @@ TEST(DummyOneConverterTest, ToRos) { EXPECT_EQ(ros.color, point_proto_ros_msgs::msg::DummyOne::COLOR_RED); } -TEST(DummyOneConverterTest, FromRos) { +TEST(DummyOneConverterTest, ToProto) { point_proto_ros_msgs::msg::DummyOne ros; ros.color = point_proto_ros_msgs::msg::DummyOne::COLOR_GREEN; ros2::test::protobuf::DummyOne proto; - point_proto_ros_msgs::proto_converters::FromRos(ros, &proto); + point_proto_ros_msgs::proto_converters::ToProto(ros, &proto); EXPECT_EQ(proto.color(), ros2::test::protobuf::COLOR_GREEN); } @@ -164,7 +164,7 @@ TEST(DummyOneConverterTest, RoundTrip) { point_proto_ros_msgs::msg::DummyOne ros; point_proto_ros_msgs::proto_converters::ToRos(original, &ros); ros2::test::protobuf::DummyOne recovered; - point_proto_ros_msgs::proto_converters::FromRos(ros, &recovered); + point_proto_ros_msgs::proto_converters::ToProto(ros, &recovered); EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); } @@ -191,7 +191,7 @@ TEST(EventConverterTest, ToRos) { EXPECT_EQ(ros.duration.nanos, 250000000u); } -TEST(EventConverterTest, FromRos) { +TEST(EventConverterTest, ToProto) { point_proto_ros_msgs::msg::Event ros; ros.stamp.seconds = 987654321; ros.stamp.nanos = 123456789; @@ -200,7 +200,7 @@ TEST(EventConverterTest, FromRos) { ros.duration.nanos = 0u; ros2::test::protobuf::Event proto; - point_proto_ros_msgs::proto_converters::FromRos(ros, &proto); + point_proto_ros_msgs::proto_converters::ToProto(ros, &proto); EXPECT_EQ(proto.stamp().seconds(), 987654321); EXPECT_EQ(proto.stamp().nanos(), 123456789); @@ -220,7 +220,7 @@ TEST(EventConverterTest, RoundTrip) { point_proto_ros_msgs::msg::Event ros; point_proto_ros_msgs::proto_converters::ToRos(original, &ros); ros2::test::protobuf::Event recovered; - point_proto_ros_msgs::proto_converters::FromRos(ros, &recovered); + point_proto_ros_msgs::proto_converters::ToProto(ros, &recovered); EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); } From 50e6293711e373974b7d7c6c297cb2c6ef1fe5aa Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Fri, 29 May 2026 08:11:07 +0000 Subject: [PATCH 25/39] Add missing CcInfo import --- ros2/protobuf/defs.bzl | 1 + 1 file changed, 1 insertion(+) diff --git a/ros2/protobuf/defs.bzl b/ros2/protobuf/defs.bzl index cbc97991..3f79948e 100644 --- a/ros2/protobuf/defs.bzl +++ b/ros2/protobuf/defs.bzl @@ -37,6 +37,7 @@ load( load("@com_google_protobuf//bazel/common:proto_info.bzl", "ProtoInfo") load("@com_google_protobuf//bazel/private:cc_proto_aspect.bzl", "cc_proto_aspect") load("@rules_cc//cc:toolchain_utils.bzl", "find_cpp_toolchain") +load("@rules_cc//cc/common:cc_info.bzl", "CcInfo") CppProtoConverterAspectInfo = provider("TBD", fields = ["cc_info"]) From 9aec90d23e93eefd5e705f1f609e18dfb4d26415 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Fri, 29 May 2026 08:16:57 +0000 Subject: [PATCH 26/39] Import cc_common --- ros2/protobuf/defs.bzl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ros2/protobuf/defs.bzl b/ros2/protobuf/defs.bzl index 3f79948e..aacf2c1e 100644 --- a/ros2/protobuf/defs.bzl +++ b/ros2/protobuf/defs.bzl @@ -36,8 +36,8 @@ load( ) load("@com_google_protobuf//bazel/common:proto_info.bzl", "ProtoInfo") load("@com_google_protobuf//bazel/private:cc_proto_aspect.bzl", "cc_proto_aspect") +load("@rules_cc//cc:defs.bzl", "CcInfo", "cc_common") load("@rules_cc//cc:toolchain_utils.bzl", "find_cpp_toolchain") -load("@rules_cc//cc/common:cc_info.bzl", "CcInfo") CppProtoConverterAspectInfo = provider("TBD", fields = ["cc_info"]) From 936293059e860f1eb5ffd99057c147cb2eec7f29 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Fri, 29 May 2026 12:10:51 +0000 Subject: [PATCH 27/39] Disable repeated bytes in proto --- ros2/protobuf/proto_to_ros2_msg.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ros2/protobuf/proto_to_ros2_msg.py b/ros2/protobuf/proto_to_ros2_msg.py index 31316a1d..70b09c33 100644 --- a/ros2/protobuf/proto_to_ros2_msg.py +++ b/ros2/protobuf/proto_to_ros2_msg.py @@ -220,9 +220,14 @@ def _convert(file_proto, output_path, proto_source, msg_type_map): sys.exit(f'Error: {proto_source}: field "{field.name}" has unknown ' f'type value {field_type_value}.') + if is_repeated and field_type_value == FieldDescriptorProto.TYPE_BYTES: + sys.exit(f'Error: {proto_source}: field "{field.name}" is ' + f'"repeated bytes", which would require uint8[][] — not ' + f'supported in ROS 2 msg types.') + ros2_type = _PROTO_TO_ROS_TYPE[field_type_value] - # proto `bytes` already becomes `uint8[]`; avoid double `[]`. + # Proto `bytes` already becomes `uint8[]`; avoid double `[]`. if is_repeated and field_type_value != FieldDescriptorProto.TYPE_BYTES: ros2_type = ros2_type + '[]' From dd8fb69a6c7824ff2e85518d87fdb06fddcddbb0 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Fri, 29 May 2026 12:14:48 +0000 Subject: [PATCH 28/39] Update docs --- ros2/protobuf/proto_to_ros2_converter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ros2/protobuf/proto_to_ros2_converter.py b/ros2/protobuf/proto_to_ros2_converter.py index 1470f873..a3d76dd4 100644 --- a/ros2/protobuf/proto_to_ros2_converter.py +++ b/ros2/protobuf/proto_to_ros2_converter.py @@ -16,8 +16,8 @@ For each proto message the tool emits two free functions in namespace ``::proto_converters``: - ToRos(const & proto); - ToProto(const & ros); + void ToRos(const & proto, * ros); + void ToProto(const & ros, * proto); Limitations mirror those of proto_to_ros2_msg.py: - Exactly one message definition per proto file. From a9537cb3c4b7e10a31cb7a9c697a4b365cf7384d Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Fri, 29 May 2026 12:52:33 +0000 Subject: [PATCH 29/39] Add proto_ros2_interface_library rule --- ros2/protobuf/defs.bzl | 24 ++++++++++++++++++++++++ ros2/test/protobuf/BUILD.bazel | 7 ++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/ros2/protobuf/defs.bzl b/ros2/protobuf/defs.bzl index aacf2c1e..85b88e89 100644 --- a/ros2/protobuf/defs.bzl +++ b/ros2/protobuf/defs.bzl @@ -119,6 +119,30 @@ proto_to_ros2_msg_aspect = aspect( provides = [Ros2InterfaceInfo], ) +def _proto_ros2_interface_library_impl(ctx): + dep_info = ctx.attr.dep[Ros2InterfaceInfo] + return [ + DefaultInfo(files = depset(dep_info.info.srcs)), + dep_info, + ] + +proto_ros2_interface_library = rule( + doc = """Generates one ROS 2 .msg file per proto source in a proto_library dep. + +Downstream interface rules (cpp_ros2_interface_library, py_ros2_interface_library, etc.) +can consume the generated messages. +""", + attrs = { + "dep": attr.label( + mandatory = True, + aspects = [proto_to_ros2_msg_aspect], + providers = [ProtoInfo], + ), + }, + provides = [Ros2InterfaceInfo], + implementation = _proto_ros2_interface_library_impl, +) + def _cpp_proto_ros2_interface_library_impl(ctx): return cc_generator_impl(ctx, CppGeneratorAspectInfo) diff --git a/ros2/test/protobuf/BUILD.bazel b/ros2/test/protobuf/BUILD.bazel index 1efbf1a9..6e255486 100644 --- a/ros2/test/protobuf/BUILD.bazel +++ b/ros2/test/protobuf/BUILD.bazel @@ -2,7 +2,7 @@ load("@com_google_protobuf//bazel:proto_library.bzl", "proto_library") load("//ros2:cc_defs.bzl", "ros2_cpp_test") -load("//ros2/protobuf:defs.bzl", "cpp_proto_ros2_converter_library", "cpp_proto_ros2_interface_library") +load("//ros2/protobuf:defs.bzl", "cpp_proto_ros2_converter_library", "cpp_proto_ros2_interface_library", "proto_ros2_interface_library") proto_library( name = "point_proto", @@ -23,6 +23,11 @@ proto_library( deps = [":point_proto"], ) +proto_ros2_interface_library( + name = "point_proto_ros_msgs", + dep = ":point_proto", +) + cpp_proto_ros2_interface_library( name = "cpp_proto_ros_msgs", deps = [":transform_proto"], From f7b4d1a3f1f6341af5f7fced8bf6ff9c3349c0ac Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Mon, 1 Jun 2026 14:15:21 +0000 Subject: [PATCH 30/39] Add readme and reject nested messages --- ros2/protobuf/README.md | 106 +++++++++++++++++++++++++++++ ros2/protobuf/proto_to_ros2_msg.py | 15 ++-- 2 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 ros2/protobuf/README.md diff --git a/ros2/protobuf/README.md b/ros2/protobuf/README.md new file mode 100644 index 00000000..e112336e --- /dev/null +++ b/ros2/protobuf/README.md @@ -0,0 +1,106 @@ +# Proto → ROS 2 interface rules + +This package provides Bazel rules for converting [Protocol Buffer](https://protobuf.dev/) +message definitions into ROS 2 interface types and optional C++ conversion +helpers. + +## Rules + +### `proto_ros2_interface_library` + +Generates one ROS 2 `.msg` file for each proto source in a single +`proto_library` dep and exposes `Ros2InterfaceInfo`. Use this rule when you +need the generated `.msg` files as input to other interface rules +(`c_ros2_interface_library`, `py_ros2_interface_library`, etc.) without +triggering a full C++ compilation. + +```python +load("//ros2/protobuf:defs.bzl", "proto_ros2_interface_library") + +proto_ros2_interface_library( + name = "point_ros_msgs", + dep = ":point_proto", +) +``` + +### `cpp_proto_ros2_interface_library` + +Runs the full pipeline (proto → `.msg` → IDL → C++ headers + type-support +sources) and produces a `CcInfo` library. Use this rule when you need to +`#include` the generated message headers in C++ code. + +```python +load("//ros2/protobuf:defs.bzl", "cpp_proto_ros2_interface_library") + +cpp_proto_ros2_interface_library( + name = "point_cpp_ros_msgs", + deps = [":point_proto"], +) +``` + +Include path: `/msg/.hpp` +(e.g. `point_proto_ros_msgs/msg/point.hpp`). + +### `cpp_proto_ros2_converter_library` + +Generates a C++ library with bidirectional conversion functions between the +proto C++ type and the ROS 2 message type. The generated functions live in +namespace `::proto_converters`: + +```cpp +void ToRos(const MyProtoType& proto, MyRosType* ros); +void ToProto(const MyRosType& ros, MyProtoType* proto); +``` + +```python +load("//ros2/protobuf:defs.bzl", "cpp_proto_ros2_converter_library") + +cpp_proto_ros2_converter_library( + name = "point_converters", + deps = [":point_proto"], +) +``` + +## Naming conventions + +| Proto source | Required message name | Generated ROS package | +| ------------------ | --------------------- | ---------------------- | +| `point.proto` | `Point` | `point_proto_ros_msgs` | +| `my_message.proto` | `MyMessage` | `my_proto_ros_msgs` | + +The message name must be the PascalCase form of the filename stem. The ROS +package name is derived from the `proto_library` target name with `_ros_msgs` +appended. + +## Type mapping + +| Proto type | ROS 2 type | +| ----------------------------- | ------------------------------ | +| `double` | `float64` | +| `float` | `float32` | +| `int32`, `sint32`, `sfixed32` | `int32` | +| `int64`, `sint64`, `sfixed64` | `int64` | +| `uint32`, `fixed32` | `uint32` | +| `uint64`, `fixed64` | `uint64` | +| `bool` | `bool` | +| `string` | `string` | +| `bytes` | `uint8[]` | +| `enum` | `int32` (with named constants) | +| `message` | `/MsgName` | +| `repeated T` | `T[]` | + +## Limitations and constraints + +- **One message per file.** Each `.proto` source must define exactly one + top-level message, and its name must be the PascalCase form of the filename + stem (e.g. `point.proto` → `Point`). +- **No services.** Service definitions in a proto file cause a build error. +- **Nested message types** are not supported. All message types used as fields + must be defined at the top level of their own proto file. +- **Enums.** Only enums defined in the same proto file are supported. + Cross-file enum references are not supported. +- **`oneof` fields** are not supported. +- **`map` fields** are not supported. +- **Group fields** are not supported. +- **`repeated bytes`** is not supported (`bytes` already maps to `uint8[]`; + `repeated bytes` would require `uint8[][]`, which is not a valid ROS 2 type). diff --git a/ros2/protobuf/proto_to_ros2_msg.py b/ros2/protobuf/proto_to_ros2_msg.py index 70b09c33..034137f6 100644 --- a/ros2/protobuf/proto_to_ros2_msg.py +++ b/ros2/protobuf/proto_to_ros2_msg.py @@ -169,14 +169,15 @@ def _convert(file_proto, output_path, proto_source, msg_type_map): lines.append('') emitted_enums.add(field.type_name) - # Build the set of fully-qualified names of map-entry nested message types - # so that map fields can be detected and rejected below. pkg_prefix = '.' + file_proto.package if file_proto.package else '' - map_entry_fq_names = { - f'{pkg_prefix}.{message.name}.{nested.name}' - for nested in message.nested_type - if nested.options.map_entry - } + map_entry_fq_names = set() + for nested in message.nested_type: + if nested.options.map_entry: + map_entry_fq_names.add(f'{pkg_prefix}.{message.name}.{nested.name}') + else: + sys.exit( + f'Error: {proto_source}: message "{message.name}" defines a ' + f'nested message type "{nested.name}", which is not supported.') for field in message.field: field_type_value = field.type From 7ccf4e474707cd860c2e7202eb8fba4d91470405 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Tue, 2 Jun 2026 07:15:51 +0000 Subject: [PATCH 31/39] Fix ros_package_name collision across Bazel packages Derive ros_package_name from the full Bazel label (package path + target name) so that same-named proto_library targets in different packages produce unique ROS package names, include paths, and C++ namespaces at the monorepo level. Co-Authored-By: Claude Sonnet 4.6 --- ros2/protobuf/README.md | 51 ++++++++++++------ ros2/protobuf/defs.bzl | 3 +- ros2/test/protobuf/converter_tests.cc | 74 ++++++++++++++++----------- ros2/test/protobuf/tests.cc | 8 +-- 4 files changed, 84 insertions(+), 52 deletions(-) diff --git a/ros2/protobuf/README.md b/ros2/protobuf/README.md index e112336e..2651f44f 100644 --- a/ros2/protobuf/README.md +++ b/ros2/protobuf/README.md @@ -9,31 +9,29 @@ helpers. ### `proto_ros2_interface_library` Generates one ROS 2 `.msg` file for each proto source in a single -`proto_library` dep and exposes `Ros2InterfaceInfo`. Use this rule when you +`proto_library` dep. Use this rule when you need the generated `.msg` files as input to other interface rules -(`c_ros2_interface_library`, `py_ros2_interface_library`, etc.) without -triggering a full C++ compilation. +(`cpp_ros2_interface_library`, `py_ros2_interface_library`, etc.). ```python load("//ros2/protobuf:defs.bzl", "proto_ros2_interface_library") proto_ros2_interface_library( - name = "point_ros_msgs", + name = "point_msgs", dep = ":point_proto", ) ``` ### `cpp_proto_ros2_interface_library` -Runs the full pipeline (proto → `.msg` → IDL → C++ headers + type-support -sources) and produces a `CcInfo` library. Use this rule when you need to -`#include` the generated message headers in C++ code. +Runs the full pipeline (proto → `.msg` → ... → C++ code → compilation). Use this rule when you need to +include the generated message headers in your C++ code. ```python load("//ros2/protobuf:defs.bzl", "cpp_proto_ros2_interface_library") cpp_proto_ros2_interface_library( - name = "point_cpp_ros_msgs", + name = "cpp_point_msgs", deps = [":point_proto"], ) ``` @@ -43,8 +41,8 @@ Include path: `/msg/.hpp` ### `cpp_proto_ros2_converter_library` -Generates a C++ library with bidirectional conversion functions between the -proto C++ type and the ROS 2 message type. The generated functions live in +Generates C++ libraries with bidirectional conversion functions between the +proto C++ types and the corresponding ROS 2 message type. The generated functions live in namespace `::proto_converters`: ```cpp @@ -63,14 +61,33 @@ cpp_proto_ros2_converter_library( ## Naming conventions -| Proto source | Required message name | Generated ROS package | -| ------------------ | --------------------- | ---------------------- | -| `point.proto` | `Point` | `point_proto_ros_msgs` | -| `my_message.proto` | `MyMessage` | `my_proto_ros_msgs` | +The message name must be the PascalCase form of the filename stem. -The message name must be the PascalCase form of the filename stem. The ROS -package name is derived from the `proto_library` target name with `_ros_msgs` -appended. +The ROS package name is derived from the full Bazel label of the +`proto_library` target: + +``` +__ros_msgs +``` + +where `/` and `-` in the Bazel package path are replaced with `_`. + +| Label | Proto source | Required message name | Generated ROS package | +| ---------------------- | -------------- | --------------------- | ----------------------------- | +| `//src/foo:perf_proto` | `perf.proto` | `Perf` | `src_foo_perf_proto_ros_msgs` | +| `//src/bar:my_proto` | `my_msg.proto` | `MyMsg` | `src_bar_my_proto_ros_msgs` | +| `//:root_proto` | `root.proto` | `Root` | `root_proto_ros_msgs` | + +Targets at the repository root (empty Bazel package) have no prefix. + +Rationale: In a monorepo, multiple Bazel packages can independently define a +`proto_library` with the same short target name (e.g. `//src/foo:perf_proto` +and `//src/bar:perf_proto`). Using only the target name for the ROS package +would produce the same string for both, causing output file collisions when +both are consumed by the same build target. Incorporating the Bazel package +path makes the ROS package name as unique as the Bazel label itself, so the +C++ namespace, include paths, and generated file directories are collision-free +across the entire repository without any extra user configuration. ## Type mapping diff --git a/ros2/protobuf/defs.bzl b/ros2/protobuf/defs.bzl index 85b88e89..4930b262 100644 --- a/ros2/protobuf/defs.bzl +++ b/ros2/protobuf/defs.bzl @@ -62,7 +62,8 @@ def _proto_to_ros2_msg_aspect_impl(target, ctx): msg_files = [] - ros_package_name = target.label.name + "_ros_msgs" + pkg = target.label.package.replace("/", "_").replace("-", "_") + ros_package_name = (pkg + "_" if pkg else "") + target.label.name + "_ros_msgs" dep_extra_args, dep_descriptor_sets = _collect_dep_proto_args(ctx.rule.attr.deps) diff --git a/ros2/test/protobuf/converter_tests.cc b/ros2/test/protobuf/converter_tests.cc index bb3b5efd..187a4c94 100644 --- a/ros2/test/protobuf/converter_tests.cc +++ b/ros2/test/protobuf/converter_tests.cc @@ -13,8 +13,8 @@ // limitations under the License. #include "gtest/gtest.h" -#include "point_proto_ros_msgs/proto_converters.h" -#include "transform_proto_ros_msgs/proto_converters.h" +#include "ros2_test_protobuf_point_proto_ros_msgs/proto_converters.h" +#include "ros2_test_protobuf_transform_proto_ros_msgs/proto_converters.h" namespace { @@ -33,8 +33,8 @@ TEST(PointConverterTest, ToRos) { proto.add_values(1.5f); proto.add_values(2.5f); - point_proto_ros_msgs::msg::Point ros; - point_proto_ros_msgs::proto_converters::ToRos(proto, &ros); + ros2_test_protobuf_point_proto_ros_msgs::msg::Point ros; + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToRos(proto, &ros); EXPECT_DOUBLE_EQ(ros.x, 1.0); EXPECT_DOUBLE_EQ(ros.y, 2.0); @@ -48,7 +48,7 @@ TEST(PointConverterTest, ToRos) { } TEST(PointConverterTest, ToProto) { - point_proto_ros_msgs::msg::Point ros; + ros2_test_protobuf_point_proto_ros_msgs::msg::Point ros; ros.x = 4.0; ros.y = 5.0; ros.z = 6.0; @@ -58,7 +58,8 @@ TEST(PointConverterTest, ToProto) { ros.values = {3.0f, 4.0f, 5.0f}; ros2::test::protobuf::Point proto; - point_proto_ros_msgs::proto_converters::ToProto(ros, &proto); + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToProto(ros, + &proto); EXPECT_DOUBLE_EQ(proto.x(), 4.0); EXPECT_DOUBLE_EQ(proto.y(), 5.0); @@ -83,10 +84,12 @@ TEST(PointConverterTest, RoundTrip) { original.add_values(0.1f); original.add_values(0.2f); - point_proto_ros_msgs::msg::Point ros; - point_proto_ros_msgs::proto_converters::ToRos(original, &ros); + ros2_test_protobuf_point_proto_ros_msgs::msg::Point ros; + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToRos(original, + &ros); ros2::test::protobuf::Point recovered; - point_proto_ros_msgs::proto_converters::ToProto(ros, &recovered); + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToProto( + ros, &recovered); EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); } @@ -104,8 +107,9 @@ TEST(TransformConverterTest, ToRos) { proto.mutable_point()->set_id(1); proto.mutable_point()->set_valid(true); - transform_proto_ros_msgs::msg::Transform ros; - transform_proto_ros_msgs::proto_converters::ToRos(proto, &ros); + ros2_test_protobuf_transform_proto_ros_msgs::msg::Transform ros; + ros2_test_protobuf_transform_proto_ros_msgs::proto_converters::ToRos(proto, + &ros); EXPECT_DOUBLE_EQ(ros.point.x, 7.0); EXPECT_DOUBLE_EQ(ros.point.y, 8.0); @@ -125,10 +129,12 @@ TEST(TransformConverterTest, RoundTrip) { original.mutable_point()->set_valid(false); original.mutable_point()->add_values(0.5f); - transform_proto_ros_msgs::msg::Transform ros; - transform_proto_ros_msgs::proto_converters::ToRos(original, &ros); + ros2_test_protobuf_transform_proto_ros_msgs::msg::Transform ros; + ros2_test_protobuf_transform_proto_ros_msgs::proto_converters::ToRos(original, + &ros); ros2::test::protobuf::Transform recovered; - transform_proto_ros_msgs::proto_converters::ToProto(ros, &recovered); + ros2_test_protobuf_transform_proto_ros_msgs::proto_converters::ToProto( + ros, &recovered); EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); } @@ -141,18 +147,21 @@ TEST(DummyOneConverterTest, ToRos) { ros2::test::protobuf::DummyOne proto; proto.set_color(ros2::test::protobuf::COLOR_RED); - point_proto_ros_msgs::msg::DummyOne ros; - point_proto_ros_msgs::proto_converters::ToRos(proto, &ros); + ros2_test_protobuf_point_proto_ros_msgs::msg::DummyOne ros; + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToRos(proto, &ros); - EXPECT_EQ(ros.color, point_proto_ros_msgs::msg::DummyOne::COLOR_RED); + EXPECT_EQ(ros.color, + ros2_test_protobuf_point_proto_ros_msgs::msg::DummyOne::COLOR_RED); } TEST(DummyOneConverterTest, ToProto) { - point_proto_ros_msgs::msg::DummyOne ros; - ros.color = point_proto_ros_msgs::msg::DummyOne::COLOR_GREEN; + ros2_test_protobuf_point_proto_ros_msgs::msg::DummyOne ros; + ros.color = + ros2_test_protobuf_point_proto_ros_msgs::msg::DummyOne::COLOR_GREEN; ros2::test::protobuf::DummyOne proto; - point_proto_ros_msgs::proto_converters::ToProto(ros, &proto); + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToProto(ros, + &proto); EXPECT_EQ(proto.color(), ros2::test::protobuf::COLOR_GREEN); } @@ -161,10 +170,12 @@ TEST(DummyOneConverterTest, RoundTrip) { ros2::test::protobuf::DummyOne original; original.set_color(ros2::test::protobuf::COLOR_BLUE); - point_proto_ros_msgs::msg::DummyOne ros; - point_proto_ros_msgs::proto_converters::ToRos(original, &ros); + ros2_test_protobuf_point_proto_ros_msgs::msg::DummyOne ros; + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToRos(original, + &ros); ros2::test::protobuf::DummyOne recovered; - point_proto_ros_msgs::proto_converters::ToProto(ros, &recovered); + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToProto( + ros, &recovered); EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); } @@ -181,8 +192,8 @@ TEST(EventConverterTest, ToRos) { proto.mutable_duration()->set_seconds(60); proto.mutable_duration()->set_nanos(250000000); - point_proto_ros_msgs::msg::Event ros; - point_proto_ros_msgs::proto_converters::ToRos(proto, &ros); + ros2_test_protobuf_point_proto_ros_msgs::msg::Event ros; + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToRos(proto, &ros); EXPECT_EQ(ros.stamp.seconds, 1234567890); EXPECT_EQ(ros.stamp.nanos, 500000000); @@ -192,7 +203,7 @@ TEST(EventConverterTest, ToRos) { } TEST(EventConverterTest, ToProto) { - point_proto_ros_msgs::msg::Event ros; + ros2_test_protobuf_point_proto_ros_msgs::msg::Event ros; ros.stamp.seconds = 987654321; ros.stamp.nanos = 123456789; ros.name = "from_ros_event"; @@ -200,7 +211,8 @@ TEST(EventConverterTest, ToProto) { ros.duration.nanos = 0u; ros2::test::protobuf::Event proto; - point_proto_ros_msgs::proto_converters::ToProto(ros, &proto); + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToProto(ros, + &proto); EXPECT_EQ(proto.stamp().seconds(), 987654321); EXPECT_EQ(proto.stamp().nanos(), 123456789); @@ -217,10 +229,12 @@ TEST(EventConverterTest, RoundTrip) { original.mutable_duration()->set_seconds(3600); original.mutable_duration()->set_nanos(1); - point_proto_ros_msgs::msg::Event ros; - point_proto_ros_msgs::proto_converters::ToRos(original, &ros); + ros2_test_protobuf_point_proto_ros_msgs::msg::Event ros; + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToRos(original, + &ros); ros2::test::protobuf::Event recovered; - point_proto_ros_msgs::proto_converters::ToProto(ros, &recovered); + ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToProto( + ros, &recovered); EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); } diff --git a/ros2/test/protobuf/tests.cc b/ros2/test/protobuf/tests.cc index 472368a7..225a62c9 100644 --- a/ros2/test/protobuf/tests.cc +++ b/ros2/test/protobuf/tests.cc @@ -13,14 +13,14 @@ // limitations under the License. #include "gtest/gtest.h" -#include "point_proto_ros_msgs/msg/dummy_one.hpp" -#include "point_proto_ros_msgs/msg/point.hpp" -#include "transform_proto_ros_msgs/msg/transform.hpp" +#include "ros2_test_protobuf_point_proto_ros_msgs/msg/dummy_one.hpp" +#include "ros2_test_protobuf_point_proto_ros_msgs/msg/point.hpp" +#include "ros2_test_protobuf_transform_proto_ros_msgs/msg/transform.hpp" namespace { TEST(DummyOneTest, EnumConstants) { - using DummyOne = point_proto_ros_msgs::msg::DummyOne; + using DummyOne = ros2_test_protobuf_point_proto_ros_msgs::msg::DummyOne; EXPECT_EQ(DummyOne::COLOR_UNKNOWN, 0); EXPECT_EQ(DummyOne::COLOR_RED, 1); EXPECT_EQ(DummyOne::COLOR_GREEN, 2); From 947da6b1e5763bf1a2794722a5568b903409241d Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Tue, 2 Jun 2026 08:00:20 +0000 Subject: [PATCH 32/39] Respect proto_source_root --- ros2/protobuf/README.md | 43 +++++++++++++++++++++++++++-------------- ros2/protobuf/defs.bzl | 23 ++++++++++++++++++++-- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/ros2/protobuf/README.md b/ros2/protobuf/README.md index 2651f44f..312152d5 100644 --- a/ros2/protobuf/README.md +++ b/ros2/protobuf/README.md @@ -37,7 +37,7 @@ cpp_proto_ros2_interface_library( ``` Include path: `/msg/.hpp` -(e.g. `point_proto_ros_msgs/msg/point.hpp`). +(e.g. `src_foo_point_proto_ros_msgs/msg/Point.hpp` for `//src/foo:point_proto`). ### `cpp_proto_ros2_converter_library` @@ -63,31 +63,44 @@ cpp_proto_ros2_converter_library( The message name must be the PascalCase form of the filename stem. -The ROS package name is derived from the full Bazel label of the -`proto_library` target: +The ROS package name is derived from the effective proto import path of the +first source file (respecting `strip_import_prefix`) and the target name: ``` -__ros_msgs +__ros_msgs ``` -where `/` and `-` in the Bazel package path are replaced with `_`. +where `` is the directory part of the proto file's import path +after `strip_import_prefix` is applied, with `/` and `-` replaced by `_`. +When the import path has no directory component the prefix is omitted. + +For ordinary local targets (no `strip_import_prefix`) the import path +equals the Bazel package path, so the result is equivalent to: + +``` +__ros_msgs +``` -| Label | Proto source | Required message name | Generated ROS package | -| ---------------------- | -------------- | --------------------- | ----------------------------- | -| `//src/foo:perf_proto` | `perf.proto` | `Perf` | `src_foo_perf_proto_ros_msgs` | -| `//src/bar:my_proto` | `my_msg.proto` | `MyMsg` | `src_bar_my_proto_ros_msgs` | -| `//:root_proto` | `root.proto` | `Root` | `root_proto_ros_msgs` | +| Label | `strip_import_prefix` | Proto source | Generated ROS package | +| ---------------------------------------- | --------------------- | --------------------------------- | ------------------------------------------ | +| `//src/foo:perf_proto` | _(none)_ | `perf.proto` | `src_foo_perf_proto_ros_msgs` | +| `//src/bar:my_proto` | _(none)_ | `my_msg.proto` | `src_bar_my_proto_ros_msgs` | +| `//:root_proto` | _(none)_ | `root.proto` | `root_proto_ros_msgs` | +| `@com_google_protobuf//:timestamp_proto` | `/src` | `google/protobuf/timestamp.proto` | `google_protobuf_timestamp_proto_ros_msgs` | -Targets at the repository root (empty Bazel package) have no prefix. +Targets at the repository root with no `strip_import_prefix` (empty import +directory) have no prefix. Rationale: In a monorepo, multiple Bazel packages can independently define a `proto_library` with the same short target name (e.g. `//src/foo:perf_proto` and `//src/bar:perf_proto`). Using only the target name for the ROS package would produce the same string for both, causing output file collisions when -both are consumed by the same build target. Incorporating the Bazel package -path makes the ROS package name as unique as the Bazel label itself, so the -C++ namespace, include paths, and generated file directories are collision-free -across the entire repository without any extra user configuration. +both are consumed by the same build target. Basing the name on the import path +rather than the raw Bazel package path also ensures that `strip_import_prefix` +is respected: e.g. Google's well-known protos strip their `src/` prefix, so +the generated name starts with `google_protobuf_` rather than +`src_google_protobuf_`. This makes the ROS package name match the proto import +namespace that users actually write in their `.proto` files. ## Type mapping diff --git a/ros2/protobuf/defs.bzl b/ros2/protobuf/defs.bzl index 4930b262..7c39d357 100644 --- a/ros2/protobuf/defs.bzl +++ b/ros2/protobuf/defs.bzl @@ -26,6 +26,7 @@ Limitations: - Proto `bytes` fields map to `uint8[]` in ROS2. """ +load("@bazel_skylib//lib:paths.bzl", "paths") load( "@com_github_mvukov_rules_ros2//ros2:interfaces.bzl", "CppGeneratorAspectInfo", @@ -41,6 +42,25 @@ load("@rules_cc//cc:toolchain_utils.bzl", "find_cpp_toolchain") CppProtoConverterAspectInfo = provider("TBD", fields = ["cc_info"]) +def _get_ros_package_name(proto_info, label_name): + """Derives the ROS package name from a ProtoInfo and the target label name. + + Uses proto_source_root so that strip_import_prefix is respected (e.g. + google/protobuf/timestamp.proto gets google_protobuf_* not src_google_*). + When there are no direct sources the directory prefix is empty. + """ + sources = proto_info.direct_sources + if sources: + root = proto_info.proto_source_root + src0 = sources[0].path + if root != "." and not paths.starts_with(src0, root): + fail("proto_source_root must be an ancestor of the source path") + logical_dir = paths.dirname(paths.relativize(src0, root)) + prefix = logical_dir.replace("/", "_").replace("-", "_") + else: + prefix = "" + return (prefix + "_" if prefix else "") + label_name + "_ros_msgs" + def _collect_dep_proto_args(deps): """Returns (dep_extra_args, dep_descriptor_sets) for proto deps.""" dep_extra_args = [] @@ -62,8 +82,7 @@ def _proto_to_ros2_msg_aspect_impl(target, ctx): msg_files = [] - pkg = target.label.package.replace("/", "_").replace("-", "_") - ros_package_name = (pkg + "_" if pkg else "") + target.label.name + "_ros_msgs" + ros_package_name = _get_ros_package_name(proto_info, target.label.name) dep_extra_args, dep_descriptor_sets = _collect_dep_proto_args(ctx.rule.attr.deps) From 77e4f51ff0672a2583d66de5985397ee5cad979d Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Wed, 3 Jun 2026 14:35:32 +0000 Subject: [PATCH 33/39] Fix nit --- ros2/protobuf/README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ros2/protobuf/README.md b/ros2/protobuf/README.md index 312152d5..0cfc1e49 100644 --- a/ros2/protobuf/README.md +++ b/ros2/protobuf/README.md @@ -81,12 +81,12 @@ equals the Bazel package path, so the result is equivalent to: __ros_msgs ``` -| Label | `strip_import_prefix` | Proto source | Generated ROS package | -| ---------------------------------------- | --------------------- | --------------------------------- | ------------------------------------------ | -| `//src/foo:perf_proto` | _(none)_ | `perf.proto` | `src_foo_perf_proto_ros_msgs` | -| `//src/bar:my_proto` | _(none)_ | `my_msg.proto` | `src_bar_my_proto_ros_msgs` | -| `//:root_proto` | _(none)_ | `root.proto` | `root_proto_ros_msgs` | -| `@com_google_protobuf//:timestamp_proto` | `/src` | `google/protobuf/timestamp.proto` | `google_protobuf_timestamp_proto_ros_msgs` | +| Label | `strip_import_prefix` | Generated ROS package | +| ---------------------------------------- | --------------------- | ------------------------------------------ | +| `//src/foo:perf_proto` | _(none)_ | `src_foo_perf_proto_ros_msgs` | +| `//src/bar:my_proto` | _(none)_ | `src_bar_my_proto_ros_msgs` | +| `//:root_proto` | _(none)_ | `root_proto_ros_msgs` | +| `@com_google_protobuf//:timestamp_proto` | `/src` | `google_protobuf_timestamp_proto_ros_msgs` | Targets at the repository root with no `strip_import_prefix` (empty import directory) have no prefix. From b52ed6c857ce16a6c841f9cd19fb59148c87776a Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Thu, 4 Jun 2026 13:53:24 +0000 Subject: [PATCH 34/39] Add tests for proto_to_ros2_msg --- ros2/test/protobuf/BUILD.bazel | 103 +++++++++ ros2/test/protobuf/file_enum.proto | 14 ++ ros2/test/protobuf/foreign_enum_source.proto | 9 + ros2/test/protobuf/foreign_enum_usage.proto | 9 + ros2/test/protobuf/map_field.proto | 7 + ros2/test/protobuf/msg_enum.proto | 13 ++ ros2/test/protobuf/nested_message.proto | 11 + ros2/test/protobuf/no_message.proto | 7 + ros2/test/protobuf/oneof_field.proto | 10 + ros2/test/protobuf/repeated_bytes.proto | 7 + ros2/test/protobuf/repeated_fields.proto | 12 + ros2/test/protobuf/scalars.proto | 21 ++ ros2/test/protobuf/tests.py | 218 +++++++++++++++++++ ros2/test/protobuf/two_messages.proto | 11 + ros2/test/protobuf/with_service.proto | 15 ++ ros2/test/protobuf/wrong_message_name.proto | 7 + 16 files changed, 474 insertions(+) create mode 100644 ros2/test/protobuf/file_enum.proto create mode 100644 ros2/test/protobuf/foreign_enum_source.proto create mode 100644 ros2/test/protobuf/foreign_enum_usage.proto create mode 100644 ros2/test/protobuf/map_field.proto create mode 100644 ros2/test/protobuf/msg_enum.proto create mode 100644 ros2/test/protobuf/nested_message.proto create mode 100644 ros2/test/protobuf/no_message.proto create mode 100644 ros2/test/protobuf/oneof_field.proto create mode 100644 ros2/test/protobuf/repeated_bytes.proto create mode 100644 ros2/test/protobuf/repeated_fields.proto create mode 100644 ros2/test/protobuf/scalars.proto create mode 100644 ros2/test/protobuf/tests.py create mode 100644 ros2/test/protobuf/two_messages.proto create mode 100644 ros2/test/protobuf/with_service.proto create mode 100644 ros2/test/protobuf/wrong_message_name.proto diff --git a/ros2/test/protobuf/BUILD.bazel b/ros2/test/protobuf/BUILD.bazel index 6e255486..cffa83a3 100644 --- a/ros2/test/protobuf/BUILD.bazel +++ b/ros2/test/protobuf/BUILD.bazel @@ -1,6 +1,8 @@ """Tests for proto -> ROS2 interface conversion.""" load("@com_google_protobuf//bazel:proto_library.bzl", "proto_library") +load("@rules_python//python:defs.bzl", "py_test") +load("@rules_ros2_pip_deps//:requirements.bzl", "requirement") load("//ros2:cc_defs.bzl", "ros2_cpp_test") load("//ros2/protobuf:defs.bzl", "cpp_proto_ros2_converter_library", "cpp_proto_ros2_interface_library", "proto_ros2_interface_library") @@ -55,3 +57,104 @@ ros2_cpp_test( "@googletest//:gtest_main", ], ) + +proto_library( + name = "scalars_proto", + srcs = ["scalars.proto"], +) + +proto_library( + name = "repeated_fields_proto", + srcs = ["repeated_fields.proto"], + deps = [":point_proto"], +) + +proto_library( + name = "file_enum_proto", + srcs = ["file_enum.proto"], +) + +proto_library( + name = "msg_enum_proto", + srcs = ["msg_enum.proto"], +) + +proto_library( + name = "no_message_proto", + srcs = ["no_message.proto"], +) + +proto_library( + name = "two_messages_proto", + srcs = ["two_messages.proto"], +) + +proto_library( + name = "with_service_proto", + srcs = ["with_service.proto"], +) + +proto_library( + name = "wrong_message_name_proto", + srcs = ["wrong_message_name.proto"], +) + +proto_library( + name = "nested_message_proto", + srcs = ["nested_message.proto"], +) + +proto_library( + name = "oneof_field_proto", + srcs = ["oneof_field.proto"], +) + +proto_library( + name = "map_field_proto", + srcs = ["map_field.proto"], +) + +proto_library( + name = "repeated_bytes_proto", + srcs = ["repeated_bytes.proto"], +) + +proto_library( + name = "foreign_enum_proto", + srcs = [ + "foreign_enum_source.proto", + "foreign_enum_usage.proto", + ], +) + +py_test( + name = "proto_to_ros2_msg_tests", + size = "small", + srcs = ["tests.py"], + args = [ + "-o", + "python_classes=*Tests", + ], + data = [ + ":file_enum_proto", + ":foreign_enum_proto", + ":map_field_proto", + ":msg_enum_proto", + ":nested_message_proto", + ":no_message_proto", + ":oneof_field_proto", + ":point_proto", + ":repeated_bytes_proto", + ":repeated_fields_proto", + ":scalars_proto", + ":transform_proto", + ":two_messages_proto", + ":with_service_proto", + ":wrong_message_name_proto", + ], + main = "tests.py", + deps = [ + "//ros2/protobuf:proto_to_ros2_msg", + requirement("pytest"), + ], +) diff --git a/ros2/test/protobuf/file_enum.proto b/ros2/test/protobuf/file_enum.proto new file mode 100644 index 00000000..38697988 --- /dev/null +++ b/ros2/test/protobuf/file_enum.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +enum Status { + STATUS_UNKNOWN = 0; + STATUS_ACTIVE = 1; + STATUS_INACTIVE = 2; +} + +message FileEnum { + Status status = 1; + repeated Status tags = 2; +} diff --git a/ros2/test/protobuf/foreign_enum_source.proto b/ros2/test/protobuf/foreign_enum_source.proto new file mode 100644 index 00000000..105f12bf --- /dev/null +++ b/ros2/test/protobuf/foreign_enum_source.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +enum ForeignColor { + FOREIGN_COLOR_UNKNOWN = 0; + FOREIGN_COLOR_RED = 1; + FOREIGN_COLOR_GREEN = 2; +} diff --git a/ros2/test/protobuf/foreign_enum_usage.proto b/ros2/test/protobuf/foreign_enum_usage.proto new file mode 100644 index 00000000..b610d5ac --- /dev/null +++ b/ros2/test/protobuf/foreign_enum_usage.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +import "ros2/test/protobuf/foreign_enum_source.proto"; + +message ForeignEnumUsage { + ForeignColor color = 1; +} diff --git a/ros2/test/protobuf/map_field.proto b/ros2/test/protobuf/map_field.proto new file mode 100644 index 00000000..cad1a313 --- /dev/null +++ b/ros2/test/protobuf/map_field.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message MapField { + map items = 1; +} diff --git a/ros2/test/protobuf/msg_enum.proto b/ros2/test/protobuf/msg_enum.proto new file mode 100644 index 00000000..c600dcd4 --- /dev/null +++ b/ros2/test/protobuf/msg_enum.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message MsgEnum { + enum Kind { + KIND_NONE = 0; + KIND_TYPE_A = 1; + KIND_TYPE_B = 2; + } + + Kind kind = 1; +} diff --git a/ros2/test/protobuf/nested_message.proto b/ros2/test/protobuf/nested_message.proto new file mode 100644 index 00000000..1ce26e3c --- /dev/null +++ b/ros2/test/protobuf/nested_message.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message NestedMessage { + message InnerType { + bool flag = 1; + } + + InnerType inner = 1; +} diff --git a/ros2/test/protobuf/no_message.proto b/ros2/test/protobuf/no_message.proto new file mode 100644 index 00000000..301c5e97 --- /dev/null +++ b/ros2/test/protobuf/no_message.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +enum Placeholder { + PLACEHOLDER_NONE = 0; +} diff --git a/ros2/test/protobuf/oneof_field.proto b/ros2/test/protobuf/oneof_field.proto new file mode 100644 index 00000000..49107928 --- /dev/null +++ b/ros2/test/protobuf/oneof_field.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message OneofField { + oneof value { + int32 num = 1; + string str = 2; + } +} diff --git a/ros2/test/protobuf/repeated_bytes.proto b/ros2/test/protobuf/repeated_bytes.proto new file mode 100644 index 00000000..5ebb2653 --- /dev/null +++ b/ros2/test/protobuf/repeated_bytes.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message RepeatedBytes { + repeated bytes chunks = 1; +} diff --git a/ros2/test/protobuf/repeated_fields.proto b/ros2/test/protobuf/repeated_fields.proto new file mode 100644 index 00000000..ea833fed --- /dev/null +++ b/ros2/test/protobuf/repeated_fields.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +import "ros2/test/protobuf/point.proto"; + +message RepeatedFields { + repeated int32 values = 1; + repeated float scores = 2; + repeated string labels = 3; + repeated Point points = 4; +} diff --git a/ros2/test/protobuf/scalars.proto b/ros2/test/protobuf/scalars.proto new file mode 100644 index 00000000..574fd795 --- /dev/null +++ b/ros2/test/protobuf/scalars.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message Scalars { + double f_double = 1; + float f_float = 2; + int32 f_int32 = 3; + int64 f_int64 = 4; + uint32 f_uint32 = 5; + uint64 f_uint64 = 6; + sint32 f_sint32 = 7; + sint64 f_sint64 = 8; + fixed32 f_fixed32 = 9; + fixed64 f_fixed64 = 10; + sfixed32 f_sfixed32 = 11; + sfixed64 f_sfixed64 = 12; + bool f_bool = 13; + string f_string = 14; + bytes f_bytes = 15; +} diff --git a/ros2/test/protobuf/tests.py b/ros2/test/protobuf/tests.py new file mode 100644 index 00000000..e13ee6f0 --- /dev/null +++ b/ros2/test/protobuf/tests.py @@ -0,0 +1,218 @@ +# Copyright 2026 Milan Vukov +# +# 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. +"""Tests for proto_to_ros2_msg.py.""" +import os +import sys +from pathlib import Path + +import pytest + +from ros2.protobuf import proto_to_ros2 +from ros2.protobuf import proto_to_ros2_msg + +_WORKSPACE = 'com_github_mvukov_rules_ros2' +_PKG = 'ros2/test/protobuf' + + +def _ds(target: str) -> str: + """Returns the path to a proto_library's descriptor set in Bazel runfiles. + """ + srcdir = os.environ['TEST_SRCDIR'] + return str( + Path(srcdir) / _WORKSPACE / _PKG / f'{target}-descriptor-set.proto.bin') + + +def _load(target: str, proto_file: str): + """Loads a FileDescriptorProto from a proto_library's descriptor set. + """ + proto_set = proto_to_ros2.load_descriptor_set(_ds(target)) + fp = proto_to_ros2.find_file_descriptor(proto_set, f'{_PKG}/{proto_file}') + assert fp is not None, ( + f'Proto file {proto_file!r} not found in descriptor set for {target!r}.' + f' Files present: {[f.name for f in proto_set.file]}') + return fp + + +class ConvertFieldsTests: + """Tests that the converter correctly maps proto field types to ROS 2 types. + """ + + def test_scalar_types(self, tmp_path): + fp = _load('scalars_proto', 'scalars.proto') + out = tmp_path / 'Scalars.msg' + proto_to_ros2_msg._convert(fp, str(out), f'{_PKG}/scalars.proto', {}) + content = out.read_text() + assert 'float64 f_double' in content + assert 'float32 f_float' in content + assert 'int32 f_int32' in content + assert 'int64 f_int64' in content + assert 'uint32 f_uint32' in content + assert 'uint64 f_uint64' in content + assert 'int32 f_sint32' in content + assert 'int64 f_sint64' in content + assert 'uint32 f_fixed32' in content + assert 'uint64 f_fixed64' in content + assert 'int32 f_sfixed32' in content + assert 'int64 f_sfixed64' in content + assert 'bool f_bool' in content + assert 'string f_string' in content + assert 'uint8[] f_bytes' in content + + def test_repeated_scalar_fields(self, tmp_path): + fp = _load('repeated_fields_proto', 'repeated_fields.proto') + msg_type_map = { + '.ros2.test.protobuf.Point': + 'ros2_test_protobuf_point_proto_ros_msgs/Point', + } + out = tmp_path / 'RepeatedFields.msg' + proto_to_ros2_msg._convert(fp, str(out), + f'{_PKG}/repeated_fields.proto', + msg_type_map) + content = out.read_text() + assert 'int32[] values' in content + assert 'float32[] scores' in content + assert 'string[] labels' in content + + def test_repeated_message_field(self, tmp_path): + fp = _load('repeated_fields_proto', 'repeated_fields.proto') + msg_type_map = { + '.ros2.test.protobuf.Point': + 'ros2_test_protobuf_point_proto_ros_msgs/Point', + } + out = tmp_path / 'RepeatedFields.msg' + proto_to_ros2_msg._convert(fp, str(out), + f'{_PKG}/repeated_fields.proto', + msg_type_map) + content = out.read_text() + assert 'ros2_test_protobuf_point_proto_ros_msgs/Point[] points' in content # noqa + + def test_message_dep_field(self, tmp_path): + fp = _load('transform_proto', 'transform.proto') + msg_type_map = { + '.ros2.test.protobuf.Point': + 'ros2_test_protobuf_point_proto_ros_msgs/Point', + } + out = tmp_path / 'Transform.msg' + proto_to_ros2_msg._convert(fp, str(out), f'{_PKG}/transform.proto', + msg_type_map) + content = out.read_text() + assert 'ros2_test_protobuf_point_proto_ros_msgs/Point point' in content + + def test_sibling_proto_same_target(self, tmp_path): + """DummyOne references Point; both are in the same proto_library target. + """ + fp = _load('point_proto', 'dummy_one.proto') + pkg = 'ros2_test_protobuf_point_proto_ros_msgs' + msg_type_map = proto_to_ros2_msg._build_msg_type_map( + dep_descriptor_set_paths=[], + dep_mapping=[], + main_descriptor_set_path=_ds('point_proto'), + proto_source=f'{_PKG}/dummy_one.proto', + self_ros_package=pkg, + ) + out = tmp_path / 'DummyOne.msg' + proto_to_ros2_msg._convert(fp, str(out), f'{_PKG}/dummy_one.proto', + msg_type_map) + content = out.read_text() + assert f'{pkg}/Point[] points' in content + + def test_file_level_enum_field(self, tmp_path): + fp = _load('file_enum_proto', 'file_enum.proto') + out = tmp_path / 'FileEnum.msg' + proto_to_ros2_msg._convert(fp, str(out), f'{_PKG}/file_enum.proto', {}) + content = out.read_text() + assert 'int32 STATUS_UNKNOWN=0' in content + assert 'int32 STATUS_ACTIVE=1' in content + assert 'int32 STATUS_INACTIVE=2' in content + assert 'int32 status' in content + + def test_repeated_enum_field(self, tmp_path): + fp = _load('file_enum_proto', 'file_enum.proto') + out = tmp_path / 'FileEnum.msg' + proto_to_ros2_msg._convert(fp, str(out), f'{_PKG}/file_enum.proto', {}) + content = out.read_text() + assert 'int32[] tags' in content + + def test_message_level_enum(self, tmp_path): + fp = _load('msg_enum_proto', 'msg_enum.proto') + out = tmp_path / 'MsgEnum.msg' + proto_to_ros2_msg._convert(fp, str(out), f'{_PKG}/msg_enum.proto', {}) + content = out.read_text() + assert 'int32 KIND_NONE=0' in content + assert 'int32 KIND_TYPE_A=1' in content + assert 'int32 kind' in content + + +class RejectInvalidProtoTests: + """Tests that the converter rejects each documented constraint violation.""" + + def _assert_error(self, fp, proto_file, msg_type_map, fragment, tmp_path): + with pytest.raises(SystemExit) as exc: + proto_to_ros2_msg._convert(fp, str(tmp_path / 'out.msg'), + f'{_PKG}/{proto_file}', msg_type_map) + assert fragment in str(exc.value.code) + + def test_rejects_zero_messages(self, tmp_path): + fp = _load('no_message_proto', 'no_message.proto') + self._assert_error(fp, 'no_message.proto', {}, + 'expected exactly 1 message definition, got 0', + tmp_path) + + def test_rejects_multiple_messages(self, tmp_path): + fp = _load('two_messages_proto', 'two_messages.proto') + self._assert_error(fp, 'two_messages.proto', {}, + 'expected exactly 1 message definition, got 2', + tmp_path) + + def test_rejects_service_definition(self, tmp_path): + fp = _load('with_service_proto', 'with_service.proto') + self._assert_error(fp, 'with_service.proto', {}, + 'services are not supported', tmp_path) + + def test_rejects_wrong_message_name(self, tmp_path): + fp = _load('wrong_message_name_proto', 'wrong_message_name.proto') + self._assert_error(fp, 'wrong_message_name.proto', {}, + 'message must be named "WrongMessageName"', tmp_path) + + def test_rejects_nested_message_type(self, tmp_path): + fp = _load('nested_message_proto', 'nested_message.proto') + self._assert_error(fp, 'nested_message.proto', {}, + 'nested message type', tmp_path) + + def test_rejects_oneof_field(self, tmp_path): + fp = _load('oneof_field_proto', 'oneof_field.proto') + self._assert_error(fp, 'oneof_field.proto', {}, 'oneof', tmp_path) + + def test_rejects_map_field(self, tmp_path): + fp = _load('map_field_proto', 'map_field.proto') + self._assert_error(fp, 'map_field.proto', {}, 'map field', tmp_path) + + def test_rejects_repeated_bytes(self, tmp_path): + fp = _load('repeated_bytes_proto', 'repeated_bytes.proto') + self._assert_error(fp, 'repeated_bytes.proto', {}, 'repeated bytes', + tmp_path) + + def test_rejects_cross_file_enum(self, tmp_path): + fp = _load('foreign_enum_proto', 'foreign_enum_usage.proto') + self._assert_error(fp, 'foreign_enum_usage.proto', {}, + 'not found in the current proto file', tmp_path) + + def test_rejects_missing_dep_mapping(self, tmp_path): + fp = _load('transform_proto', 'transform.proto') + self._assert_error(fp, 'transform.proto', {}, 'no dep_mapping entry', + tmp_path) + + +if __name__ == '__main__': + sys.exit(pytest.main([__file__, '-v', *sys.argv[1:]])) diff --git a/ros2/test/protobuf/two_messages.proto b/ros2/test/protobuf/two_messages.proto new file mode 100644 index 00000000..139fb9ca --- /dev/null +++ b/ros2/test/protobuf/two_messages.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message MessageOne { + bool x = 1; +} + +message MessageTwo { + bool y = 1; +} diff --git a/ros2/test/protobuf/with_service.proto b/ros2/test/protobuf/with_service.proto new file mode 100644 index 00000000..48b4e9b0 --- /dev/null +++ b/ros2/test/protobuf/with_service.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message WithServiceRequest { + bool flag = 1; +} + +message WithServiceResponse { + bool result = 1; +} + +service WithService { + rpc Call(WithServiceRequest) returns (WithServiceResponse); +} diff --git a/ros2/test/protobuf/wrong_message_name.proto b/ros2/test/protobuf/wrong_message_name.proto new file mode 100644 index 00000000..e4b16b6f --- /dev/null +++ b/ros2/test/protobuf/wrong_message_name.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message NotTheRightName { + bool x = 1; +} From 47fd03324d2f0396501609b4a5e8e5cb9253bec7 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Thu, 4 Jun 2026 14:27:32 +0000 Subject: [PATCH 35/39] Clean up tests --- ros2/test/protobuf/tests.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/ros2/test/protobuf/tests.py b/ros2/test/protobuf/tests.py index e13ee6f0..2323e734 100644 --- a/ros2/test/protobuf/tests.py +++ b/ros2/test/protobuf/tests.py @@ -12,25 +12,20 @@ # See the License for the specific language governing permissions and # limitations under the License. """Tests for proto_to_ros2_msg.py.""" -import os import sys -from pathlib import Path import pytest from ros2.protobuf import proto_to_ros2 from ros2.protobuf import proto_to_ros2_msg -_WORKSPACE = 'com_github_mvukov_rules_ros2' _PKG = 'ros2/test/protobuf' def _ds(target: str) -> str: """Returns the path to a proto_library's descriptor set in Bazel runfiles. """ - srcdir = os.environ['TEST_SRCDIR'] - return str( - Path(srcdir) / _WORKSPACE / _PKG / f'{target}-descriptor-set.proto.bin') + return f'{_PKG}/{target}-descriptor-set.proto.bin' def _load(target: str, proto_file: str): From 8cd78f0c95fb81c08fedc31db9af8bde1e51532c Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Thu, 4 Jun 2026 19:01:47 +0000 Subject: [PATCH 36/39] Reject file-level enum definitions in proto_to_ros2_msg File-scope enums alongside the message create a silent correctness hazard: two same-named enums at different scopes both emit `# EnumName constants` blocks in the generated .msg, causing duplicate-constant build errors if value names overlap. Enforcing that enums must be nested inside the message removes this ambiguity. - _convert: exits with a clear error when file_proto.enum_type is non-empty - _build_enum_map: drops the dead file-level enum loop - README: updates the Enums limitation bullet - Tests: adds test_rejects_file_level_enum; replaces the former file-level enum happy-path tests with a message-level enum + repeated enum test; adds sibling_base/sibling_ref proto fixtures (clean replacements for dummy_one.proto which carried a file-level Color enum) Co-Authored-By: Claude Sonnet 4.6 --- ros2/protobuf/README.md | 5 ++-- ros2/protobuf/proto_to_ros2_msg.py | 14 +++++---- ros2/test/protobuf/BUILD.bazel | 9 ++++++ ros2/test/protobuf/msg_enum.proto | 1 + ros2/test/protobuf/sibling_base.proto | 7 +++++ ros2/test/protobuf/sibling_ref.proto | 10 +++++++ ros2/test/protobuf/tests.py | 42 ++++++++++----------------- 7 files changed, 54 insertions(+), 34 deletions(-) create mode 100644 ros2/test/protobuf/sibling_base.proto create mode 100644 ros2/test/protobuf/sibling_ref.proto diff --git a/ros2/protobuf/README.md b/ros2/protobuf/README.md index 0cfc1e49..a6cceac4 100644 --- a/ros2/protobuf/README.md +++ b/ros2/protobuf/README.md @@ -127,8 +127,9 @@ namespace that users actually write in their `.proto` files. - **No services.** Service definitions in a proto file cause a build error. - **Nested message types** are not supported. All message types used as fields must be defined at the top level of their own proto file. -- **Enums.** Only enums defined in the same proto file are supported. - Cross-file enum references are not supported. +- **Enums.** Only enums defined **inside the message** (nested enum types) are + supported. File-level enum definitions cause a build error. Cross-file enum + references are not supported. - **`oneof` fields** are not supported. - **`map` fields** are not supported. - **Group fields** are not supported. diff --git a/ros2/protobuf/proto_to_ros2_msg.py b/ros2/protobuf/proto_to_ros2_msg.py index 034137f6..f6038198 100644 --- a/ros2/protobuf/proto_to_ros2_msg.py +++ b/ros2/protobuf/proto_to_ros2_msg.py @@ -73,16 +73,13 @@ def _build_enum_map(file_proto, message): - """Build {'.pkg.EnumName': EnumDescriptorProto} for enums in this file. - - Covers top-level enums and enums nested directly inside the message. + """Build {'.pkg.Message.EnumName': EnumDescriptorProto} for enums nested + directly inside the message. """ enum_map = {} pkg_prefix = '.' + file_proto.package if file_proto.package else '' - for enum_type in file_proto.enum_type: # top-level enums - enum_map[f'{pkg_prefix}.{enum_type.name}'] = enum_type msg_prefix = f'{pkg_prefix}.{message.name}' - for enum_type in message.enum_type: # nested enums + for enum_type in message.enum_type: enum_map[f'{msg_prefix}.{enum_type.name}'] = enum_type return enum_map @@ -150,6 +147,11 @@ def _convert(file_proto, output_path, proto_source, msg_type_map): f'Error: {proto_source}: message must be named "{expected_name}" ' f'to match the proto filename, got "{message.name}".') + if file_proto.enum_type: + names = ', '.join(e.name for e in file_proto.enum_type) + sys.exit(f'Error: {proto_source}: file-level enum definitions are not ' + f'supported ({names}); define enums inside the message.') + lines = [f'# Generated from proto source: {proto_source}', ''] # Emit enum constants before fields (deduplicated per enum type). diff --git a/ros2/test/protobuf/BUILD.bazel b/ros2/test/protobuf/BUILD.bazel index cffa83a3..3704c402 100644 --- a/ros2/test/protobuf/BUILD.bazel +++ b/ros2/test/protobuf/BUILD.bazel @@ -127,6 +127,14 @@ proto_library( ], ) +proto_library( + name = "sibling_proto", + srcs = [ + "sibling_base.proto", + "sibling_ref.proto", + ], +) + py_test( name = "proto_to_ros2_msg_tests", size = "small", @@ -147,6 +155,7 @@ py_test( ":repeated_bytes_proto", ":repeated_fields_proto", ":scalars_proto", + ":sibling_proto", ":transform_proto", ":two_messages_proto", ":with_service_proto", diff --git a/ros2/test/protobuf/msg_enum.proto b/ros2/test/protobuf/msg_enum.proto index c600dcd4..a48b8874 100644 --- a/ros2/test/protobuf/msg_enum.proto +++ b/ros2/test/protobuf/msg_enum.proto @@ -10,4 +10,5 @@ message MsgEnum { } Kind kind = 1; + repeated Kind extra_kinds = 2; } diff --git a/ros2/test/protobuf/sibling_base.proto b/ros2/test/protobuf/sibling_base.proto new file mode 100644 index 00000000..f7284ed3 --- /dev/null +++ b/ros2/test/protobuf/sibling_base.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message SiblingBase { + bool flag = 1; +} diff --git a/ros2/test/protobuf/sibling_ref.proto b/ros2/test/protobuf/sibling_ref.proto new file mode 100644 index 00000000..e2110d61 --- /dev/null +++ b/ros2/test/protobuf/sibling_ref.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +import "ros2/test/protobuf/sibling_base.proto"; + +message SiblingRef { + SiblingBase base = 1; + repeated SiblingBase items = 2; +} diff --git a/ros2/test/protobuf/tests.py b/ros2/test/protobuf/tests.py index 2323e734..3c23d56b 100644 --- a/ros2/test/protobuf/tests.py +++ b/ros2/test/protobuf/tests.py @@ -105,39 +105,22 @@ def test_message_dep_field(self, tmp_path): assert 'ros2_test_protobuf_point_proto_ros_msgs/Point point' in content def test_sibling_proto_same_target(self, tmp_path): - """DummyOne references Point; both are in the same proto_library target. - """ - fp = _load('point_proto', 'dummy_one.proto') - pkg = 'ros2_test_protobuf_point_proto_ros_msgs' + """SiblingRef references SiblingBase; both in the same proto_library.""" + fp = _load('sibling_proto', 'sibling_ref.proto') + pkg = 'ros2_test_protobuf_sibling_proto_ros_msgs' msg_type_map = proto_to_ros2_msg._build_msg_type_map( dep_descriptor_set_paths=[], dep_mapping=[], - main_descriptor_set_path=_ds('point_proto'), - proto_source=f'{_PKG}/dummy_one.proto', + main_descriptor_set_path=_ds('sibling_proto'), + proto_source=f'{_PKG}/sibling_ref.proto', self_ros_package=pkg, ) - out = tmp_path / 'DummyOne.msg' - proto_to_ros2_msg._convert(fp, str(out), f'{_PKG}/dummy_one.proto', + out = tmp_path / 'SiblingRef.msg' + proto_to_ros2_msg._convert(fp, str(out), f'{_PKG}/sibling_ref.proto', msg_type_map) content = out.read_text() - assert f'{pkg}/Point[] points' in content - - def test_file_level_enum_field(self, tmp_path): - fp = _load('file_enum_proto', 'file_enum.proto') - out = tmp_path / 'FileEnum.msg' - proto_to_ros2_msg._convert(fp, str(out), f'{_PKG}/file_enum.proto', {}) - content = out.read_text() - assert 'int32 STATUS_UNKNOWN=0' in content - assert 'int32 STATUS_ACTIVE=1' in content - assert 'int32 STATUS_INACTIVE=2' in content - assert 'int32 status' in content - - def test_repeated_enum_field(self, tmp_path): - fp = _load('file_enum_proto', 'file_enum.proto') - out = tmp_path / 'FileEnum.msg' - proto_to_ros2_msg._convert(fp, str(out), f'{_PKG}/file_enum.proto', {}) - content = out.read_text() - assert 'int32[] tags' in content + assert f'{pkg}/SiblingBase base' in content + assert f'{pkg}/SiblingBase[] items' in content def test_message_level_enum(self, tmp_path): fp = _load('msg_enum_proto', 'msg_enum.proto') @@ -147,6 +130,7 @@ def test_message_level_enum(self, tmp_path): assert 'int32 KIND_NONE=0' in content assert 'int32 KIND_TYPE_A=1' in content assert 'int32 kind' in content + assert 'int32[] extra_kinds' in content class RejectInvalidProtoTests: @@ -203,6 +187,12 @@ def test_rejects_cross_file_enum(self, tmp_path): self._assert_error(fp, 'foreign_enum_usage.proto', {}, 'not found in the current proto file', tmp_path) + def test_rejects_file_level_enum(self, tmp_path): + fp = _load('file_enum_proto', 'file_enum.proto') + self._assert_error(fp, 'file_enum.proto', {}, + 'file-level enum definitions are not supported', + tmp_path) + def test_rejects_missing_dep_mapping(self, tmp_path): fp = _load('transform_proto', 'transform.proto') self._assert_error(fp, 'transform.proto', {}, 'no dep_mapping entry', From 2279bc86846e455fed3864378e2400e5fd13ed46 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Thu, 4 Jun 2026 19:31:27 +0000 Subject: [PATCH 37/39] Fix dummy_one test proto --- ros2/test/protobuf/converter_tests.cc | 6 +++--- ros2/test/protobuf/dummy_one.proto | 13 ++++++------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/ros2/test/protobuf/converter_tests.cc b/ros2/test/protobuf/converter_tests.cc index 187a4c94..25a2ed3d 100644 --- a/ros2/test/protobuf/converter_tests.cc +++ b/ros2/test/protobuf/converter_tests.cc @@ -145,7 +145,7 @@ TEST(TransformConverterTest, RoundTrip) { TEST(DummyOneConverterTest, ToRos) { ros2::test::protobuf::DummyOne proto; - proto.set_color(ros2::test::protobuf::COLOR_RED); + proto.set_color(ros2::test::protobuf::DummyOne::COLOR_RED); ros2_test_protobuf_point_proto_ros_msgs::msg::DummyOne ros; ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToRos(proto, &ros); @@ -163,12 +163,12 @@ TEST(DummyOneConverterTest, ToProto) { ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToProto(ros, &proto); - EXPECT_EQ(proto.color(), ros2::test::protobuf::COLOR_GREEN); + EXPECT_EQ(proto.color(), ros2::test::protobuf::DummyOne::COLOR_GREEN); } TEST(DummyOneConverterTest, RoundTrip) { ros2::test::protobuf::DummyOne original; - original.set_color(ros2::test::protobuf::COLOR_BLUE); + original.set_color(ros2::test::protobuf::DummyOne::COLOR_BLUE); ros2_test_protobuf_point_proto_ros_msgs::msg::DummyOne ros; ros2_test_protobuf_point_proto_ros_msgs::proto_converters::ToRos(original, diff --git a/ros2/test/protobuf/dummy_one.proto b/ros2/test/protobuf/dummy_one.proto index 6f39a9fb..f94d092a 100644 --- a/ros2/test/protobuf/dummy_one.proto +++ b/ros2/test/protobuf/dummy_one.proto @@ -4,14 +4,13 @@ package ros2.test.protobuf; import "ros2/test/protobuf/point.proto"; -enum Color { - COLOR_UNKNOWN = 0; - COLOR_RED = 1; - COLOR_GREEN = 2; - COLOR_BLUE = 3; -} - message DummyOne { + enum Color { + COLOR_UNKNOWN = 0; + COLOR_RED = 1; + COLOR_GREEN = 2; + COLOR_BLUE = 3; + } repeated Point points = 1; Color color = 2; } From 6a132b222e1a7d838aa9b6c582403ba2cfa2783e Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Sun, 7 Jun 2026 10:32:20 +0000 Subject: [PATCH 38/39] Add support for deprecated fields --- ros2/protobuf/README.md | 2 ++ ros2/protobuf/proto_to_ros2_converter.py | 7 ++-- ros2/protobuf/proto_to_ros2_msg.py | 8 ++++- ros2/test/protobuf/BUILD.bazel | 19 ++++++++++ ros2/test/protobuf/converter_tests.cc | 21 ++++++++++++ ros2/test/protobuf/deprecated_field.proto | 9 +++++ ros2/test/protobuf/deprecated_map.proto | 8 +++++ ros2/test/protobuf/tests.py | 42 ++++++++++++++++++++++- 8 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 ros2/test/protobuf/deprecated_field.proto create mode 100644 ros2/test/protobuf/deprecated_map.proto diff --git a/ros2/protobuf/README.md b/ros2/protobuf/README.md index a6cceac4..c80bd801 100644 --- a/ros2/protobuf/README.md +++ b/ros2/protobuf/README.md @@ -135,3 +135,5 @@ namespace that users actually write in their `.proto` files. - **Group fields** are not supported. - **`repeated bytes`** is not supported (`bytes` already maps to `uint8[]`; `repeated bytes` would require `uint8[][]`, which is not a valid ROS 2 type). +- **Deprecated fields.** Fields marked `deprecated = true` are silently skipped + and do not appear in the generated `.msg` file. diff --git a/ros2/protobuf/proto_to_ros2_converter.py b/ros2/protobuf/proto_to_ros2_converter.py index a3d76dd4..92260f0a 100644 --- a/ros2/protobuf/proto_to_ros2_converter.py +++ b/ros2/protobuf/proto_to_ros2_converter.py @@ -24,8 +24,9 @@ - Service definitions are not supported. - Message-type fields require a --dep_mapping entry so the ROS package can be resolved. -- Enum fields are supported (cast to/from int32_t). Only enums defined in - the same proto file are supported. +- Enum fields are supported (cast to/from int32_t). Only enums nested + inside the message are supported; file-level enums cause a build error. +- Fields marked `deprecated = true` are silently skipped (not emitted). - Group fields are not supported. - Repeated bytes fields are not supported. """ @@ -131,6 +132,8 @@ def _field_conversions(message, proto_source, proto_types_to_ros_pkgs): dep_pkgs = set() for field in message.field: + if field.options.deprecated: + continue name = field.name is_repeated = field.label == FieldDescriptorProto.LABEL_REPEATED ftype = field.type diff --git a/ros2/protobuf/proto_to_ros2_msg.py b/ros2/protobuf/proto_to_ros2_msg.py index f6038198..a0b246ce 100644 --- a/ros2/protobuf/proto_to_ros2_msg.py +++ b/ros2/protobuf/proto_to_ros2_msg.py @@ -19,7 +19,9 @@ - Message-type fields are supported as cross-package ROS references (e.g. `pkg/Type`). The caller must supply --dep_mapping for each imported proto. - Enum fields are supported (mapped to int32 with named constants). - Only enums defined in the same proto file are supported. + Only enums nested inside the message are supported; file-level enum + definitions cause a build error. +- Fields marked `deprecated = true` are silently skipped (not emitted). - oneof and map fields are not supported. - Group fields are not supported. - Repeated scalar and message fields are supported and map to dynamic arrays @@ -158,6 +160,8 @@ def _convert(file_proto, output_path, proto_source, msg_type_map): enum_map = _build_enum_map(file_proto, message) emitted_enums = set() for field in message.field: + if field.options.deprecated: + continue if field.type == FieldDescriptorProto.TYPE_ENUM: if field.type_name not in emitted_enums: enum_desc = enum_map.get(field.type_name) @@ -182,6 +186,8 @@ def _convert(file_proto, output_path, proto_source, msg_type_map): f'nested message type "{nested.name}", which is not supported.') for field in message.field: + if field.options.deprecated: + continue field_type_value = field.type is_repeated = (field.label == FieldDescriptorProto.LABEL_REPEATED) diff --git a/ros2/test/protobuf/BUILD.bazel b/ros2/test/protobuf/BUILD.bazel index 3704c402..bc22ac2e 100644 --- a/ros2/test/protobuf/BUILD.bazel +++ b/ros2/test/protobuf/BUILD.bazel @@ -54,6 +54,7 @@ ros2_cpp_test( srcs = ["converter_tests.cc"], deps = [ ":cpp_proto_ros2_converters", + ":deprecated_field_converters", "@googletest//:gtest_main", ], ) @@ -135,6 +136,21 @@ proto_library( ], ) +proto_library( + name = "deprecated_field_proto", + srcs = ["deprecated_field.proto"], +) + +cpp_proto_ros2_converter_library( + name = "deprecated_field_converters", + deps = [":deprecated_field_proto"], +) + +proto_library( + name = "deprecated_map_proto", + srcs = ["deprecated_map.proto"], +) + py_test( name = "proto_to_ros2_msg_tests", size = "small", @@ -144,6 +160,8 @@ py_test( "python_classes=*Tests", ], data = [ + ":deprecated_field_proto", + ":deprecated_map_proto", ":file_enum_proto", ":foreign_enum_proto", ":map_field_proto", @@ -163,6 +181,7 @@ py_test( ], main = "tests.py", deps = [ + "//ros2/protobuf:proto_to_ros2_converter", "//ros2/protobuf:proto_to_ros2_msg", requirement("pytest"), ], diff --git a/ros2/test/protobuf/converter_tests.cc b/ros2/test/protobuf/converter_tests.cc index 25a2ed3d..93cf1d9a 100644 --- a/ros2/test/protobuf/converter_tests.cc +++ b/ros2/test/protobuf/converter_tests.cc @@ -13,6 +13,7 @@ // limitations under the License. #include "gtest/gtest.h" +#include "ros2_test_protobuf_deprecated_field_proto_ros_msgs/proto_converters.h" #include "ros2_test_protobuf_point_proto_ros_msgs/proto_converters.h" #include "ros2_test_protobuf_transform_proto_ros_msgs/proto_converters.h" @@ -239,4 +240,24 @@ TEST(EventConverterTest, RoundTrip) { EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); } +// --------------------------------------------------------------------------- +// DeprecatedField converter tests (verifies deprecated fields are excluded) +// --------------------------------------------------------------------------- + +TEST(DeprecatedFieldConverterTest, RoundTrip) { + ros2::test::protobuf::DeprecatedField original; + original.set_active("round"); + original.set_keep_me(true); + // legacy is left at its zero default; it is not touched by the converter. + + ros2_test_protobuf_deprecated_field_proto_ros_msgs::msg::DeprecatedField ros; + ros2_test_protobuf_deprecated_field_proto_ros_msgs::proto_converters::ToRos( + original, &ros); + ros2::test::protobuf::DeprecatedField recovered; + ros2_test_protobuf_deprecated_field_proto_ros_msgs::proto_converters::ToProto( + ros, &recovered); + + EXPECT_EQ(original.SerializeAsString(), recovered.SerializeAsString()); +} + } // namespace diff --git a/ros2/test/protobuf/deprecated_field.proto b/ros2/test/protobuf/deprecated_field.proto new file mode 100644 index 00000000..a6b713ab --- /dev/null +++ b/ros2/test/protobuf/deprecated_field.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message DeprecatedField { + string active = 1; + int32 legacy = 2 [deprecated = true]; + bool keep_me = 3; +} diff --git a/ros2/test/protobuf/deprecated_map.proto b/ros2/test/protobuf/deprecated_map.proto new file mode 100644 index 00000000..6ad722f6 --- /dev/null +++ b/ros2/test/protobuf/deprecated_map.proto @@ -0,0 +1,8 @@ +syntax = "proto3"; + +package ros2.test.protobuf; + +message DeprecatedMap { + string label = 1; + map old_items = 2 [deprecated = true]; +} diff --git a/ros2/test/protobuf/tests.py b/ros2/test/protobuf/tests.py index 3c23d56b..2b32f449 100644 --- a/ros2/test/protobuf/tests.py +++ b/ros2/test/protobuf/tests.py @@ -11,12 +11,13 @@ # 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. -"""Tests for proto_to_ros2_msg.py.""" +"""Tests for proto_to_ros2_msg.py and proto_to_ros2_converter.py.""" import sys import pytest from ros2.protobuf import proto_to_ros2 +from ros2.protobuf import proto_to_ros2_converter from ros2.protobuf import proto_to_ros2_msg _PKG = 'ros2/test/protobuf' @@ -132,6 +133,26 @@ def test_message_level_enum(self, tmp_path): assert 'int32 kind' in content assert 'int32[] extra_kinds' in content + def test_deprecated_field_is_skipped(self, tmp_path): + fp = _load('deprecated_field_proto', 'deprecated_field.proto') + out = tmp_path / 'DeprecatedField.msg' + proto_to_ros2_msg._convert(fp, str(out), + f'{_PKG}/deprecated_field.proto', {}) + content = out.read_text() + assert 'string active' in content + assert 'bool keep_me' in content + assert 'legacy' not in content + + def test_deprecated_unsupported_type_is_skipped(self, tmp_path): + """Deprecated fields are skipped before constraint validation.""" + fp = _load('deprecated_map_proto', 'deprecated_map.proto') + out = tmp_path / 'DeprecatedMap.msg' + proto_to_ros2_msg._convert(fp, str(out), f'{_PKG}/deprecated_map.proto', + {}) + content = out.read_text() + assert 'string label' in content + assert 'old_items' not in content + class RejectInvalidProtoTests: """Tests that the converter rejects each documented constraint violation.""" @@ -199,5 +220,24 @@ def test_rejects_missing_dep_mapping(self, tmp_path): tmp_path) +class ConverterSkipsDeprecatedTests: + """Tests that the C++ converter generator skips deprecated fields.""" + + def _field_lines(self, target, proto_file): + fp = _load(target, proto_file) + message = fp.message_type[0] + to_ros, from_ros, _ = proto_to_ros2_converter._field_conversions( + message, f'{_PKG}/{proto_file}', {}) + return to_ros, from_ros + + def test_deprecated_scalar_not_in_generated_code(self): + to_ros, from_ros = self._field_lines('deprecated_field_proto', + 'deprecated_field.proto') + all_lines = to_ros + from_ros + assert any('active' in line for line in all_lines) + assert any('keep_me' in line for line in all_lines) + assert not any('legacy' in line for line in all_lines) + + if __name__ == '__main__': sys.exit(pytest.main([__file__, '-v', *sys.argv[1:]])) From 5312156c736a2f019f5f58973941f33191ba7b00 Mon Sep 17 00:00:00 2001 From: Milan Vukov Date: Sun, 7 Jun 2026 17:51:22 +0000 Subject: [PATCH 39/39] Add a plan for sized arrays --- ros2/protobuf/sized_arrays.md | 232 ++++++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 ros2/protobuf/sized_arrays.md diff --git a/ros2/protobuf/sized_arrays.md b/ros2/protobuf/sized_arrays.md new file mode 100644 index 00000000..39ab857f --- /dev/null +++ b/ros2/protobuf/sized_arrays.md @@ -0,0 +1,232 @@ +# Plan: Sized arrays via `ros2_field_options.proto` + +## Context + +`proto_to_ros2_msg.py` always emits dynamic arrays (`T[]`) for `bytes` fields and +`repeated T` fields. Users need a way to annotate proto fields to emit **fixed-size +arrays** (`T[N]`) in ROS 2 msg format. Fixed-size arrays avoid heap allocation in +the ROS 2 serialization layer and are important for performance-critical types +(hashes, fixed-length point clouds, pose arrays, etc.). + +The mechanism: a new custom proto field option `(ros2.msg.array_size) = N` defined in +`ros2/protobuf/ros2_field_options.proto`. protoc serialises this as extension field +bytes inside `FieldOptions` in the descriptor set. The Python tool reads it via +`field.options.UnknownFields()` — no `py_proto_library` Bazel target is needed. + +--- + +## New file: `ros2/protobuf/ros2_field_options.proto` + +```protobuf +syntax = "proto3"; +package ros2.msg; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + // Fixed array size for the generated ROS .msg field. + // Valid on: singular bytes fields and repeated fields of any type. + // When N > 0 the generator emits T[N] instead of T[]. + // Value 0 (the proto3 default) means dynamic array (no change). + uint32 array_size = 50000; +} +``` + +Field number 50000 is in the open custom-extension range. + +--- + +## Changes + +### 1. `ros2/protobuf/BUILD.bazel` + +Add a `proto_library` for the new options file (public so downstream `proto_library` +targets can depend on it): + +```python +proto_library( + name = "ros2_field_options_proto", + srcs = ["ros2_field_options.proto"], + deps = ["@com_google_protobuf//:descriptor_proto"], + visibility = ["//visibility:public"], +) +``` + +No changes to the `proto_to_ros2_msg` `py_binary` deps — the tool reads the option +via `UnknownFields()` at runtime without importing a generated Python extension module. + +### 2. `ros2/protobuf/proto_to_ros2_msg.py` + +**Add constant and helper** near the top of the file (after imports): + +```python +# Field number for the ros2.msg.array_size FieldOptions extension. +_ARRAY_SIZE_FIELD_NUMBER = 50000 + + +def _get_array_size(field_options): + """Returns the (ros2.msg.array_size) value from field options, or 0.""" + for uf in field_options.UnknownFields(): + if uf.field_number == _ARRAY_SIZE_FIELD_NUMBER: + return uf.data # wire type 0 (varint) → Python int + return 0 +``` + +**Modify the field-emission loop** in `_convert()`: + +After the `deprecated` guard, read `array_size` once per field and validate: + +```python +array_size = _get_array_size(field.options) +is_repeated = (field.label == FieldDescriptorProto.LABEL_REPEATED) + +if array_size and not is_repeated and field_type_value != FieldDescriptorProto.TYPE_BYTES: + sys.exit( + f'Error: {proto_source}: field "{field.name}" has array_size={array_size} ' + f'but is not a bytes field or a repeated field.') +``` + +Then change each array-suffix site (three places): + +| Field kind | Before | After | +| ------------------------------- | --------------------------------------- | ----------------------------------------------------------------------- | +| `TYPE_MESSAGE` (repeated) | `ros2_type + '[]'` | `ros2_type + f'[{array_size}]'` if `array_size` else `ros2_type + '[]'` | +| `TYPE_ENUM` | `'int32[]' if is_repeated else 'int32'` | `f'int32[{array_size}]'` if `array_size and is_repeated` else original | +| scalar / bytes (bottom of loop) | `ros2_type + '[]'` | sized suffix for repeated and for singular bytes | + +Concretely, the last block becomes: + +```python +ros2_type = _PROTO_TO_ROS_TYPE[field_type_value] + +if is_repeated and field_type_value == FieldDescriptorProto.TYPE_BYTES: + sys.exit(...) # unchanged + +suffix = f'[{array_size}]' if array_size else '[]' + +if field_type_value == FieldDescriptorProto.TYPE_BYTES: + # bytes already maps to 'uint8[]'; replace the trailing [] with the suffix. + ros2_type = 'uint8' + suffix +elif is_repeated: + ros2_type = ros2_type + suffix +# singular non-bytes: ros2_type unchanged (no suffix) + +lines.append(f'{ros2_type} {field.name}') +``` + +### 3. Test fixtures — `ros2/test/protobuf/` (new files) + +**`sized_bytes.proto`** — singular `bytes` with and without a size: + +```protobuf +syntax = "proto3"; +package ros2.test.protobuf; +import "ros2/protobuf/ros2_field_options.proto"; +message SizedBytes { + bytes hash = 1 [(ros2.msg.array_size) = 32]; + bytes raw = 2; +} +``` + +Expected: `uint8[32] hash`, `uint8[] raw`. + +**`sized_array.proto`** — repeated scalar and repeated message with fixed sizes: + +```protobuf +syntax = "proto3"; +package ros2.test.protobuf; +import "ros2/protobuf/ros2_field_options.proto"; +import "ros2/test/protobuf/point.proto"; +message SizedArray { + repeated float coords = 1 [(ros2.msg.array_size) = 3]; + repeated float dynamic = 2; + repeated ros2.test.protobuf.Point pts = 3 [(ros2.msg.array_size) = 4]; +} +``` + +Expected: `float32[3] coords`, `float32[] dynamic`, +`ros2_test_protobuf_point_proto_ros_msgs/Point[4] pts`. + +**`sized_bad.proto`** — singular non-bytes scalar with `array_size` (error test): + +```protobuf +syntax = "proto3"; +package ros2.test.protobuf; +import "ros2/protobuf/ros2_field_options.proto"; +message SizedBad { + int32 x = 1 [(ros2.msg.array_size) = 3]; +} +``` + +### 4. `ros2/test/protobuf/BUILD.bazel` + +Add three `proto_library` targets (the `sized_array_proto` depends on `:point_proto`): + +```python +proto_library( + name = "sized_bytes_proto", + srcs = ["sized_bytes.proto"], + deps = ["//ros2/protobuf:ros2_field_options_proto"], +) + +proto_library( + name = "sized_array_proto", + srcs = ["sized_array.proto"], + deps = [ + ":point_proto", + "//ros2/protobuf:ros2_field_options_proto", + ], +) + +proto_library( + name = "sized_bad_proto", + srcs = ["sized_bad.proto"], + deps = ["//ros2/protobuf:ros2_field_options_proto"], +) +``` + +Add all three to the `data` list of `proto_to_ros2_msg_tests`. + +### 5. `ros2/test/protobuf/tests.py` + +Add a new class **`SizedArrayTests`**: + +```python +class SizedArrayTests: + def test_bytes_with_array_size(self, tmp_path): + ... # assert 'uint8[32] hash' and 'uint8[] raw' in output + + def test_repeated_scalar_with_array_size(self, tmp_path): + ... # assert 'float32[3] coords' and 'float32[] dynamic' in output + + def test_repeated_message_with_array_size(self, tmp_path): + ... # assert '/Point[4] pts' in output + + def test_array_size_on_singular_scalar_is_error(self, tmp_path): + ... # assert SystemExit with 'array_size' in message +``` + +The `test_repeated_message_with_array_size` test must supply the msg_type_map for +`.ros2.test.protobuf.Point` (same pattern as `test_message_dep_field`). + +### 6. `ros2/protobuf/README.md` + +Add a **Fixed-size arrays** entry to the Limitations section and a prose paragraph +documenting the option and import path. + +--- + +## Out of scope + +`proto_to_ros2_converter.py` is not changed here. Fixed-size ROS arrays require +different C++ copy logic (`std::copy` / `memcpy` vs. `assign`); that is a follow-up. + +--- + +## Verification + +``` +bazel test //ros2/test/protobuf:proto_to_ros2_msg_tests --test_output=streamed +``` + +All tests in `SizedArrayTests` must pass; all pre-existing tests must continue to pass.