Skip to content

Commit 53276b4

Browse files
authored
Merge pull request #10 from strohganoff/feature/typer-cli
Feature/typer cli
2 parents 1409199 + b44f6c4 commit 53276b4

File tree

4 files changed

+55
-100
lines changed

4 files changed

+55
-100
lines changed

pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"pydantic_core >= 2.23.4",
6363
"tomli >= 2.0.2",
6464
"websockets >= 13.1",
65+
"typer >= 0.15.1",
6566
]
6667

6768
[project.optional-dependencies]

streamdeck/__main__.py

+30-81
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
from __future__ import annotations
2-
31
import json
42
import logging
53
import sys
6-
from argparse import ArgumentParser
74
from pathlib import Path
8-
from typing import Protocol, cast
5+
from typing import Annotated, Union
6+
7+
import typer
98

109
from streamdeck.manager import PluginManager
1110
from streamdeck.models.configs import PyProjectConfigs
@@ -16,99 +15,45 @@
1615

1716

1817

19-
class DirectoryNotFoundError(FileNotFoundError):
20-
"""Custom exception to indicate that a specified directory was not found."""
21-
def __init__(self, *args: object, directory: Path):
22-
super().__init__(*args)
23-
self.directory = directory
24-
25-
26-
class CliArgsNamespace(Protocol):
27-
"""Represents the command-line arguments namespace."""
28-
plugin_dir: Path | None
29-
action_scripts: list[str] | None
30-
31-
# Args always passed in by StreamDeck software
32-
port: int
33-
pluginUUID: str # noqa: N815
34-
registerEvent: str # noqa: N815
35-
info: str # Actually a string representation of json object
18+
plugin = typer.Typer()
3619

3720

38-
def setup_cli() -> ArgumentParser:
39-
"""Set up the command-line interface for the script.
21+
@plugin.command()
22+
def main(
23+
port: Annotated[int, typer.Option("-p", "-port")],
24+
plugin_registration_uuid: Annotated[str, typer.Option("-pluginUUID")],
25+
register_event: Annotated[str, typer.Option("-registerEvent")],
26+
info: Annotated[str, typer.Option("-info")],
27+
plugin_dir: Annotated[Path, typer.Option(file_okay=False, exists=True, readable=True)] = Path.cwd(), # noqa: B008
28+
action_scripts: Union[list[str], None] = None, # noqa: UP007
29+
) -> None:
30+
"""Start the Stream Deck plugin with the given configuration.
4031
41-
Returns:
42-
argparse.ArgumentParser: The argument parser for the CLI.
32+
NOTE: Single flag long-name options are extected & passed in by the Stream Deck software.
33+
Double flag long-name options are used during development and testing.
4334
"""
44-
parser = ArgumentParser(description="CLI to load Actions from action scripts.")
45-
group = parser.add_mutually_exclusive_group(required=False)
46-
group.add_argument(
47-
"plugin_dir",
48-
type=Path,
49-
nargs="?",
50-
help="The directory containing plugin files to load Actions from.",
51-
)
52-
group.add_argument(
53-
"--action-scripts",
54-
type=str,
55-
nargs="+",
56-
help="A list of action script file paths to load Actions from or a single value to be processed.",
57-
)
58-
59-
# Options that will always be passed in by the StreamDeck software when running this plugin.
60-
parser.add_argument("-port", dest="port", type=int, help="Port", required=True)
61-
parser.add_argument(
62-
"-pluginUUID", dest="pluginUUID", type=str, help="pluginUUID", required=True
63-
)
64-
parser.add_argument(
65-
"-registerEvent", dest="registerEvent", type=str, help="registerEvent", required=True
66-
)
67-
parser.add_argument("-info", dest="info", type=str, help="info", required=True)
68-
69-
return parser
70-
71-
72-
def main() -> None:
73-
"""Main function to parse arguments, load actions, and execute them."""
74-
parser = setup_cli()
75-
args = cast(CliArgsNamespace, parser.parse_args())
76-
77-
# If `plugin_dir` was not passed in as a cli option, then fall back to using the CWD.
78-
if args.plugin_dir is None:
79-
plugin_dir = Path.cwd()
80-
# Also validate the plugin_dir argument.
81-
elif not args.plugin_dir.is_dir():
82-
msg = f"The provided plugin directory '{args.plugin_dir}' is not a directory."
83-
raise NotADirectoryError(msg)
84-
elif not args.plugin_dir.exists():
85-
msg = f"The provided plugin directory '{args.plugin_dir}' does not exist."
86-
raise DirectoryNotFoundError(msg, directory=args.plugin_dir)
87-
else:
88-
plugin_dir = args.plugin_dir
89-
9035
# Ensure plugin_dir is in `sys.path`, so that import statements in the plugin module will work as expected.
9136
if str(plugin_dir) not in sys.path:
9237
sys.path.insert(0, str(plugin_dir))
9338

94-
info = json.loads(args.info)
95-
plugin_uuid = info["plugin"]["uuid"]
39+
info_data = json.loads(info)
40+
plugin_uuid = info_data["plugin"]["uuid"]
9641

9742
# After configuring once here, we can grab the logger in any other module with `logging.getLogger("streamdeck")`, or
9843
# a child logger with `logging.getLogger("streamdeck.mycomponent")`, all with the same handler/formatter configuration.
9944
configure_streamdeck_logger(name="streamdeck", plugin_uuid=plugin_uuid)
10045

101-
pyproject = PyProjectConfigs.validate_from_toml_file(plugin_dir / "pyproject.toml")
102-
actions = list(pyproject.streamdeck_plugin_actions)
46+
pyproject = PyProjectConfigs.validate_from_toml_file(plugin_dir / "pyproject.toml", action_scripts=action_scripts)
47+
actions = pyproject.streamdeck_plugin_actions
10348

10449
manager = PluginManager(
105-
port=args.port,
50+
port=port,
10651
plugin_uuid=plugin_uuid,
10752
# NOT the configured plugin UUID in the manifest.json,
10853
# which can be pulled out of `info["plugin"]["uuid"]`
109-
plugin_registration_uuid=args.pluginUUID,
110-
register_event=args.registerEvent,
111-
info=info,
54+
plugin_registration_uuid=plugin_registration_uuid,
55+
register_event=register_event,
56+
info=info_data,
11257
)
11358

11459
for action in actions:
@@ -117,5 +62,9 @@ def main() -> None:
11762
manager.run()
11863

11964

120-
if __name__ == "__main__":
121-
main()
65+
# Also run the plugin if this script is ran as a console script.
66+
if __name__ in ("__main__", "streamdeck.__main__"):
67+
plugin()
68+
69+
70+

streamdeck/manager.py

+3-4
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from logging import getLogger
55
from typing import TYPE_CHECKING
66

7-
from streamdeck.actions import ActionRegistry
7+
from streamdeck.actions import Action, ActionBase, ActionRegistry
88
from streamdeck.command_sender import StreamDeckCommandSender
99
from streamdeck.models.events import ContextualEventMixin, event_adapter
1010
from streamdeck.types import (
@@ -21,7 +21,6 @@
2121
if TYPE_CHECKING:
2222
from typing import Any, Literal
2323

24-
from streamdeck.actions import Action
2524
from streamdeck.models.events import EventBase
2625

2726

@@ -62,14 +61,14 @@ def __init__(
6261

6362
self._registry = ActionRegistry()
6463

65-
def register_action(self, action: Action) -> None:
64+
def register_action(self, action: ActionBase) -> None:
6665
"""Register an action with the PluginManager, and configure its logger.
6766
6867
Args:
6968
action (Action): The action to register.
7069
"""
7170
# First, configure a logger for the action, giving it the last part of its uuid as name (if it has one).
72-
action_component_name = action.uuid.split(".")[-1] if hasattr(action, "uuid") else "global"
71+
action_component_name = action.uuid.split(".")[-1] if isinstance(action, Action) else "global"
7372
configure_streamdeck_logger(name=action_component_name, plugin_uuid=self.uuid)
7473

7574
self._registry.register(action)

streamdeck/models/configs.py

+21-15
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
ImportString,
1111
ValidationInfo,
1212
field_validator,
13+
model_validator,
1314
)
1415

1516
from streamdeck.actions import ActionBase
@@ -33,14 +34,32 @@ def validate_from_toml_file(cls, filepath: Path, action_scripts: list[str] | Non
3334
with filepath.open("rb") as f:
3435
pyproject_configs = toml.load(f)
3536

36-
# Pass the action scripts to the context dictionary if they are provided, so they can be used in the before-validater for the nested StreamDeckToolConfig model.
37+
# Pass the action scripts to the context dictionary if they are provided,
38+
# so they can be used in the before-validater for the nested StreamDeckToolConfig model.
3739
ctx = {"action_scripts": action_scripts} if action_scripts else None
3840

3941
# Return the loaded PyProjectConfigs model instance.
4042
return cls.model_validate(pyproject_configs, context=ctx)
4143

44+
@model_validator(mode="before")
45+
@classmethod
46+
def overwrite_action_scripts(cls, data: object, info: ValidationInfo) -> object:
47+
"""If action scripts were provided as a context variable, overwrite the action_scripts field in the PyProjectConfigs model."""
48+
context = info.context
49+
50+
# If no action scripts were provided, return the data as-is.
51+
if context is None or "action_scripts" not in context:
52+
return data
53+
54+
# If data isn't a dict as expected, let Pydantic's validation handle them as usual in its next validations step.
55+
if isinstance(data, dict):
56+
# We also need to ensure the "tool" and "streamdeck" sections exist in the data dictionary in case they were not defined in the PyProject.toml file.
57+
data.setdefault("tool", {}).setdefault("streamdeck", {})["action_scripts"] = context["action_scripts"]
58+
59+
return data
60+
4261
@property
43-
def streamdeck_plugin_actions(self) -> Generator[type[ActionBase], Any, None]:
62+
def streamdeck_plugin_actions(self) -> Generator[ActionBase, Any, None]:
4463
"""Reach into the [tool.streamdeck] section of the PyProject.toml file and yield the plugin's actions configured by the developer."""
4564
for loaded_action_script in self.tool.streamdeck.action_script_modules:
4665
for object_name in dir(loaded_action_script):
@@ -72,19 +91,6 @@ class StreamDeckToolConfig(BaseModel, arbitrary_types_allowed=True):
7291
This field is filtered to only include objects that are subclasses of ActionBase (as well as the built-in magic methods and attributes typically found in a module).
7392
"""
7493

75-
@field_validator("action_script_modules", mode="before")
76-
@classmethod
77-
def overwrite_action_scripts_with_user_provided_data(cls, value: list[str], info: ValidationInfo) -> list[str]:
78-
"""Overwrite the list of action script modules with the user-provided data.
79-
80-
NOTE: This is a before-validator that runs before the next field_validator method on the same field.
81-
"""
82-
# If the user provided action_scripts to load, use that instead of the value from the PyProject.toml file.
83-
if info.context is not None and "action_scripts" in info.context:
84-
return info.context["action_scripts"]
85-
86-
return value
87-
8894
@field_validator("action_script_modules", mode="after")
8995
@classmethod
9096
def filter_module_objects(cls, value: list[ModuleType]) -> list[ModuleType]:

0 commit comments

Comments
 (0)