Skip to content

Commit 84eeb0e

Browse files
committed
Add code and tests for new global actions feature
1 parent 7caf6a1 commit 84eeb0e

File tree

7 files changed

+335
-14
lines changed

7 files changed

+335
-14
lines changed

streamdeck/actions.py

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -39,24 +39,17 @@
3939
}
4040

4141

42-
class Action:
43-
"""Represents an action that can be performed, with event handlers for specific event types."""
42+
class ActionBase:
43+
"""Base class for all actions."""
4444

45-
def __init__(self, uuid: str):
45+
def __init__(self):
4646
"""Initialize an Action instance.
4747
4848
Args:
4949
uuid (str): The unique identifier for the action.
5050
"""
51-
self.uuid = uuid
52-
5351
self._events: dict[EventNameStr, set[EventHandlerFunc]] = defaultdict(set)
5452

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-
6053
def on(self, event_name: EventNameStr, /) -> Callable[[EventHandlerFunc], EventHandlerFunc]:
6154
"""Register an event handler for a specific event.
6255
@@ -99,14 +92,36 @@ def get_event_handlers(self, event_name: EventNameStr, /) -> Generator[EventHand
9992
yield from self._events[event_name]
10093

10194

95+
class GlobalAction(ActionBase):
96+
"""Represents an action that is performed at the plugin level, meaning it isn't associated with a specific device or action."""
97+
98+
99+
class Action(ActionBase):
100+
"""Represents an action that can be performed for a specific action, with event handlers for specific event types."""
101+
102+
def __init__(self, uuid: str):
103+
"""Initialize an Action instance.
104+
105+
Args:
106+
uuid (str): The unique identifier for the action.
107+
"""
108+
super().__init__()
109+
self.uuid = uuid
110+
111+
@cached_property
112+
def name(self) -> str:
113+
"""The name of the action, derived from the last part of the UUID."""
114+
return self.uuid.split(".")[-1]
115+
116+
102117
class ActionRegistry:
103118
"""Manages the registration and retrieval of actions and their event handlers."""
104119

105120
def __init__(self) -> None:
106121
"""Initialize an ActionRegistry instance."""
107-
self._plugin_actions: list[Action] = []
122+
self._plugin_actions: list[ActionBase] = []
108123

109-
def register(self, action: Action) -> None:
124+
def register(self, action: ActionBase) -> None:
110125
"""Register an action with the registry.
111126
112127
Args:
@@ -126,9 +141,10 @@ def get_action_handlers(self, event_name: EventNameStr, event_action_uuid: str |
126141
EventHandlerFunc: The event handler functions for the specified event.
127142
"""
128143
for action in self._plugin_actions:
129-
# If the event is action-specific, only get handlers for that action, as we don't want to trigger
144+
# If the event is action-specific (i.e is not a GlobalAction and has a UUID attribute),
145+
# only get handlers for that action, as we don't want to trigger
130146
# and pass this event to handlers for other actions.
131-
if event_action_uuid is not None and event_action_uuid != action.uuid:
147+
if event_action_uuid is not None and (hasattr(action, "uuid") and action.uuid != event_action_uuid):
132148
continue
133149

134150
yield from action.get_event_handlers(event_name)

streamdeck/models/events.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ def is_action_specific(cls) -> bool:
2121
"""Check if the event is specific to an action instance (i.e. the event has an "action" field)."""
2222
return "action" in cls.model_fields
2323

24+
@classmethod
25+
def is_device_specific(cls) -> bool:
26+
"""Check if the event is specific to a device instance (i.e. the event has a "device" field)."""
27+
return "device" in cls.model_fields
28+
29+
@classmethod
30+
def is_action_instance_specific(cls) -> bool:
31+
"""Check if the event is specific to an action instance (i.e. the event has a "context" field)."""
32+
return "context" in cls.model_fields
33+
2434

2535
class ApplicationDidLaunchEvent(EventBase):
2636
event: Literal["applicationDidLaunch"]

tests/actions/__init__.py

Whitespace-only changes.
File renamed without changes.
File renamed without changes.
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
from unittest.mock import create_autospec
5+
6+
import pytest
7+
from polyfactory.factories.pydantic_factory import ModelFactory
8+
from streamdeck.actions import Action, ActionRegistry, GlobalAction
9+
from streamdeck.models import events
10+
11+
12+
if TYPE_CHECKING:
13+
from unittest.mock import Mock
14+
15+
16+
17+
@pytest.fixture
18+
def mock_event_handler() -> Mock:
19+
def dummy_handler(event: events.EventBase) -> None:
20+
"""Dummy event handler function that matches the EventHandlerFunc TypeAlias."""
21+
22+
return create_autospec(dummy_handler, spec_set=True)
23+
24+
25+
class ApplicationDidLaunchEventFactory(ModelFactory[events.ApplicationDidLaunchEvent]):
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.DeviceDidConnectEvent]):
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.KeyDownEvent]):
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+
47+
@pytest.mark.parametrize(("event_name","event_factory"), [
48+
("keyDown", KeyDownEventFactory),
49+
("deviceDidConnect", DeviceDidConnectFactory),
50+
("applicationDidLaunch", ApplicationDidLaunchEventFactory)
51+
])
52+
def test_global_action_gets_triggered_by_event(
53+
mock_event_handler: Mock,
54+
event_name: str,
55+
event_factory: ModelFactory[events.EventBase],
56+
):
57+
"""Test that a global action's event handlers are triggered by an event.
58+
59+
Global actions should be triggered by any event type that is registered with them,
60+
regardless of the event's unique identifier properties (or whether they're even present).
61+
"""
62+
fake_event_data = event_factory.build()
63+
64+
global_action = GlobalAction()
65+
66+
global_action.on(event_name)(mock_event_handler)
67+
68+
for handler in global_action.get_event_handlers(event_name):
69+
handler(fake_event_data)
70+
71+
assert mock_event_handler.call_count == 1
72+
assert fake_event_data in mock_event_handler.call_args.args
73+
74+
75+
@pytest.mark.parametrize(("event_name","event_factory"), [
76+
("keyDown", KeyDownEventFactory),
77+
("deviceDidConnect", DeviceDidConnectFactory),
78+
("applicationDidLaunch", ApplicationDidLaunchEventFactory)
79+
])
80+
def test_action_gets_triggered_by_event(mock_event_handler: Mock, event_name: str, event_factory: ModelFactory[events.EventBase]):
81+
# Create a fake event model instance
82+
fake_event_data: events.EventBase = event_factory.build()
83+
# Extract the action UUID from the fake event data, or use a default value
84+
action_uuid: str = fake_event_data.action if fake_event_data.is_action_specific() else "my-fake-action-uuid"
85+
86+
action = Action(uuid=action_uuid)
87+
88+
# Register the mock event handler with the action
89+
action.on(event_name)(mock_event_handler)
90+
91+
# Get the action's event handlers for the event and call them
92+
for handler in action.get_event_handlers(event_name):
93+
handler(fake_event_data)
94+
95+
# For some reason, assert_called_once() and assert_called_once_with() are returning False here...
96+
# assert mock_event_handler.assert_called_once(fake_event_data)
97+
assert mock_event_handler.call_count == 1
98+
assert fake_event_data in mock_event_handler.call_args.args
99+
100+
101+
102+
@pytest.mark.parametrize(("event_name","event_factory"), [
103+
("keyDown", KeyDownEventFactory),
104+
("deviceDidConnect", DeviceDidConnectFactory),
105+
("applicationDidLaunch", ApplicationDidLaunchEventFactory)
106+
])
107+
def test_global_action_registry_get_action_handlers_filtering(mock_event_handler: Mock, event_name: str, event_factory: ModelFactory[events.EventBase]):
108+
# Create a fake event model instance
109+
fake_event_data: events.EventBase = event_factory.build()
110+
# Extract the action UUID from the fake event data, or use a default value
111+
action_uuid: str = fake_event_data.action if fake_event_data.is_action_specific() else None
112+
113+
registry = ActionRegistry()
114+
# Create an Action instance, without an action UUID as global actions aren't associated with a specific action
115+
global_action = GlobalAction()
116+
117+
global_action.on(event_name)(mock_event_handler)
118+
119+
# Register the global action with the registry
120+
registry.register(global_action)
121+
122+
for handler in registry.get_action_handlers(
123+
event_name=fake_event_data.event,
124+
event_action_uuid=action_uuid,
125+
):
126+
handler(fake_event_data)
127+
128+
assert mock_event_handler.call_count == 1
129+
assert fake_event_data in mock_event_handler.call_args.args
130+
131+
132+
133+
@pytest.mark.parametrize(("event_name","event_factory"), [
134+
("keyDown", KeyDownEventFactory),
135+
("deviceDidConnect", DeviceDidConnectFactory),
136+
("applicationDidLaunch", ApplicationDidLaunchEventFactory)
137+
])
138+
def test_action_registry_get_action_handlers_filtering(mock_event_handler: Mock, event_name: str, event_factory: ModelFactory[events.EventBase]):
139+
# Create a fake event model instance
140+
fake_event_data: events.EventBase = event_factory.build()
141+
# Extract the action UUID from the fake event data, or use a default value
142+
action_uuid: str = fake_event_data.action if fake_event_data.is_action_specific() else None
143+
144+
registry = ActionRegistry()
145+
# Create an Action instance, using either the fake event's action UUID or a default value
146+
action = Action(uuid=action_uuid or "my-fake-action-uuid")
147+
148+
action.on(event_name)(mock_event_handler)
149+
150+
# Register the action with the registry
151+
registry.register(action)
152+
153+
for handler in registry.get_action_handlers(
154+
event_name=fake_event_data.event,
155+
event_action_uuid=action_uuid, # This will be None if the event is not action-specific (i.e. doesn't have an action UUID property)
156+
):
157+
handler(fake_event_data)
158+
159+
assert mock_event_handler.call_count == 1
160+
assert fake_event_data in mock_event_handler.call_args.args
161+
162+
163+
164+
def test_multiple_actions_filtering():
165+
registry = ActionRegistry()
166+
action = Action("my-fake-action-uuid-1")
167+
global_action = GlobalAction()
168+
169+
global_action_event_handler_called = False
170+
action_event_handler_called = False
171+
172+
@global_action.on("applicationDidLaunch")
173+
def _global_app_did_launch_action_handler(event: events.EventBase):
174+
nonlocal global_action_event_handler_called
175+
global_action_event_handler_called = True
176+
177+
@action.on("keyDown")
178+
def _action_key_down_event_handler(event: events.EventBase):
179+
nonlocal action_event_handler_called
180+
action_event_handler_called = True
181+
182+
# Register both actions with the registry
183+
registry.register(global_action)
184+
registry.register(action)
185+
186+
# Create a fake event model instances
187+
fake_app_did_launch_event_data: events.ApplicationDidLaunchEvent = ApplicationDidLaunchEventFactory.build()
188+
fake_key_down_event_data: events.KeyDownEvent = KeyDownEventFactory.build(action=action.uuid)
189+
190+
for handler in registry.get_action_handlers(event_name=fake_app_did_launch_event_data.event):
191+
handler(fake_app_did_launch_event_data)
192+
193+
assert global_action_event_handler_called
194+
assert not action_event_handler_called
195+
196+
# Reset the flag for global action event handler
197+
global_action_event_handler_called = False
198+
199+
# Get the action handlers for the event and call them
200+
for handler in registry.get_action_handlers(event_name=fake_key_down_event_data.event, event_action_uuid=fake_key_down_event_data.action):
201+
handler(fake_key_down_event_data)
202+
203+
assert action_event_handler_called
204+
assert not global_action_event_handler_called
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
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
9+
10+
11+
if TYPE_CHECKING:
12+
from streamdeck.models.events import EventBase
13+
from streamdeck.types import EventNameStr
14+
15+
16+
def test_global_action_register_event_handler():
17+
"""Test that an event handler can be registered for each valid event name."""
18+
global_action = GlobalAction()
19+
20+
for event_name in available_event_names:
21+
@global_action.on(event_name)
22+
def handler(event: EventBase) -> None:
23+
pass
24+
25+
# Ensure the handler is registered for the correct event name
26+
assert len(global_action._events[event_name]) == 1
27+
assert handler in global_action._events[event_name]
28+
29+
30+
def test_global_action_register_invalid_event_handler():
31+
"""Test that attempting to register an invalid event handler raises an exception."""
32+
global_action = GlobalAction()
33+
34+
with pytest.raises(Exception):
35+
@global_action.on("InvalidEvent")
36+
def handler(event: EventBase):
37+
pass
38+
39+
40+
def test_global_action_get_event_handlers():
41+
"""Test that the correct event handlers are retrieved for each event name."""
42+
global_action = GlobalAction()
43+
44+
for event_name in available_event_names:
45+
# Register a handler for the event name
46+
@global_action.on(event_name)
47+
def handler(event: EventBase):
48+
pass
49+
50+
# Retrieve the handlers using the generator
51+
handlers = list(global_action.get_event_handlers(event_name))
52+
53+
# Ensure that the correct handler is retrieved
54+
assert len(handlers) == 1
55+
assert handlers[0] == handler
56+
57+
58+
def test_global_action_get_event_handlers_no_event_registered():
59+
"""Test that attempting to get handlers for an event with no registered handlers raises an exception."""
60+
global_action = GlobalAction()
61+
62+
with pytest.raises(Exception):
63+
list(global_action.get_event_handlers("InvalidEvent"))
64+
65+
66+
def test_global_action_register_multiple_handlers_for_event():
67+
"""Test that multiple handlers can be registered for an event."""
68+
global_action = GlobalAction()
69+
70+
@global_action.on("keyDown")
71+
def handler_one(event: EventBase):
72+
pass
73+
74+
@global_action.on("keyDown")
75+
def handler_two(event: EventBase):
76+
pass
77+
78+
handlers = list(global_action.get_event_handlers("keyDown"))
79+
80+
# Ensure both handlers are registered for the event
81+
assert len(handlers) == 2
82+
assert handler_one in handlers
83+
assert handler_two in handlers
84+
85+
86+
def test_global_action_get_event_handlers_invalid_event():
87+
"""Test that attempting to get handlers for an invalid event raises a KeyError."""
88+
global_action = GlobalAction()
89+
90+
with pytest.raises(KeyError):
91+
list(global_action.get_event_handlers("InvalidEvent"))

0 commit comments

Comments
 (0)