diff --git a/launch/launch/actions/__init__.py b/launch/launch/actions/__init__.py index f60fdc4b8..2a97c8a8f 100644 --- a/launch/launch/actions/__init__.py +++ b/launch/launch/actions/__init__.py @@ -23,6 +23,7 @@ from .for_loop import ForLoop from .group_action import GroupAction from .include_launch_description import IncludeLaunchDescription +from .include_scoped_launch_description import ScopedIncludeLaunchDescription from .log import Log from .log import LogDebug from .log import LogError @@ -35,13 +36,16 @@ from .push_environment import PushEnvironment from .push_launch_configurations import PushLaunchConfigurations from .register_event_handler import RegisterEventHandler +from .register_global_launch_configuration import RegisterGlobalLaunchConfiguration from .reset_environment import ResetEnvironment from .reset_launch_configurations import ResetLaunchConfigurations from .set_environment_variable import SetEnvironmentVariable +from .set_global_launch_configuration import SetGlobalLaunchConfiguration from .set_launch_configuration import SetLaunchConfiguration from .shutdown_action import Shutdown from .timer_action import TimerAction from .unregister_event_handler import UnregisterEventHandler +from .unregister_global_launch_configuration import UnregisterGlobalLaunchConfiguration from .unset_environment_variable import UnsetEnvironmentVariable from .unset_launch_configuration import UnsetLaunchConfiguration @@ -66,14 +70,18 @@ 'PopLaunchConfigurations', 'PushEnvironment', 'PushLaunchConfigurations', + 'RegisterEventHandler', + 'RegisterGlobalLaunchConfiguration', 'ResetEnvironment', 'ResetLaunchConfigurations', - 'RegisterEventHandler', + 'ScopedIncludeLaunchDescription', 'SetEnvironmentVariable', + 'SetGlobalLaunchConfiguration', 'SetLaunchConfiguration', 'Shutdown', 'TimerAction', 'UnregisterEventHandler', + 'UnregisterGlobalLaunchConfiguration', 'UnsetEnvironmentVariable', 'UnsetLaunchConfiguration', ] diff --git a/launch/launch/actions/include_scoped_launch_description.py b/launch/launch/actions/include_scoped_launch_description.py new file mode 100644 index 000000000..defc5a705 --- /dev/null +++ b/launch/launch/actions/include_scoped_launch_description.py @@ -0,0 +1,101 @@ +# Copyright 2025 Open Source Robotics Foundation, Inc. +# +# 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. + +"""Module for the ScopedIncludeLaunchDescription action.""" + +# from typing import override # Available starting from Python3.12 +from typing import Dict +from typing import List +from typing import Text + +from .include_launch_description import IncludeLaunchDescription +from .pop_environment import PopEnvironment +from .pop_launch_configurations import PopLaunchConfigurations +from .push_environment import PushEnvironment +from .push_launch_configurations import PushLaunchConfigurations +from .reset_environment import ResetEnvironment +from .reset_launch_configurations import ResetLaunchConfigurations +from .set_launch_configuration import SetLaunchConfiguration +from ..frontend import expose_action +from ..launch_context import LaunchContext +from ..launch_description_entity import LaunchDescriptionEntity +from ..some_substitutions_type import SomeSubstitutionsType +from ..utilities import normalize_to_list_of_substitutions +from ..utilities import perform_substitutions + + +@expose_action('scoped_include') +class ScopedIncludeLaunchDescription(IncludeLaunchDescription): + # TODO(SuperJappie08) Propper Documentation + + # NOTE(SuperJappie08) __init__ is not required since the function signature will be the same + # However maybe it is interresting for documentation purposes + + def get_sub_entities(self): + """Get subentities.""" + # ret = super().get_sub_entities() + # TODO(SuperJappie08)? Do these internals need to be hidden? + # print(self.launch_arguments) + return [ + PushLaunchConfigurations(), + PushEnvironment(), + ResetEnvironment(), + # NOTE(SuperJappie08) Need weird remap, since AnySubstitution type can be a List which + # is not Hashable. + # ResetLaunchConfigurations({k: v for k, v in self.launch_arguments}), + # *ret, + ResetLaunchConfigurations(), + *[SetLaunchConfiguration(k, v) for k, v in self.launch_arguments], + *super().get_sub_entities(), + PopEnvironment(), + PopLaunchConfigurations(), + ] + + def execute(self, context: LaunchContext) -> List[LaunchDescriptionEntity]: + """Execute the action.""" + evaluated_configurations: Dict[SomeSubstitutionsType, SomeSubstitutionsType] = {} + + for name in getattr(context.locals, 'globals', set()): + expanded_name = perform_substitutions( + context, + normalize_to_list_of_substitutions(name) + ) + + if expanded_name in context.launch_configurations: + evaluated_configurations[expanded_name] = \ + context.launch_configurations[expanded_name] + + for k, v in self.launch_arguments: + # Perform substitutions, since the required launch configurations might not be + # available in the inner scope. + evaluated_k = perform_substitutions(context, normalize_to_list_of_substitutions(k)) + evaluated_v = perform_substitutions(context, normalize_to_list_of_substitutions(v)) + evaluated_configurations[evaluated_k] = evaluated_v + + return [ + PushLaunchConfigurations(), + PushEnvironment(), + ResetEnvironment(), + ResetLaunchConfigurations(evaluated_configurations), + IncludeLaunchDescription( + launch_description_source=self.launch_description_source, + launch_arguments=self.launch_arguments, + condition=self.condition), + PopEnvironment(), + PopLaunchConfigurations() + ] + + def __repr__(self) -> Text: + """Return a description of this ScopedIncludeLaunchDescription as a string.""" + return f'ScopedIncludeLaunchDescription({self.launch_description_source.location})' diff --git a/launch/launch/actions/register_global_launch_configuration.py b/launch/launch/actions/register_global_launch_configuration.py new file mode 100644 index 000000000..15247b629 --- /dev/null +++ b/launch/launch/actions/register_global_launch_configuration.py @@ -0,0 +1,59 @@ +# Copyright 2025 Open Source Robotics Foundation, Inc. +# +# 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. + +"""Module for the RegisterGlobalLaunchConfiguration.""" + +from typing import List + +from ..action import Action +from ..frontend import Entity +from ..frontend import expose_action +from ..frontend import Parser +from ..launch_context import LaunchContext +from ..some_substitutions_type import SomeSubstitutionsType +from ..substitution import Substitution +from ..utilities import normalize_to_list_of_substitutions +from ..utilities import register_global + + +@expose_action('register-global') +@expose_action('register_global') +class RegisterGlobalLaunchConfiguration(Action): + """ + Action that add a launch configuration to the globals set by name. + + This allows the configuration to be overscoped when including a scoped launch description. + """ + + def __init__(self, name: SomeSubstitutionsType, **kwargs) -> None: + """Create a RegisterGlobalLaunchConfiguration action.""" + super().__init__(**kwargs) + self.__name = normalize_to_list_of_substitutions(name) + + @classmethod + def parse(cls, entity: Entity, parser: Parser): + """Return `RegisterGlobalLaunchConfiguration` action and kwargs for constructing it.""" + name = parser.parse_substitution(entity.get_attr('name')) + _, kwargs = super().parse(entity, parser) + kwargs['name'] = name + return cls, kwargs + + @property + def name(self) -> List[Substitution]: + """Getter for self.__name.""" + return self.__name + + def execute(self, context: LaunchContext): + """Execute the action.""" + register_global(context, self.name) diff --git a/launch/launch/actions/set_global_launch_configuration.py b/launch/launch/actions/set_global_launch_configuration.py new file mode 100644 index 000000000..00966eba9 --- /dev/null +++ b/launch/launch/actions/set_global_launch_configuration.py @@ -0,0 +1,47 @@ +# Copyright 2025 Open Source Robotics Foundation, Inc. +# +# 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. + +"""Module for the SetGlobalLaunchConfiguration action.""" + +from .set_launch_configuration import SetLaunchConfiguration +from ..frontend import expose_action +from ..launch_context import LaunchContext +from ..utilities import register_global + + +@expose_action('global') +class SetGlobalLaunchConfiguration(SetLaunchConfiguration): + """ + Action that sets a global launch configuration by name. + + Launch configurations can be accessed by the LaunchConfiguration + substitution and are accessible after being set, even in included + LaunchDescription's, but can be scoped with groups. + + A global launch configuration is registered in the globals group + to allow to in scoped included launch descriptions. + """ + + def __init__( + self, + **kwargs + ) -> None: + """Create a SetGlobalLaunchConfiguration action.""" + super().__init__(**kwargs) + + def execute(self, context: LaunchContext): + """Execute the action.""" + register_global(context, self.name) + + super().execute(context) diff --git a/launch/launch/actions/unregister_global_launch_configuration.py b/launch/launch/actions/unregister_global_launch_configuration.py new file mode 100644 index 000000000..8e6bc9f80 --- /dev/null +++ b/launch/launch/actions/unregister_global_launch_configuration.py @@ -0,0 +1,55 @@ +# Copyright 2025 Open Source Robotics Foundation, Inc. +# +# 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. + +"""Module for the UnregisterGlobalLaunchConfiguration.""" + +from typing import List + +from ..action import Action +from ..frontend import Entity +from ..frontend import expose_action +from ..frontend import Parser +from ..launch_context import LaunchContext +from ..some_substitutions_type import SomeSubstitutionsType +from ..substitution import Substitution +from ..utilities import normalize_to_list_of_substitutions +from ..utilities import unregister_global + + +@expose_action('unregister-global') +@expose_action('unregister_global') +class UnregisterGlobalLaunchConfiguration(Action): + """Action that add a launch configuration to the globals set by name.""" + + def __init__(self, name: SomeSubstitutionsType, **kwargs) -> None: + """Create a UnregisterGlobalLaunchConfiguration action.""" + super().__init__(**kwargs) + self.__name = normalize_to_list_of_substitutions(name) + + @classmethod + def parse(cls, entity: Entity, parser: Parser): + """Return `UnregisterGlobalLaunchConfiguration` action and kwargs for constructing it.""" + name = parser.parse_substitution(entity.get_attr('name')) + _, kwargs = super().parse(entity, parser) + kwargs['name'] = name + return cls, kwargs + + @property + def name(self) -> List[Substitution]: + """Getter for self.__name.""" + return self.__name + + def execute(self, context: LaunchContext): + """Execute the action.""" + unregister_global(context, self.name) diff --git a/launch/launch/utilities/__init__.py b/launch/launch/utilities/__init__.py index 7af96ef36..6dd23826b 100644 --- a/launch/launch/utilities/__init__.py +++ b/launch/launch/utilities/__init__.py @@ -16,6 +16,7 @@ from .class_tools_impl import is_a, is_a_subclass, isclassinstance from .ensure_argument_type_impl import ensure_argument_type +from .global_launch_configurations import register_global, unregister_global from .normalize_to_list_of_entities_impl import normalize_to_list_of_entities from .normalize_to_list_of_substitutions_impl import normalize_to_list_of_substitutions from .perform_substitutions_impl import perform_substitutions @@ -32,4 +33,6 @@ 'normalize_to_list_of_substitutions', 'normalize_to_list_of_entities', 'visit_all_entities_and_collect_futures', + 'register_global', + 'unregister_global' ] diff --git a/launch/launch/utilities/global_launch_configurations.py b/launch/launch/utilities/global_launch_configurations.py new file mode 100644 index 000000000..f8ce707f2 --- /dev/null +++ b/launch/launch/utilities/global_launch_configurations.py @@ -0,0 +1,39 @@ +# Copyright 2025 Open Source Robotics Foundation, Inc. +# +# 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 typing import Set +from typing import Text + +from .normalize_to_list_of_substitutions_impl import normalize_to_list_of_substitutions +from .perform_substitutions_impl import perform_substitutions +from ..launch_context import LaunchContext +from ..some_substitutions_type import SomeSubstitutionsType + + +def register_global(context: LaunchContext, name: SomeSubstitutionsType) -> None: + """ + Register a launch configuration to the globals set. + + This allows it to be accessible in when scoped including a launch description. + """ + globals_set: Set[Text] = getattr(context.locals, 'globals', {'globals'}) + globals_set.add(perform_substitutions(context, normalize_to_list_of_substitutions(name))) + context.extend_locals({'globals': globals_set}) + + +def unregister_global(context: LaunchContext, name: SomeSubstitutionsType) -> None: + """Unregisters a launch configuration to the globals set.""" + globals_set: Set[Text] = getattr(context.locals, 'globals', {'globals'}) + globals_set.discard(perform_substitutions(context, normalize_to_list_of_substitutions(name))) + context.extend_locals({'globals': globals_set}) diff --git a/launch/test/launch/actions/test_include_scoped_launch_description.py b/launch/test/launch/actions/test_include_scoped_launch_description.py new file mode 100644 index 000000000..07df21f35 --- /dev/null +++ b/launch/test/launch/actions/test_include_scoped_launch_description.py @@ -0,0 +1,141 @@ +# Copyright 2025 Open Source Robotics Foundation, Inc. +# +# 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. + +"""Tests for the ScopedIncludeLaunchDescription action class.""" + +import os + +from launch import LaunchContext +from launch import LaunchDescription +from launch import LaunchDescriptionSource +from launch.actions import DeclareLaunchArgument +from launch.actions import IncludeLaunchDescription +from launch.actions import PopEnvironment +from launch.actions import PopLaunchConfigurations +from launch.actions import PushEnvironment +from launch.actions import PushLaunchConfigurations +from launch.actions import ResetEnvironment +from launch.actions import ResetLaunchConfigurations +from launch.actions import ScopedIncludeLaunchDescription + +import pytest + + +def test_include_launch_description_constructors(): + """Test the constructors for ScopedIncludeLaunchDescription class.""" + ScopedIncludeLaunchDescription(LaunchDescriptionSource(LaunchDescription())) + ScopedIncludeLaunchDescription( + LaunchDescriptionSource(LaunchDescription()), + launch_arguments={'foo': 'FOO'}.items()) + + +# TODO(SuperJappie08) Is this test necessary, my idea was check if filelocation does not leak +@pytest.mark.skip(reason='Not finished') +def test_scoped_include_launch_description_launch_file_location(): + """Test the ability of ScopedIncludeLaunchDescription to set the launch file location.""" + ld = LaunchDescription() + action = ScopedIncludeLaunchDescription(LaunchDescriptionSource(ld, '