Skip to content

Commit 286c92d

Browse files
committed
Update PluginManager class to bind the command_sender object to handler args when present in their signature. Add tests as well.
1 parent 739977f commit 286c92d

File tree

2 files changed

+166
-1
lines changed

2 files changed

+166
-1
lines changed

streamdeck/manager.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from __future__ import annotations
22

3+
import functools
4+
import inspect
35
import logging
46
from logging import getLogger
5-
from typing import TYPE_CHECKING, cast
7+
from typing import TYPE_CHECKING
68

79
from streamdeck.actions import ActionRegistry
810
from streamdeck.command_sender import StreamDeckCommandSender
@@ -12,6 +14,7 @@
1214

1315

1416
if TYPE_CHECKING:
17+
from collections.abc import Callable
1518
from typing import Any, Literal
1619

1720
from streamdeck.actions import Action
@@ -67,6 +70,24 @@ def register_action(self, action: Action) -> None:
6770

6871
self._registry.register(action)
6972

73+
def _inject_command_sender(self, handler: Callable[..., None], command_sender: StreamDeckCommandSender) -> Callable[..., None]:
74+
"""Inject command_sender into handler if it accepts it as a parameter.
75+
76+
Args:
77+
handler: The event handler function
78+
command_sender: The StreamDeckCommandSender instance
79+
80+
Returns:
81+
The handler with command_sender injected if needed
82+
"""
83+
args: dict[str, inspect.Parameter] = inspect.signature(handler).parameters
84+
85+
# Check dynamically if the `command_sender`'s name is in the handler's arguments.
86+
if "command_sender" in args:
87+
return functools.partial(handler, command_sender=command_sender)
88+
89+
return handler
90+
7091
def run(self) -> None:
7192
"""Run the PluginManager by connecting to the WebSocket server and processing incoming events.
7293
@@ -86,5 +107,7 @@ def run(self) -> None:
86107
event_action_uuid = data.action if isinstance(data, ContextualEventMixin) else None
87108

88109
for handler in self._registry.get_action_handlers(event_name=data.event, event_action_uuid=event_action_uuid):
110+
handler = self._inject_command_sender(handler, command_sender)
89111
# TODO: from contextual event occurences, save metadata to the action's properties.
112+
90113
handler(data)
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
from __future__ import annotations
2+
3+
import inspect
4+
from functools import partial
5+
from typing import TYPE_CHECKING, Any, cast
6+
from unittest.mock import Mock, create_autospec
7+
8+
import pytest
9+
from pprintpp import pprint
10+
from streamdeck.actions import Action
11+
from streamdeck.command_sender import StreamDeckCommandSender
12+
from streamdeck.websocket import WebSocketClient
13+
14+
from tests.test_utils.fake_event_factories import KeyDownEventFactory
15+
16+
17+
if TYPE_CHECKING:
18+
from collections.abc import Callable
19+
20+
from streamdeck.manager import PluginManager
21+
from streamdeck.models import events
22+
from typing_extensions import TypeAlias # noqa: UP035
23+
24+
EventHandlerFunc: TypeAlias = Callable[[events.EventBase], None] | Callable[[events.EventBase, StreamDeckCommandSender], None]
25+
26+
27+
28+
def create_event_handler(include_command_sender_param: bool = False) -> EventHandlerFunc:
29+
"""Create a dummy event handler function that matches the EventHandlerFunc TypeAlias.
30+
31+
Args:
32+
include_command_sender_param (bool, optional): Whether to include the `command_sender` parameter in the handler. Defaults to False.
33+
34+
Returns:
35+
Callable[[events.EventBase], None] | Callable[[events.EventBase, StreamDeckCommandSender], None]: A dummy event handler function.
36+
"""
37+
if not include_command_sender_param:
38+
def dummy_handler_without_cmd_sender(event: events.EventBase) -> None:
39+
"""Dummy event handler function that matches the EventHandlerFunc TypeAlias without `command_sender` param."""
40+
41+
return dummy_handler_without_cmd_sender
42+
43+
def dummy_handler_with_cmd_sender(event: events.EventBase, command_sender: StreamDeckCommandSender) -> None:
44+
"""Dummy event handler function that matches the EventHandlerFunc TypeAlias with `command_sender` param."""
45+
46+
return dummy_handler_with_cmd_sender
47+
48+
49+
@pytest.fixture(params=[True, False])
50+
def mock_event_handler(request: pytest.FixtureRequest) -> Mock:
51+
include_command_sender_param: bool = request.param
52+
dummy_handler: EventHandlerFunc = create_event_handler(include_command_sender_param)
53+
54+
return create_autospec(dummy_handler, spec_set=True)
55+
56+
57+
@pytest.fixture
58+
def mock_websocket_client_with_fake_events(patch_websocket_client: Mock) -> tuple[Mock, list[events.EventBase]]:
59+
"""Fixture that mocks the WebSocketClient and provides a list of fake event messages yielded by the mock client.
60+
61+
Args:
62+
patch_websocket_client: Mocked instance of the patched WebSocketClient.
63+
64+
Returns:
65+
tuple: Mocked instance of WebSocketClient, and a list of fake event messages.
66+
"""
67+
# Create a list of fake event messages, and convert them to json strings to be passed back by the client.listen_forever() method.
68+
fake_event_messages: list[events.EventBase] = [
69+
KeyDownEventFactory.build(action="my-fake-action-uuid"),
70+
]
71+
72+
patch_websocket_client.listen_forever.return_value = [event.model_dump_json() for event in fake_event_messages]
73+
74+
return patch_websocket_client, fake_event_messages
75+
76+
77+
78+
def test_inject_command_sender_func(
79+
plugin_manager: PluginManager,
80+
mock_event_handler: Mock,
81+
):
82+
"""Test that the command_sender is injected into the handler."""
83+
mock_command_sender = Mock()
84+
result_handler = plugin_manager._inject_command_sender(mock_event_handler, mock_command_sender)
85+
86+
resulting_handler_params = inspect.signature(result_handler).parameters
87+
88+
# If this condition is true, then the `result_handler` is a partial function.
89+
if "command_sender" in resulting_handler_params:
90+
91+
# Check that the `result_handler` is not the same as the original `mock_event_handler`.
92+
assert result_handler != mock_event_handler
93+
94+
# Check that the `command_sender` parameter is bound to the correct value.
95+
resulting_handler_bound_kwargs: dict[str, Any] = cast(partial[Any], result_handler).keywords
96+
assert resulting_handler_bound_kwargs["command_sender"] == mock_command_sender
97+
98+
# If there isn't a `command_sender` parameter, then the `result_handler` is the original handler unaltered.
99+
else:
100+
assert result_handler == mock_event_handler
101+
102+
103+
def test_run_manager_events_handled_with_correct_params(
104+
mock_websocket_client_with_fake_events: tuple[Mock, list[events.EventBase]],
105+
plugin_manager: PluginManager,
106+
mock_command_sender: Mock,
107+
):
108+
"""Test that the PluginManager runs and triggers event handlers with the correct parameter binding.
109+
110+
This test will:
111+
- Register an action with the PluginManager.
112+
- Create and register mock event handlers with and without the `command_sender` parameter.
113+
- Run the PluginManager and let it process the fake event messages generated by the mocked WebSocketClient.
114+
- Ensure that mocked event handlers were called with the correct params,
115+
binding the `command_sender` parameter if defined in the handler's signature.
116+
117+
Args:
118+
mock_websocket_client_with_fake_events (tuple[Mock, list[events.EventBase]]): Mocked instance of WebSocketClient, and a list of fake event messages it will yield.
119+
plugin_manager (PluginManager): Instance of PluginManager with test parameters.
120+
mock_command_sender (Mock): Patched instance of StreamDeckCommandSender. Used here to ensure that the `command_sender` parameter is bound correctly.
121+
"""
122+
# As of now, fake_event_messages is a list of one KeyDown event. If this changes, I'll need to update this test.
123+
fake_event_message: events.KeyDown = mock_websocket_client_with_fake_events[1][0]
124+
125+
action = Action(fake_event_message.action)
126+
127+
# Create a mock event handler with the `command_sender` parameter and register it with the action for an event type.
128+
mock_event_handler_with_cmd_sender: Mock = create_autospec(create_event_handler(include_command_sender_param=True), spec_set=True)
129+
action.on("keyDown")(mock_event_handler_with_cmd_sender)
130+
131+
# Create a mock event handler without the `command_sender` parameter and register it with the action for an event type.
132+
mock_event_handler_without_cmd_sender: Mock = create_autospec(create_event_handler(include_command_sender_param=False), spec_set=True)
133+
action.on("keyDown")(mock_event_handler_without_cmd_sender)
134+
135+
plugin_manager.register_action(action)
136+
137+
# Run the PluginManager and let it process the fake event messages generated by the mocked WebSocketClient.
138+
plugin_manager.run()
139+
140+
# Ensure that mocked event handlers were called with the correct params, binding the `command_sender` parameter if defined in the handler's signature.
141+
mock_event_handler_without_cmd_sender.assert_called_once_with(fake_event_message)
142+
mock_event_handler_with_cmd_sender.assert_called_once_with(fake_event_message, mock_command_sender)

0 commit comments

Comments
 (0)