1
1
from __future__ import annotations
2
2
3
- import importlib .util
4
3
import json
5
4
import logging
6
5
import sys
7
6
from argparse import ArgumentParser
8
7
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
+
23
10
from streamdeck .manager import PluginManager
11
+ from streamdeck .models .configs import PyProjectConfigs
24
12
from streamdeck .utils .logging import configure_streamdeck_logger
25
13
26
14
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
+
32
17
33
18
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
35
36
36
37
37
38
def setup_cli () -> ArgumentParser :
@@ -68,145 +69,27 @@ def setup_cli() -> ArgumentParser:
68
69
return parser
69
70
70
71
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 :
204
73
"""Main function to parse arguments, load actions, and execute them."""
205
74
parser = setup_cli ()
206
75
args = cast (CliArgsNamespace , parser .parse_args ())
207
76
208
77
# 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 ))
210
93
211
94
info = json .loads (args .info )
212
95
plugin_uuid = info ["plugin" ]["uuid" ]
@@ -215,12 +98,8 @@ def main():
215
98
# a child logger with `logging.getLogger("streamdeck.mycomponent")`, all with the same handler/formatter configuration.
216
99
configure_streamdeck_logger (name = "streamdeck" , plugin_uuid = plugin_uuid )
217
100
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 )
224
103
225
104
manager = PluginManager (
226
105
port = args .port ,
0 commit comments