11from __future__ import annotations
22
3- import importlib .util
43import json
54import logging
65import sys
76from argparse import ArgumentParser
87from 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+
2310from streamdeck .manager import PluginManager
11+ from streamdeck .models .configs import PyProjectConfigs
2412from 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
3738def 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 ,
0 commit comments