diff --git a/rosidl_runtime_py/package.xml b/rosidl_runtime_py/package.xml new file mode 100644 index 00000000..cb6bdbf5 --- /dev/null +++ b/rosidl_runtime_py/package.xml @@ -0,0 +1,24 @@ + + + + rosidl_runtime_py + 0.6.2 + Runtime utilities for working with generated ROS interfaces in Python. + Jacob Perron + Apache License 2.0 + + Dirk Thomas + + python3-numpy + python3-yaml + + ament_copyright + ament_flake8 + ament_pep257 + python3-pytest + test_msgs + + + ament_python + + diff --git a/rosidl_runtime_py/rosidl_runtime_py/__init__.py b/rosidl_runtime_py/rosidl_runtime_py/__init__.py new file mode 100644 index 00000000..5f40b2ed --- /dev/null +++ b/rosidl_runtime_py/rosidl_runtime_py/__init__.py @@ -0,0 +1,26 @@ +# Copyright 2019 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from .convert import message_to_csv +from .convert import message_to_ordereddict +from .convert import message_to_yaml +from .set_message import set_message_fields + + +__all__ = [ + 'message_to_csv', + 'message_to_ordereddict', + 'message_to_yaml', + 'set_message_fields', +] diff --git a/rosidl_runtime_py/rosidl_runtime_py/convert.py b/rosidl_runtime_py/rosidl_runtime_py/convert.py new file mode 100644 index 00000000..573d4caf --- /dev/null +++ b/rosidl_runtime_py/rosidl_runtime_py/convert.py @@ -0,0 +1,158 @@ +# Copyright 2016-2019 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import array +from collections import OrderedDict +import sys +from typing import Any + +import numpy +import yaml + + +__yaml_representer_registered = False + + +# Custom representer for getting clean YAML output that preserves the order in an OrderedDict. +# Inspired by: http://stackoverflow.com/a/16782282/7169408 +def __represent_ordereddict(dumper, data): + items = [] + for k, v in data.items(): + items.append((dumper.represent_data(k), dumper.represent_data(v))) + return yaml.nodes.MappingNode(u'tag:yaml.org,2002:map', items) + + +def message_to_yaml(msg: Any, truncate_length: int = None) -> str: + """ + Convert a ROS message to a YAML string. + + :param msg: The ROS message to convert. + :param truncate_length: Truncate values for all message fields to this length. + This does not truncate the list of message fields. + :returns: A YAML string representation of the input ROS message. + """ + global __yaml_representer_registered + + # Register our custom representer for YAML output + if not __yaml_representer_registered: + yaml.add_representer(OrderedDict, __represent_ordereddict) + __yaml_representer_registered = True + + return yaml.dump( + message_to_ordereddict(msg, truncate_length=truncate_length), + width=sys.maxsize, + ) + + +def message_to_csv(msg: Any, truncate_length: int = None) -> str: + """ + Convert a ROS message to string of comma-separated values. + + :param msg: The ROS message to convert. + :param truncate_length: Truncate values for all message fields to this length. + This does not truncate the list of message fields. + :returns: A string of comma-separated values representing the input message. + """ + def to_string(val): + nonlocal truncate_length + r = '' + if any(isinstance(val, t) for t in [list, tuple, array.array, numpy.ndarray]): + for i, v in enumerate(val): + if r: + r += ',' + if truncate_length is not None and i >= truncate_length: + r += '...' + break + r += to_string(v) + elif any(isinstance(val, t) for t in [bool, bytes, float, int, str, numpy.number]): + if any(isinstance(val, t) for t in [bytes, str]): + if truncate_length is not None and len(val) > truncate_length: + val = val[:truncate_length] + if isinstance(val, bytes): + val += b'...' + else: + val += '...' + r = str(val) + else: + r = message_to_csv(val, truncate_length) + return r + result = '' + # We rely on __slots__ retaining the order of the fields in the .msg file. + for field_name in msg.__slots__: + value = getattr(msg, field_name) + if result: + result += ',' + result += to_string(value) + return result + + +# Convert a msg to an OrderedDict. We do this instead of implementing a generic __dict__() method +# in the msg because we want to preserve order of fields from the .msg file(s). +def message_to_ordereddict(msg: Any, truncate_length: int = None) -> OrderedDict: + """ + Convert a ROS message to an OrderedDict. + + :param msg: The ROS message to convert. + :param truncate_length: Truncate values for all message fields to this length. + This does not truncate the list of fields (ie. the dictionary keys). + :returns: An OrderedDict where the keys are the ROS message fields and the values are + set to the values of the input message. + """ + d = OrderedDict() + # We rely on __slots__ retaining the order of the fields in the .msg file. + for field_name in msg.__slots__: + value = getattr(msg, field_name, None) + value = _convert_value(value, truncate_length=truncate_length) + # Remove leading underscore from field name + d[field_name[1:]] = value + return d + + +def _convert_value(value, truncate_length=None): + if isinstance(value, bytes): + if truncate_length is not None and len(value) > truncate_length: + value = ''.join([chr(c) for c in value[:truncate_length]]) + '...' + else: + value = ''.join([chr(c) for c in value]) + elif isinstance(value, str): + if truncate_length is not None and len(value) > truncate_length: + value = value[:truncate_length] + '...' + elif (any( + isinstance(value, t) for t in [list, tuple, array.array, numpy.ndarray] + )): + # Since arrays and ndarrays can't contain mixed types convert to list + typename = tuple if isinstance(value, tuple) else list + if truncate_length is not None and len(value) > truncate_length: + # Truncate the sequence + value = value[:truncate_length] + # Truncate every item in the sequence + value = typename( + [_convert_value(v, truncate_length) for v in value] + ['...']) + else: + # Truncate every item in the list + value = typename( + [_convert_value(v, truncate_length) for v in value]) + elif isinstance(value, dict) or isinstance(value, OrderedDict): + # Convert each key and value in the mapping + new_value = {} if isinstance(value, dict) else OrderedDict() + for k, v in value.items(): + # Don't truncate keys because that could result in key collisions and data loss + new_value[_convert_value(k)] = _convert_value(v, truncate_length=truncate_length) + value = new_value + elif ( + not any(isinstance(value, t) for t in (bool, float, int, numpy.number)) + ): + # Assuming value is a message since it is neither a collection nor a primitive type + value = message_to_ordereddict(value, truncate_length=truncate_length) + return value diff --git a/rosidl_runtime_py/rosidl_runtime_py/set_message.py b/rosidl_runtime_py/rosidl_runtime_py/set_message.py new file mode 100644 index 00000000..cfb36563 --- /dev/null +++ b/rosidl_runtime_py/rosidl_runtime_py/set_message.py @@ -0,0 +1,36 @@ +# Copyright 2017-2019 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Any +from typing import Dict + + +def set_message_fields(msg: Any, values: Dict[str, str]) -> None: + """ + Set the fields of a ROS message. + + :param msg: The ROS message to populate. + :param values: The values to set in the ROS message. The keys of the dictionary represent + fields of the message. + :raises AttributeError: If the message does not have a field provided in the input dictionary. + :raises ValueError: If a message value does not match its field type. + """ + for field_name, field_value in values.items(): + field_type = type(getattr(msg, field_name)) + try: + value = field_type(field_value) + except TypeError: + value = field_type() + set_message_fields(value, field_value) + setattr(msg, field_name, value) diff --git a/rosidl_runtime_py/setup.py b/rosidl_runtime_py/setup.py new file mode 100644 index 00000000..ac476894 --- /dev/null +++ b/rosidl_runtime_py/setup.py @@ -0,0 +1,28 @@ +from setuptools import find_packages +from setuptools import setup + +setup( + name='rosidl_runtime_py', + version='0.6.2', + packages=find_packages(exclude=['test']), + zip_safe=False, + author='Dirk Thomas', + author_email='dthomas@osrfoundation.org', + maintainer='Jacob Perron', + maintainer_email='jacob@openrobotics.org', + url='https://github.com/ros2/rosidl_python/tree/master/rosidl_runtime_py', + download_url='https://github.com/ros2/rosidl_python/releases', + keywords=[], + classifiers=[ + 'Environment :: Console', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Apache Software License', + 'Programming Language :: Python', + ], + description='Runtime utilities for working with generated ROS interfaces in Python.', + long_description=( + 'This package provides functions for operations such as populating ROS messages ' + 'and converting messages to different representations.'), + license='Apache License, Version 2.0', + tests_require=['pytest'], +) diff --git a/rosidl_runtime_py/test/rosidl_runtime_py/test_convert.py b/rosidl_runtime_py/test/rosidl_runtime_py/test_convert.py new file mode 100644 index 00000000..012efd2e --- /dev/null +++ b/rosidl_runtime_py/test/rosidl_runtime_py/test_convert.py @@ -0,0 +1,113 @@ +# Copyright 2017-2019 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections import OrderedDict + +from rosidl_runtime_py import message_to_csv +from rosidl_runtime_py import message_to_ordereddict +from rosidl_runtime_py import message_to_yaml +from rosidl_runtime_py.convert import _convert_value + +from test_msgs import message_fixtures + + +def test_primitives(): + # Smoke-test the formatters on a bunch of messages + msgs = [] + msgs.extend(message_fixtures.get_msg_bounded_array_nested()) + msgs.extend(message_fixtures.get_msg_bounded_array_primitives()) + msgs.extend(message_fixtures.get_msg_builtins()) + msgs.extend(message_fixtures.get_msg_dynamic_array_nested()) + msgs.extend(message_fixtures.get_msg_dynamic_array_primitives()) + msgs.extend(message_fixtures.get_msg_dynamic_array_primitives_nested()) + msgs.extend(message_fixtures.get_msg_empty()) + msgs.extend(message_fixtures.get_msg_nested()) + msgs.extend(message_fixtures.get_msg_primitives()) + msgs.extend(message_fixtures.get_msg_static_array_nested()) + msgs.extend(message_fixtures.get_msg_static_array_primitives()) + for m in msgs: + message_to_csv(m, 100) + message_to_csv(m, None) + message_to_ordereddict(m, 100) + message_to_ordereddict(m, None) + message_to_yaml(m, 100) + message_to_yaml(m, None) + + +def test_convert_primitives(): + assert 5 == _convert_value(5) + assert 5 == _convert_value(5, truncate_length=0) + assert 5 == _convert_value(5, truncate_length=1) + assert 5 == _convert_value(5, truncate_length=10000) + assert 42.0 == _convert_value(42.0) + assert 42.0 == _convert_value(42.0, truncate_length=0) + assert 42.0 == _convert_value(42.0, truncate_length=1) + assert 42.0 == _convert_value(42.0, truncate_length=10000) + assert True is _convert_value(True) + assert True is _convert_value(True, truncate_length=0) + assert True is _convert_value(True, truncate_length=1) + assert True is _convert_value(True, truncate_length=10000) + assert False is _convert_value(False) + assert False is _convert_value(False, truncate_length=0) + assert False is _convert_value(False, truncate_length=1) + assert False is _convert_value(False, truncate_length=10000) + + +def test_convert_tuple(): + assert (1, 2, 3) == _convert_value((1, 2, 3)) + assert ('...',) == _convert_value((1, 2, 3), truncate_length=0) + assert (1, 2, '...') == _convert_value((1, 2, 3), truncate_length=2) + assert ('123', '456', '789') == _convert_value(('123', '456', '789')) + assert ('12...', '45...', '...') == _convert_value(('123', '456', '789'), truncate_length=2) + assert ('123', '456', '789') == _convert_value(('123', '456', '789'), truncate_length=5) + + +def test_convert_list(): + assert [1, 2, 3] == _convert_value([1, 2, 3]) + assert ['...'] == _convert_value([1, 2, 3], truncate_length=0) + assert [1, 2, '...'] == _convert_value([1, 2, 3], truncate_length=2) + assert ['123', '456', '789'] == _convert_value(['123', '456', '789']) + assert ['12...', '45...', '...'] == _convert_value(['123', '456', '789'], truncate_length=2) + assert ['123', '456', '789'] == _convert_value(['123', '456', '789'], truncate_length=5) + + +def test_convert_str(): + assert 'hello world' == _convert_value('hello world') + assert 'hello...' == _convert_value('hello world', truncate_length=5) + assert 'hello world' == _convert_value('hello world', truncate_length=1000) + + +def test_convert_bytes(): + assert 'hello world' == _convert_value(b'hello world') + assert 'hello...' == _convert_value(b'hello world', truncate_length=5) + assert 'hello world' == _convert_value(b'hello world', truncate_length=1000) + + +def test_convert_ordered_dict(): + assert OrderedDict([(1, 'a'), ('2', 'b')]) == _convert_value( + OrderedDict([(1, 'a'), ('2', 'b')])) + assert OrderedDict([(1, 'a'), ('2', 'b')]) == _convert_value( + OrderedDict([(1, 'a'), ('2', 'b')]), truncate_length=1) + assert OrderedDict([(1, 'a'), ('2', 'b')]) == _convert_value( + OrderedDict([(1, 'a'), ('2', 'b')]), truncate_length=1000) + assert OrderedDict([(1, 'a...'), ('234', 'b...')]) == _convert_value( + OrderedDict([(1, 'abc'), ('234', 'bcd')]), truncate_length=1) + + +def test_convert_dict(): + assert {1: 'a', '2': 'b'} == _convert_value({1: 'a', '2': 'b'}) + assert {1: 'a', '2': 'b'} == _convert_value({1: 'a', '2': 'b'}, truncate_length=1) + assert {1: 'a', '2': 'b'} == _convert_value({1: 'a', '2': 'b'}, truncate_length=1000) + assert {1: 'a...', '234': 'b...'} == _convert_value( + {1: 'abc', '234': 'bcd'}, truncate_length=1) diff --git a/rosidl_runtime_py/test/rosidl_runtime_py/test_set_message.py b/rosidl_runtime_py/test/rosidl_runtime_py/test_set_message.py new file mode 100644 index 00000000..f8877daa --- /dev/null +++ b/rosidl_runtime_py/test/rosidl_runtime_py/test_set_message.py @@ -0,0 +1,94 @@ +# Copyright 2019 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import copy + +import pytest + +from rosidl_runtime_py import set_message_fields +from test_msgs import message_fixtures + + +def test_set_message_fields_none(): + # Smoke-test on a bunch of messages + msgs = [] + msgs.extend(message_fixtures.get_msg_bounded_array_nested()) + msgs.extend(message_fixtures.get_msg_bounded_array_primitives()) + msgs.extend(message_fixtures.get_msg_builtins()) + msgs.extend(message_fixtures.get_msg_dynamic_array_nested()) + msgs.extend(message_fixtures.get_msg_dynamic_array_primitives()) + msgs.extend(message_fixtures.get_msg_dynamic_array_primitives_nested()) + msgs.extend(message_fixtures.get_msg_empty()) + msgs.extend(message_fixtures.get_msg_nested()) + msgs.extend(message_fixtures.get_msg_primitives()) + msgs.extend(message_fixtures.get_msg_static_array_nested()) + msgs.extend(message_fixtures.get_msg_static_array_primitives()) + for m in msgs: + original_m = copy.copy(m) + set_message_fields(m, {}) + # Assert message is not modified when setting no fields + assert original_m == m + + +def test_set_message_fields_partial(): + original_msg = message_fixtures.get_msg_primitives()[0] + original_msg.bool_value = False + original_msg.char_value = 3 + original_msg.int32_value = 42 + original_msg.string_value = '' + + modified_msg = copy.copy(original_msg) + values = {} + values['bool_value'] = True + values['char_value'] = 1 + values['int32_value'] = 24 + values['string_value'] = 'testing set message fields partial' + set_message_fields(modified_msg, values) + + for _attr in original_msg.__slots__: + # Remove underscore prefix + attr = _attr[1:] + if attr in values: + assert getattr(modified_msg, attr) == values[attr] + else: + assert getattr(modified_msg, attr) == getattr(original_msg, attr) + + +def test_set_message_fields_full(): + msg_list = message_fixtures.get_msg_primitives() + msg0 = msg_list[0] + msg1 = msg_list[1] + + # Set msg0 values to the values of msg1 + values = {} + for _attr in msg1.__slots__: + # Remove underscore prefix + attr = _attr[1:] + values[attr] = getattr(msg1, attr) + set_message_fields(msg0, values) + + assert msg0 == msg1 + + +def test_set_message_fields_invalid(): + msg = message_fixtures.get_msg_primitives()[0] + invalid_field = {} + invalid_field['test_invalid_field'] = 42 + with pytest.raises(AttributeError): + set_message_fields(msg, invalid_field) + + invalid_type = {} + invalid_type['int32_value'] = 'this is not an integer' + with pytest.raises(ValueError): + set_message_fields(msg, invalid_type) diff --git a/rosidl_runtime_py/test/test_copyright.py b/rosidl_runtime_py/test/test_copyright.py new file mode 100644 index 00000000..cf0fae31 --- /dev/null +++ b/rosidl_runtime_py/test/test_copyright.py @@ -0,0 +1,23 @@ +# Copyright 2017 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ament_copyright.main import main +import pytest + + +@pytest.mark.copyright +@pytest.mark.linter +def test_copyright(): + rc = main(argv=['.', 'test']) + assert rc == 0, 'Found errors' diff --git a/rosidl_runtime_py/test/test_flake8.py b/rosidl_runtime_py/test/test_flake8.py new file mode 100644 index 00000000..eff82996 --- /dev/null +++ b/rosidl_runtime_py/test/test_flake8.py @@ -0,0 +1,23 @@ +# Copyright 2017 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ament_flake8.main import main +import pytest + + +@pytest.mark.flake8 +@pytest.mark.linter +def test_flake8(): + rc = main(argv=[]) + assert rc == 0, 'Found errors' diff --git a/rosidl_runtime_py/test/test_pep257.py b/rosidl_runtime_py/test/test_pep257.py new file mode 100644 index 00000000..0e38a6c6 --- /dev/null +++ b/rosidl_runtime_py/test/test_pep257.py @@ -0,0 +1,23 @@ +# Copyright 2017 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ament_pep257.main import main +import pytest + + +@pytest.mark.linter +@pytest.mark.pep257 +def test_pep257(): + rc = main(argv=[]) + assert rc == 0, 'Found code style errors / warnings'