From 0d687b9f0ce0c935b0342577ee5617764d50ade6 Mon Sep 17 00:00:00 2001 From: Jacob Perron Date: Thu, 28 Mar 2019 10:50:24 -0700 Subject: [PATCH 1/2] Add rosidl_runtime_py package Migrated utility functions for working with Python ROS messages from the package `ros2topic`. Conversion implementation taken from: https://github.com/ros2/ros2cli/blob/978fc8807cb1f70b5f9c2b085e7b6fa74d191d0e/ros2topic/ros2topic/verb/echo.py Set message implementation taken from https://github.com/ros2/ros2cli/blob/978fc8807cb1f70b5f9c2b085e7b6fa74d191d0e/ros2topic/ros2topic/api/__init__.py#L93-L119 Signed-off-by: Jacob Perron --- rosidl_runtime_py/package.xml | 24 +++ .../rosidl_runtime_py/__init__.py | 28 ++++ .../rosidl_runtime_py/convert.py | 138 ++++++++++++++++++ .../rosidl_runtime_py/set_message.py | 41 ++++++ rosidl_runtime_py/setup.py | 28 ++++ .../test/rosidl_runtime_py/test_convert.py | 85 +++++++++++ rosidl_runtime_py/test/test_copyright.py | 23 +++ rosidl_runtime_py/test/test_flake8.py | 23 +++ rosidl_runtime_py/test/test_pep257.py | 23 +++ 9 files changed, 413 insertions(+) create mode 100644 rosidl_runtime_py/package.xml create mode 100644 rosidl_runtime_py/rosidl_runtime_py/__init__.py create mode 100644 rosidl_runtime_py/rosidl_runtime_py/convert.py create mode 100644 rosidl_runtime_py/rosidl_runtime_py/set_message.py create mode 100644 rosidl_runtime_py/setup.py create mode 100644 rosidl_runtime_py/test/rosidl_runtime_py/test_convert.py create mode 100644 rosidl_runtime_py/test/test_copyright.py create mode 100644 rosidl_runtime_py/test/test_flake8.py create mode 100644 rosidl_runtime_py/test/test_pep257.py 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..d7c7313e --- /dev/null +++ b/rosidl_runtime_py/rosidl_runtime_py/__init__.py @@ -0,0 +1,28 @@ +# 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 msg_to_csv +from .convert import msg_to_ordereddict +from .convert import msg_to_yaml +from .set_message import set_msg_fields +from .set_message import SetFieldError + + +__all__ = [ + 'msg_to_csv', + 'msg_to_ordereddict', + 'msg_to_yaml', + 'SetFieldError', + 'set_msg_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..4a4de9b5 --- /dev/null +++ b/rosidl_runtime_py/rosidl_runtime_py/convert.py @@ -0,0 +1,138 @@ +# Copyright 2016-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. + +import array +from collections import OrderedDict +import sys + +import numpy +import yaml + + +def register_yaml_representer(): + # Register our custom representer for YAML output + yaml.add_representer(OrderedDict, represent_ordereddict) + + +# 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 msg_to_yaml(args, msg): + return yaml.dump( + msg_to_ordereddict( + msg, + truncate_length=args.truncate_length if not args.full_length else None + ), width=sys.maxsize) + + +def msg_to_csv(args, msg): + def to_string(val): + nonlocal args + 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 not args.full_length and i >= args.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 not args.full_length and len(val) > args.truncate_length: + val = val[:args.truncate_length] + if isinstance(val, bytes): + val += b'...' + else: + val += '...' + r = str(val) + else: + r = msg_to_csv(args, val) + 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, None) + 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 msg_to_ordereddict(msg, truncate_length=None): + 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 = msg_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..9b36353f --- /dev/null +++ b/rosidl_runtime_py/rosidl_runtime_py/set_message.py @@ -0,0 +1,41 @@ +# 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. + +class SetFieldError(Exception): + + def __init__(self, field_name, exception): + super(SetFieldError, self).__init__() + self.field_name = field_name + self.exception = exception + + +def set_msg_fields(msg, values): + 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() + try: + set_msg_fields(value, field_value) + except SetFieldError as e: + raise SetFieldError( + '{field_name}.{e.field_name}'.format_map(locals()), + e.exception) + except ValueError as e: + raise SetFieldError(field_name, e) + try: + setattr(msg, field_name, value) + except Exception as e: + raise SetFieldError(field_name, e) 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..021aa6de --- /dev/null +++ b/rosidl_runtime_py/test/rosidl_runtime_py/test_convert.py @@ -0,0 +1,85 @@ +# Copyright 2018 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.convert import _convert_value + + +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/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' From 4171c5a86a8c689ff374011909d2495ed19fbb82 Mon Sep 17 00:00:00 2001 From: Jacob Perron Date: Thu, 28 Mar 2019 11:46:20 -0700 Subject: [PATCH 2/2] Refactor rosidl_runtime_py functions * Add docstrings and annotations. * Rename 'msg' -> 'message' * Add more tests * Remove default value from getattr in convert function * Remove custom SetFieldError exception Signed-off-by: Jacob Perron --- .../rosidl_runtime_py/__init__.py | 18 ++-- .../rosidl_runtime_py/convert.py | 100 +++++++++++------- .../rosidl_runtime_py/set_message.py | 31 +++--- .../test/rosidl_runtime_py/test_convert.py | 30 +++++- .../rosidl_runtime_py/test_set_message.py | 94 ++++++++++++++++ 5 files changed, 204 insertions(+), 69 deletions(-) create mode 100644 rosidl_runtime_py/test/rosidl_runtime_py/test_set_message.py diff --git a/rosidl_runtime_py/rosidl_runtime_py/__init__.py b/rosidl_runtime_py/rosidl_runtime_py/__init__.py index d7c7313e..5f40b2ed 100644 --- a/rosidl_runtime_py/rosidl_runtime_py/__init__.py +++ b/rosidl_runtime_py/rosidl_runtime_py/__init__.py @@ -12,17 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .convert import msg_to_csv -from .convert import msg_to_ordereddict -from .convert import msg_to_yaml -from .set_message import set_msg_fields -from .set_message import SetFieldError +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__ = [ - 'msg_to_csv', - 'msg_to_ordereddict', - 'msg_to_yaml', - 'SetFieldError', - 'set_msg_fields', + '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 index 4a4de9b5..573d4caf 100644 --- a/rosidl_runtime_py/rosidl_runtime_py/convert.py +++ b/rosidl_runtime_py/rosidl_runtime_py/convert.py @@ -1,4 +1,4 @@ -# Copyright 2016-2017 Open Source Robotics Foundation, Inc. +# 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. @@ -15,85 +15,106 @@ import array from collections import OrderedDict import sys +from typing import Any import numpy import yaml -def register_yaml_representer(): - # Register our custom representer for YAML output - yaml.add_representer(OrderedDict, represent_ordereddict) +__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): +# 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 msg_to_yaml(args, msg): +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( - msg_to_ordereddict( - msg, - truncate_length=args.truncate_length if not args.full_length else None - ), width=sys.maxsize) + 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. -def msg_to_csv(args, msg): + :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 args + nonlocal truncate_length r = '' - if (any( - isinstance(val, t) - for t in [list, tuple, array.array, numpy.ndarray] - )): + if any(isinstance(val, t) for t in [list, tuple, array.array, numpy.ndarray]): for i, v in enumerate(val): if r: r += ',' - if not args.full_length and i >= args.truncate_length: + 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] - )): + 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 not args.full_length and len(val) > args.truncate_length: - val = val[:args.truncate_length] + 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 = msg_to_csv(args, val) + 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, None) + 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 msg_to_ordereddict(msg, truncate_length=None): +# 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 + # Remove leading underscore from field name d[field_name[1:]] = value return d @@ -110,7 +131,7 @@ def _convert_value(value, truncate_length=None): 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 + # 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 @@ -123,16 +144,15 @@ def _convert_value(value, truncate_length=None): 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 + # 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 + # 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 = msg_to_ordereddict(value, truncate_length=truncate_length) + # 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 index 9b36353f..cfb36563 100644 --- a/rosidl_runtime_py/rosidl_runtime_py/set_message.py +++ b/rosidl_runtime_py/rosidl_runtime_py/set_message.py @@ -12,30 +12,25 @@ # See the License for the specific language governing permissions and # limitations under the License. -class SetFieldError(Exception): +from typing import Any +from typing import Dict - def __init__(self, field_name, exception): - super(SetFieldError, self).__init__() - self.field_name = field_name - self.exception = exception +def set_message_fields(msg: Any, values: Dict[str, str]) -> None: + """ + Set the fields of a ROS message. -def set_msg_fields(msg, values): + :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() - try: - set_msg_fields(value, field_value) - except SetFieldError as e: - raise SetFieldError( - '{field_name}.{e.field_name}'.format_map(locals()), - e.exception) - except ValueError as e: - raise SetFieldError(field_name, e) - try: - setattr(msg, field_name, value) - except Exception as e: - raise SetFieldError(field_name, e) + set_message_fields(value, field_value) + setattr(msg, field_name, value) diff --git a/rosidl_runtime_py/test/rosidl_runtime_py/test_convert.py b/rosidl_runtime_py/test/rosidl_runtime_py/test_convert.py index 021aa6de..012efd2e 100644 --- a/rosidl_runtime_py/test/rosidl_runtime_py/test_convert.py +++ b/rosidl_runtime_py/test/rosidl_runtime_py/test_convert.py @@ -1,4 +1,4 @@ -# Copyright 2018 Open Source Robotics Foundation, Inc. +# 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. @@ -14,8 +14,36 @@ 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) 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)