Skip to content

Commit eea54a5

Browse files
authored
Merge pull request #3 from strohganoff/dispatch-to-action
Update code to handle dispatching events only to their related action
2 parents b08b731 + b443883 commit eea54a5

File tree

6 files changed

+69
-26
lines changed

6 files changed

+69
-26
lines changed

README.md

+3
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ def handle_will_appear(event):
6464
print("Will Appear event received:", event)
6565
```
6666

67+
!!!INFO Handlers for action-specific events are dispatched only if the event is triggered by the associated action, ensuring isolation and predictability. For other types of events that are not associated with a specific action, handlers are dispatched without such restrictions.
68+
69+
6770

6871
### Writing Logs
6972

streamdeck/actions.py

+16-8
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from collections import defaultdict
4+
from functools import cached_property
45
from logging import getLogger
56
from typing import TYPE_CHECKING
67

@@ -51,6 +52,11 @@ def __init__(self, uuid: str):
5152

5253
self._events: dict[EventNameStr, set[EventHandlerFunc]] = defaultdict(set)
5354

55+
@cached_property
56+
def name(self) -> str:
57+
"""The name of the action, derived from the last part of the UUID."""
58+
return self.uuid.split(".")[-1]
59+
5460
def on(self, event_name: EventNameStr, /) -> Callable[[EventHandlerFunc], EventHandlerFunc]:
5561
"""Register an event handler for a specific event.
5662
@@ -90,14 +96,13 @@ def get_event_handlers(self, event_name: EventNameStr, /) -> Generator[EventHand
9096
msg = f"Provided event name for pulling handlers from action does not exist: {event_name}"
9197
raise KeyError(msg)
9298

93-
for event in self._events[event_name]:
94-
yield event
99+
yield from self._events[event_name]
95100

96101

97102
class ActionRegistry:
98103
"""Manages the registration and retrieval of actions and their event handlers."""
99104

100-
def __init__(self):
105+
def __init__(self) -> None:
101106
"""Initialize an ActionRegistry instance."""
102107
self._plugin_actions: list[Action] = []
103108

@@ -109,18 +114,21 @@ def register(self, action: Action) -> None:
109114
"""
110115
self._plugin_actions.append(action)
111116

112-
def get_action_handlers(self, event_name: EventNameStr) -> Generator[EventHandlerFunc, None, None]:
117+
def get_action_handlers(self, event_name: EventNameStr, event_action_uuid: str | None = None) -> Generator[EventHandlerFunc, None, None]:
113118
"""Get all event handlers for a specific event from all registered actions.
114119
115120
Args:
116121
event_name (EventName): The name of the event to retrieve handlers for.
122+
event_action_uuid (str | None): The action UUID to get handlers for.
123+
If None (i.e., the event is not action-specific), get all handlers for the event.
117124
118125
Yields:
119126
EventHandlerFunc: The event handler functions for the specified event.
120127
"""
121128
for action in self._plugin_actions:
122-
yield from action.get_event_handlers(event_name)
123-
124-
125-
129+
# If the event is action-specific, only get handlers for that action, as we don't want to trigger
130+
# and pass this event to handlers for other actions.
131+
if event_action_uuid is not None and event_action_uuid != action.uuid:
132+
continue
126133

134+
yield from action.get_event_handlers(event_name)

streamdeck/manager.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import logging
44
from logging import getLogger
5-
from typing import TYPE_CHECKING
5+
from typing import TYPE_CHECKING, cast
66

77
from streamdeck.actions import ActionRegistry
88
from streamdeck.command_sender import StreamDeckCommandSender
@@ -82,6 +82,9 @@ def run(self) -> None:
8282
data: EventBase = event_adapter.validate_json(message)
8383
logger.debug("Event received: %s", data.event)
8484

85-
for handler in self._registry.get_action_handlers(data.event): # type: ignore
85+
# If the event is action-specific, we'll pass the action's uuid to the handler to ensure only the correct action is triggered.
86+
event_action_uuid: str | None = cast(str, data.action) if data.is_action_specific() else None
87+
88+
for handler in self._registry.get_action_handlers(event_name=data.event, event_action_uuid=event_action_uuid):
8689
# TODO: from contextual event occurences, save metadata to the action's properties.
8790
handler(data)

streamdeck/models/events.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ class EventBase(BaseModel, ABC):
1717
"""Name of the event used to identify what occurred."""
1818

1919
@classmethod
20-
def is_action_specific(cls):
21-
return "context" in cls.model_fields
20+
def is_action_specific(cls) -> bool:
21+
"""Check if the event is specific to an action instance (i.e. the event has an "action" field)."""
22+
return "action" in cls.model_fields
2223

2324

2425
class ApplicationDidLaunchEvent(EventBase):

tests/test_action_registry.py

+22-8
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING
4-
53
import pytest
4+
from polyfactory.factories.pydantic_factory import ModelFactory
65
from streamdeck.actions import Action, ActionRegistry
6+
from streamdeck.models import events
7+
8+
9+
class DialUpEventFactory(ModelFactory[events.DialUpEvent]):
10+
"""Polyfactory factory for creating a fake dialUp event message based on our Pydantic model."""
711

12+
class DialDownEventFactory(ModelFactory[events.DialDownEvent]):
13+
"""Polyfactory factory for creating a fake dialDown event message based on our Pydantic model."""
14+
15+
class KeyUpEventFactory(ModelFactory[events.KeyUpEvent]):
16+
"""Polyfactory factory for creating a fake keyUp event message based on our Pydantic model."""
817

9-
if TYPE_CHECKING:
10-
from streamdeck.models.events import EventBase
1118

1219

1320

@@ -30,7 +37,8 @@ def test_get_action_handlers_no_handlers():
3037

3138
registry.register(action)
3239

33-
handlers = list(registry.get_action_handlers("dialUp"))
40+
fake_event_data: events.DialUpEvent = DialUpEventFactory.build()
41+
handlers = list(registry.get_action_handlers(event_name=fake_event_data.event, event_action_uuid=fake_event_data.action))
3442
assert len(handlers) == 0
3543

3644

@@ -40,11 +48,14 @@ def test_get_action_handlers_with_handlers():
4048
action = Action("my-fake-action-uuid")
4149

4250
@action.on("dialDown")
43-
def dial_down_handler(event: EventBase):
51+
def dial_down_handler(event: events.EventBase):
4452
pass
4553

4654
registry.register(action)
47-
handlers = list(registry.get_action_handlers("dialDown"))
55+
56+
fake_event_data: events.DialDownEvent = DialDownEventFactory.build(action=action.uuid)
57+
handlers = list(registry.get_action_handlers(event_name=fake_event_data.event, event_action_uuid=fake_event_data.action))
58+
# handlers = list(registry.get_action_handlers("dialDown"))
4859
assert len(handlers) == 1
4960
assert handlers[0] == dial_down_handler
5061

@@ -67,7 +78,10 @@ def key_up_handler2(event):
6778
registry.register(action1)
6879
registry.register(action2)
6980

70-
handlers = list(registry.get_action_handlers("keyUp"))
81+
fake_event_data: events.KeyUpEvent = KeyUpEventFactory.build(action=action1.uuid)
82+
# Notice no action uuid is passed in here, so we should get all handlers for the event.
83+
handlers = list(registry.get_action_handlers(event_name=fake_event_data.event))
84+
7185
assert len(handlers) == 2
7286
assert key_up_handler1 in handlers
7387
assert key_up_handler2 in handlers

tests/test_plugin_manager.py

+20-6
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def plugin_manager(port_number: int) -> PluginManager:
3232
plugin_uuid=plugin_uuid,
3333
plugin_registration_uuid=plugin_registration_uuid,
3434
register_event=register_event,
35-
info=info
35+
info=info,
3636
)
3737

3838

@@ -51,7 +51,7 @@ def patch_websocket_client(monkeypatch: pytest.MonkeyPatch) -> tuple[MagicMock,
5151
mock_websocket_client.__enter__.return_value = mock_websocket_client
5252

5353
# Create a fake event message, and convert it to a json string to be passed back by the client.listen_forever() method.
54-
fake_event_message = DialRotateEventFactory.build()
54+
fake_event_message: DialRotateEvent = DialRotateEventFactory.build()
5555
mock_websocket_client.listen_forever.return_value = [fake_event_message.model_dump_json()]
5656

5757
monkeypatch.setattr("streamdeck.manager.WebSocketClient", lambda port: mock_websocket_client)
@@ -75,7 +75,9 @@ def mock_command_sender(mocker: pytest_mock.MockerFixture) -> Mock:
7575

7676

7777
@pytest.fixture
78-
def _spy_action_registry_get_action_handlers(mocker: pytest_mock.MockerFixture, plugin_manager: PluginManager) -> None:
78+
def _spy_action_registry_get_action_handlers(
79+
mocker: pytest_mock.MockerFixture, plugin_manager: PluginManager
80+
) -> None:
7981
"""Fixture that wraps and spies on the get_action_handlers method of the action_registry.
8082
8183
Args:
@@ -113,7 +115,9 @@ def test_plugin_manager_register_action(plugin_manager: PluginManager):
113115

114116

115117
@pytest.mark.usefixtures("patch_websocket_client")
116-
def test_plugin_manager_sends_registration_event(mock_command_sender: Mock, plugin_manager: PluginManager):
118+
def test_plugin_manager_sends_registration_event(
119+
mock_command_sender: Mock, plugin_manager: PluginManager
120+
):
117121
"""Test that StreamDeckCommandSender.send_action_registration() method is called with correct arguments within the PluginManager.run() method."""
118122
plugin_manager.run()
119123

@@ -125,16 +129,26 @@ def test_plugin_manager_sends_registration_event(mock_command_sender: Mock, plug
125129

126130
@pytest.mark.usefixtures("_spy_action_registry_get_action_handlers")
127131
@pytest.mark.usefixtures("_spy_event_adapter_validate_json")
128-
def test_plugin_manager_process_event(patch_websocket_client: tuple[MagicMock, EventBase], plugin_manager: PluginManager):
132+
def test_plugin_manager_process_event(
133+
patch_websocket_client: tuple[MagicMock, EventBase], plugin_manager: PluginManager
134+
):
129135
"""Test that PluginManager processes events correctly, calling event_adapter.validate_json and action_registry.get_action_handlers."""
130136
mock_websocket_client, fake_event_message = patch_websocket_client
131137

132138
plugin_manager.run()
133139

140+
# First check that the WebSocketClient's listen_forever() method was called.
141+
# This has been stubbed to return the fake_event_message's json string.
134142
mock_websocket_client.listen_forever.assert_called_once()
135143

144+
# Check that the event_adapter.validate_json method was called with the stub json string returned by listen_forever().
136145
spied_event_adapter_validate_json = cast(Mock, event_adapter.validate_json)
137146
spied_event_adapter_validate_json.assert_called_once_with(fake_event_message.model_dump_json())
147+
# Check that the validate_json method returns the same event type model as the fake_event_message.
138148
assert spied_event_adapter_validate_json.spy_return == fake_event_message
139149

140-
cast(Mock, plugin_manager._registry.get_action_handlers).assert_called_once_with(fake_event_message.event)
150+
# Check that the action_registry.get_action_handlers method was called with the event name and action uuid.
151+
cast(Mock, plugin_manager._registry.get_action_handlers).assert_called_once_with(
152+
event_name=fake_event_message.event, event_action_uuid=fake_event_message.action
153+
)
154+

0 commit comments

Comments
 (0)