Skip to content

Commit bb1cd1b

Browse files
authored
Merge pull request #5 from strohganoff/feature/inject-command_sender-b
Feature/inject command sender in event handlers
2 parents 798903c + 83bcdd0 commit bb1cd1b

11 files changed

+309
-97
lines changed

README.md

+13-1
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,18 @@ def handle_will_appear(event_data):
8787

8888
!!!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.
8989

90+
Handlers can optionally include a `command_sender` parameter to access Stream Deck command sending capabilities.
91+
92+
```python
93+
@my_action.on("willAppear")
94+
def handle_will_appear(event_data, command_sender: StreamDeckCommandSender):
95+
# Use command_sender to interact with Stream Deck
96+
command_sender.set_title(context=event_data.context, title="Hello!")
97+
command_sender.set_state(context=event_data.context, state=0)
98+
```
99+
100+
The `command_sender` parameter is optional. If included in the handler's signature, the SDK automatically injects a `StreamDeckCommandSender` instance that can be used to send commands back to the Stream Deck (like setting titles, states, or images).
101+
90102

91103

92104
### Writing Logs
@@ -234,7 +246,7 @@ Contributions are welcome! Please open an issue or submit a pull request on GitH
234246
The following upcoming improvements are in the works:
235247

236248
- **Improved Documentation**: Expand and improve the documentation with more examples, guides, and use cases.
237-
- **Bind Command Sender**: Automatically bind `command_sender` and action instance context-holding objects to handler function arguments if they are included in the definition.
249+
- **Store & Bind Settings**: Automatically store and bind action instance context-holding objects to handler function arguments if they are included in the definition.
238250
- **Optional Event Pattern Matching on Hooks**: Add support for optional pattern-matching on event messages to further filter when hooks get called.
239251
- **Async Support**: Introduce asynchronous features to handle WebSocket communication more efficiently.
240252

streamdeck/manager.py

+24-1
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)

tests/actions/test_action_registry.py

+5-12
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,14 @@
11
from __future__ import annotations
22

33
import pytest
4-
from polyfactory.factories.pydantic_factory import ModelFactory
54
from streamdeck.actions import Action, ActionRegistry
65
from streamdeck.models import events
76

8-
9-
class DialUpEventFactory(ModelFactory[events.DialUp]):
10-
"""Polyfactory factory for creating a fake dialUp event message based on our Pydantic model."""
11-
12-
class DialDownEventFactory(ModelFactory[events.DialDown]):
13-
"""Polyfactory factory for creating a fake dialDown event message based on our Pydantic model."""
14-
15-
class KeyUpEventFactory(ModelFactory[events.KeyUp]):
16-
"""Polyfactory factory for creating a fake keyUp event message based on our Pydantic model."""
17-
18-
7+
from tests.test_utils.fake_event_factories import (
8+
DialDownEventFactory,
9+
DialUpEventFactory,
10+
KeyUpEventFactory,
11+
)
1912

2013

2114
def test_register_action():

tests/actions/test_event_handler_filtering.py

+8-23
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,21 @@
44
from unittest.mock import create_autospec
55

66
import pytest
7-
from polyfactory.factories.pydantic_factory import ModelFactory
87
from streamdeck.actions import Action, ActionRegistry, GlobalAction
98
from streamdeck.models import events
109

10+
from tests.test_utils.fake_event_factories import (
11+
ApplicationDidLaunchEventFactory,
12+
DeviceDidConnectFactory,
13+
KeyDownEventFactory,
14+
)
15+
1116

1217
if TYPE_CHECKING:
1318
from unittest.mock import Mock
1419

20+
from polyfactory.factories.pydantic_factory import ModelFactory
21+
1522

1623

1724
@pytest.fixture
@@ -22,28 +29,6 @@ def dummy_handler(event: events.EventBase) -> None:
2229
return create_autospec(dummy_handler, spec_set=True)
2330

2431

25-
class ApplicationDidLaunchEventFactory(ModelFactory[events.ApplicationDidLaunch]):
26-
"""Polyfactory factory for creating fake applicationDidLaunch event message based on our Pydantic model.
27-
28-
ApplicationDidLaunchEvent's hold no unique identifier properties, besides the almost irrelevant `event` name property.
29-
"""
30-
31-
class DeviceDidConnectFactory(ModelFactory[events.DeviceDidConnect]):
32-
"""Polyfactory factory for creating fake deviceDidConnect event message based on our Pydantic model.
33-
34-
DeviceDidConnectEvent's have `device` unique identifier property.
35-
"""
36-
37-
class KeyDownEventFactory(ModelFactory[events.KeyDown]):
38-
"""Polyfactory factory for creating fake keyDown event message based on our Pydantic model.
39-
40-
KeyDownEvent's have the unique identifier properties:
41-
`device`: Identifies the Stream Deck device that this event is associated with.
42-
`action`: Identifies the action that caused the event.
43-
`context`: Identifies the *instance* of an action that caused the event.
44-
"""
45-
46-
4732
@pytest.mark.parametrize(("event_name","event_factory"), [
4833
("keyDown", KeyDownEventFactory),
4934
("deviceDidConnect", DeviceDidConnectFactory),

tests/actions/test_global_action.py

+1-4
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,11 @@
33
from typing import TYPE_CHECKING
44

55
import pytest
6-
from polyfactory.factories.pydantic_factory import ModelFactory
7-
from streamdeck.actions import Action, GlobalAction, available_event_names
8-
from streamdeck.models import events
6+
from streamdeck.actions import GlobalAction, available_event_names
97

108

119
if TYPE_CHECKING:
1210
from streamdeck.models.events import EventBase
13-
from streamdeck.types import EventNameStr
1411

1512

1613
def test_global_action_register_event_handler():

tests/plugin_manager/__init__.py

Whitespace-only changes.

tests/plugin_manager/conftest.py

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import annotations
2+
3+
import uuid
4+
from unittest.mock import Mock, create_autospec
5+
6+
import pytest
7+
from streamdeck.manager import PluginManager
8+
from streamdeck.websocket import WebSocketClient
9+
10+
11+
@pytest.fixture
12+
def plugin_manager(port_number: int) -> PluginManager:
13+
"""Fixture that provides a configured instance of PluginManager for testing.
14+
15+
Returns:
16+
PluginManager: An instance of PluginManager with test parameters.
17+
"""
18+
plugin_uuid = "test-plugin-uuid"
19+
plugin_registration_uuid = str(uuid.uuid1())
20+
register_event = "registerPlugin"
21+
info = {"some": "info"}
22+
23+
return PluginManager(
24+
port=port_number,
25+
plugin_uuid=plugin_uuid,
26+
plugin_registration_uuid=plugin_registration_uuid,
27+
register_event=register_event,
28+
info=info,
29+
)
30+
31+
32+
@pytest.fixture
33+
def patch_websocket_client(monkeypatch: pytest.MonkeyPatch) -> Mock:
34+
"""Fixture that uses pytest's MonkeyPatch to mock WebSocketClient for the PluginManager run method.
35+
36+
The mocked WebSocketClient can be given fake event messages to yield when listen_forever() is called:
37+
```patch_websocket_client.listen_forever.return_value = [fake_event_json1, fake_event_json2, ...]```
38+
39+
Args:
40+
monkeypatch: pytest's monkeypatch fixture.
41+
42+
Returns:
43+
"""
44+
mock_websocket_client: Mock = create_autospec(WebSocketClient, spec_set=True)
45+
mock_websocket_client.__enter__.return_value = mock_websocket_client
46+
47+
monkeypatch.setattr("streamdeck.manager.WebSocketClient", (lambda *args, **kwargs: mock_websocket_client))
48+
49+
return mock_websocket_client
50+
51+
52+
@pytest.fixture
53+
def mock_command_sender(mocker: pytest_mock.MockerFixture) -> Mock:
54+
"""Fixture that patches the StreamDeckCommandSender.
55+
56+
Args:
57+
mocker: pytest-mock's mocker fixture.
58+
59+
Returns:
60+
Mock: Mocked instance of StreamDeckCommandSender
61+
"""
62+
mock_cmd_sender = Mock()
63+
mocker.patch("streamdeck.manager.StreamDeckCommandSender", return_value=mock_cmd_sender)
64+
return mock_cmd_sender
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)