From 9f238099f2977c4dec45e7744f3aa8cf21a69bdb Mon Sep 17 00:00:00 2001 From: Chris Timperley Date: Thu, 30 Apr 2020 16:10:05 -0400 Subject: [PATCH] Replaced basic launch method with roslaunch manager (#352) * added roslaunch module * updated roscore * added PackageNotFound exception * updated PackageNotFound * added LaunchFileNotFound * added locate method * moved old launch submodules * updated API for locate method * bad whitespace * updated moved code * implemented read method * ignore long URL * fixed optional arg * added __call__ alias * updated config module * added node submodule * added parameter submodule * added launch submodule * added missing import * fixed bad import * bad import * finished tidying config submodule * removed old launch method * updated CHANGELOG * added stub write method * implemented write method * updated recipes --- CHANGELOG.md | 5 + docs/recipes/record_to_bag.py | 2 +- docs/recipes/service_call.py | 2 +- setup.cfg | 5 +- src/roswire/exceptions.py | 18 ++ src/roswire/proxy/launch/__init__.py | 5 - src/roswire/proxy/launch/config.py | 152 --------------- src/roswire/proxy/roscore.py | 67 +------ src/roswire/proxy/roslaunch/__init__.py | 2 + .../proxy/roslaunch/config/__init__.py | 7 + src/roswire/proxy/roslaunch/config/launch.py | 79 ++++++++ src/roswire/proxy/roslaunch/config/node.py | 53 ++++++ .../proxy/roslaunch/config/parameter.py | 33 ++++ .../proxy/{launch => roslaunch}/context.py | 0 .../proxy/{launch => roslaunch}/reader.py | 55 +++--- src/roswire/proxy/roslaunch/roslaunch.py | 177 ++++++++++++++++++ .../proxy/{launch => roslaunch}/rosparam.py | 0 .../{launch => roslaunch}/substitution.py | 0 18 files changed, 415 insertions(+), 247 deletions(-) delete mode 100644 src/roswire/proxy/launch/__init__.py delete mode 100644 src/roswire/proxy/launch/config.py create mode 100644 src/roswire/proxy/roslaunch/__init__.py create mode 100644 src/roswire/proxy/roslaunch/config/__init__.py create mode 100644 src/roswire/proxy/roslaunch/config/launch.py create mode 100644 src/roswire/proxy/roslaunch/config/node.py create mode 100644 src/roswire/proxy/roslaunch/config/parameter.py rename src/roswire/proxy/{launch => roslaunch}/context.py (100%) rename src/roswire/proxy/{launch => roslaunch}/reader.py (92%) create mode 100644 src/roswire/proxy/roslaunch/roslaunch.py rename src/roswire/proxy/{launch => roslaunch}/rosparam.py (100%) rename src/roswire/proxy/{launch => roslaunch}/substitution.py (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f34fe69..14f83f51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ 3.6 compatibility. * Moved all logging from Python's built-in logging library to loguru. * Added `to_xml_tree` method to `LaunchConfig`. +* Added `PackageNotFound` and `LaunchFileNotFound` exception. +* Added `roslaunch` property to `ROSCore`, which exposes a `ROSLaunchManager`. + The manager provides various `roslaunch`-related functionality including + locating, generating, parsing, flattening, and launching launch files. +* Removed `launch` method from `ROSCore`. Replaced with `roslaunch`. # 1.1.0 (2020-23-04) diff --git a/docs/recipes/record_to_bag.py b/docs/recipes/record_to_bag.py index b925547d..bf559d40 100644 --- a/docs/recipes/record_to_bag.py +++ b/docs/recipes/record_to_bag.py @@ -15,7 +15,7 @@ ps_sitl = system.shell.popen(f'{FN_SITL} --model copter --defaults {FN_PARAMS}') # use roslaunch to launch the application inside the ROS session - ros.launch('apm.launch', package='mavros', args={'fcu_url': 'tcp://127.0.0.1:5760@5760'}) + ros.roslaunch('apm.launch', package='mavros', args={'fcu_url': 'tcp://127.0.0.1:5760@5760'}) # to record all ROS topic data for 300 seconds with ros.record('filepath-on-host-machine.bag') as recorder: diff --git a/docs/recipes/service_call.py b/docs/recipes/service_call.py index 39ff7194..38327d9b 100644 --- a/docs/recipes/service_call.py +++ b/docs/recipes/service_call.py @@ -24,7 +24,7 @@ ps_sitl = system.shell.popen(f'{FN_SITL} --model copter --defaults {FN_PARAMS}') # use roslaunch to launch the application inside the ROS session - ros.launch('apm.launch', package='mavros', args={'fcu_url': 'tcp://127.0.0.1:5760@5760'}) + ros.roslaunch('apm.launch', package='mavros', args={'fcu_url': 'tcp://127.0.0.1:5760@5760'}) # let's wait some time for the copter to become armable time.sleep(60) diff --git a/setup.cfg b/setup.cfg index a4e675d2..a2c75c6e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -48,8 +48,9 @@ max-line-length = 79 per-file-ignores = src/roswire/__init__.py:E402,F401 src/roswire/proxy/__init__.py:F401 - src/roswire/proxy/launch/__init__.py:F401 - src/roswire/proxy/launch/reader.py:F811,E704 + src/roswire/proxy/roslaunch/__init__.py:F401 + src/roswire/proxy/roslaunch/config/__init__.py:F401 + src/roswire/proxy/roslaunch/reader.py:F811,E704,E501 src/roswire/definitions/__init__.py:F401 [tox] diff --git a/src/roswire/exceptions.py b/src/roswire/exceptions.py index a07a8479..1a029e3e 100644 --- a/src/roswire/exceptions.py +++ b/src/roswire/exceptions.py @@ -6,6 +6,24 @@ class ROSWireException(Exception): """Base class used by all ROSWire exceptions.""" +@_attr.s(frozen=True, auto_exc=True, auto_attribs=True, str=False) +class PackageNotFound(ValueError, ROSWireException): + """No package was found with a given name.""" + package: str + + def __str__(self) -> str: + return f"Could not find package with name: {self.package}" + + +@_attr.s(frozen=True, auto_exc=True, auto_attribs=True, str=False) +class LaunchFileNotFound(ValueError, ROSWireException): + """No launch file was found at the given path.""" + path: str + + def __str__(self) -> str: + return f"Could not find launch file at path: {self.path}" + + class FailedToParseLaunchFile(ROSWireException): """An attempt to parse a launch file failed.""" diff --git a/src/roswire/proxy/launch/__init__.py b/src/roswire/proxy/launch/__init__.py deleted file mode 100644 index 343bfd37..00000000 --- a/src/roswire/proxy/launch/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# -*- coding: utf-8 -*- -"""This module provides :class:`LaunchFileReader`, a class for parsing the -contents of XML launch files. -""" -from .reader import LaunchFileReader diff --git a/src/roswire/proxy/launch/config.py b/src/roswire/proxy/launch/config.py deleted file mode 100644 index a9503e7f..00000000 --- a/src/roswire/proxy/launch/config.py +++ /dev/null @@ -1,152 +0,0 @@ -# -*- coding: utf-8 -*- -""" -This file provides data structures that represent ROS launch configurations. -""" -__all__ = ('ROSConfig', 'NodeConfig') - -from typing import Tuple, FrozenSet, Optional, Dict, Any -import xml.etree.ElementTree as ET - -from loguru import logger -import attr -import yaml - -try: - from yaml import Dumper as YamlDumper # type: ignore -except ImportError: - from yaml import CDumper as YamlDumper # type: ignore - -from ...exceptions import FailedToParseLaunchFile -from ...name import (namespace_join, canonical_name, name_is_global, - namespaces_of) - - -@attr.s(frozen=True, slots=True, auto_attribs=True) -class Parameter: - name: str - typ: str - value: Any - - def to_xml_element(self) -> ET.Element: - element = ET.Element('param') - element.attrib['name'] = self.name - element.attrib['type'] = self.typ - if self.typ in ('int', 'double', 'bool', 'auto'): - value = str(self.value) - elif self.typ == 'yaml': - value = yaml.dump(self.value, Dumper=YamlDumper) - else: - value = self.value - print(self) - element.attrib['value'] = value - return element - - -@attr.s(frozen=True, slots=True, auto_attribs=True) -class NodeConfig: - namespace: str - name: str - typ: str - package: str - remappings: Tuple[Tuple[str, str], ...] = attr.ib(default=tuple()) - filename: Optional[str] = attr.ib(default=None) - output: Optional[str] = attr.ib(default=None) - required: bool = attr.ib(default=False) - respawn: bool = attr.ib(default=False) - respawn_delay: float = attr.ib(default=0.0) - env_args: Tuple[Tuple[str, str], ...] = attr.ib(default=tuple()) - cwd: Optional[str] = attr.ib(default=None) - args: Optional[str] = attr.ib(default=None) - launch_prefix: Optional[str] = attr.ib(default=None) - - @property - def full_name(self) -> str: - return namespace_join(self.namespace, self.name) - - def to_xml_element(self) -> ET.Element: - element = ET.Element('node') - element.attrib['pkg'] = self.package - element.attrib['type'] = self.typ - element.attrib['name'] = self.name - element.attrib['ns'] = self.namespace - element.attrib['respawn'] = str(self.respawn) - element.attrib['respawn_delay'] = str(self.respawn_delay) - element.attrib['required'] = str(self.required) - if self.launch_prefix: - element.attrib['launch-prefix'] = self.launch_prefix - if self.args: - element.attrib['args'] = self.args - if self.cwd: - element.attrib['cwd'] = self.cwd - if self.output: - element.attrib['output'] = self.output - for remap_from, remap_to in self.remappings: - attrib = {'from': remap_from, 'to': remap_to} - ET.SubElement(element, 'remap', attrib=attrib) - return element - - -@attr.s(frozen=True, slots=True) -class ROSConfig: - nodes: FrozenSet[NodeConfig] = attr.ib(default=frozenset(), - converter=frozenset) - executables: Tuple[str, ...] = attr.ib(default=tuple()) - roslaunch_files: Tuple[str, ...] = attr.ib(default=tuple()) - params: Dict[str, Any] = attr.ib(factory=dict) - clear_params: Tuple[str, ...] = attr.ib(default=tuple()) - errors: Tuple[str, ...] = attr.ib(default=tuple()) - - def with_clear_param(self, ns: str) -> 'ROSConfig': - """ - Specifies a parameter that should be cleared before new parameters - are set. - """ - ns = canonical_name(ns) - if ns in self.clear_params: - return self - clear_params = self.clear_params + (ns,) - return attr.evolve(self, clear_params=clear_params) - - def with_param(self, name: str, typ: str, value: Any) -> 'ROSConfig': - """Adds a parameter to this configuration.""" - param = Parameter(name=name, typ=typ, value=value) - params = self.params.copy() - errors = self.errors - - if not name_is_global(name): - m = f"expected parameter name to be global: {name}" - raise FailedToParseLaunchFile(m) - - for parent_name in (n for n in namespaces_of(name) if n in params): - err = f"parameter [{name}] conflicts with parent [{parent_name}]" - errors = errors + (err,) - - params[name] = param - return attr.evolve(self, params=params, errors=errors) - - def with_executable(self, executable: str) -> 'ROSConfig': - """Specify an executable that should be run at launch.""" - executables = self.executables + (executable,) - return attr.evolve(self, executables=executables) - - def with_roslaunch_file(self, filename: str) -> 'ROSConfig': - roslaunch_files = self.roslaunch_files + (filename,) - return attr.evolve(self, roslaunch_files=roslaunch_files) - - def with_node(self, node: NodeConfig) -> 'ROSConfig': - logger.debug(f"adding node to config: {node}") - used_names = {n.full_name for n in self.nodes} - if node.full_name in used_names: - m = 'multiple definitions of node [{}] in launch configuration' - m = m.format(node.full_name) - raise FailedToParseLaunchFile(m) - nodes = self.nodes | frozenset({node}) - return attr.evolve(self, nodes=nodes) - - def to_xml_tree(self) -> ET.ElementTree: - root = ET.Element('launch') - for param in self.params.values(): - root.append(param.to_xml_element()) - for node in self.nodes: - root.append(node.to_xml_element()) - return ET.ElementTree(root) diff --git a/src/roswire/proxy/roscore.py b/src/roswire/proxy/roscore.py index bb50e7bf..c2a115cb 100644 --- a/src/roswire/proxy/roscore.py +++ b/src/roswire/proxy/roscore.py @@ -1,20 +1,20 @@ # -*- coding: utf-8 -*- __all__ = ('ROSCore',) -from typing import Dict, List, Mapping, Optional, Union +from typing import Dict, Optional import os import xmlrpc.client -import shlex import time from loguru import logger import dockerblade -from .bag import BagRecorder, BagPlayer from ..description import SystemDescription from ..exceptions import ROSWireException +from .bag import BagRecorder, BagPlayer from .node import NodeManager from .parameters import ParameterServer +from .roslaunch import ROSLaunchManager from .service import ServiceManager @@ -29,6 +29,8 @@ class ROSCore: The XML-RPC connection to the ROS master. nodes: NodeManager Provides access to the nodes running on this ROS Master. + roslaunch: ROSLaunchManager + Provides access to roslaunch-related functionality. services: ServiceManager Provides access to the services advertised on this ROS Master. parameters: ParameterServer @@ -65,6 +67,8 @@ def __init__(self, self.__ip_address, self.__connection, self.__shell) + self.roslaunch: ROSLaunchManager = \ + ROSLaunchManager(self.__shell, self.__files) @property def nodes(self) -> NodeManager: @@ -90,63 +94,6 @@ def topic_to_type(self) -> Dict[str, str]: raise ROSWireException("bad API call!") return {name: typ for (name, typ) in result} - def launch(self, - filename: str, - *, - package: Optional[str] = None, - args: Optional[Dict[str, Union[int, str]]] = None, - prefix: Optional[str] = None, - launch_prefixes: Optional[Mapping[str, str]] = None - ) -> None: - """Provides an interface to roslaunch. - - Parameters - ---------- - filename: str - The name of the launch file, or an absolute path to the launch - file inside the container. - package: str, optional - The name of the package to which the launch file belongs. - args: Dict[str, Union[int, str]], optional - Keyword arguments that should be supplied to roslaunch. - prefix: str, optional - An optional prefix to add before the roslaunch command. - launch_prefixes: Mapping[str, str], optional - An optional mapping from nodes, given by their names, to their - individual launch prefix. - """ - shell = self.__shell - if not args: - args = {} - if not launch_prefixes: - launch_prefixes = {} - launch_args: List[str] = [f'{arg}:={val}' for arg, val in args.items()] - - if launch_prefixes: - m = "individual launch prefixes are not yet implemented" - raise NotImplementedError(m) - - # determine the absolute path of the launch file - if package: - filename_original = filename - logger.debug(f'determing location of launch file [{filename}]' - f' in package [{package}]') - package_escaped = shlex.quote(package) - find_package_command = f'rospack find {package_escaped}' - package_path = shell.check_output(find_package_command, - stderr=False) - filename = os.path.join(package_path, 'launch', filename) - logger.debug('determined location of launch file' - f' [{filename_original}] in package [{package}]: ' - f'{filename}') - - cmd = ['roslaunch', shlex.quote(filename)] - cmd += launch_args - if prefix: - cmd = [prefix] + cmd - cmd_str = ' '.join(cmd) - self.__shell.popen(cmd_str, stdout=False, stderr=False) - def record(self, fn: str, exclude_topics: Optional[str] = None diff --git a/src/roswire/proxy/roslaunch/__init__.py b/src/roswire/proxy/roslaunch/__init__.py new file mode 100644 index 00000000..eaa4727e --- /dev/null +++ b/src/roswire/proxy/roslaunch/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from .roslaunch import ROSLaunchManager diff --git a/src/roswire/proxy/roslaunch/config/__init__.py b/src/roswire/proxy/roslaunch/config/__init__.py new file mode 100644 index 00000000..6713be21 --- /dev/null +++ b/src/roswire/proxy/roslaunch/config/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +""" +This module provides data structures for representing roslaunch configurations. +""" +from .launch import LaunchConfig +from .node import NodeConfig +from .parameter import Parameter diff --git a/src/roswire/proxy/roslaunch/config/launch.py b/src/roswire/proxy/roslaunch/config/launch.py new file mode 100644 index 00000000..f34df5a4 --- /dev/null +++ b/src/roswire/proxy/roslaunch/config/launch.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +__all__ = ('LaunchConfig',) + +from typing import Tuple, FrozenSet, Dict, Any +import xml.etree.ElementTree as ET + +from loguru import logger +import attr + +from .node import NodeConfig +from .parameter import Parameter +from ....exceptions import FailedToParseLaunchFile +from ....name import canonical_name, name_is_global, namespaces_of + + +@attr.s(frozen=True, slots=True) +class LaunchConfig: + nodes: FrozenSet[NodeConfig] = attr.ib(default=frozenset(), + converter=frozenset) + executables: Tuple[str, ...] = attr.ib(default=tuple()) + roslaunch_files: Tuple[str, ...] = attr.ib(default=tuple()) + params: Dict[str, Any] = attr.ib(factory=dict) + clear_params: Tuple[str, ...] = attr.ib(default=tuple()) + errors: Tuple[str, ...] = attr.ib(default=tuple()) + + def with_clear_param(self, ns: str) -> 'LaunchConfig': + """ + Specifies a parameter that should be cleared before new parameters + are set. + """ + ns = canonical_name(ns) + if ns in self.clear_params: + return self + clear_params = self.clear_params + (ns,) + return attr.evolve(self, clear_params=clear_params) + + def with_param(self, name: str, typ: str, value: Any) -> 'LaunchConfig': + """Adds a parameter to this configuration.""" + param = Parameter(name=name, typ=typ, value=value) + params = self.params.copy() + errors = self.errors + + if not name_is_global(name): + m = f"expected parameter name to be global: {name}" + raise FailedToParseLaunchFile(m) + + for parent_name in (n for n in namespaces_of(name) if n in params): + err = f"parameter [{name}] conflicts with parent [{parent_name}]" + errors = errors + (err,) + + params[name] = param + return attr.evolve(self, params=params, errors=errors) + + def with_executable(self, executable: str) -> 'LaunchConfig': + """Specify an executable that should be run at launch.""" + executables = self.executables + (executable,) + return attr.evolve(self, executables=executables) + + def with_roslaunch_file(self, filename: str) -> 'LaunchConfig': + roslaunch_files = self.roslaunch_files + (filename,) + return attr.evolve(self, roslaunch_files=roslaunch_files) + + def with_node(self, node: NodeConfig) -> 'LaunchConfig': + logger.debug(f"adding node to config: {node}") + used_names = {n.full_name for n in self.nodes} + if node.full_name in used_names: + m = 'multiple definitions of node [{}] in launch configuration' + m = m.format(node.full_name) + raise FailedToParseLaunchFile(m) + nodes = self.nodes | frozenset({node}) + return attr.evolve(self, nodes=nodes) + + def to_xml_tree(self) -> ET.ElementTree: + root = ET.Element('launch') + for param in self.params.values(): + root.append(param.to_xml_element()) + for node in self.nodes: + root.append(node.to_xml_element()) + return ET.ElementTree(root) diff --git a/src/roswire/proxy/roslaunch/config/node.py b/src/roswire/proxy/roslaunch/config/node.py new file mode 100644 index 00000000..7c4ba048 --- /dev/null +++ b/src/roswire/proxy/roslaunch/config/node.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +__all__ = ('NodeConfig',) + +from typing import Optional, Tuple +import xml.etree.ElementTree as ET + +import attr + +from ....name import namespace_join + + +@attr.s(frozen=True, slots=True, auto_attribs=True) +class NodeConfig: + namespace: str + name: str + typ: str + package: str + remappings: Tuple[Tuple[str, str], ...] = attr.ib(default=tuple()) + filename: Optional[str] = attr.ib(default=None) + output: Optional[str] = attr.ib(default=None) + required: bool = attr.ib(default=False) + respawn: bool = attr.ib(default=False) + respawn_delay: float = attr.ib(default=0.0) + env_args: Tuple[Tuple[str, str], ...] = attr.ib(default=tuple()) + cwd: Optional[str] = attr.ib(default=None) + args: Optional[str] = attr.ib(default=None) + launch_prefix: Optional[str] = attr.ib(default=None) + + @property + def full_name(self) -> str: + return namespace_join(self.namespace, self.name) + + def to_xml_element(self) -> ET.Element: + element = ET.Element('node') + element.attrib['pkg'] = self.package + element.attrib['type'] = self.typ + element.attrib['name'] = self.name + element.attrib['ns'] = self.namespace + element.attrib['respawn'] = str(self.respawn) + element.attrib['respawn_delay'] = str(self.respawn_delay) + element.attrib['required'] = str(self.required) + if self.launch_prefix: + element.attrib['launch-prefix'] = self.launch_prefix + if self.args: + element.attrib['args'] = self.args + if self.cwd: + element.attrib['cwd'] = self.cwd + if self.output: + element.attrib['output'] = self.output + for remap_from, remap_to in self.remappings: + attrib = {'from': remap_from, 'to': remap_to} + ET.SubElement(element, 'remap', attrib=attrib) + return element diff --git a/src/roswire/proxy/roslaunch/config/parameter.py b/src/roswire/proxy/roslaunch/config/parameter.py new file mode 100644 index 00000000..c1b8dd0d --- /dev/null +++ b/src/roswire/proxy/roslaunch/config/parameter.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +__all__ = ('Parameter',) + +from typing import Any +import xml.etree.ElementTree as ET + +import attr +import yaml + +try: + from yaml import Dumper as YamlDumper # type: ignore +except ImportError: + from yaml import CDumper as YamlDumper # type: ignore + + +@attr.s(frozen=True, slots=True, auto_attribs=True) +class Parameter: + name: str + typ: str + value: Any + + def to_xml_element(self) -> ET.Element: + element = ET.Element('param') + element.attrib['name'] = self.name + element.attrib['type'] = self.typ + if self.typ in ('int', 'double', 'bool', 'auto'): + value = str(self.value) + elif self.typ == 'yaml': + value = yaml.dump(self.value, Dumper=YamlDumper) + else: + value = self.value + element.attrib['value'] = value + return element diff --git a/src/roswire/proxy/launch/context.py b/src/roswire/proxy/roslaunch/context.py similarity index 100% rename from src/roswire/proxy/launch/context.py rename to src/roswire/proxy/roslaunch/context.py diff --git a/src/roswire/proxy/launch/reader.py b/src/roswire/proxy/roslaunch/reader.py similarity index 92% rename from src/roswire/proxy/launch/reader.py rename to src/roswire/proxy/roslaunch/reader.py index a164ac74..efc1763f 100644 --- a/src/roswire/proxy/launch/reader.py +++ b/src/roswire/proxy/roslaunch/reader.py @@ -12,7 +12,7 @@ import dockerblade from .rosparam import load_from_yaml_string as load_rosparam_from_string -from .config import ROSConfig, NodeConfig +from .config import LaunchConfig, NodeConfig from .context import LaunchContext from .substitution import ArgumentResolver from ...name import (namespace_join, global_name, namespace, name_is_global, @@ -85,9 +85,9 @@ def tag(name: str, legal_attributes: Collection[str] = tuple()): def wrap(loader): def wrapped(self, ctx: LaunchContext, - cfg: ROSConfig, + cfg: LaunchConfig, elem: ET.Element - ) -> Tuple[LaunchContext, ROSConfig]: + ) -> Tuple[LaunchContext, LaunchConfig]: logger.debug(f"parsing <{name}> tag") for attribute in elem.attrib: if attribute not in legal_attributes: @@ -122,9 +122,9 @@ def _parse_file(self, fn: str) -> ET.Element: def _load_tags(self, ctx: LaunchContext, - cfg: ROSConfig, + cfg: LaunchConfig, tags: Sequence[ET.Element] - ) -> Tuple[LaunchContext, ROSConfig]: + ) -> Tuple[LaunchContext, LaunchConfig]: for tag in (t for t in tags if t.tag in _TAG_TO_LOADER): loader = _TAG_TO_LOADER[tag.tag] ctx, cfg = loader(self, ctx, cfg, tag) @@ -133,9 +133,9 @@ def _load_tags(self, @tag('group', ['ns']) def _load_group_tag(self, ctx: LaunchContext, - cfg: ROSConfig, + cfg: LaunchConfig, tag: ET.Element - ) -> Tuple[LaunchContext, ROSConfig]: + ) -> Tuple[LaunchContext, LaunchConfig]: # create context for group ns = self._read_namespace(ctx, tag) ctx_child = ctx.child(ns) @@ -149,9 +149,9 @@ def _load_group_tag(self, @tag('param', ['name', 'value', 'type', 'textfile', 'binfile', 'command']) def _load_param_tag(self, ctx: LaunchContext, - cfg: ROSConfig, + cfg: LaunchConfig, tag: ET.Element - ) -> Tuple[LaunchContext, ROSConfig]: + ) -> Tuple[LaunchContext, LaunchConfig]: name = self._read_required(tag, 'name', ctx) typ = self._read_optional(tag, 'type', ctx) or 'auto' logger.debug(f"adding parameter [{name}] with type [{typ}]") @@ -196,9 +196,9 @@ def _load_param_tag(self, @tag('rosparam', ['command', 'ns', 'file', 'param', 'subst_value']) def _load_rosparam_tag(self, ctx: LaunchContext, - cfg: ROSConfig, + cfg: LaunchConfig, tag: ET.Element - ) -> Tuple[LaunchContext, ROSConfig]: + ) -> Tuple[LaunchContext, LaunchConfig]: filename = self._read_optional(tag, 'file', ctx) subst_value = self._read_optional_bool(tag, 'subst_value', ctx, False) ns = self._read_optional(tag, 'ns', ctx) or '' @@ -253,9 +253,9 @@ def _load_rosparam_tag(self, @tag('remap', ['from', 'to']) def _load_remap_tag(self, ctx: LaunchContext, - cfg: ROSConfig, + cfg: LaunchConfig, tag: ET.Element - ) -> Tuple[LaunchContext, ROSConfig]: + ) -> Tuple[LaunchContext, LaunchConfig]: frm = self._read_required(tag, 'from', ctx) to = self._read_required(tag, 'to', ctx) ctx = ctx.with_remapping(frm, to) @@ -265,9 +265,9 @@ def _load_remap_tag(self, 'respawn', 'ns', 'output', 'args', 'ns', 'launch-prefix']) def _load_node_tag(self, ctx: LaunchContext, - cfg: ROSConfig, + cfg: LaunchConfig, tag: ET.Element - ) -> Tuple[LaunchContext, ROSConfig]: + ) -> Tuple[LaunchContext, LaunchConfig]: name = self._read_required(tag, 'name', ctx) package = self._read_required(tag, 'pkg', ctx) node_type = self._read_required(tag, 'type', ctx) @@ -314,9 +314,9 @@ def _load_node_tag(self, @tag('arg', ['name', 'default', 'value', 'doc']) def _load_arg_tag(self, ctx: LaunchContext, - cfg: ROSConfig, + cfg: LaunchConfig, tag: ET.Element - ) -> Tuple[LaunchContext, ROSConfig]: + ) -> Tuple[LaunchContext, LaunchConfig]: name = self._read_required(tag, 'name', ctx) value = self._read_optional(tag, 'value', ctx) default = self._read_optional(tag, 'default', ctx) @@ -330,9 +330,9 @@ def _load_arg_tag(self, @tag('env', ['name', 'value']) def _load_env_tag(self, ctx: LaunchContext, - cfg: ROSConfig, + cfg: LaunchConfig, tag: ET.Element - ) -> Tuple[LaunchContext, ROSConfig]: + ) -> Tuple[LaunchContext, LaunchConfig]: name = self._read_required(tag, 'name', ctx) value = self._read_required(tag, 'value', ctx) ctx = ctx.with_env_arg(name, value) @@ -341,9 +341,9 @@ def _load_env_tag(self, @tag('include', ['file', 'pass_all_args', 'ns', 'clear_params']) def _load_include_tag(self, ctx: LaunchContext, - cfg: ROSConfig, + cfg: LaunchConfig, tag: ET.Element - ) -> Tuple[LaunchContext, ROSConfig]: + ) -> Tuple[LaunchContext, LaunchConfig]: include_filename = self._read_required(tag, 'file', ctx) logger.debug(f"include file: {include_filename}") cfg = cfg.with_roslaunch_file(include_filename) @@ -471,20 +471,23 @@ def _resolve_args(self, s: str, ctx: LaunchContext) -> str: context=resolve_ctx) return resolver.resolve(s) - def read(self, fn: str, argv: Optional[Sequence[str]] = None) -> ROSConfig: + def read(self, + fn: str, + argv: Optional[Sequence[str]] = None + ) -> LaunchConfig: """Parses the contents of a given launch file. Returns ------- - ROSConfig + LaunchConfig A description of the launch configuration. Reference --------- - http://wiki.ros.org/roslaunch/XML/node - http://docs.ros.org/kinetic/api/roslaunch/html/roslaunch.xmlloader.XmlLoader-class.html + * http://wiki.ros.org/roslaunch/XML/node + * http://docs.ros.org/kinetic/api/roslaunch/html/roslaunch.xmlloader.XmlLoader-class.html """ - cfg = ROSConfig() + cfg = LaunchConfig() ctx = LaunchContext(namespace='/', filename=fn) if argv: ctx = ctx.with_argv(argv) diff --git a/src/roswire/proxy/roslaunch/roslaunch.py b/src/roswire/proxy/roslaunch/roslaunch.py new file mode 100644 index 00000000..016cdd00 --- /dev/null +++ b/src/roswire/proxy/roslaunch/roslaunch.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +__all__ = ('ROSLaunchManager',) + +from typing import List, Mapping, Optional, Sequence, Union +import os +import shlex +import xml.etree.ElementTree as ET + +from loguru import logger +import attr +import dockerblade + +from .config import LaunchConfig +from .reader import LaunchFileReader +from ... import exceptions as exc + + +@attr.s(eq=False) +class ROSLaunchManager: + _shell: dockerblade.shell.Shell = attr.ib(repr=False) + _files: dockerblade.files.FileSystem = attr.ib(repr=False) + + def __call__(self, *args, **kwargs) -> None: + """Provides an alias for :code:`launch`.""" + return self.launch(*args, **kwargs) + + def read(self, + filename: str, + *, + package: Optional[str] = None, + argv: Optional[Sequence[str]] = None + ) -> LaunchConfig: + """Produces a summary of the effects of a launch file. + + Parameters + ---------- + filename: str + The name of the launch file, or an absolute path to the launch + file inside the container. + package: str, optional + The name of the package to which the launch file belongs. + argv: Sequence[str], optional + An optional sequence of command-line arguments that should be + supplied to :code:`roslaunch`. + + Raises + ------ + PackageNotFound + If the given package could not be found. + LaunchFileNotFound + If the given launch file could not be found in the package. + """ + filename = self.locate(filename, package=package) + reader = LaunchFileReader(shell=self._shell, files=self._files) + return reader.read(filename, argv) + + def write(self, + config: LaunchConfig, + *, + filename: Optional[str] = None + ) -> str: + """Writes a given launch configuration to disk as an XML launch file. + + Parameters + ---------- + config: LaunchConfig + A launch configuration. + filename: str, optional + The name of the file to which the configuration should be written. + If no filename is given, a temporary file will be created. It is + the responsibility of the caller to ensure that the temporary file + is appropriately destroyed. + + Returns + ------- + str + The absolute path to the generated XML launch file. + """ + if not filename: + filename = self._files.mkstemp(suffix='.xml.launch') + contents = ET.tostring(config.to_xml_tree().getroot()) + self._files.write(filename, contents) + return filename # type: ignore + + def locate(self, filename: str, *, package: Optional[str] = None) -> str: + """Locates a given launch file. + + Parameters + ---------- + filename: str + The name of the launch file, or an absolute path to the launch + file inside the container. + package: str, optional + Optionally specifies the name of the package to which the launch + file belongs. + + Returns + ------- + The absolute path to the launch file, if it exists. + + Raises + ------ + PackageNotFound + If the given package could not be found. + LaunchFileNotFound + If the given launch file could not be found in the package. + """ + if not package: + assert os.path.isabs(filename) + return filename + + filename_original = filename + logger.debug(f'determing location of launch file [{filename}]' + f' in package [{package}]') + command = f'rospack find {shlex.quote(package)}' + try: + path = self._shell.check_output(command, stderr=False) + except dockerblade.CalledProcessError as err: + raise exc.PackageNotFound(package) from err + path = os.path.join(path, 'launch', filename) + if not self._files.isfile(path): + raise exc.LaunchFileNotFound(path=path) + logger.debug('determined location of launch file' + f' [{filename_original}] in package [{package}]: ' + f'{filename}') + return filename + + def launch(self, + filename: str, + *, + package: Optional[str] = None, + args: Optional[Mapping[str, Union[int, str]]] = None, + prefix: Optional[str] = None, + launch_prefixes: Optional[Mapping[str, str]] = None + ) -> None: + """Provides an interface to the roslaunch command. + + Parameters + ---------- + filename: str + The name of the launch file, or an absolute path to the launch + file inside the container. + package: str, optional + The name of the package to which the launch file belongs. + args: Dict[str, Union[int, str]], optional + Keyword arguments that should be supplied to roslaunch. + prefix: str, optional + An optional prefix to add before the roslaunch command. + launch_prefixes: Mapping[str, str], optional + An optional mapping from nodes, given by their names, to their + individual launch prefix. + + Raises + ------ + PackageNotFound + If the given package could not be found. + LaunchFileNotFound + If the given launch file could not be found in the package. + """ + shell = self._shell + if not args: + args = {} + if not launch_prefixes: + launch_prefixes = {} + filename = self.locate(filename, package=package) + + if launch_prefixes: + m = "individual launch prefixes are not yet implemented" + raise NotImplementedError(m) + + cmd = ['roslaunch', shlex.quote(filename)] + launch_args: List[str] = [f'{arg}:={val}' for arg, val in args.items()] + cmd += launch_args + if prefix: + cmd = [prefix] + cmd + cmd_str = ' '.join(cmd) + shell.popen(cmd_str, stdout=False, stderr=False) diff --git a/src/roswire/proxy/launch/rosparam.py b/src/roswire/proxy/roslaunch/rosparam.py similarity index 100% rename from src/roswire/proxy/launch/rosparam.py rename to src/roswire/proxy/roslaunch/rosparam.py diff --git a/src/roswire/proxy/launch/substitution.py b/src/roswire/proxy/roslaunch/substitution.py similarity index 100% rename from src/roswire/proxy/launch/substitution.py rename to src/roswire/proxy/roslaunch/substitution.py