Skip to content

Commit d804e11

Browse files
artivissloretz
authored andcommitted
Add param dump <node-name> (#285)
* wip param dump Signed-off-by: artivis <[email protected]> * default path & cleanup Signed-off-by: artivis <[email protected]> * wip test verb dump Signed-off-by: artivis <[email protected]> * rm spin_once Signed-off-by: artivis <[email protected]> * nested namespaces Signed-off-by: artivis <[email protected]> * cleaning up Signed-off-by: artivis <[email protected]> * multithread the test Signed-off-by: artivis <[email protected]> * todo use PARAMETER_SEPARATOR_STRING Signed-off-by: artivis <[email protected]> * test comp generate<->expected param file Signed-off-by: artivis <[email protected]> * lipstick Signed-off-by: artivis <[email protected]> * use proper PARAMETER_SEPARATOR_STRING Signed-off-by: artivis <[email protected]> * mv common code to api Signed-off-by: artivis <[email protected]> * rename param output-dir Signed-off-by: artivis <[email protected]> * rm line breaks Signed-off-by: artivis <[email protected]> * raise rather than print Signed-off-by: artivis <[email protected]> * rm useless import Signed-off-by: artivis <[email protected]> * raise rather than print Signed-off-by: artivis <[email protected]> * add --print option Signed-off-by: artivis <[email protected]> * prepend node namespace to output filename Signed-off-by: artivis <[email protected]> * preempted -> preempt Signed-off-by: Shane Loretz <[email protected]> * "w" -> 'w' Signed-off-by: Shane Loretz <[email protected]> * Output file using fully qualified node name Signed-off-by: Shane Loretz<[email protected]> Signed-off-by: Shane Loretz <[email protected]> * fix linter tests Signed-off-by: artivis <[email protected]> * relaxe --print preempt test Signed-off-by: artivis <[email protected]>
1 parent 96c9ff2 commit d804e11

File tree

4 files changed

+293
-0
lines changed

4 files changed

+293
-0
lines changed

ros2param/ros2param/api/__init__.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,34 @@
2020
import yaml
2121

2222

23+
def get_value(*, parameter_value):
24+
"""Get the value from a ParameterValue."""
25+
if parameter_value.type == ParameterType.PARAMETER_BOOL:
26+
value = parameter_value.bool_value
27+
elif parameter_value.type == ParameterType.PARAMETER_INTEGER:
28+
value = parameter_value.integer_value
29+
elif parameter_value.type == ParameterType.PARAMETER_DOUBLE:
30+
value = parameter_value.double_value
31+
elif parameter_value.type == ParameterType.PARAMETER_STRING:
32+
value = parameter_value.string_value
33+
elif parameter_value.type == ParameterType.PARAMETER_BYTE_ARRAY:
34+
value = parameter_value.byte_array_value
35+
elif parameter_value.type == ParameterType.PARAMETER_BOOL_ARRAY:
36+
value = parameter_value.bool_array_value
37+
elif parameter_value.type == ParameterType.PARAMETER_INTEGER_ARRAY:
38+
value = parameter_value.integer_array_value
39+
elif parameter_value.type == ParameterType.PARAMETER_DOUBLE_ARRAY:
40+
value = parameter_value.double_array_value
41+
elif parameter_value.type == ParameterType.PARAMETER_STRING_ARRAY:
42+
value = parameter_value.string_array_value
43+
elif parameter_value.type == ParameterType.PARAMETER_NOT_SET:
44+
value = None
45+
else:
46+
value = None
47+
48+
return value
49+
50+
2351
def get_parameter_value(*, string_value):
2452
"""Guess the desired type of the parameter based on the string value."""
2553
value = ParameterValue()

ros2param/ros2param/verb/dump.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Copyright 2019 Canonical Ltd.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import os
16+
17+
from rcl_interfaces.srv import ListParameters
18+
19+
import rclpy
20+
from rclpy.parameter import PARAMETER_SEPARATOR_STRING
21+
22+
from ros2cli.node.direct import DirectNode
23+
from ros2cli.node.strategy import add_arguments
24+
from ros2cli.node.strategy import NodeStrategy
25+
26+
from ros2node.api import get_absolute_node_name
27+
from ros2node.api import get_node_names
28+
from ros2node.api import NodeNameCompleter
29+
from ros2node.api import parse_node_name
30+
31+
from ros2param.api import call_get_parameters
32+
from ros2param.api import get_value
33+
from ros2param.verb import VerbExtension
34+
35+
import yaml
36+
37+
38+
class DumpVerb(VerbExtension):
39+
"""Dump the parameters of a node to a yaml file."""
40+
41+
def add_arguments(self, parser, cli_name): # noqa: D102
42+
add_arguments(parser)
43+
arg = parser.add_argument(
44+
'node_name', help='Name of the ROS node')
45+
arg.completer = NodeNameCompleter(
46+
include_hidden_nodes_key='include_hidden_nodes')
47+
parser.add_argument(
48+
'--include-hidden-nodes', action='store_true',
49+
help='Consider hidden nodes as well')
50+
parser.add_argument(
51+
'--output-dir',
52+
default='.',
53+
help='The absolute path were to save the generated file')
54+
parser.add_argument(
55+
'--print', action='store_true',
56+
help='Print generated file in terminal rather than saving a file.')
57+
58+
@staticmethod
59+
def get_parameter_value(node, node_name, param):
60+
response = call_get_parameters(
61+
node=node, node_name=node_name,
62+
parameter_names=[param])
63+
64+
# requested parameter not set
65+
if not response.values:
66+
return '# Parameter not set'
67+
68+
# extract type specific value
69+
return get_value(parameter_value=response.values[0])
70+
71+
def insert_dict(self, dictionary, key, value):
72+
split = key.split(PARAMETER_SEPARATOR_STRING, 1)
73+
if len(split) > 1:
74+
if not split[0] in dictionary:
75+
dictionary[split[0]] = {}
76+
self.insert_dict(dictionary[split[0]], split[1], value)
77+
else:
78+
dictionary[key] = value
79+
80+
def main(self, *, args): # noqa: D102
81+
82+
with NodeStrategy(args) as node:
83+
node_names = get_node_names(node=node, include_hidden_nodes=args.include_hidden_nodes)
84+
85+
absolute_node_name = get_absolute_node_name(args.node_name)
86+
node_name = parse_node_name(absolute_node_name)
87+
if absolute_node_name:
88+
if absolute_node_name not in [n.full_name for n in node_names]:
89+
return 'Node not found'
90+
91+
if not os.path.isdir(args.output_dir):
92+
raise RuntimeError(
93+
"'{args.output_dir}' is not a valid directory.".format_map(locals()))
94+
95+
with DirectNode(args) as node:
96+
# create client
97+
service_name = '{absolute_node_name}/list_parameters'.format_map(locals())
98+
client = node.create_client(ListParameters, service_name)
99+
100+
client.wait_for_service()
101+
102+
if not client.service_is_ready():
103+
raise RuntimeError("Could not reach service '{service_name}'".format_map(locals()))
104+
105+
request = ListParameters.Request()
106+
future = client.call_async(request)
107+
108+
# wait for response
109+
rclpy.spin_until_future_complete(node, future)
110+
111+
yaml_output = {node_name.name: {'ros__parameters': {}}}
112+
113+
# retrieve values
114+
if future.result() is not None:
115+
response = future.result()
116+
for param_name in sorted(response.result.names):
117+
pval = self.get_parameter_value(node, absolute_node_name, param_name)
118+
self.insert_dict(
119+
yaml_output[node_name.name]['ros__parameters'], param_name, pval)
120+
else:
121+
e = future.exception()
122+
raise RuntimeError('Exception while calling service of node '
123+
"'{node_name.full_name}': {e}".format_map(locals()))
124+
125+
if args.print:
126+
print(yaml.dump(yaml_output, default_flow_style=False))
127+
return
128+
129+
if absolute_node_name[0] == '/':
130+
file_name = absolute_node_name[1:].replace('/', '__')
131+
else:
132+
file_name = absolute_node_name.replace('/', '__')
133+
134+
print('Saving to: ', os.path.join(args.output_dir, file_name + '.yaml'))
135+
with open(os.path.join(args.output_dir, file_name + '.yaml'), 'w') as yaml_file:
136+
yaml.dump(yaml_output, yaml_file, default_flow_style=False)

ros2param/setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
],
3535
'ros2param.verb': [
3636
'delete = ros2param.verb.delete:DeleteVerb',
37+
'dump = ros2param.verb.dump:DumpVerb',
3738
'get = ros2param.verb.get:GetVerb',
3839
'list = ros2param.verb.list:ListVerb',
3940
'set = ros2param.verb.set:SetVerb',

ros2param/test/test_verb_dump.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# Copyright 2019 Canonical Ltd
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from io import StringIO
16+
import os
17+
import tempfile
18+
import threading
19+
import unittest
20+
from unittest.mock import patch
21+
22+
import rclpy
23+
from rclpy.executors import MultiThreadedExecutor
24+
from rclpy.parameter import PARAMETER_SEPARATOR_STRING
25+
26+
from ros2cli import cli
27+
28+
TEST_NODE = 'test_node'
29+
TEST_NAMESPACE = 'foo'
30+
31+
EXPECTED_PARAMETER_FILE = """\
32+
test_node:
33+
ros__parameters:
34+
bool_param: true
35+
double_param: 1.23
36+
foo:
37+
bar:
38+
str_param: foobar
39+
str_param: foo
40+
int_param: 42
41+
str_param: Hello World
42+
"""
43+
44+
45+
class TestVerbDump(unittest.TestCase):
46+
47+
@classmethod
48+
def setUpClass(cls):
49+
cls.context = rclpy.context.Context()
50+
rclpy.init(context=cls.context)
51+
cls.node = rclpy.create_node(
52+
TEST_NODE, namespace=TEST_NAMESPACE, context=cls.context)
53+
54+
cls.executor = MultiThreadedExecutor(context=cls.context, num_threads=2)
55+
cls.executor.add_node(cls.node)
56+
57+
cls.node.declare_parameter('bool_param', True)
58+
cls.node.declare_parameter('int_param', 42)
59+
cls.node.declare_parameter('double_param', 1.23)
60+
cls.node.declare_parameter('str_param', 'Hello World')
61+
cls.node.declare_parameter('foo' + PARAMETER_SEPARATOR_STRING +
62+
'str_param', 'foo')
63+
cls.node.declare_parameter('foo' + PARAMETER_SEPARATOR_STRING +
64+
'bar' + PARAMETER_SEPARATOR_STRING +
65+
'str_param', 'foobar')
66+
67+
# We need both the test node and 'dump'
68+
# node to be able to spin
69+
cls.exec_thread = threading.Thread(target=cls.executor.spin)
70+
cls.exec_thread.start()
71+
72+
@classmethod
73+
def tearDownClass(cls):
74+
cls.executor.shutdown()
75+
cls.node.destroy_node()
76+
rclpy.shutdown(context=cls.context)
77+
cls.exec_thread.join()
78+
79+
def _output_file(self):
80+
81+
ns = self.node.get_namespace()
82+
name = self.node.get_name()
83+
if '/' == ns:
84+
fqn = ns + name
85+
else:
86+
fqn = ns + '/' + name
87+
return fqn[1:].replace('/', '__') + '.yaml'
88+
89+
def test_verb_dump_invalid_node(self):
90+
assert cli.main(
91+
argv=['param', 'dump', 'invalid_node']) == 'Node not found'
92+
93+
assert cli.main(
94+
argv=['param', 'dump', 'invalid_ns/test_node']) == 'Node not found'
95+
96+
def test_verb_dump_invalid_path(self):
97+
assert cli.main(
98+
argv=['param', 'dump', 'foo/test_node', '--output-dir', 'invalid_path']) \
99+
== "'invalid_path' is not a valid directory."
100+
101+
def test_verb_dump(self):
102+
with tempfile.TemporaryDirectory() as tmpdir:
103+
assert cli.main(
104+
argv=['param', 'dump', '/foo/test_node', '--output-dir', tmpdir]) is None
105+
106+
# Compare generated parameter file against expected
107+
generated_param_file = os.path.join(tmpdir, self._output_file())
108+
assert (open(generated_param_file, 'r').read() == EXPECTED_PARAMETER_FILE)
109+
110+
def test_verb_dump_print(self):
111+
with patch('sys.stdout', new=StringIO()) as fake_stdout:
112+
assert cli.main(
113+
argv=['param', 'dump', 'foo/test_node', '--print']) is None
114+
115+
# Compare generated stdout against expected
116+
assert fake_stdout.getvalue().strip() == EXPECTED_PARAMETER_FILE[:-1]
117+
118+
with tempfile.TemporaryDirectory() as tmpdir:
119+
assert cli.main(
120+
argv=['param', 'dump', 'foo/test_node', '--output-dir', tmpdir, '--print']) is None
121+
122+
not_generated_param_file = os.path.join(tmpdir, self._output_file())
123+
124+
with self.assertRaises(OSError) as context:
125+
open(not_generated_param_file, 'r')
126+
127+
# Make sure the file was not create, thus '--print' did preempt
128+
assert '[Errno 2] No such file or directory' in str(context.exception)

0 commit comments

Comments
 (0)