diff --git a/rosidl_generator_py/CMakeLists.txt b/rosidl_generator_py/CMakeLists.txt index e5ff834f..0a878bec 100644 --- a/rosidl_generator_py/CMakeLists.txt +++ b/rosidl_generator_py/CMakeLists.txt @@ -53,6 +53,21 @@ if(BUILD_TESTING) SKIP_INSTALL ) + # Ensure build directory contains a complete package by symlinking/copying python files from source + foreach(_pypath + "__init__.py" + "cli.py" + "field_type_syntax.py" + "generate_py_impl.py" + "import_type_support_impl.py" + ) + file(CREATE_LINK + "${CMAKE_CURRENT_SOURCE_DIR}/rosidl_generator_py/${_pypath}" + "${CMAKE_CURRENT_BINARY_DIR}/rosidl_generator_py/rosidl_generator_py/${_pypath}" + SYMBOLIC + COPY_ON_ERROR) + endforeach() + set(_append_library_dirs "") if(WIN32) set(_append_library_dirs "${CMAKE_CURRENT_BINARY_DIR}/$") diff --git a/rosidl_generator_py/resource/_msg.py.em b/rosidl_generator_py/resource/_msg.py.em index a8682df0..07eb1a5b 100644 --- a/rosidl_generator_py/resource/_msg.py.em +++ b/rosidl_generator_py/resource/_msg.py.em @@ -457,9 +457,24 @@ if isinstance(type_, AbstractNestedType): return True @@classmethod - def get_fields_and_field_types(cls) -> dict[str, str]: + def get_fields_and_field_types(cls, *, syntax: str = 'omg') -> dict[str, str]: + """ + Return a dict of field name to field type string. + + :param syntax: Either 'omg' (default) for the OMG IDL 4.2 type + strings historically returned by this method (e.g. + ``sequence, 32>``), or 'ros' for ROS 2 .msg-syntax + type strings (e.g. ``string<=256[<=32]``) as expected by + .msg-based tooling such as the MCAP ros2msg schema encoding. + """ from copy import copy - return copy(cls._fields_and_field_types) + fields = copy(cls._fields_and_field_types) + if syntax == 'omg': + return fields + if syntax == 'ros': + from rosidl_generator_py.field_type_syntax import omg_to_ros_syntax + return {name: omg_to_ros_syntax(t) for name, t in fields.items()} + raise ValueError(f"Unknown syntax {syntax!r}: expected 'omg' or 'ros'") @[for member in message.structure.members]@ @[ if len(message.structure.members) == 1 and member.name == EMPTY_STRUCTURE_REQUIRED_MEMBER_NAME]@ @[ continue]@ diff --git a/rosidl_generator_py/rosidl_generator_py/__init__.py b/rosidl_generator_py/rosidl_generator_py/__init__.py index ecf221fa..c2075183 100644 --- a/rosidl_generator_py/rosidl_generator_py/__init__.py +++ b/rosidl_generator_py/rosidl_generator_py/__init__.py @@ -15,9 +15,10 @@ import logging import traceback +from .field_type_syntax import omg_to_ros_syntax from .import_type_support_impl import import_type_support -__all__ = ['import_type_support'] +__all__ = ['omg_to_ros_syntax', 'import_type_support'] try: from .generate_py_impl import generate_py # noqa: F401 diff --git a/rosidl_generator_py/rosidl_generator_py/field_type_syntax.py b/rosidl_generator_py/rosidl_generator_py/field_type_syntax.py new file mode 100644 index 00000000..add944f0 --- /dev/null +++ b/rosidl_generator_py/rosidl_generator_py/field_type_syntax.py @@ -0,0 +1,54 @@ +# Copyright 2026 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Convert OMG IDL 4.2 field-type strings to ROS 2 .msg-syntax field-type strings. + +Generated message classes store field types as OMG IDL 4.2 strings in +``_fields_and_field_types`` (e.g. ``sequence, 32>``). Some +tooling -- such as consumers of the MCAP ``ros2msg`` schema encoding -- +expects ROS 2 ``.msg`` syntax instead (e.g. ``string<=256[<=32]``). This +module implements that conversion so it can be requested on demand via +``get_fields_and_field_types(syntax='ros')`` without changing the stored +IDL representation. +""" + +import re + +_BOUNDED_STRING_RE = re.compile(r'(w?string)<(\d+)>') +_BOUNDED_SEQUENCE_RE = re.compile(r'^sequence<(.+), (\d+)>$') +_UNBOUNDED_SEQUENCE_RE = re.compile(r'^sequence<(.+)>$') + + +def omg_to_ros_syntax(omg_type: str) -> str: + """ + Convert a single OMG IDL 4.2 field-type string to ROS 2 .msg syntax. + + :param omg_type: A field-type string as stored in a generated message + class's ``_fields_and_field_types`` dict, e.g. + ``sequence, 32>``. + :return: The equivalent ROS 2 .msg-syntax string, e.g. + ``string<=256[<=32]``. + """ + match = _BOUNDED_SEQUENCE_RE.match(omg_type) + if match: + value_type, max_size = match.groups() + return f'{omg_to_ros_syntax(value_type)}[<={max_size}]' + + match = _UNBOUNDED_SEQUENCE_RE.match(omg_type) + if match: + value_type = match.group(1) + return f'{omg_to_ros_syntax(value_type)}[]' + + return _BOUNDED_STRING_RE.sub(r'\1<=\2', omg_type) diff --git a/rosidl_generator_py/test/test_field_type_syntax.py b/rosidl_generator_py/test/test_field_type_syntax.py new file mode 100644 index 00000000..735c26ec --- /dev/null +++ b/rosidl_generator_py/test/test_field_type_syntax.py @@ -0,0 +1,35 @@ +# Copyright 2026 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from rosidl_generator_py.field_type_syntax import omg_to_ros_syntax + + +@pytest.mark.parametrize( + 'omg_type,expected_ros_type', + [ + ('string<5>', 'string<=5'), + ('wstring<5>', 'wstring<=5'), + ('string', 'string'), + ('char', 'char'), + ('sequence', 'char[]'), + ('sequence', 'string[]'), + ('sequence', 'string[<=10]'), + ('sequence>', 'string<=5[]'), + ('sequence, 10>', 'string<=5[<=10]'), + ], +) +def test_omg_to_ros_syntax(omg_type: str, expected_ros_type: str) -> None: + assert omg_to_ros_syntax(omg_type) == expected_ros_type diff --git a/rosidl_generator_py/test/test_interfaces.py b/rosidl_generator_py/test/test_interfaces.py index 64225d9c..c3a90a7b 100644 --- a/rosidl_generator_py/test/test_interfaces.py +++ b/rosidl_generator_py/test/test_interfaces.py @@ -938,6 +938,8 @@ def test_string_slot_attributes() -> None: assert hasattr(msg, 'get_fields_and_field_types') assert hasattr(msg, '__slots__') string_slot_types_dict = getattr(msg, 'get_fields_and_field_types')() + string_slot_types_dict_explicit = getattr(msg, 'get_fields_and_field_types')(syntax='omg') + assert string_slot_types_dict == string_slot_types_dict_explicit expected_string_slot_types_dict = { 'ub_string_static_array_value': 'string<5>[3]', 'ub_string_ub_array_value': 'sequence, 10>', @@ -959,6 +961,40 @@ def test_string_slot_attributes() -> None: assert expected_slot_type == string_slot_types_dict[expected_field] +def test_string_slot_attributes_ros_syntax() -> None: + msg = StringArrays(check_fields=True) + assert hasattr(msg, 'get_fields_and_field_types') + string_slot_types_dict = getattr(msg, 'get_fields_and_field_types')(syntax='ros') + expected_string_slot_types_dict = { + 'ub_string_static_array_value': 'string<=5[3]', + 'ub_string_ub_array_value': 'string<=5[<=10]', + 'ub_string_dynamic_array_value': 'string<=5[]', + 'string_dynamic_array_value': 'string[]', + 'string_static_array_value': 'string[3]', + 'string_bounded_array_value': 'string[<=10]', + 'def_string_dynamic_array_value': 'string[]', + 'def_string_static_array_value': 'string[3]', + 'def_string_bounded_array_value': 'string[<=10]', + 'def_various_quotes': 'string[]', + 'def_various_commas': 'string[]', + } + + assert len(string_slot_types_dict) == len(expected_string_slot_types_dict) + + for expected_field, expected_slot_type in expected_string_slot_types_dict.items(): + assert expected_field in string_slot_types_dict.keys() + assert expected_slot_type == string_slot_types_dict[expected_field] + + +def test_get_fields_and_field_types_invalid_syntax() -> None: + msg = StringArrays(check_fields=True) + try: + getattr(msg, 'get_fields_and_field_types')(syntax='bogus') + assert False, 'expected ValueError for unknown syntax' + except ValueError: + pass + + def test_modifying_slot_fields_and_types() -> None: msg = StringArrays(check_fields=True) assert hasattr(msg, 'get_fields_and_field_types')