Skip to content

Commit 1409199

Browse files
authored
Merge pull request #9 from strohganoff/feature/refactor-action-loading
Feature/refactor action loading
2 parents 35c01fa + 6d9f87b commit 1409199

File tree

7 files changed

+159
-236
lines changed

7 files changed

+159
-236
lines changed

streamdeck/__main__.py

+40-161
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,38 @@
11
from __future__ import annotations
22

3-
import importlib.util
43
import json
54
import logging
65
import sys
76
from argparse import ArgumentParser
87
from pathlib import Path
9-
from typing import TYPE_CHECKING, cast
10-
11-
import tomli as toml
12-
13-
from streamdeck.actions import ActionBase
14-
from streamdeck.cli.errors import (
15-
DirectoryNotFoundError,
16-
NotAFileError,
17-
)
18-
from streamdeck.cli.models import (
19-
CliArgsNamespace,
20-
PyProjectConfigDict,
21-
StreamDeckConfigDict,
22-
)
8+
from typing import Protocol, cast
9+
2310
from streamdeck.manager import PluginManager
11+
from streamdeck.models.configs import PyProjectConfigs
2412
from streamdeck.utils.logging import configure_streamdeck_logger
2513

2614

27-
if TYPE_CHECKING:
28-
from collections.abc import Generator # noqa: I001
29-
from importlib.machinery import ModuleSpec
30-
from types import ModuleType
31-
from typing_extensions import Self # noqa: UP035
15+
logger = logging.getLogger("streamdeck")
16+
3217

3318

34-
logger = logging.getLogger("streamdeck")
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
3536

3637

3738
def setup_cli() -> ArgumentParser:
@@ -68,145 +69,27 @@ def setup_cli() -> ArgumentParser:
6869
return parser
6970

7071

71-
def determine_action_scripts(
72-
plugin_dir: Path,
73-
action_scripts: list[str] | None,
74-
) -> list[str]:
75-
"""Determine the action scripts to be loaded based on provided arguments.
76-
77-
plugin_dir and action_scripts cannot both have values -> either only one of them isn't None, or they are both None.
78-
79-
Args:
80-
plugin_dir (Path | None): The directory containing plugin files to load Actions from.
81-
action_scripts (list[str] | None): A list of action script file paths.
82-
83-
Returns:
84-
list[str]: A list of action script file paths.
85-
86-
Raises:
87-
KeyError: If the 'action_scripts' setting is missing from the streamdeck config.
88-
"""
89-
# If `action_scripts` arg was provided, then we can ignore plugin_dir (because we can assume plugin_dir is None).
90-
if action_scripts is not None:
91-
return action_scripts
92-
93-
# If `action_scripts` is None, then either plugin_dir has a value or it is the default CWD.
94-
# Thus either use the value given to plugin_value if it was given one, or fallback to using the current working directory.
95-
streamdeck_config = read_streamdeck_config_from_pyproject(plugin_dir=plugin_dir)
96-
try:
97-
return streamdeck_config["action_scripts"]
98-
99-
except KeyError as e:
100-
msg = f"'action_plugin' setting missing from streamdeck config in pyproject.toml in '{plugin_dir}'."
101-
raise KeyError(msg) from e
102-
103-
104-
def read_streamdeck_config_from_pyproject(plugin_dir: Path) -> StreamDeckConfigDict:
105-
"""Get the streamdeck section from a plugin directory by reading pyproject.toml.
106-
107-
Plugin devs add a section to their pyproject.toml for "streamdeck" to configure setup for their plugin.
108-
109-
Args:
110-
plugin_dir (Path): The directory containing the pyproject.toml and plugin files.
111-
112-
Returns:
113-
List[Path]: A list of file paths found in the specified scripts.
114-
115-
Raises:
116-
DirectoryNotFoundError: If the specified plugin_dir does not exist.
117-
NotADirectoryError: If the specified plugin_dir is not a directory.
118-
FileNotFoundError: If the pyproject.toml file does not exist in the plugin_dir.
119-
"""
120-
if not plugin_dir.exists():
121-
msg = f"The directory '{plugin_dir}' does not exist."
122-
raise DirectoryNotFoundError(msg, directory=plugin_dir)
123-
124-
pyproject_path = plugin_dir / "pyproject.toml"
125-
with pyproject_path.open("rb") as f:
126-
try:
127-
pyproject_config: PyProjectConfigDict = toml.load(f)
128-
129-
except FileNotFoundError as e:
130-
msg = f"There is no 'pyproject.toml' in the given directory '{plugin_dir}"
131-
raise FileNotFoundError(msg) from e
132-
133-
except NotADirectoryError as e:
134-
msg = f"The provided directory exists but is not a directory: '{plugin_dir}'."
135-
raise NotADirectoryError(msg) from e
136-
137-
try:
138-
streamdeck_config = pyproject_config["tool"]["streamdeck"]
139-
140-
except KeyError as e:
141-
msg = f"Section 'tool.streamdeck' is missing from '{pyproject_path}'."
142-
raise KeyError(msg) from e
143-
144-
return streamdeck_config
145-
146-
147-
class ActionLoader:
148-
@classmethod
149-
def load_actions(cls: type[Self], plugin_dir: Path, files: list[str]) -> Generator[ActionBase, None, None]:
150-
# Ensure the parent directory of the plugin modules is in `sys.path`,
151-
# so that import statements in the plugin module will work as expected.
152-
if str(plugin_dir) not in sys.path:
153-
sys.path.insert(0, str(plugin_dir))
154-
155-
for action_script in files:
156-
module = cls._load_module_from_file(filepath=Path(action_script))
157-
yield from cls._get_actions_from_loaded_module(module=module)
158-
159-
@staticmethod
160-
def _load_module_from_file(filepath: Path) -> ModuleType:
161-
"""Load module from a given Python file.
162-
163-
Args:
164-
filepath (str): The path to the Python file.
165-
166-
Returns:
167-
ModuleType: A loaded module located at the specified filepath.
168-
169-
Raises:
170-
FileNotFoundError: If the specified file does not exist.
171-
NotAFileError: If the specified file exists, but is not a file.
172-
"""
173-
# First validate the filepath arg here.
174-
if not filepath.exists():
175-
msg = f"The file '{filepath}' does not exist."
176-
raise FileNotFoundError(msg)
177-
if not filepath.is_file():
178-
msg = f"The provided filepath '{filepath}' is not a file."
179-
raise NotAFileError(msg)
180-
181-
# Create a module specification for a module located at the given filepath.
182-
# A "specification" is an object that contains information about how to load the module, such as its location and loader.
183-
# "module.name" is an arbitrary name used to identify the module internally.
184-
spec: ModuleSpec = importlib.util.spec_from_file_location("module.name", str(filepath)) # type: ignore
185-
# Create a new module object from the given specification.
186-
# At this point, the module is created but not yet loaded (i.e. its code hasn't been executed).
187-
module: ModuleType = importlib.util.module_from_spec(spec)
188-
# Load the module by executing its code, making available its functions, classes, and variables.
189-
spec.loader.exec_module(module) # type: ignore
190-
191-
return module
192-
193-
@staticmethod
194-
def _get_actions_from_loaded_module(module: ModuleType) -> Generator[ActionBase, None, None]:
195-
# Iterate over all attributes in the module to find Action subclasses
196-
for attribute_name in dir(module):
197-
attribute = getattr(module, attribute_name)
198-
# Check if the attribute is an instance of the Action class or GlobalAction class.
199-
if issubclass(type(attribute), ActionBase):
200-
yield attribute
201-
202-
203-
def main():
72+
def main() -> None:
20473
"""Main function to parse arguments, load actions, and execute them."""
20574
parser = setup_cli()
20675
args = cast(CliArgsNamespace, parser.parse_args())
20776

20877
# If `plugin_dir` was not passed in as a cli option, then fall back to using the CWD.
209-
plugin_dir = args.plugin_dir or Path.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+
90+
# Ensure plugin_dir is in `sys.path`, so that import statements in the plugin module will work as expected.
91+
if str(plugin_dir) not in sys.path:
92+
sys.path.insert(0, str(plugin_dir))
21093

21194
info = json.loads(args.info)
21295
plugin_uuid = info["plugin"]["uuid"]
@@ -215,12 +98,8 @@ def main():
21598
# a child logger with `logging.getLogger("streamdeck.mycomponent")`, all with the same handler/formatter configuration.
21699
configure_streamdeck_logger(name="streamdeck", plugin_uuid=plugin_uuid)
217100

218-
action_scripts = determine_action_scripts(
219-
plugin_dir=plugin_dir,
220-
action_scripts=args.action_scripts,
221-
)
222-
223-
actions = list(ActionLoader.load_actions(plugin_dir=plugin_dir, files=action_scripts))
101+
pyproject = PyProjectConfigs.validate_from_toml_file(plugin_dir / "pyproject.toml")
102+
actions = list(pyproject.streamdeck_plugin_actions)
224103

225104
manager = PluginManager(
226105
port=args.port,

streamdeck/cli/__init__.py

Whitespace-only changes.

streamdeck/cli/errors.py

-20
This file was deleted.

streamdeck/cli/models.py

-52
This file was deleted.

0 commit comments

Comments
 (0)