Skip to content

Commit 1baca29

Browse files
authored
Add filter option to ros2topic (#575)
* Refactor ros2topic echo verb implementation * Move subscription functions into class for easier reuse arguments. * Prefix functions with an underscore to indicate they are private. Signed-off-by: Jacob Perron <[email protected]> * Add filter option to ros2topic A new '--filter' option makes it easy to echo only part of a message. For example, to only echo the position part of an odometry message: ros2 topic echo /odom --filter pose/pose/position The filter option takes one value that must not be empty (or only forward slashes '/'). Signed-off-by: Jacob Perron <[email protected]> * Change option name and syntax Option is now named '--field' and the '.' character is used as the delimiter. Also updated the help message with an example and added a test for accessing a nested field. Signed-off-by: Jacob Perron <[email protected]>
1 parent df55a4e commit 1baca29

File tree

2 files changed

+165
-86
lines changed

2 files changed

+165
-86
lines changed

ros2topic/ros2topic/verb/echo.py

Lines changed: 104 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
from typing import Any
16-
from typing import Callable
1715
from typing import Optional
1816
from typing import TypeVar
1917

@@ -59,6 +57,13 @@ def add_arguments(self, parser, cli_name):
5957
'--csv', action='store_true',
6058
help='Output all recursive fields separated by commas (e.g. for '
6159
'plotting)')
60+
parser.add_argument(
61+
'--field', type=str, default=None,
62+
help='Echo a selected field of a message. '
63+
"Use '.' to select sub-fields. "
64+
'For example, to echo the position field of a nav_msgs/msg/Odometry message: '
65+
"'ros2 topic echo /odom --field pose.pose.position'",
66+
)
6267
parser.add_argument(
6368
'--full-length', '-f', action='store_true',
6469
help='Output all elements for arrays, bytes, and string with a '
@@ -78,98 +83,111 @@ def add_arguments(self, parser, cli_name):
7883
'--raw', action='store_true', help='Echo the raw binary representation')
7984

8085
def main(self, *, args):
81-
return main(args)
86+
# Select print function
87+
self.print_func = _print_yaml
88+
if args.csv:
89+
self.print_func = _print_csv
90+
91+
# Validate field selection
92+
self.field = args.field
93+
if self.field is not None:
94+
self.field = list(filter(None, self.field.split('.')))
95+
if not self.field:
96+
raise RuntimeError(f"Invalid field value '{args.field}'")
97+
98+
self.truncate_length = args.truncate_length if not args.full_length else None
99+
self.no_arr = args.no_arr
100+
self.no_str = args.no_str
101+
102+
qos_profile = qos_profile_from_short_keys(
103+
args.qos_profile,
104+
reliability=args.qos_reliability,
105+
durability=args.qos_durability,
106+
depth=args.qos_depth,
107+
history=args.qos_history)
108+
109+
with NodeStrategy(args) as node:
110+
if args.message_type is None:
111+
message_type = get_msg_class(
112+
node, args.topic_name, include_hidden_topics=True)
113+
else:
114+
try:
115+
message_type = get_message(args.message_type)
116+
except (AttributeError, ModuleNotFoundError, ValueError):
117+
raise RuntimeError('The passed message type is invalid')
118+
119+
if message_type is None:
120+
raise RuntimeError(
121+
'Could not determine the type for the passed topic')
122+
123+
self.subscribe_and_spin(
124+
node,
125+
args.topic_name,
126+
message_type,
127+
qos_profile,
128+
args.lost_messages,
129+
args.raw)
130+
131+
def subscribe_and_spin(
132+
self,
133+
node: Node,
134+
topic_name: str,
135+
message_type: MsgType,
136+
qos_profile: QoSProfile,
137+
report_lost_messages: bool,
138+
raw: bool
139+
) -> Optional[str]:
140+
"""Initialize a node with a single subscription and spin."""
141+
event_callbacks = None
142+
if report_lost_messages:
143+
event_callbacks = SubscriptionEventCallbacks(
144+
message_lost=_message_lost_event_callback)
145+
try:
146+
node.create_subscription(
147+
message_type,
148+
topic_name,
149+
self._subscriber_callback,
150+
qos_profile,
151+
event_callbacks=event_callbacks,
152+
raw=raw)
153+
except UnsupportedEventTypeError:
154+
assert report_lost_messages
155+
print(
156+
f"The rmw implementation '{get_rmw_implementation_identifier()}'"
157+
' does not support reporting lost messages'
158+
)
159+
rclpy.spin(node)
82160

161+
def _subscriber_callback(self, msg):
162+
submsg = msg
163+
if self.field is not None:
164+
for field in self.field:
165+
try:
166+
submsg = getattr(submsg, field)
167+
except AttributeError as ex:
168+
raise RuntimeError(f"Invalid field '{'.'.join(self.field)}': {ex}")
83169

84-
def main(args):
85-
if not args.csv:
86-
truncate_length = args.truncate_length if not args.full_length else None
87-
callback = subscriber_cb(truncate_length, args.no_arr, args.no_str)
88-
else:
89-
truncate_length = args.truncate_length if not args.full_length else None
90-
callback = subscriber_cb_csv(truncate_length, args.no_arr, args.no_str)
91-
qos_profile = qos_profile_from_short_keys(
92-
args.qos_profile, reliability=args.qos_reliability, durability=args.qos_durability,
93-
depth=args.qos_depth, history=args.qos_history)
94-
with NodeStrategy(args) as node:
95-
if args.message_type is None:
96-
message_type = get_msg_class(
97-
node, args.topic_name, include_hidden_topics=True)
98-
else:
99-
try:
100-
message_type = get_message(args.message_type)
101-
except (AttributeError, ModuleNotFoundError, ValueError):
102-
raise RuntimeError('The passed message type is invalid')
103-
104-
if message_type is None:
105-
raise RuntimeError(
106-
'Could not determine the type for the passed topic')
107-
108-
subscriber(
109-
node,
110-
args.topic_name,
111-
message_type,
112-
callback,
113-
qos_profile,
114-
args.lost_messages,
115-
args.raw)
116-
117-
118-
def subscriber(
119-
node: Node,
120-
topic_name: str,
121-
message_type: MsgType,
122-
callback: Callable[[MsgType], Any],
123-
qos_profile: QoSProfile,
124-
report_lost_messages: bool,
125-
raw: bool
126-
) -> Optional[str]:
127-
"""Initialize a node with a single subscription and spin."""
128-
event_callbacks = None
129-
if report_lost_messages:
130-
event_callbacks = SubscriptionEventCallbacks(
131-
message_lost=message_lost_event_callback)
132-
try:
133-
node.create_subscription(
134-
message_type,
135-
topic_name,
136-
callback,
137-
qos_profile,
138-
event_callbacks=event_callbacks,
139-
raw=raw)
140-
except UnsupportedEventTypeError:
141-
assert report_lost_messages
142-
print(
143-
f"The rmw implementation '{get_rmw_implementation_identifier()}'"
144-
' does not support reporting lost messages'
145-
)
146-
rclpy.spin(node)
170+
self.print_func(submsg, self.truncate_length, self.no_arr, self.no_str)
147171

148172

149-
def subscriber_cb(truncate_length, noarr, nostr):
150-
def cb(msg):
151-
nonlocal truncate_length, noarr, nostr
152-
if isinstance(msg, bytes):
153-
print(msg, end='\n---\n')
154-
else:
155-
print(
156-
message_to_yaml(
157-
msg, truncate_length=truncate_length, no_arr=noarr, no_str=nostr),
158-
end='---\n')
159-
return cb
173+
def _print_yaml(msg, truncate_length, noarr, nostr):
174+
if hasattr(msg, '__slots__'):
175+
print(
176+
message_to_yaml(
177+
msg, truncate_length=truncate_length, no_arr=noarr, no_str=nostr),
178+
end='---\n')
179+
else:
180+
print(msg, end='\n---\n')
160181

161182

162-
def subscriber_cb_csv(truncate_length, noarr, nostr):
163-
def cb(msg):
164-
nonlocal truncate_length, noarr, nostr
165-
if isinstance(msg, bytes):
166-
print(msg)
167-
else:
168-
print(message_to_csv(msg, truncate_length=truncate_length, no_arr=noarr, no_str=nostr))
169-
return cb
183+
def _print_csv(msg, truncate_length, noarr, nostr):
184+
if hasattr(msg, '__slots__'):
185+
print(message_to_csv(msg, truncate_length=truncate_length, no_arr=noarr, no_str=nostr))
186+
else:
187+
print(msg)
170188

171189

172-
def message_lost_event_callback(message_lost_status):
190+
def _message_lost_event_callback(message_lost_status):
173191
print(
174192
'A message was lost!!!\n\ttotal count change:'
175193
f'{message_lost_status.total_count_change}'

ros2topic/test/test_cli.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,67 @@ def test_truncate_length_topic_echo(self):
543543
), timeout=10)
544544
assert topic_command.wait_for_shutdown(timeout=10)
545545

546+
@launch_testing.markers.retry_on_failure(times=5, delay=1)
547+
def test_topic_echo_field(self):
548+
with self.launch_topic_command(
549+
arguments=['echo', '/arrays', '--field', 'alignment_check'],
550+
) as topic_command:
551+
assert topic_command.wait_for_output(functools.partial(
552+
launch_testing.tools.expect_output, expected_lines=[
553+
'0',
554+
'---',
555+
], strict=True
556+
), timeout=10)
557+
assert topic_command.wait_for_shutdown(timeout=10)
558+
559+
@launch_testing.markers.retry_on_failure(times=5, delay=1)
560+
def test_topic_echo_field_nested(self):
561+
with self.launch_topic_command(
562+
arguments=['echo', '/cmd_vel', '--field', 'twist.angular'],
563+
) as topic_command:
564+
assert topic_command.wait_for_output(functools.partial(
565+
launch_testing.tools.expect_output, expected_lines=[
566+
'x: 0.0',
567+
'y: 0.0',
568+
'z: 0.0',
569+
'---',
570+
], strict=True
571+
), timeout=10)
572+
assert topic_command.wait_for_shutdown(timeout=10)
573+
574+
@launch_testing.markers.retry_on_failure(times=5, delay=1)
575+
def test_topic_echo_field_not_a_member(self):
576+
with self.launch_topic_command(
577+
arguments=['echo', '/arrays', '--field', 'not_member'],
578+
) as topic_command:
579+
assert topic_command.wait_for_output(functools.partial(
580+
launch_testing.tools.expect_output, expected_lines=[
581+
"Invalid field 'not_member': 'Arrays' object has no attribute 'not_member'",
582+
], strict=True
583+
), timeout=10)
584+
assert topic_command.wait_for_shutdown(timeout=10)
585+
586+
def test_topic_echo_field_invalid(self):
587+
with self.launch_topic_command(
588+
arguments=['echo', '/arrays', '--field', '/'],
589+
) as topic_command:
590+
assert topic_command.wait_for_output(functools.partial(
591+
launch_testing.tools.expect_output, expected_lines=[
592+
"Invalid field '/': 'Arrays' object has no attribute '/'",
593+
], strict=True
594+
), timeout=10)
595+
assert topic_command.wait_for_shutdown(timeout=10)
596+
597+
with self.launch_topic_command(
598+
arguments=['echo', '/arrays', '--field', '.'],
599+
) as topic_command:
600+
assert topic_command.wait_for_output(functools.partial(
601+
launch_testing.tools.expect_output, expected_lines=[
602+
"Invalid field value '.'",
603+
], strict=True
604+
), timeout=10)
605+
assert topic_command.wait_for_shutdown(timeout=10)
606+
546607
def test_topic_echo_no_publisher(self):
547608
with self.launch_topic_command(
548609
arguments=['echo', '/this_topic_has_no_pub'],

0 commit comments

Comments
 (0)