def expand_dict(input_dict): expanded_dict = {} for k, v in input_dict.items(): # Key (parameter/group name) can only be a string/Substitutions that evaluates # to a string. expanded_key = perform_substitutions( context, normalize_to_list_of_substitutions(k)) if isinstance(v, dict): # Expand the nested dict. expanded_value = expand_dict(v) elif isinstance(v, list): # Expand each element. expanded_value = [] for e in v: if isinstance(e, list): raise TypeError( 'Nested lists are not supported for parameters: {} found in {}' .format(e, v)) expanded_value.append( perform_substitution_if_applicable(context, e)) # Tuples are treated as Substitution(s) to be concatenated. elif isinstance(v, tuple): for e in v: ensure_argument_type( e, SomeSubstitutionsType_types_tuple, 'parameter dictionary tuple entry', 'Node') expanded_value = perform_substitutions( context, normalize_to_list_of_substitutions(v)) else: expanded_value = perform_substitution_if_applicable( context, v) expanded_dict[expanded_key] = expanded_value return expanded_dict
def evaluate(self, context: LaunchContext) -> Path: """Evaluate and return a parameter file path.""" if self.__evaluated_param_file is not None: return self.__evaluated_param_file param_file = self.__param_file if isinstance(param_file, list): # list of substitutions param_file = perform_substitutions(context, self.__param_file) allow_substs = perform_typed_substitution(context, self.__allow_substs, data_type=bool) param_file_path: Path = Path(param_file) if allow_substs: with open(param_file_path, 'r') as f, NamedTemporaryFile(mode='w', prefix='launch_params_', delete=False) as h: parsed = perform_substitutions(context, parse_substitution(f.read())) try: yaml.safe_load(parsed) except Exception: raise SubstitutionFailure( 'The substituted parameter file is not a valid yaml file' ) h.write(parsed) param_file_path = Path(h.name) self.__created_tmp_file = True self.__evaluated_param_file = param_file_path return param_file_path
def execute(self, context: LaunchContext): """Execute the action.""" src = perform_substitutions(context, self.__src) dst = perform_substitutions(context, self.__dst) global_remaps = context.launch_configurations.get('ros_remaps', []) global_remaps.append((src, dst)) context.launch_configurations['ros_remaps'] = global_remaps
def _load_nodes_in_docker(self, context: LaunchContext) -> None: """Load all nodes into Docker container.""" if self._container is None: self.__logger.error('Unable to load nodes into Docker container: ' 'no active Docker container!') return for description in self._node_descriptions: package_name = perform_substitutions(context=context, subs=description.package) executable_name = perform_substitutions( context=context, subs=description.node_executable) cmd = _containerized_cmd(entrypoint=self._policy.entrypoint, package=package_name, executable=executable_name) self._container.exec_run( cmd=cmd, detach=True, tty=True, ) self.__logger.debug('Running \"{}\" in container: \"{}\"'.format( cmd, self._policy.container_name))
def test_mixed_substitutions(): lc = LaunchContext() rule = (('foo', 'bar'), ['bar', TextSubstitution(text='baz')]) output_rule = normalize_remap_rule(rule) assert isinstance(output_rule, tuple) assert len(output_rule) == 2 assert 'foobar' == perform_substitutions(lc, output_rule[0]) assert 'barbaz' == perform_substitutions(lc, output_rule[1])
def test_plain_text(): lc = LaunchContext() rule = ('foo', 'bar') output_rule = normalize_remap_rule(rule) assert isinstance(output_rule, tuple) assert len(output_rule) == 2 assert rule[0] == perform_substitutions(lc, output_rule[0]) assert rule[1] == perform_substitutions(lc, output_rule[1])
def test_multiple_rules(): lc = LaunchContext() rules = [('ping', 'pong'), (('baz', 'foo'), ['bar', TextSubstitution(text='baz')])] output_rules = list(normalize_remap_rules(rules)) assert len(rules) == len(output_rules) assert 'ping' == perform_substitutions(lc, output_rules[0][0]) assert 'pong' == perform_substitutions(lc, output_rules[0][1]) assert 'bazfoo' == perform_substitutions(lc, output_rules[1][0]) assert 'barbaz' == perform_substitutions(lc, output_rules[1][1])
def apply( self, context: LaunchContext, node_descriptions: List[SandboxedNode] ) -> Action: """Apply the policy any launches the ROS2 nodes in the sandbox.""" user = self.run_as pw_record = pwd.getpwuid(user.uid) env = os.environ.copy() env['HOME'] = pw_record.pw_dir env['LOGNAME'] = pw_record.pw_name env['USER'] = pw_record.pw_name self.__logger.debug('Running as: {}'.format(pw_record.pw_name)) self.__logger.debug('\tuid: {}'.format(user.uid)) self.__logger.debug('\tgid: {}'.format(user.gid)) self.__logger.debug('\thome: {}'.format(pw_record.pw_dir)) def set_user() -> None: """Set the current user.""" os.setgid(user.gid) os.setuid(user.uid) for description in node_descriptions: package_name = perform_substitutions( context, description.package ) executable_name = perform_substitutions( context, description.node_executable ) # TODO: support node namespace and node name # TODO: support parameters # TODO: support remappings cmd = [ExecutableInPackage( package=package_name, executable=executable_name ).perform(context)] self.__logger.info('Running: {}'.format(cmd)) subprocess.Popen( cmd, preexec_fn=set_user, env=env ) # TODO: handle events for process # TODO: LaunchAsUser is currently NO-OP due to all sandboxing logic being handled here. return LoadRunAsNodes()
def test_log_info_methods(): """Test the methods of the LogInfo class.""" launch_context = LaunchContext() log_info = LogInfo(msg='') assert perform_substitutions(launch_context, log_info.msg) == '' log_info = LogInfo(msg='foo') assert perform_substitutions(launch_context, log_info.msg) == 'foo' log_info = LogInfo(msg=['foo', 'bar', 'baz']) assert perform_substitutions(launch_context, log_info.msg) == 'foobarbaz'
def test_valid_substitutions(): """Test the perform_substitutions() function with valid input.""" context = LaunchContext() class MockSubstitution(Substitution): def perform(self, context): return 'Mock substitution' mock_sub = MockSubstitution() sub_mock_sub = perform_substitutions(context, [mock_sub]) assert 'Mock substitution' == sub_mock_sub sub_mock_sub_multi = perform_substitutions(context, [mock_sub, mock_sub, mock_sub]) assert 'Mock substitutionMock substitutionMock substitution' == sub_mock_sub_multi
def get_composable_node_load_request( composable_node_description: ComposableNode, context: LaunchContext): """Get the request that will be send to the composable node container.""" request = composition_interfaces.srv.LoadNode.Request() request.package_name = perform_substitutions( context, composable_node_description.package) request.plugin_name = perform_substitutions( context, composable_node_description.node_plugin) if composable_node_description.node_name is not None: request.node_name = perform_substitutions( context, composable_node_description.node_name) expanded_ns = composable_node_description.node_namespace if expanded_ns is not None: expanded_ns = perform_substitutions(context, expanded_ns) base_ns = context.launch_configurations.get('ros_namespace', None) combined_ns = make_namespace_absolute( prefix_namespace(base_ns, expanded_ns)) if combined_ns is not None: request.node_namespace = combined_ns # request.log_level = perform_substitutions(context, node_description.log_level) remappings = [] global_remaps = context.launch_configurations.get('ros_remaps', None) if global_remaps: remappings.extend([f'{src}:={dst}' for src, dst in global_remaps]) if composable_node_description.remappings: remappings.extend([ f'{perform_substitutions(context, src)}:={perform_substitutions(context, dst)}' for src, dst in composable_node_description.remappings ]) if remappings: request.remap_rules = remappings global_params = context.launch_configurations.get('ros_params', None) parameters = [] if global_params is not None: parameters.append(normalize_parameter_dict(global_params)) if composable_node_description.parameters is not None: parameters.extend(list(composable_node_description.parameters)) if parameters: request.parameters = [ param.to_parameter_msg() for param in to_parameters_list( context, evaluate_parameters(context, parameters)) ] if composable_node_description.extra_arguments is not None: request.extra_arguments = [ param.to_parameter_msg() for param in to_parameters_list( context, evaluate_parameters( context, composable_node_description.extra_arguments)) ] return request
def evaluate_parameters(context: LaunchContext, parameters: Parameters) -> EvaluatedParameters: """ Evaluate substitutions to produce paths and name/value pairs. The parameters must have been normalized with normalize_parameters() prior to calling this. Substitutions for parameter values in dictionaries will be evaluated according to yaml rules. If you want the substitution to stay a string, the output of the substition must have quotes. :param parameters: normalized parameters :returns: values after evaluating lists of substitutions """ output_params = [ ] # type: List[Union[pathlib.Path, Dict[str, EvaluatedParameterValue]]] for param in parameters: # If it's a list of substitutions then evaluate them to a string and return a pathlib.Path if isinstance(param, tuple) and len(param) and isinstance( param[0], Substitution): # Evaluate a list of Substitution to a file path output_params.append( pathlib.Path(perform_substitutions(context, list(param)))) elif isinstance(param, Mapping): # It's a list of name/value pairs output_params.append(evaluate_parameter_dict(context, param)) return tuple(output_params)
def execute(self, context: LaunchContext): """Execute the action.""" filename = perform_substitutions(context, self._input_file) global_param_list = context.launch_configurations.get( 'global_params', []) global_param_list.append(filename) context.launch_configurations['global_params'] = global_param_list
def test_set_param_with_node(): lc = MockContext() node = Node(package='asd', executable='bsd', name='my_node', namespace='my_ns', parameters=[{ 'asd': 'bsd' }]) set_param = SetParameter(name='my_param', value='my_value') set_param.execute(lc) node._perform_substitutions(lc) actual_command = [ perform_substitutions(lc, item) for item in node.cmd if type(item[0]) == TextSubstitution ] assert actual_command.count('--params-file') == 1 assert actual_command.count('-p') == 1 param_cmdline_index = actual_command.index('-p') + 1 param_cmdline = actual_command[param_cmdline_index] assert param_cmdline == 'my_param:=my_value' param_file_index = actual_command.index('--params-file') + 1 param_file_path = actual_command[param_file_index] assert os.path.isfile(param_file_path) with open(param_file_path, 'r') as h: expanded_parameters_dict = yaml.load(h, Loader=yaml.FullLoader) assert expanded_parameters_dict == { '/my_ns/my_node': { 'ros__parameters': { 'asd': 'bsd' } } }
def execute(self, context: LaunchContext) -> Optional[List[Action]]: """Execute the action.""" # resolve target container node name if is_a_subclass(self.__target_container, ComposableNodeContainer): self.__final_target_container_name = self.__target_container.node_name elif isinstance(self.__target_container, SomeSubstitutionsType_types_tuple): subs = normalize_to_list_of_substitutions(self.__target_container) self.__final_target_container_name = perform_substitutions( context, subs) else: self.__logger.error( 'target container is neither a ComposableNodeContainer nor a SubstitutionType' ) return # Create a client to load nodes in the target container. self.__rclpy_load_node_client = get_ros_node(context).create_client( composition_interfaces.srv.LoadNode, '{}/_container/load_node'.format( self.__final_target_container_name)) # Generate load requests before execute() exits to avoid race with context changing # due to scope change (e.g. if loading nodes from within a GroupAction). load_node_requests = [ get_composable_node_load_request(node_description, context) for node_description in self.__composable_node_descriptions ] context.add_completion_future( context.asyncio_loop.run_in_executor(None, self._load_in_sequence, load_node_requests, context))
def execute( self, context: LaunchContext ) -> Optional[List[Action]]: """Execute the action.""" # resolve target container node name if is_a_subclass(self.__target_container, ComposableNodeContainer): self.__final_target_container_name = self.__target_container.node_name elif isinstance(self.__target_container, SomeSubstitutionsType_types_tuple): subs = normalize_to_list_of_substitutions(self.__target_container) self.__final_target_container_name = perform_substitutions( context, subs) else: self.__logger.error( 'target container is neither a ComposableNodeContainer nor a SubstitutionType') return # Create a client to load nodes in the target container. self.__rclpy_load_node_client = get_ros_node(context).create_client( composition_interfaces.srv.LoadNode, '{}/_container/load_node'.format( self.__final_target_container_name ) ) context.add_completion_future( context.asyncio_loop.run_in_executor( None, self._load_in_sequence, self.__composable_node_descriptions, context ) )
def perform(self, context: LaunchContext) -> Text: """Perform the substitution by locating the executable.""" executable = perform_substitutions(context, self.executable) package = perform_substitutions(context, self.package) package_prefix = super().perform(context) package_libexec = os.path.join(package_prefix, 'lib', package) if not os.path.exists(package_libexec): raise SubstitutionFailure( "package '{}' found at '{}', but libexec directory '{}' does not exist" .format(package, package_prefix, package_libexec)) result = which(executable, path=package_libexec) if result is None: raise SubstitutionFailure( "executable '{}' not found on the libexec directory '{}' ". format(executable, package_libexec)) return result
def test_include_launch_description_launch_arguments(): """Test the interactions between declared launch arguments and IncludeLaunchDescription.""" # test that arguments are set when given, even if they are not declared ld1 = LaunchDescription([]) action1 = IncludeLaunchDescription( LaunchDescriptionSource(ld1), launch_arguments={'foo': 'FOO'}.items(), ) assert len(action1.launch_arguments) == 1 lc1 = LaunchContext() result1 = action1.visit(lc1) assert len(result1) == 2 assert isinstance(result1[0], SetLaunchConfiguration) assert perform_substitutions(lc1, result1[0].name) == 'foo' assert perform_substitutions(lc1, result1[0].value) == 'FOO' assert result1[1] == ld1 # test that a declared argument that is not provided raises an error ld2 = LaunchDescription([DeclareLaunchArgument('foo')]) action2 = IncludeLaunchDescription(LaunchDescriptionSource(ld2)) lc2 = LaunchContext() with pytest.raises(RuntimeError) as excinfo2: action2.visit(lc2) assert 'Included launch description missing required argument' in str( excinfo2) # test that a declared argument that is not provided raises an error, but with other args set ld2 = LaunchDescription([DeclareLaunchArgument('foo')]) action2 = IncludeLaunchDescription( LaunchDescriptionSource(ld2), launch_arguments={'not_foo': 'NOT_FOO'}.items(), ) lc2 = LaunchContext() with pytest.raises(RuntimeError) as excinfo2: action2.visit(lc2) assert 'Included launch description missing required argument' in str( excinfo2) assert 'not_foo' in str(excinfo2) # test that a declared argument with a default value that is not provided does not raise ld2 = LaunchDescription( [DeclareLaunchArgument('foo', default_value='FOO')]) action2 = IncludeLaunchDescription(LaunchDescriptionSource(ld2)) lc2 = LaunchContext() action2.visit(lc2)
def _perform_substitutions(self, context: LaunchContext) -> None: try: if self.__substitutions_performed: # This function may have already been called by a subclass' `execute`, for example. return self.__substitutions_performed = True if self.__node_name is not None: self.__expanded_node_name = perform_substitutions( context, normalize_to_list_of_substitutions(self.__node_name)) validate_node_name(self.__node_name) self.__expanded_node_name.lstrip('/') if self.__node_namespace is not None: self.__expanded_node_namespace = perform_substitutions( context, normalize_to_list_of_substitutions(self.__node_namespace)) if not self.__expanded_node_namespace.startswith('/'): self.__expanded_node_namespace = '/' + self.__expanded_node_namespace validate_namespace(self.__expanded_node_namespace) except Exception: print( "Error while expanding or validating node name or namespace for '{}':" .format( 'package={}, node_executable={}, name={}, namespace={}'. format( self.__package, self.__node_executable, self.__node_name, self.__node_namespace, ))) raise self.__final_node_name = '' if self.__expanded_node_namespace not in ['', '/']: self.__final_node_name += self.__expanded_node_namespace self.__final_node_name += '/' + self.__expanded_node_name # expand remappings too if self.__remappings is not None: self.__expanded_remappings = {} for k, v in self.__remappings: key = perform_substitutions( context, normalize_to_list_of_substitutions(k)) value = perform_substitutions( context, normalize_to_list_of_substitutions(v)) self.__expanded_remappings[key] = value
def perform_substitution_if_applicable(context, var): if isinstance(var, (int, float, str)): # No substitution necessary. return var if isinstance(var, Substitution): return perform_substitutions( context, normalize_to_list_of_substitutions(var)) if isinstance(var, tuple): try: return perform_substitutions( context, normalize_to_list_of_substitutions(var)) except TypeError: raise TypeError( 'Invalid element received in parameters dictionary ' '(not all tuple elements are Substitutions): {}'. format(var)) else: raise TypeError( 'Unsupported type received in parameters dictionary: {}' .format(type(var)))
def evaluate( self, context: LaunchContext) -> Tuple[Text, 'EvaluatedParameterValue']: """Evaluate and return parameter rule.""" if self.__evaluated_parameter_rule is not None: return self.__evaluated_parameter_rule name = perform_substitutions(context, self.name) value = self.__parameter_value.evaluate(context) self.__evaluated_parameter_name = name self.__evaluated_parameter_rule = (name, value) return (name, value)
def _load_node(self, composable_node_description: ComposableNode, context: LaunchContext) -> None: """ Load node synchronously. :param composable_node_description: description of composable node to be loaded :param context: current launch context """ while not self.__rclpy_load_node_client.wait_for_service( timeout_sec=1.0): if context.is_shutdown: self.__logger.warning( "Abandoning wait for the '{}' service, due to shutdown.". format(self.__rclpy_load_node_client.srv_name)) return request = composition_interfaces.srv.LoadNode.Request() request.package_name = perform_substitutions( context, composable_node_description.package) request.plugin_name = perform_substitutions( context, composable_node_description.node_plugin) if composable_node_description.node_name is not None: request.node_name = perform_substitutions( context, composable_node_description.node_name) if composable_node_description.node_namespace is not None: request.node_namespace = perform_substitutions( context, composable_node_description.node_namespace) # request.log_level = perform_substitutions(context, node_description.log_level) if composable_node_description.remappings is not None: for from_, to in composable_node_description.remappings: request.remap_rules.append('{}:={}'.format( perform_substitutions(context, list(from_)), perform_substitutions(context, list(to)), )) if composable_node_description.parameters is not None: request.parameters = [ param.to_parameter_msg() for param in to_parameters_list( context, evaluate_parameters( context, composable_node_description.parameters)) ] if composable_node_description.extra_arguments is not None: request.extra_arguments = [ param.to_parameter_msg() for param in to_parameters_list( context, evaluate_parameters( context, composable_node_description.extra_arguments)) ] response = self.__rclpy_load_node_client.call(request) if not response.success: self.__logger.error( "Failed to load node '{}' of type '{}' in container '{}': {}". format( response.full_node_name if response.full_node_name else request.node_name, request.plugin_name, self.__final_target_container_name, response.error_message)) self.__logger.info("Loaded node '{}' in container '{}'".format( response.full_node_name, self.__final_target_container_name))
def evaluate_parameter_dict( context: LaunchContext, parameters: ParametersDict ) -> Dict[str, EvaluatedParameterValue]: if not isinstance(parameters, Mapping): raise TypeError('expected dict') output_dict = {} # type: Dict[str, EvaluatedParameterValue] for name, value in parameters.items(): if not isinstance(name, tuple): raise TypeError('Expecting tuple of substitutions got {}'.format(repr(name))) evaluated_name = perform_substitutions(context, list(name)) # type: str evaluated_value = None # type: Optional[EvaluatedParameterValue] if isinstance(value, tuple) and len(value): if isinstance(value[0], Substitution): # Value is a list of substitutions, so perform them to make a string evaluated_value = perform_substitutions(context, list(value)) elif isinstance(value[0], Sequence): # Value is an array of a list of substitutions output_subvalue = [] # List[str] for subvalue in value: output_subvalue.append(perform_substitutions(context, list(subvalue))) evaluated_value = tuple(output_subvalue) else: # Value is an array of the same type, so nothing to evaluate. output_value = [] target_type = type(value[0]) for i, subvalue in enumerate(value): output_value.append(target_type(subvalue)) evaluated_value = tuple(output_value) else: # Value is a singular type, so nothing to evaluate ensure_argument_type(value, (float, int, str, bool, bytes), 'value') evaluated_value = cast(Union[float, int, str, bool, bytes], value) if evaluated_value is None: raise TypeError('given unnormalized parameters %r, %r' % (name, value)) output_dict[evaluated_name] = evaluated_value return output_dict
def test_log(): launch_context = LaunchContext() xml_file = \ """\ <launch> <log message="Hello world!" /> </launch> """ xml_file = textwrap.dedent(xml_file) root_entity, parser = Parser.load(io.StringIO(xml_file)) launch_description = parser.parse_description(root_entity) log_info = launch_description.entities[0] assert isinstance(log_info, LogInfo) assert perform_substitutions(launch_context, log_info.msg) == 'Hello world!'
def execute(self, context: LaunchContext): """Execute the action.""" pushed_namespace = perform_substitutions(context, self.namespace) previous_namespace = context.launch_configurations.get( 'ros_namespace', None) namespace = make_namespace_absolute( prefix_namespace(previous_namespace, pushed_namespace)) try: validate_namespace(namespace) except Exception: raise SubstitutionFailure( 'The resulting namespace is invalid:' " [previous_namespace='{}', pushed_namespace='{}']".format( previous_namespace, pushed_namespace)) context.launch_configurations['ros_namespace'] = namespace
def robot_state_publisher( context: LaunchContext, **substitutions: launch.substitutions.LaunchConfiguration ) -> List[Node]: kwargs = { k: perform_substitutions(context, [v]) for k, v in substitutions.items() } params = {'robot_description': urdf(**kwargs)} with open('test.urdf', 'w+') as f: f.write(params['robot_description']) node = Node(package='robot_state_publisher', node_executable='robot_state_publisher', node_name='robot_state_publisher', parameters=[params], output='screen') return [node]
def execute(self, context: LaunchContext): """Execute the action.""" pushed_namespace = perform_substitutions(context, self.namespace) previous_namespace = context.launch_configurations.get( 'ros_namespace', '') namespace = pushed_namespace if not pushed_namespace.startswith('/'): namespace = previous_namespace + '/' + pushed_namespace namespace = namespace.rstrip('/') if namespace != '': try: validate_namespace(namespace) except Exception: raise SubstitutionFailure( 'The resulting namespace is invalid:' " [previous_namespace='{}', pushed_namespace='{}']".format( previous_namespace, pushed_namespace)) context.launch_configurations['ros_namespace'] = namespace
def get_composable_node_load_request( composable_node_description: ComposableNode, context: LaunchContext ): """Get the request that will be send to the composable node container.""" request = composition_interfaces.srv.LoadNode.Request() request.package_name = perform_substitutions( context, composable_node_description.package ) request.plugin_name = perform_substitutions( context, composable_node_description.node_plugin ) if composable_node_description.node_name is not None: request.node_name = perform_substitutions( context, composable_node_description.node_name ) if composable_node_description.node_namespace is not None: request.node_namespace = perform_substitutions( context, composable_node_description.node_namespace ) # request.log_level = perform_substitutions(context, node_description.log_level) if composable_node_description.remappings is not None: for from_, to in composable_node_description.remappings: request.remap_rules.append('{}:={}'.format( perform_substitutions(context, list(from_)), perform_substitutions(context, list(to)), )) global_params = context.launch_configurations.get('ros_params', None) parameters = [] if global_params is not None: parameters.append(normalize_parameter_dict(global_params)) if composable_node_description.parameters is not None: parameters.extend(list(composable_node_description.parameters)) if parameters: request.parameters = [ param.to_parameter_msg() for param in to_parameters_list( context, evaluate_parameters( context, parameters ) ) ] if composable_node_description.extra_arguments is not None: request.extra_arguments = [ param.to_parameter_msg() for param in to_parameters_list( context, evaluate_parameters( context, composable_node_description.extra_arguments ) ) ] return request
def test_parameter_file_description(original_contents, expected_contents, allow_substs): lc = MockContext() with get_parameter_file(original_contents) as file_name: desc = ParameterFile(file_name, allow_substs=allow_substs) if isinstance(desc.param_file, list): assert perform_substitutions(lc, desc.param_file) == file_name else: assert desc.param_file == file_name assert desc.allow_substs == allow_substs evaluated_param_file = desc.evaluate(lc) with open(evaluated_param_file, 'r') as new_f: new_f.read() == expected_contents assert desc.param_file == evaluated_param_file if not allow_substs: assert os.fspath(desc.param_file) == os.fspath(file_name) param_file = desc.param_file desc.cleanup() if allow_substs: assert not param_file.exists() assert isinstance(desc.param_file, list) else: assert param_file.exists() assert os.fspath(desc.param_file) == os.fspath(file_name)
def execute(self, context: LaunchContext): continue_after_fail = self.__continue_after_fail if not isinstance(continue_after_fail, bool): continue_after_fail = perform_substitutions( context, normalize_to_list_of_substitutions( continue_after_fail)).lower() if continue_after_fail in ['true', 'on', '1']: continue_after_fail = True elif continue_after_fail in ['false', 'off', '1']: continue_after_fail = False else: raise ValueError( 'continue_after_fail should be a boolean, got {}'.format( continue_after_fail)) on_first_action_exited = OnProcessExit( target_action=self.__actions[0], on_exit=lambda event, context: ([InOrderGroup(self.__actions[1:])] if event.exitcode == 0 or continue_after_fail else [])) return [ self.__actions[0], RegisterEventHandler(on_first_action_exited) ]