Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 42 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,13 @@ This guide will help you set up your first Stream Deck plugin using the library.
- Stream Deck software installed
- A valid `manifest.json` file for your plugin

### Creating an Action
### Creating Actions

An **Action** represents a specific functionality in your plugin. You can create multiple actions, each with its own set of event handlers.
The SDK provides two types of actions: `Action` and `GlobalAction`. Each represents functionality with different scopes in your plugin, determining how events are handled.

#### Regular Actions

An `Action` handles events that are specifically associated with it based on event metadata. When the Stream Deck sends an event, the action's handlers only run if the event metadata indicates it was triggered by or is intended for that specific action instance.

```python
from streamdeck import Action
Expand All @@ -52,18 +56,33 @@ from streamdeck import Action
my_action = Action(uuid="com.example.myplugin.myaction")
```

#### Global Actions

A `GlobalAction` runs its event handlers for all events of a given type, regardless of which action the events were originally intended for. Unlike regular Actions which only process events specifically targeted at their UUID, GlobalActions handle events meant for any action in the plugin, making them useful for implementing plugin-wide behaviors or monitoring.

```python
from streamdeck import GlobalAction

# Create a global action
my_global_action = GlobalAction()
```

Choose `GlobalAction` when you want to handle events at the plugin-scope (i.e. globally) without filtering by action, and `Action` when you need to process events specific to particular actions.

Note that an action with its UUID still needs to be defined in the manifest.json. Global Actions are an abstract component unique to this library — the global behavior is not how the Stream Deck software itself handles registering actions and publishing events.

### Registering Event Handlers

Use the `.on()` method to register event handlers for specific events.

```python
@my_action.on("keyDown")
def handle_key_down(event):
print("Key Down event received:", event)
def handle_key_down(event_data):
print("Key Down event received:", event_data)

@my_action.on("willAppear")
def handle_will_appear(event):
print("Will Appear event received:", event)
def handle_will_appear(event_data):
print("Will Appear event received:", event_data)
```

!!!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.
Expand Down Expand Up @@ -159,22 +178,34 @@ Below is an example of the pyproject.toml configuration and how to run the plugi

## Simple Example

Below is a complete example that creates a plugin with a single action. The action handles the `keyDown` event and simply prints a statement that the event occurred.
Below is a complete example that creates a plugin with a single action. The action handles the `keyDown` and `applicationDidLaunch` event and simply prints a statement that an event occurred.

```python
# main.py
import logging
from streamdeck import Action, PluginManager, events
from streamdeck import Action, GlobalAction, PluginManager, events

logger = logging.getLogger("myaction")

# Define your action
my_action = Action(uuid="com.example.myplugin.myaction")

# Register event handlers
# Define your global action
my_global_action = GlobalAction()

# Register event handlers for regular action
@my_action.on("applicationDidLaunch")
def handle_application_did_launch(event_data: events.ApplicationDidLaunch):
logger.debug("Application Did Launch event recieved:", event_data)

@my_action.on("keyDown")
def handle_key_down(event):
logger.debug("Key Down event received:", event)
def handle_key_down(event_data: events.KeyDown):
logger.debug("Key Down event received:", event_data)

# Register event handlers for global action
@my_global_action.on("keyDown")
def handle_global_key_down(event_data: events.KeyDown):
logger.debug("Global Key Down event received:", event_data)
```

```toml
Expand Down
10 changes: 4 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,11 @@
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Development Status :: 4 - Beta",
"Environment :: Desktop",
"Environment :: MacOS X",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Topic :: CLI",
"Topic :: Stream Deck",
"Topic :: Stream Deck :: Plugin",
"Topic :: Stream Deck :: Plugin :: SDK",
"Topic :: Stream Deck :: SDK",
"Topic :: Software Development :: Libraries :: Application Frameworks",
"Topic :: Software Development :: Libraries :: Python Modules",
"Typing :: Typed",
]
requires-python = ">=3.9"
Expand Down
10 changes: 5 additions & 5 deletions streamdeck/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import tomli as toml

from streamdeck.actions import Action
from streamdeck.actions import ActionBase
from streamdeck.cli.errors import (
DirectoryNotFoundError,
NotAFileError,
Expand Down Expand Up @@ -146,7 +146,7 @@ def read_streamdeck_config_from_pyproject(plugin_dir: Path) -> StreamDeckConfigD

class ActionLoader:
@classmethod
def load_actions(cls: type[Self], plugin_dir: Path, files: list[str]) -> Generator[Action, None, None]:
def load_actions(cls: type[Self], plugin_dir: Path, files: list[str]) -> Generator[ActionBase, None, None]:
# Ensure the parent directory of the plugin modules is in `sys.path`,
# so that import statements in the plugin module will work as expected.
if str(plugin_dir) not in sys.path:
Expand Down Expand Up @@ -191,12 +191,12 @@ def _load_module_from_file(filepath: Path) -> ModuleType:
return module

@staticmethod
def _get_actions_from_loaded_module(module: ModuleType) -> Generator[Action, None, None]:
def _get_actions_from_loaded_module(module: ModuleType) -> Generator[ActionBase, None, None]:
# Iterate over all attributes in the module to find Action subclasses
for attribute_name in dir(module):
attribute = getattr(module, attribute_name)
# Check if the attribute is an instance of the Action class
if isinstance(attribute, Action):
# Check if the attribute is an instance of the Action class or GlobalAction class.
if issubclass(type(attribute), ActionBase):
yield attribute


Expand Down
48 changes: 34 additions & 14 deletions streamdeck/actions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from abc import ABC
from collections import defaultdict
from functools import cached_property
from logging import getLogger
Expand Down Expand Up @@ -39,24 +40,17 @@
}


class Action:
"""Represents an action that can be performed, with event handlers for specific event types."""
class ActionBase(ABC):
"""Base class for all actions."""

def __init__(self, uuid: str):
def __init__(self):
"""Initialize an Action instance.

Args:
uuid (str): The unique identifier for the action.
"""
self.uuid = uuid

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

@cached_property
def name(self) -> str:
"""The name of the action, derived from the last part of the UUID."""
return self.uuid.split(".")[-1]

def on(self, event_name: EventNameStr, /) -> Callable[[EventHandlerFunc], EventHandlerFunc]:
"""Register an event handler for a specific event.

Expand Down Expand Up @@ -96,17 +90,42 @@ def get_event_handlers(self, event_name: EventNameStr, /) -> Generator[EventHand
msg = f"Provided event name for pulling handlers from action does not exist: {event_name}"
raise KeyError(msg)

if event_name not in self._events:
return

yield from self._events[event_name]


class GlobalAction(ActionBase):
"""Represents an action that is performed at the plugin level, meaning it isn't associated with a specific device or action."""


class Action(ActionBase):
"""Represents an action that can be performed for a specific action, with event handlers for specific event types."""

def __init__(self, uuid: str):
"""Initialize an Action instance.

Args:
uuid (str): The unique identifier for the action.
"""
super().__init__()
self.uuid = uuid

@cached_property
def name(self) -> str:
"""The name of the action, derived from the last part of the UUID."""
return self.uuid.split(".")[-1]


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

def __init__(self) -> None:
"""Initialize an ActionRegistry instance."""
self._plugin_actions: list[Action] = []
self._plugin_actions: list[ActionBase] = []

def register(self, action: Action) -> None:
def register(self, action: ActionBase) -> None:
"""Register an action with the registry.

Args:
Expand All @@ -126,9 +145,10 @@ def get_action_handlers(self, event_name: EventNameStr, event_action_uuid: str |
EventHandlerFunc: The event handler functions for the specified event.
"""
for action in self._plugin_actions:
# If the event is action-specific, only get handlers for that action, as we don't want to trigger
# If the event is action-specific (i.e is not a GlobalAction and has a UUID attribute),
# only get handlers for that action, as we don't want to trigger
# and pass this event to handlers for other actions.
if event_action_uuid is not None and event_action_uuid != action.uuid:
if event_action_uuid is not None and (hasattr(action, "uuid") and action.uuid != event_action_uuid):
continue

yield from action.get_event_handlers(event_name)
8 changes: 4 additions & 4 deletions streamdeck/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from streamdeck.actions import ActionRegistry
from streamdeck.command_sender import StreamDeckCommandSender
from streamdeck.models.events import event_adapter
from streamdeck.models.events import ContextualEventMixin, event_adapter
from streamdeck.utils.logging import configure_streamdeck_logger
from streamdeck.websocket import WebSocketClient

Expand Down Expand Up @@ -61,8 +61,8 @@ def register_action(self, action: Action) -> None:
Args:
action (Action): The action to register.
"""
# First, configure a logger for the action, giving it the last part of its uuid as name.
action_component_name = action.uuid.split(".")[-1]
# First, configure a logger for the action, giving it the last part of its uuid as name (if it has one).
action_component_name = action.uuid.split(".")[-1] if hasattr(action, "uuid") else "global"
configure_streamdeck_logger(name=action_component_name, plugin_uuid=self.uuid)

self._registry.register(action)
Expand All @@ -83,7 +83,7 @@ def run(self) -> None:
logger.debug("Event received: %s", data.event)

# If the event is action-specific, we'll pass the action's uuid to the handler to ensure only the correct action is triggered.
event_action_uuid: str | None = cast(str, data.action) if data.is_action_specific() else None
event_action_uuid = data.action if isinstance(data, ContextualEventMixin) else None

for handler in self._registry.get_action_handlers(event_name=data.event, event_action_uuid=event_action_uuid):
# TODO: from contextual event occurences, save metadata to the action's properties.
Expand Down
Loading
Loading