From 767c794db9245b89ecd0b2c807ff4ef693eea408 Mon Sep 17 00:00:00 2001 From: Leander Stephen D'Souza Date: Sat, 28 Feb 2026 02:32:05 +0000 Subject: [PATCH 1/2] Rename and move the custom completion finder to the helpers module. Signed-off-by: Leander Stephen D'Souza --- ros2cli/ros2cli/helpers.py | 28 +++++++++++++++++++++++++++- ros2topic/ros2topic/api/__init__.py | 13 ------------- ros2topic/ros2topic/verb/pub.py | 8 ++++---- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/ros2cli/ros2cli/helpers.py b/ros2cli/ros2cli/helpers.py index ccf74df91..79eb30fdc 100644 --- a/ros2cli/ros2cli/helpers.py +++ b/ros2cli/ros2cli/helpers.py @@ -20,10 +20,11 @@ import subprocess import sys import time - from typing import Dict from typing import Optional +from argcomplete import CompletionFinder + def get_ros_domain_id(): return int(os.environ.get('ROS_DOMAIN_ID', 0)) @@ -238,3 +239,28 @@ def interactive_select( except (OSError, subprocess.SubprocessError) as e: print(f'Error during interactive selection: {e}', file=sys.stderr) return None + + +class UnescapedCompletionFinder(CompletionFinder): + + def quote_completions( + self, + completions: list[str], + cword_prequote: str, + last_wordbreak_pos: Optional[int], + ) -> list[str]: + """ + Return completions without shell escaping. + + Overrides the parent method to prevent mangling of YAML tokens + (dashes, braces, colons, etc.) that would otherwise be escaped + by the default shell quoting logic. + + :param completions: List of completion strings to process. + :param cword_prequote: The quote character preceding the word + being completed, if any. + :param last_wordbreak_pos: Position of the last word-break + character in the current word, or None. + :return: The completions list, unmodified. + """ + return completions diff --git a/ros2topic/ros2topic/api/__init__.py b/ros2topic/ros2topic/api/__init__.py index dc55ab237..1a81d88fb 100644 --- a/ros2topic/ros2topic/api/__init__.py +++ b/ros2topic/ros2topic/api/__init__.py @@ -19,8 +19,6 @@ import warnings -from argcomplete import CompletionFinder - import rclpy from rclpy.expand_topic_name import expand_topic_name @@ -163,17 +161,6 @@ def _get_msg_class(node, topic, include_hidden_topics): raise RuntimeError("The message type '%s' is invalid" % message_type) -class YamlCompletionFinder(CompletionFinder): - def quote_completions( - self, completions: list[str], - cword_prequote: str, last_wordbreak_pos: Optional[int]): - - # For YAML content, return as-is without escaping - if not any('-' in c for c in completions): - return completions - return super().quote_completions(completions, cword_prequote, last_wordbreak_pos) - - class TopicMessagePrototypeCompleter: """Callable returning a message prototype.""" diff --git a/ros2topic/ros2topic/verb/pub.py b/ros2topic/ros2topic/verb/pub.py index 9732ab1ee..05ca1f799 100644 --- a/ros2topic/ros2topic/verb/pub.py +++ b/ros2topic/ros2topic/verb/pub.py @@ -22,14 +22,14 @@ from rclpy.node import Node from rclpy.qos import QoSProfile -from ros2cli.helpers import collect_stdin +from ros2cli.helpers import collect_stdin, UnescapedCompletionFinder from ros2cli.node.direct import add_arguments as add_direct_node_arguments from ros2cli.node.direct import DirectNode from ros2cli.qos import add_qos_arguments from ros2cli.qos import profile_configure_short_keys from ros2topic.api import positive_float -from ros2topic.api import TopicMessagePrototypeCompleter, YamlCompletionFinder +from ros2topic.api import TopicMessagePrototypeCompleter from ros2topic.api import TopicNameCompleter from ros2topic.api import TopicTypeCompleter from ros2topic.verb import VerbExtension @@ -115,8 +115,8 @@ def add_arguments(self, parser, cli_name): '-n', '--node-name', help='Name of the created publishing node') - # Use the custom completion finder - argcomplete.autocomplete = YamlCompletionFinder(parser) + # Prevent argcomplete from escaping special characters in the YAML string + argcomplete.autocomplete = UnescapedCompletionFinder(parser) add_qos_arguments(parser, 'publish', 'default') add_direct_node_arguments(parser) From da53e718341a01835dc5325a7d1a883c5d5f9158 Mon Sep 17 00:00:00 2001 From: Leander Stephen D'Souza Date: Sat, 28 Feb 2026 02:32:52 +0000 Subject: [PATCH 2/2] Enable ros2service call to accept YAML input without escaping special characters Signed-off-by: Leander Stephen D'Souza --- ros2service/ros2service/api/__init__.py | 3 ++- ros2service/ros2service/verb/call.py | 19 +++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/ros2service/ros2service/api/__init__.py b/ros2service/ros2service/api/__init__.py index d4605af6f..61c6402f3 100644 --- a/ros2service/ros2service/api/__init__.py +++ b/ros2service/ros2service/api/__init__.py @@ -140,4 +140,5 @@ def __init__(self, *, service_type_key=None): def __call__(self, prefix, parsed_args, **kwargs): service = get_service(getattr(parsed_args, self.service_type_key)) - return [message_to_yaml(service.Request())] + yaml_snippet = "'" + message_to_yaml(service.Request()) + "'" + return [yaml_snippet] diff --git a/ros2service/ros2service/verb/call.py b/ros2service/ros2service/verb/call.py index 1c529128c..de57dc7d9 100644 --- a/ros2service/ros2service/verb/call.py +++ b/ros2service/ros2service/verb/call.py @@ -15,11 +15,13 @@ import time from typing import Optional +import argcomplete + import rclpy from rclpy.qos import QoSPresetProfiles from rclpy.qos import QoSProfile -from ros2cli.helpers import collect_stdin +from ros2cli.helpers import collect_stdin, UnescapedCompletionFinder from ros2cli.helpers import interactive_select from ros2cli.node import NODE_NAME_PREFIX from ros2cli.node.strategy import NodeStrategy @@ -71,6 +73,10 @@ def add_arguments(self, parser, cli_name): '--timeout', metavar='N', type=float, help='Maximum time to wait for service response in seconds. ' 'If not specified, waits indefinitely.') + + # Prevent argcomplete from escaping special characters in the YAML string + argcomplete.autocomplete = UnescapedCompletionFinder(parser) + add_qos_arguments(parser, 'service client', 'services_default') def main(self, *, args): @@ -122,7 +128,16 @@ def requester(service_type: str, service_name: str, values, period: Optional[flo except (AttributeError, ModuleNotFoundError): raise RuntimeError('The passed service type is invalid') - values_dictionary = yaml.safe_load(values) + try: + # Handle cases where the user pastes the autocompleted bash safe string + if '^J' in values: + values = values.replace("'", '') + values = values.replace('^J', '\n') + + values_dictionary = yaml.safe_load(values) + + except (yaml.parser.ParserError, yaml.scanner.ScannerError): + return 'The passed value needs to be in YAML string or a dictionary' with rclpy.init(): node = rclpy.create_node(NODE_NAME_PREFIX + '_requester_%s_%s' % (package_name, srv_name))