Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add 'handle_once' property for unregistering an EventHandler after one event #141

Merged
merged 3 commits into from
Sep 25, 2018

Conversation

jacobperron
Copy link
Member

  • Add 'handle_once' property to EventHandler
    This is useful for implementing "one shot" events.
    The property can be set via the constructor or setter.
    If 'handle_once' is set to True, then the EventHandler unregisters itself from the context after the first time it is handled.
    Tests included.

  • Add 'handle_once' option to RegisterEventHandler
    This allows the user to configure "one shot" events via launch actions.

Closes #111.


Motivated by #90, now the snippet can be modified so that if the talker cycles between "inactive" and "active" states, we avoid multiple listeners from starting:

from launch import LaunchDescription, LaunchIntrospector
import launch
import launch.actions
import launch.events
import launch_ros.actions
import launch_ros.events
import launch_ros.events.lifecycle

import lifecycle_msgs.msg

def generate_launch_description():
    lifecycle_talker = launch_ros.actions.LifecycleNode(
        node_name = 'talker', package='lifecycle', node_executable='lifecycle_talker', output='screen')

    lifecycle_listener = launch_ros.actions.LifecycleNode(
        node_name='listener', package='lifecycle', node_executable='lifecycle_listener', output='screen')

    # When the talker reaches the 'inactive' state, make it take the 'activate' transition
    register_event_handler_for_talker_reaches_inactive_state = launch.actions.RegisterEventHandler(
        launch_ros.event_handlers.OnStateTransition(
            target_lifecycle_node=lifecycle_talker, goal_state='inactive',
            entities=[
                launch.actions.LogInfo(
                    msg="node 'talker' reached the 'inactive' state, 'activating'."),
                launch.actions.EmitEvent(event=launch_ros.events.lifecycle.ChangeState(
                    lifecycle_node_matcher=launch.events.process.matches_action(lifecycle_talker),
                    transition_id=lifecycle_msgs.msg.Transition.TRANSITION_ACTIVATE,
                )),
            ],
        )
    )

    # When the talker node reaches the 'active' state, log a message and start the listener node.
    register_event_handler_for_talker_reaches_active_state = launch.actions.RegisterEventHandler(
        launch_ros.event_handlers.OnStateTransition(
            target_lifecycle_node=lifecycle_talker, goal_state='active',
            entities=[
                launch.actions.LogInfo(
                    msg="node 'talker' reached the 'active' state, launching 'listener'."),
                lifecycle_listener,
            ],
        ),
        handle_once=True
    )

    # Make the talker node take the 'configure' transition.
    emit_event_to_request_that_talker_does_configure_transition = launch.actions.EmitEvent(
        event=launch_ros.events.lifecycle.ChangeState(
            lifecycle_node_matcher=launch.events.process.matches_action(lifecycle_talker),
            transition_id=lifecycle_msgs.msg.Transition.TRANSITION_CONFIGURE,
        )
    )

    ld =  LaunchDescription([
        register_event_handler_for_talker_reaches_inactive_state,
        register_event_handler_for_talker_reaches_active_state,
        lifecycle_talker,
        emit_event_to_request_that_talker_does_configure_transition
    ])

    print(LaunchIntrospector().format_launch_description(ld))

    """Launch a talker and a listener."""
    return ld

For example, the talker can be cycled between states with ros2 lifecycle set /talker deactivate.

Here's the diff from the original (launch introspection also added):

@@ -1,4 +1,4 @@
-from launch import LaunchDescription
+from launch import LaunchDescription, LaunchIntrospector
 import launch
 import launch.actions
 import launch.events
@@ -39,7 +39,8 @@ def generate_launch_description():
                     msg="node 'talker' reached the 'active' state, launching 'listener'."),
                 lifecycle_listener,
             ],
-        )
+        ),
+        handle_once=True
     )
 
     # Make the talker node take the 'configure' transition.
@@ -49,10 +50,15 @@ def generate_launch_description():
             transition_id=lifecycle_msgs.msg.Transition.TRANSITION_CONFIGURE,
         )
     )
-    """Launch a talker and a listener."""
-    return LaunchDescription([
+
+    ld =  LaunchDescription([
         register_event_handler_for_talker_reaches_inactive_state,
         register_event_handler_for_talker_reaches_active_state,
         lifecycle_talker,
         emit_event_to_request_that_talker_does_configure_transition
     ])
+
+    print(LaunchIntrospector().format_launch_description(ld))
+
+    """Launch a talker and a listener."""
+    return ld

Introspection output:

<launch.launch_description.LaunchDescription object at 0x7f6d280f4cf8>
├── RegisterEventHandler('<launch_ros.event_handlers.on_state_transition.OnStateTransition object at 0x7f6d280f4b38>'):
│   ├── OnStateTransition(matcher='event == StateTransition and event.action == LifecycleNode(0x7f6d280f4668)', handler=<actions>)
│       ├── LogInfo('node 'talker' reached the 'inactive' state, 'activating'.')
│       └── EmitEvent(event='launch_ros.events.lifecycle.ChangeState')
├── RegisterEventHandler('<launch_ros.event_handlers.on_state_transition.OnStateTransition object at 0x7f6d280f4c18>'):
│   ├── OnStateTransition(matcher='event == StateTransition and event.action == LifecycleNode(0x7f6d280f4668)', handler=<actions>)
│       ├── LogInfo('node 'talker' reached the 'active' state, launching 'listener'.')
│       └── ExecuteProcess(cmd=[ExecInPkg(pkg='lifecycle', exec='lifecycle_listener'), LocalVar('node name')], cwd=None, env=None, shell=False)
├── ExecuteProcess(cmd=[ExecInPkg(pkg='lifecycle', exec='lifecycle_talker'), LocalVar('node name')], cwd=None, env=None, shell=False)
└── EmitEvent(event='launch_ros.events.lifecycle.ChangeState')

Maybe it's worth looking into communicating the "handle once" aspect via the introspector somehow...

@jacobperron jacobperron added the in progress Actively being worked on (Kanban column) label Sep 21, 2018
@jacobperron jacobperron self-assigned this Sep 21, 2018
@jacobperron
Copy link
Member Author

  • Linux Build Status
  • Linux-aarch64 Build Status
  • macOS Build Status
  • Windows Build Status

@jacobperron jacobperron added in review Waiting for review (Kanban column) and removed in progress Actively being worked on (Kanban column) labels Sep 21, 2018
Copy link
Member

@dhood dhood left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what I'm not sure about with this approach is that it mutates the event handler that's passed in. If it's reasonable that someone might want to declare and EventHandler once and then register it multiple times in different situations, then I think a different approach is needed, because this would be surprising to users:

my_event_handler = EventHandler(..., handle_once=False)

RegisterEventHandler(my_event_handler, handle_once=True)

...

RegisterEventHandler(my_event_handler)  # now handle_once is True for my_event_handler

I am not 100% sure that event handlers are supposed to be reused like that, so this might be a non-issue (I'll let @wjwwood weigh in).

Perhaps what we're looking for is that subclasses on EventHandlers also have the handle_once option. That way, rather than passing handle_once to RegisterEventHandler, we would pass it to the event handler itself, e.g.:

    # When the talker node reaches the 'active' state, log a message and start the listener node.
    register_event_handler_for_talker_reaches_active_state = launch.actions.RegisterEventHandler(
        launch_ros.event_handlers.OnStateTransition(
            target_lifecycle_node=lifecycle_talker, goal_state='active',
            entities=[
                launch.actions.LogInfo(
                    msg="node 'talker' reached the 'active' state, launching 'listener'."),
                lifecycle_listener,
            ],
            handle_once=True
        ),
        # handle_once=True
    )

To accomplish that we would want to add kwargs to the constructor of OnStateTransition and then pass them through to its parent's constructor, as is done for NodeAction.

However, this doesn't seem to be what @wjwwood had in mind when writing #111 (it mentions adding the API to RegisterEventHandler, as you've done), so it might not be the direction to go in. I am trying to get some ideas on paper to help when @wjwwood gets a chance to look at this.

I've added a few review comments; depending on the direction some may not be relevant/worth fixing.

if isinstance(value, bool):
self.__handle_once = value
else:
raise TypeError(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as a general-purpose review comment, we try to avoid nesting where possible by returning early.
This could be restructured to avoid nesting by:

if not isinstance(value, bool):
  raise TypeError(...)
self.__handle_once = value

@@ -32,10 +32,11 @@ class RegisterEventHandler(Action):
place.
"""

def __init__(self, event_handler: EventHandler, **kwargs) -> None:
def __init__(self, event_handler: EventHandler, handle_once: bool = False, **kwargs) -> None:
Copy link
Member

@dhood dhood Sep 21, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to force that the caller specifies the handle_once argument by name, please add *, before it (here's an example:

def __init__(self, *, node_name: Text, **kwargs) -> None:

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@@ -32,10 +32,11 @@ class RegisterEventHandler(Action):
place.
"""

def __init__(self, event_handler: EventHandler, **kwargs) -> None:
def __init__(self, event_handler: EventHandler, handle_once: bool = False, **kwargs) -> None:
"""Constructor."""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

be good to add docs for handle_once here also

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@jacobperron
Copy link
Member Author

@dhood Thanks for the review 👍

Letting subclasses use the handle_once seems more elegant; I can't think of a reason against it. I will wait for additional input from @wjwwood before further changes.

@wjwwood
Copy link
Member

wjwwood commented Sep 21, 2018

Definitely agree that the kwargs should be passed to the base class from the subclasses of EventHandler, as @dhood suggested and as she pointed out is done by the Node action.

I'll review the code now.

Copy link
Member

@wjwwood wjwwood left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

w.r.t.:

what I'm not sure about with this approach is that it mutates the event handler that's passed in. If it's reasonable that someone might want to declare and EventHandler once and then register it multiple times in different situations, then I think a different approach is needed, because this would be surprising to users:

I think that this might be something people want to do, but most classes at the moment aren't written to handle this reuse pattern. There's this issue #113 which touches on the problem w.r.t. Actions and I think could be extended to EventHandlers as well.

I agree that @dhood's example where they set handle_once on the event handler to False and then set it to True when registering it might lead to confusion. And, I do think the option needs to be part of the event handler itself rather than the register event handler action instance or the context.

Therefore, maybe we should just remove the option from the RegisterEventHandler action and only allow it to be set in the EventHandler instance itself. What do you guys think?

Re: introspection, I think we should just modify the string that describes an event handler to include whether or not handle_once is true.

@@ -32,10 +32,11 @@ class RegisterEventHandler(Action):
place.
"""

def __init__(self, event_handler: EventHandler, **kwargs) -> None:
def __init__(self, event_handler: EventHandler, handle_once: bool = False, **kwargs) -> None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@@ -32,10 +32,11 @@ class RegisterEventHandler(Action):
place.
"""

def __init__(self, event_handler: EventHandler, **kwargs) -> None:
def __init__(self, event_handler: EventHandler, handle_once: bool = False, **kwargs) -> None:
"""Constructor."""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@jacobperron
Copy link
Member Author

... maybe we should just remove the option from the RegisterEventHandler action and only allow it to be set in the EventHandler instance itself. What do you guys think?

👍

I can roll back one commit and update as per the review comments.

This is useful for implementing "one shot" events.
The property can be set via the constructor or setter.
If 'handle_once' is set to True, then the EventHandler unregisters itself from the context after the first time it is handled.
All subclasses of EventHandler pass it kwargs so they can use the new property.
Tests included.
@jacobperron
Copy link
Member Author

I've addressed the comments. Now the handle_once option only exists for EventHandler and its children. There is no common description for EventHandler (which would be convenient for describing handle_once):

# TODO(wjwwood): setup standard interface for describing event handlers

Rather than adding to the description of every child of EventHandler, I think it should be added to a common interface. I can look into the common interface as part of this PR, or leave it for #103.

@wjwwood
Copy link
Member

wjwwood commented Sep 22, 2018

Common interface sounds good to me.

A common pattern for describing event handlers has been moved to the parent class.
Subclasses should implement the 'handler_description' and 'matcher_description' properties rather than implementing a describe method.
@jacobperron
Copy link
Member Author

I've implemented a common interface for event handlers (which includes the handle_once description). But, left a TODO for properly describing actions passed to OnProcessExit:

# TODO(jacobperron): revisit how to describe known actions that are passed in.
# It would be nice if the parent class could output their desciption
# via the 'entities' property.
if self.__actions_on_exit:
return '<actions>'
return '{}'.format(self.__on_exit)

One solution that comes to mind is to add an overridable method/property to EventHandler for getting additional entities just for introspection purposes, but this somewhat unsatisfying since it makes the executed entities decoupled from the description.

Alternatively, we could just leave in the descibe() method overriden in OnProcessExit.

Ideas?

@jacobperron
Copy link
Member Author

  • Linux Build Status
  • Linux-aarch64 Build Status
  • macOS Build Status
  • Windows Build Status

@@ -59,6 +62,8 @@ def __init__(
@property
def entities(self):
"""Getter for entities."""
# if self.__entities is None:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll remove this.


@handle_once.setter
def handle_once(self, value):
"""Setter for handle_once flag."""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be wise to remove the public setter so that users can't get into strange situations by modifying the flag. FWICT it'd be sufficient to just set it in the constructor now. If a need comes up later, we can consider re-adding it to the API (it's harder though to remove API).

@jacobperron
Copy link
Member Author

@wjwwood, as per our offline discussion, leaving further introspection refactoring for #103.

  • Linux Build Status
  • Linux-aarch64 Build Status
  • macOS Build Status
  • Windows Build Status

@davetcoleman
Copy link

I'm just curious - is there documentation for how those Travis badge comments are generated? Is there a script or is it a very manual process? And why is it repeated multiple times in a PR? I'd like to learn more about this policy...

@wjwwood
Copy link
Member

wjwwood commented Sep 25, 2018

They're generated for each job on https://ci.ros2.org/, but the group is generated as the output of our "launcher" job:

https://ci.ros2.org/job/ci_launcher/

There are some more generic notes about our pull request workflow and how CI works here:

https://github.com/ros2/ros2/wiki/ROS-2-On-boarding-Guide#developer-workflow

@jacobperron jacobperron merged commit 8597466 into master Sep 25, 2018
@jacobperron jacobperron removed the in review Waiting for review (Kanban column) label Sep 25, 2018
@jacobperron jacobperron deleted the register_event_handler_once branch September 25, 2018 01:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants