Skip to content

Commit bc7b89b

Browse files
Implemented support for rosparam load in launch files (fixes #256) (#265)
* outlined _load_param_tag * throw error on unsupported commands * fixed most mypy issues * workaround for mypy bug * remove f-string * use YAML full_load * added rosparam * added partial load_radians * added rosparam loaders * added annotations * implemented rosparam loading * fixed dict checking * excess whitespace
1 parent adf336e commit bc7b89b

File tree

2 files changed

+118
-0
lines changed

2 files changed

+118
-0
lines changed

src/roswire/proxy/launch/__init__.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
import xml.etree.ElementTree as ET
1212

1313
import attr
14+
import yaml
1415

16+
from .rosparam import load_from_yaml_string as load_rosparam_from_string
1517
from .config import ROSConfig, NodeConfig, Parameter
1618
from .context import LaunchContext
1719
from ..substitution import resolve as resolve_args
@@ -27,6 +29,12 @@
2729
_TAG_TO_LOADER = {}
2830

2931

32+
def _read_contents(tag: ET.Element) -> str:
33+
"""Reads the text contents of an XML element."""
34+
# FIXME add support for CDATA -- possibly via lxml or xml.dom?
35+
return ''.join(t.text for t in tag if t.text)
36+
37+
3038
def _parse_bool(attr: str, val: str) -> bool:
3139
"""Parses a boolean value from an XML attribute."""
3240
val = val.lower()
@@ -170,6 +178,65 @@ def _load_param_tag(self,
170178

171179
return ctx, cfg
172180

181+
@tag('rosparam', ['command', 'ns', 'file', 'param', 'subst_value'])
182+
def _load_rosparam_tag(self,
183+
ctx: LaunchContext,
184+
cfg: ROSConfig,
185+
tag: ET.Element
186+
) -> Tuple[LaunchContext, ROSConfig]:
187+
filename = self._read_optional(tag, 'file', ctx)
188+
subst_value = self._read_optional_bool(tag, 'subst_value', ctx, False)
189+
ns = self._read_optional(tag, 'ns', ctx) or ''
190+
param = self._read_optional(tag, 'param', ctx) or ''
191+
param = namespace_join(ns, param)
192+
full_param = namespace_join(ctx.namespace, param)
193+
value = _read_contents(tag)
194+
195+
cmd: str = self._read_optional(tag, 'command', ctx) or 'load'
196+
if cmd not in ('load', 'delete', 'dump'):
197+
m = f"<rosparam> unsupported 'command': {cmd}"
198+
raise FailedToParseLaunchFile(m)
199+
200+
if cmd == 'load' and not filename:
201+
m = "<rosparam> load command requires 'filename' attribute"
202+
raise FailedToParseLaunchFile(m)
203+
204+
if cmd == 'load':
205+
assert filename is not None # mypy can't work this out
206+
if not self.__files.isfile(filename):
207+
m = f"<rosparam> file does not exist: {filename}"
208+
raise FailedToParseLaunchFile(m)
209+
210+
if cmd == 'delete' and filename is not None:
211+
m = "<rosparam> command:delete does not support filename"
212+
raise FailedToParseLaunchFile(m)
213+
214+
# handle load command
215+
if cmd == 'load':
216+
assert filename is not None # mypy can't work this out
217+
yml_text = self.__files.read(filename)
218+
if subst_value:
219+
yml_text = self._resolve_args(yml_text, ctx)
220+
logger.debug("parsing rosparam YAML:\n%s", yml_text)
221+
data = load_rosparam_from_string(yml_text)
222+
logger.debug("rosparam values: %s", data)
223+
if not isinstance(data, dict) and not param:
224+
m = "<rosparam> requires 'param' for non-dictionary values"
225+
raise FailedToParseLaunchFile(m)
226+
cfg = cfg.with_param(full_param, data)
227+
228+
# handle dump command
229+
if cmd == 'dump':
230+
m = "'dump' command is currently not supported in <rosparam>"
231+
raise NotImplementedError(m)
232+
233+
# handle delete command
234+
if cmd == 'delete':
235+
m = "'delete' command is currently not supported in <rosparam>"
236+
raise NotImplementedError(m)
237+
238+
return ctx, cfg
239+
173240
@tag('remap', ['from', 'to'])
174241
def _load_remap_tag(self,
175242
ctx: LaunchContext,
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
This file provides utilities for interacting with rosparam.
4+
"""
5+
__all__ = ('load_from_yaml_string',)
6+
7+
from typing import Dict, Any
8+
import math
9+
import re
10+
11+
import yaml
12+
13+
14+
class YAMLLoader(yaml.SafeLoader):
15+
"""A custom YAML loader for rosparam files."""
16+
17+
18+
def load_from_yaml_string(s: str) -> Dict[str, Any]:
19+
"""Parses the contents of a rosparam file to a dictionary."""
20+
return yaml.load(s, Loader=YAMLLoader) or {}
21+
22+
23+
def __load_radians(loader: YAMLLoader, node: yaml.YAMLObject) -> float:
24+
"""Safely converts rad(num) to a float value.
25+
26+
Note
27+
----
28+
This does not support evaluation of expressions.
29+
"""
30+
expr_s = loader.construct_scalar(node).strip()
31+
if expr_s.startswith('rad('):
32+
expr_s = expr_s[4:-1]
33+
34+
# TODO safely parse and evaluate expression
35+
return float(expr_s)
36+
37+
38+
def __load_degrees(loader: YAMLLoader, node: yaml.YAMLObject) -> float:
39+
"""Safely converts deg(num) to a float value."""
40+
expr_s = loader.construct_scalar(node).strip()
41+
if expr_s.startswith('def('):
42+
expr_s = expr_s[4:-1]
43+
return float(expr_s) * math.pi / 180.0
44+
45+
46+
YAMLLoader.add_constructor('!degrees', __load_degrees)
47+
YAMLLoader.add_implicit_resolver(
48+
'!degrees', re.compile('^deg\([^\)]*\)$'), first='deg(')
49+
YAMLLoader.add_constructor('!radians', __load_radians)
50+
YAMLLoader.add_implicit_resolver(
51+
'!radians', re.compile('^rad\([^\)]*\)$'), first='rad(')

0 commit comments

Comments
 (0)