Skip to content

Commit

Permalink
Add param dump <node-name> (#285)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
artivis authored and sloretz committed Jul 26, 2019
1 parent 96c9ff2 commit d804e11
Show file tree
Hide file tree
Showing 4 changed files with 293 additions and 0 deletions.
28 changes: 28 additions & 0 deletions ros2param/ros2param/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,34 @@
import yaml


def get_value(*, parameter_value):
"""Get the value from a ParameterValue."""
if parameter_value.type == ParameterType.PARAMETER_BOOL:
value = parameter_value.bool_value
elif parameter_value.type == ParameterType.PARAMETER_INTEGER:
value = parameter_value.integer_value
elif parameter_value.type == ParameterType.PARAMETER_DOUBLE:
value = parameter_value.double_value
elif parameter_value.type == ParameterType.PARAMETER_STRING:
value = parameter_value.string_value
elif parameter_value.type == ParameterType.PARAMETER_BYTE_ARRAY:
value = parameter_value.byte_array_value
elif parameter_value.type == ParameterType.PARAMETER_BOOL_ARRAY:
value = parameter_value.bool_array_value
elif parameter_value.type == ParameterType.PARAMETER_INTEGER_ARRAY:
value = parameter_value.integer_array_value
elif parameter_value.type == ParameterType.PARAMETER_DOUBLE_ARRAY:
value = parameter_value.double_array_value
elif parameter_value.type == ParameterType.PARAMETER_STRING_ARRAY:
value = parameter_value.string_array_value
elif parameter_value.type == ParameterType.PARAMETER_NOT_SET:
value = None
else:
value = None

return value


def get_parameter_value(*, string_value):
"""Guess the desired type of the parameter based on the string value."""
value = ParameterValue()
Expand Down
136 changes: 136 additions & 0 deletions ros2param/ros2param/verb/dump.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Copyright 2019 Canonical Ltd.
#
# 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 os

from rcl_interfaces.srv import ListParameters

import rclpy
from rclpy.parameter import PARAMETER_SEPARATOR_STRING

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 parse_node_name

from ros2param.api import call_get_parameters
from ros2param.api import get_value
from ros2param.verb import VerbExtension

import yaml


class DumpVerb(VerbExtension):
"""Dump the parameters of a node to a yaml file."""

def add_arguments(self, parser, cli_name): # noqa: D102
add_arguments(parser)
arg = parser.add_argument(
'node_name', help='Name of the ROS node')
arg.completer = NodeNameCompleter(
include_hidden_nodes_key='include_hidden_nodes')
parser.add_argument(
'--include-hidden-nodes', action='store_true',
help='Consider hidden nodes as well')
parser.add_argument(
'--output-dir',
default='.',
help='The absolute path were to save the generated file')
parser.add_argument(
'--print', action='store_true',
help='Print generated file in terminal rather than saving a file.')

@staticmethod
def get_parameter_value(node, node_name, param):
response = call_get_parameters(
node=node, node_name=node_name,
parameter_names=[param])

# requested parameter not set
if not response.values:
return '# Parameter not set'

# extract type specific value
return get_value(parameter_value=response.values[0])

def insert_dict(self, dictionary, key, value):
split = key.split(PARAMETER_SEPARATOR_STRING, 1)
if len(split) > 1:
if not split[0] in dictionary:
dictionary[split[0]] = {}
self.insert_dict(dictionary[split[0]], split[1], value)
else:
dictionary[key] = value

def main(self, *, args): # noqa: D102

with NodeStrategy(args) as node:
node_names = get_node_names(node=node, include_hidden_nodes=args.include_hidden_nodes)

absolute_node_name = get_absolute_node_name(args.node_name)
node_name = parse_node_name(absolute_node_name)
if absolute_node_name:
if absolute_node_name not in [n.full_name for n in node_names]:
return 'Node not found'

if not os.path.isdir(args.output_dir):
raise RuntimeError(
"'{args.output_dir}' is not a valid directory.".format_map(locals()))

with DirectNode(args) as node:
# create client
service_name = '{absolute_node_name}/list_parameters'.format_map(locals())
client = node.create_client(ListParameters, service_name)

client.wait_for_service()

if not client.service_is_ready():
raise RuntimeError("Could not reach service '{service_name}'".format_map(locals()))

request = ListParameters.Request()
future = client.call_async(request)

# wait for response
rclpy.spin_until_future_complete(node, future)

yaml_output = {node_name.name: {'ros__parameters': {}}}

# retrieve values
if future.result() is not None:
response = future.result()
for param_name in sorted(response.result.names):
pval = self.get_parameter_value(node, absolute_node_name, param_name)
self.insert_dict(
yaml_output[node_name.name]['ros__parameters'], param_name, pval)
else:
e = future.exception()
raise RuntimeError('Exception while calling service of node '
"'{node_name.full_name}': {e}".format_map(locals()))

if args.print:
print(yaml.dump(yaml_output, default_flow_style=False))
return

if absolute_node_name[0] == '/':
file_name = absolute_node_name[1:].replace('/', '__')
else:
file_name = absolute_node_name.replace('/', '__')

print('Saving to: ', os.path.join(args.output_dir, file_name + '.yaml'))
with open(os.path.join(args.output_dir, file_name + '.yaml'), 'w') as yaml_file:
yaml.dump(yaml_output, yaml_file, default_flow_style=False)
1 change: 1 addition & 0 deletions ros2param/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
],
'ros2param.verb': [
'delete = ros2param.verb.delete:DeleteVerb',
'dump = ros2param.verb.dump:DumpVerb',
'get = ros2param.verb.get:GetVerb',
'list = ros2param.verb.list:ListVerb',
'set = ros2param.verb.set:SetVerb',
Expand Down
128 changes: 128 additions & 0 deletions ros2param/test/test_verb_dump.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Copyright 2019 Canonical Ltd
#
# 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 io import StringIO
import os
import tempfile
import threading
import unittest
from unittest.mock import patch

import rclpy
from rclpy.executors import MultiThreadedExecutor
from rclpy.parameter import PARAMETER_SEPARATOR_STRING

from ros2cli import cli

TEST_NODE = 'test_node'
TEST_NAMESPACE = 'foo'

EXPECTED_PARAMETER_FILE = """\
test_node:
ros__parameters:
bool_param: true
double_param: 1.23
foo:
bar:
str_param: foobar
str_param: foo
int_param: 42
str_param: Hello World
"""


class TestVerbDump(unittest.TestCase):

@classmethod
def setUpClass(cls):
cls.context = rclpy.context.Context()
rclpy.init(context=cls.context)
cls.node = rclpy.create_node(
TEST_NODE, namespace=TEST_NAMESPACE, context=cls.context)

cls.executor = MultiThreadedExecutor(context=cls.context, num_threads=2)
cls.executor.add_node(cls.node)

cls.node.declare_parameter('bool_param', True)
cls.node.declare_parameter('int_param', 42)
cls.node.declare_parameter('double_param', 1.23)
cls.node.declare_parameter('str_param', 'Hello World')
cls.node.declare_parameter('foo' + PARAMETER_SEPARATOR_STRING +
'str_param', 'foo')
cls.node.declare_parameter('foo' + PARAMETER_SEPARATOR_STRING +
'bar' + PARAMETER_SEPARATOR_STRING +
'str_param', 'foobar')

# We need both the test node and 'dump'
# node to be able to spin
cls.exec_thread = threading.Thread(target=cls.executor.spin)
cls.exec_thread.start()

@classmethod
def tearDownClass(cls):
cls.executor.shutdown()
cls.node.destroy_node()
rclpy.shutdown(context=cls.context)
cls.exec_thread.join()

def _output_file(self):

ns = self.node.get_namespace()
name = self.node.get_name()
if '/' == ns:
fqn = ns + name
else:
fqn = ns + '/' + name
return fqn[1:].replace('/', '__') + '.yaml'

def test_verb_dump_invalid_node(self):
assert cli.main(
argv=['param', 'dump', 'invalid_node']) == 'Node not found'

assert cli.main(
argv=['param', 'dump', 'invalid_ns/test_node']) == 'Node not found'

def test_verb_dump_invalid_path(self):
assert cli.main(
argv=['param', 'dump', 'foo/test_node', '--output-dir', 'invalid_path']) \
== "'invalid_path' is not a valid directory."

def test_verb_dump(self):
with tempfile.TemporaryDirectory() as tmpdir:
assert cli.main(
argv=['param', 'dump', '/foo/test_node', '--output-dir', tmpdir]) is None

# Compare generated parameter file against expected
generated_param_file = os.path.join(tmpdir, self._output_file())
assert (open(generated_param_file, 'r').read() == EXPECTED_PARAMETER_FILE)

def test_verb_dump_print(self):
with patch('sys.stdout', new=StringIO()) as fake_stdout:
assert cli.main(
argv=['param', 'dump', 'foo/test_node', '--print']) is None

# Compare generated stdout against expected
assert fake_stdout.getvalue().strip() == EXPECTED_PARAMETER_FILE[:-1]

with tempfile.TemporaryDirectory() as tmpdir:
assert cli.main(
argv=['param', 'dump', 'foo/test_node', '--output-dir', tmpdir, '--print']) is None

not_generated_param_file = os.path.join(tmpdir, self._output_file())

with self.assertRaises(OSError) as context:
open(not_generated_param_file, 'r')

# Make sure the file was not create, thus '--print' did preempt
assert '[Errno 2] No such file or directory' in str(context.exception)

0 comments on commit d804e11

Please sign in to comment.