Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion launch_ros/launch_ros/actions/load_composable_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from launch_ros.parameter_descriptions import ParameterFile

import lifecycle_msgs.msg
import rclpy.logging

from .composable_node_container import ComposableNodeContainer
from .lifecycle_transition import LifecycleTransition
Expand Down Expand Up @@ -321,7 +322,6 @@ def get_composable_node_load_request(
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:
Expand Down Expand Up @@ -366,4 +366,18 @@ def get_composable_node_load_request(
)
)
]

if composable_node_description.log_level is not None:
log_level_str = perform_substitutions(
context, composable_node_description.log_level)
try:
request.log_level = int(
rclpy.logging.get_logging_severity_from_string(log_level_str))
except Exception:
raise RuntimeError(
f"Invalid log_level '{log_level_str}' for node "
f"'{request.node_name}'. Valid values are: "
"DEBUG, INFO, WARN, ERROR, FATAL (case-insensitive)."
)

return request
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,8 @@ def remappings(self) -> Optional[RemapRules]:
def extra_arguments(self) -> Optional[Parameters]:
"""Get container extra arguments YAML files or dicts with substitutions to be performed."""
return super().extra_arguments

@property
def log_level(self) -> Optional[List[Substitution]]:
"""Get log level as a sequence of substitutions to be performed."""
return super().log_level
15 changes: 15 additions & 0 deletions launch_ros/launch_ros/descriptions/composable_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def __init__(
remappings: Optional[SomeRemapRules] = None,
extra_arguments: Optional[SomeParameters] = None,
condition: Optional[Condition] = None,
log_level: Optional[SomeSubstitutionsType] = None,
) -> None:
"""
Initialize a ComposableNode description.
Expand All @@ -57,6 +58,7 @@ def __init__(
:param remappings: list of from/to pairs for remapping names
:param extra_arguments: container specific arguments to be passed to the loaded node
:param condition: action will be executed if the condition evaluates to true
:param log_level: log level for the node (e.g. 'debug', 'info', 'warn', 'error', 'fatal')
"""
self.__package = normalize_to_list_of_substitutions(package)
self.__node_plugin = normalize_to_list_of_substitutions(plugin)
Expand All @@ -83,6 +85,10 @@ def __init__(

self.__condition = condition

self.__log_level = None # type: Optional[List[Substitution]]
if log_level is not None:
self.__log_level = normalize_to_list_of_substitutions(log_level)

@classmethod
def parse(cls, parser: Parser, entity: Entity):
"""Parse composable_node."""
Expand Down Expand Up @@ -138,6 +144,10 @@ def parse(cls, parser: Parser, entity: Entity):
for extra_arg in extra_arguments:
extra_arg.assert_entity_completely_parsed()

log_level = entity.get_attr('log_level', optional=True)
if log_level is not None:
kwargs['log_level'] = parser.parse_substitution(log_level)

return cls, kwargs

@property
Expand Down Expand Up @@ -178,3 +188,8 @@ def extra_arguments(self) -> Optional[Parameters]:
def condition(self) -> Optional[Condition]:
"""Getter for condition."""
return self.__condition

@property
def log_level(self) -> Optional[List[Substitution]]:
"""Get log level as a sequence of substitutions to be performed."""
return self.__log_level
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import rclpy
import rclpy.context
import rclpy.executors
import rclpy.logging
import rclpy.node

TEST_CONTAINER_NAME = 'mock_component_container'
Expand Down Expand Up @@ -84,6 +85,7 @@ def _load_composable_node(
condition=None,
parameters=None,
remappings=None,
log_level=None,
target_container=f'/{TEST_CONTAINER_NAME}'
):
return LoadComposableNodes(
Expand All @@ -97,6 +99,7 @@ def _load_composable_node(
namespace=namespace,
parameters=parameters,
remappings=remappings,
log_level=log_level,
)
])

Expand Down Expand Up @@ -654,3 +657,59 @@ def test_load_node_with_condition_in_group(mock_component_container):
assert len(request.remap_rules) == 0
assert len(request.parameters) == 0
assert len(request.extra_arguments) == 0


def test_load_node_without_log_level(mock_component_container):
"""Test that log_level defaults to 0 (UNSET) when not specified."""
_assert_launch_no_errors([
_load_composable_node(
package='foo_package',
plugin='bar_plugin',
name='test_node_name',
)
])

assert len(mock_component_container.requests) == 1
request = mock_component_container.requests[0]
assert request.log_level == 0


@pytest.mark.parametrize('log_level_str,expected', [
('DEBUG', rclpy.logging.LoggingSeverity.DEBUG),
('debug', rclpy.logging.LoggingSeverity.DEBUG),
('INFO', rclpy.logging.LoggingSeverity.INFO),
('info', rclpy.logging.LoggingSeverity.INFO),
('WARN', rclpy.logging.LoggingSeverity.WARN),
('warn', rclpy.logging.LoggingSeverity.WARN),
('ERROR', rclpy.logging.LoggingSeverity.ERROR),
('error', rclpy.logging.LoggingSeverity.ERROR),
('FATAL', rclpy.logging.LoggingSeverity.FATAL),
('fatal', rclpy.logging.LoggingSeverity.FATAL),
])
def test_load_node_with_log_level(mock_component_container, log_level_str, expected):
"""Test that log_level is correctly mapped for all valid values."""
_assert_launch_no_errors([
_load_composable_node(
package='foo_package',
plugin='bar_plugin',
name='test_node_name',
log_level=log_level_str,
)
])

assert len(mock_component_container.requests) == 1
request = mock_component_container.requests[0]
assert request.log_level == int(expected)


def test_load_node_with_invalid_log_level(mock_component_container):
"""Test that an invalid log_level raises an exception."""
with pytest.raises(RuntimeError):
_assert_launch_no_errors([
_load_composable_node(
package='foo_package',
plugin='bar_plugin',
name='test_node_name',
log_level='INVALID_LEVEL',
)
])