Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 56 additions & 8 deletions ros2param/ros2param/verb/load.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,39 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from rclpy.parameter import parameter_dict_from_yaml_file
from ros2cli.node.direct import DirectNode
from ros2cli.node.strategy import add_arguments
from ros2cli.node.strategy import NodeStrategy
from ros2node.api import get_absolute_node_name
from ros2node.api import get_node_names
from ros2node.api import NodeNameCompleter
from ros2node.api import wait_for_node
from ros2param.api import load_parameter_file
from ros2param.verb import VerbExtension


_NO_PARAMETERS_ERROR = 'Param file does not contain any valid parameters'


def _parameter_file_matches_node(parameter_file, node_name, use_wildcard):
try:
parameter_dict_from_yaml_file(
parameter_file, use_wildcard, target_nodes=[node_name])
except RuntimeError as e:
if str(e) == _NO_PARAMETERS_ERROR:
Comment thread
sylvesterkaczmarek marked this conversation as resolved.
Outdated
return False
raise
return True


class LoadVerb(VerbExtension):
"""Load parameter file for a node."""
"""Load a parameter file for one node or all matching nodes."""

def add_arguments(self, parser, cli_name): # noqa: D102
add_arguments(parser)
arg = parser.add_argument(
'node_name', help='Name of the ROS node')
'node_name', nargs='?', help='Name of the ROS node (omit to load all matching nodes)')
arg.completer = NodeNameCompleter(
include_hidden_nodes_key='include_hidden_nodes')
parser.add_argument(
Expand All @@ -48,12 +64,44 @@ def add_arguments(self, parser, cli_name): # noqa: D102
'(default: waits indefinitely)')

def main(self, *, args): # noqa: D102
node_name = get_absolute_node_name(args.node_name)
use_wildcard = not args.no_use_wildcard

if args.node_name is not None:
node_name = get_absolute_node_name(args.node_name)
with NodeStrategy(args) as node:
if not wait_for_node(node, node_name, args.include_hidden_nodes, args.timeout):
return 'Node not found'

with DirectNode(args) as node:
load_parameter_file(
node=node, node_name=node_name, parameter_file=args.parameter_file,
use_wildcard=use_wildcard, timeout=args.service_timeout)
return

# Validate the file before discovery so malformed files fail consistently.
parameter_dict_from_yaml_file(args.parameter_file, use_wildcard)

with NodeStrategy(args) as node:
if not wait_for_node(node, node_name, args.include_hidden_nodes, args.timeout):
Comment thread
sylvesterkaczmarek marked this conversation as resolved.
return 'Node not found'
discovered_nodes = get_node_names(
node=node, include_hidden_nodes=args.include_hidden_nodes)
local_node_name = None
if node.daemon_node is None:
local_node_name = node.direct_node.get_fully_qualified_name()

matching_node_names = []
for node_name in sorted({n.full_name for n in discovered_nodes}):
# A direct discovery node is an implementation detail of this command and
# must not become a target of a system-wide wildcard parameter file.
if node_name == local_node_name:
continue
if _parameter_file_matches_node(args.parameter_file, node_name, use_wildcard):
matching_node_names.append(node_name)

if not matching_node_names:
return 'No matching nodes found'

with DirectNode(args) as node:
load_parameter_file(
node=node, node_name=node_name, parameter_file=args.parameter_file,
use_wildcard=not args.no_use_wildcard, timeout=args.service_timeout)
for node_name in matching_node_names:
load_parameter_file(
node=node, node_name=node_name, parameter_file=args.parameter_file,
use_wildcard=use_wildcard, timeout=args.service_timeout)
116 changes: 116 additions & 0 deletions ros2param/test/test_load_all_nodes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Copyright 2026 Open Source Robotics Foundation, Inc.
Comment thread
sylvesterkaczmarek marked this conversation as resolved.
#
# 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 argparse
from types import SimpleNamespace
from unittest.mock import call
from unittest.mock import MagicMock
from unittest.mock import patch

from ros2param.verb.load import LoadVerb
from ros2param.verb.load import _NO_PARAMETERS_ERROR


def _args(**kwargs):
values = {
'node_name': None,
'parameter_file': 'params.yaml',
'no_use_wildcard': False,
'include_hidden_nodes': False,
'timeout': 1,
'service_timeout': None,
}
values.update(kwargs)
return SimpleNamespace(**values)


def test_parser_accepts_parameter_file_without_node_name():
parser = argparse.ArgumentParser()
LoadVerb().add_arguments(parser, 'ros2 param load')

args = parser.parse_args(['params.yaml'])

assert args.node_name is None
assert args.parameter_file == 'params.yaml'


def test_loads_parameter_file_for_all_matching_nodes():
args = _args()
strategy = MagicMock()
strategy.daemon_node = MagicMock()
strategy_context = MagicMock()
strategy_context.__enter__.return_value = strategy
strategy_context.__exit__.return_value = False
direct = MagicMock()
direct_context = MagicMock()
direct_context.__enter__.return_value = direct
direct_context.__exit__.return_value = False

discovered_nodes = [
SimpleNamespace(full_name='/first'),
SimpleNamespace(full_name='/second'),
SimpleNamespace(full_name='/unmatched'),
]

def parse_parameter_file(parameter_file, use_wildcard, target_nodes=None):
if target_nodes == ['/unmatched']:
raise RuntimeError(_NO_PARAMETERS_ERROR)
return {'parameter': object()}

with patch('ros2param.verb.load.NodeStrategy', return_value=strategy_context), \
patch('ros2param.verb.load.DirectNode', return_value=direct_context), \
patch('ros2param.verb.load.get_node_names', return_value=discovered_nodes), \
patch(
'ros2param.verb.load.parameter_dict_from_yaml_file',
side_effect=parse_parameter_file
), \
patch('ros2param.verb.load.load_parameter_file') as load_parameter_file:
result = LoadVerb().main(args=args)

assert result is None
assert load_parameter_file.call_args_list == [
call(
node=direct, node_name='/first', parameter_file='params.yaml',
use_wildcard=True, timeout=None),
call(
node=direct, node_name='/second', parameter_file='params.yaml',
use_wildcard=True, timeout=None),
]


def test_returns_error_when_no_running_node_matches_file():
args = _args()
strategy = MagicMock()
strategy.daemon_node = MagicMock()
strategy_context = MagicMock()
strategy_context.__enter__.return_value = strategy
strategy_context.__exit__.return_value = False

def parse_parameter_file(parameter_file, use_wildcard, target_nodes=None):
if target_nodes is not None:
raise RuntimeError(_NO_PARAMETERS_ERROR)
return {'parameter': object()}

with patch('ros2param.verb.load.NodeStrategy', return_value=strategy_context), \
patch(
'ros2param.verb.load.get_node_names',
return_value=[SimpleNamespace(full_name='/unmatched')]
), \
patch(
'ros2param.verb.load.parameter_dict_from_yaml_file',
side_effect=parse_parameter_file
):
result = LoadVerb().main(args=args)

assert result == 'No matching nodes found'
5 changes: 2 additions & 3 deletions ros2param/test/test_verb_load.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,16 +241,15 @@ def test_verb_load_missing_args(self):
assert param_load_command.exit_code != launch_testing.asserts.EXIT_OK
assert launch_testing.tools.expect_output(
expected_lines=['ros2 param load: error: the following arguments are required: '
'node_name, parameter_file'],
'parameter_file'],
text=param_load_command.output,
strict=False
)
with self.launch_param_load_command(arguments=['some_node']) as param_load_command:
assert param_load_command.wait_for_shutdown(timeout=TEST_TIMEOUT)
assert param_load_command.exit_code != launch_testing.asserts.EXIT_OK
assert launch_testing.tools.expect_output(
expected_lines=['ros2 param load: error: the following arguments are required: '
'parameter_file'],
expected_lines=['No such file or directory'],
text=param_load_command.output,
strict=False
)
Expand Down