gl-plugin 0.0.1b2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gl_plugin-0.0.1b2/PKG-INFO +16 -0
- gl_plugin-0.0.1b2/README.md +0 -0
- gl_plugin-0.0.1b2/gl_plugin/__init__.py +1 -0
- gl_plugin-0.0.1b2/gl_plugin/plugin/__init__.py +1 -0
- gl_plugin-0.0.1b2/gl_plugin/plugin/handler.py +55 -0
- gl_plugin-0.0.1b2/gl_plugin/plugin/manager.py +395 -0
- gl_plugin-0.0.1b2/gl_plugin/plugin/plugin.py +99 -0
- gl_plugin-0.0.1b2/gl_plugin/plugin/registry.py +92 -0
- gl_plugin-0.0.1b2/gl_plugin/services/__init__.py +5 -0
- gl_plugin-0.0.1b2/gl_plugin/services/config.py +97 -0
- gl_plugin-0.0.1b2/gl_plugin.egg-info/PKG-INFO +16 -0
- gl_plugin-0.0.1b2/gl_plugin.egg-info/SOURCES.txt +16 -0
- gl_plugin-0.0.1b2/gl_plugin.egg-info/dependency_links.txt +1 -0
- gl_plugin-0.0.1b2/gl_plugin.egg-info/requires.txt +11 -0
- gl_plugin-0.0.1b2/gl_plugin.egg-info/top_level.txt +1 -0
- gl_plugin-0.0.1b2/pyproject.toml +29 -0
- gl_plugin-0.0.1b2/setup.cfg +4 -0
- gl_plugin-0.0.1b2/setup.py +10 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gl-plugin
|
|
3
|
+
Version: 0.0.1b2
|
|
4
|
+
Summary: GL SDK's Plugin Architecture Implementation
|
|
5
|
+
Requires-Python: <3.14,>=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: python-dotenv>=1.2.1
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pre-commit<4.0.0,>=3.6.2; extra == "dev"
|
|
10
|
+
Requires-Dist: pytest<9.0.0,>=8.3.4; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest-mock<4.0.0,>=3.14.0; extra == "dev"
|
|
12
|
+
Requires-Dist: pytest-asyncio<1.0.0,>=0.25.3; extra == "dev"
|
|
13
|
+
Requires-Dist: coverage<8.0.0,>=7.6.10; extra == "dev"
|
|
14
|
+
Requires-Dist: mypy<2.0.0,>=1.11.2; extra == "dev"
|
|
15
|
+
Requires-Dist: ruff<1.0.0,>=0.9.9; extra == "dev"
|
|
16
|
+
Requires-Dist: pytest-cov<6.0.0,>=5.0.0; extra == "dev"
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""GL SDK's Plugin Architecture Implementation."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Plugin Modules."""
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""PluginHandler interface for plugin system.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Samuel Lusandi (samuel.lusandi@gdplabs.id)
|
|
5
|
+
|
|
6
|
+
References:
|
|
7
|
+
None
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from typing import Any, Dict, Type
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PluginHandler(ABC):
|
|
15
|
+
"""Base interface for classes that can provide injections to the plugin system."""
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def create_injections(cls, instance: Any) -> Dict[Type, Any]:
|
|
20
|
+
"""Create injection mappings for this interface.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
instance: The instance that will provide the injections
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Dictionary mapping service types to their instances
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def initialize_plugin(cls, instance: Any, plugin: Any) -> None:
|
|
32
|
+
"""Initialize plugin-specific resources for this interface.
|
|
33
|
+
|
|
34
|
+
This method is called after the plugin is created and services are injected.
|
|
35
|
+
Override this method to perform any plugin-specific initialization.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
instance: The instance that provides the initialization
|
|
39
|
+
plugin: The plugin instance to initialize
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
async def ainitialize_plugin(cls, instance: Any, plugin: Any) -> None: # pragma: no cover
|
|
44
|
+
"""Initialize plugin-specific resources for this interface.
|
|
45
|
+
|
|
46
|
+
This method is called after the plugin is created and services are injected.
|
|
47
|
+
Override this method to perform any plugin-specific initialization. This method is
|
|
48
|
+
provided for compatibility with async plugins. By default it just calls the sync
|
|
49
|
+
version of the method.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
instance: The instance that provides the initialization
|
|
53
|
+
plugin: The plugin instance to initialize
|
|
54
|
+
"""
|
|
55
|
+
cls.initialize_plugin(instance, plugin)
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Plugin manager.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Samuel Lusandi (samuel.lusandi@gdplabs.id)
|
|
5
|
+
|
|
6
|
+
References:
|
|
7
|
+
None
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from threading import Lock
|
|
14
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, TypeVar, Union, get_origin
|
|
15
|
+
|
|
16
|
+
from dotenv import load_dotenv
|
|
17
|
+
|
|
18
|
+
from gl_plugin.plugin.handler import PluginHandler
|
|
19
|
+
from gl_plugin.plugin.plugin import Plugin
|
|
20
|
+
from gl_plugin.plugin.registry import ServiceRegistry
|
|
21
|
+
from gl_plugin.services.config import ConfigService
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T", bound=PluginHandler)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HandlerNotFoundError(RuntimeError):
|
|
27
|
+
"""Raised when a requested plugin handler is not found."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PluginManager:
|
|
31
|
+
"""Manages plugin lifecycle and service injection.
|
|
32
|
+
|
|
33
|
+
This manager can handle different types of plugins based on the services provided.
|
|
34
|
+
Services are automatically registered based on the plugin handler interfaces provided.
|
|
35
|
+
|
|
36
|
+
This class follows the singleton pattern - only one instance will ever exist.
|
|
37
|
+
Thread-safe implementation using double-checked locking pattern.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_instance = None
|
|
41
|
+
_multi_instance = {}
|
|
42
|
+
_lock = Lock()
|
|
43
|
+
_initialized = False
|
|
44
|
+
_multi_initialized = {} # Track initialization per key
|
|
45
|
+
|
|
46
|
+
def __new__(
|
|
47
|
+
cls,
|
|
48
|
+
*,
|
|
49
|
+
handlers: Optional[List[PluginHandler]] = None,
|
|
50
|
+
env_file: Optional[str] = None,
|
|
51
|
+
global_services: List[Any] = (),
|
|
52
|
+
key_instance: Optional[str] = None,
|
|
53
|
+
) -> "PluginManager":
|
|
54
|
+
"""Create or return the singleton instance or multi-instance.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
handlers: List of plugin handlers that provide injections
|
|
58
|
+
env_file: Optional environment file for loading environment variables
|
|
59
|
+
global_services: List of services to register globally
|
|
60
|
+
key_instance: Optional key for multi-instance pattern. If None, uses singleton.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
The singleton PluginManager instance or specified multi-instance
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ValueError: If handlers is not provided during first initialization
|
|
67
|
+
"""
|
|
68
|
+
with cls._lock:
|
|
69
|
+
if key_instance is not None:
|
|
70
|
+
# Multi-instance pattern
|
|
71
|
+
if key_instance not in cls._multi_instance:
|
|
72
|
+
if handlers is None:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"handlers must be provided when creating instance '{key_instance}' for the first time"
|
|
75
|
+
)
|
|
76
|
+
instance = super().__new__(cls)
|
|
77
|
+
instance._key_instance = key_instance # Store the key for this instance
|
|
78
|
+
instance._initialized = False
|
|
79
|
+
cls._multi_instance[key_instance] = instance
|
|
80
|
+
cls._multi_initialized[key_instance] = False
|
|
81
|
+
return cls._multi_instance[key_instance]
|
|
82
|
+
else:
|
|
83
|
+
# Singleton pattern
|
|
84
|
+
if cls._instance is None:
|
|
85
|
+
if handlers is None:
|
|
86
|
+
raise ValueError("handlers must be provided when creating the first instance")
|
|
87
|
+
instance = super().__new__(cls)
|
|
88
|
+
instance._key_instance = None # Mark as singleton
|
|
89
|
+
instance._initialized = False
|
|
90
|
+
cls._instance = instance
|
|
91
|
+
return cls._instance
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
handlers: Optional[List[PluginHandler]] = None,
|
|
97
|
+
env_file: Optional[str] = None,
|
|
98
|
+
global_services: List[Any] = (),
|
|
99
|
+
key_instance: Optional[str] = None,
|
|
100
|
+
):
|
|
101
|
+
"""Initialize plugin manager.
|
|
102
|
+
|
|
103
|
+
This will only run once per instance (singleton or multi-instance).
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
handlers: List of plugin handlers that provide injections
|
|
107
|
+
env_file: Optional environment file for loading environment variables
|
|
108
|
+
global_services: List of custom services to be injected into the global registry
|
|
109
|
+
key_instance: Optional key for multi-instance pattern
|
|
110
|
+
"""
|
|
111
|
+
with self._lock:
|
|
112
|
+
# Check if this specific instance is already initialized
|
|
113
|
+
if key_instance is not None:
|
|
114
|
+
if self._multi_initialized.get(key_instance, False):
|
|
115
|
+
return
|
|
116
|
+
self._multi_initialized[key_instance] = True
|
|
117
|
+
else:
|
|
118
|
+
if self._initialized:
|
|
119
|
+
return
|
|
120
|
+
self._initialized = True
|
|
121
|
+
|
|
122
|
+
if not handlers:
|
|
123
|
+
raise ValueError("At least one plugin handler must be provided")
|
|
124
|
+
|
|
125
|
+
self._load_environment(env_file)
|
|
126
|
+
|
|
127
|
+
self._handler_registries: Dict[Type[PluginHandler], ServiceRegistry] = {}
|
|
128
|
+
self._plugins: Dict[str, Plugin] = {}
|
|
129
|
+
self._handlers: Dict[Type[PluginHandler], PluginHandler] = {}
|
|
130
|
+
|
|
131
|
+
for handler in handlers:
|
|
132
|
+
handler_type = type(handler)
|
|
133
|
+
registry = ServiceRegistry()
|
|
134
|
+
registry.register(ConfigService, ConfigService())
|
|
135
|
+
|
|
136
|
+
for service in global_services:
|
|
137
|
+
registry.register(type(service), service)
|
|
138
|
+
|
|
139
|
+
for service_type, service_instance in handler_type.create_injections(handler).items():
|
|
140
|
+
registry.register(service_type, service_instance)
|
|
141
|
+
|
|
142
|
+
self._handler_registries[handler_type] = registry
|
|
143
|
+
self._handlers[handler_type] = handler
|
|
144
|
+
|
|
145
|
+
def _load_environment(self, env_file: Optional[str] = None) -> None:
|
|
146
|
+
"""Load environment variables from .env file.
|
|
147
|
+
|
|
148
|
+
If env_file is provided, loads from that file.
|
|
149
|
+
Otherwise, searches for .env in current directory and up to 3 levels up.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
env_file: Optional path to environment file
|
|
153
|
+
"""
|
|
154
|
+
if env_file:
|
|
155
|
+
load_dotenv(env_file)
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
current_dir = Path.cwd()
|
|
159
|
+
env_file_path = None
|
|
160
|
+
|
|
161
|
+
for _ in range(4):
|
|
162
|
+
if (current_dir / ".env").exists():
|
|
163
|
+
env_file_path = current_dir / ".env"
|
|
164
|
+
break
|
|
165
|
+
current_dir = current_dir.parent
|
|
166
|
+
|
|
167
|
+
if env_file_path:
|
|
168
|
+
load_dotenv(env_file_path)
|
|
169
|
+
|
|
170
|
+
def _prepare_plugin_registration( # noqa: PLR0912
|
|
171
|
+
self, plugin_class: Type[Plugin], additional_params: Optional[Dict[str, Any]] = None
|
|
172
|
+
) -> Tuple[Any, ServiceRegistry, Type, Plugin]:
|
|
173
|
+
"""Prepare plugin registration by validating and setting up plugin instance.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
plugin_class: Plugin class to register
|
|
177
|
+
additional_params: Optional dictionary of keyword arguments to pass to the plugin constructor
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Tuple containing:
|
|
181
|
+
- compatible_handler: The handler instance compatible with this plugin
|
|
182
|
+
- compatible_registry: The service registry to use for this plugin
|
|
183
|
+
- handler_type: The type of handler required by the plugin
|
|
184
|
+
- plugin: The instantiated plugin instance
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
ValueError: If plugin doesn't specify a handler type
|
|
188
|
+
RuntimeError: If no compatible handler is found
|
|
189
|
+
"""
|
|
190
|
+
if not plugin_class.get_handler_type():
|
|
191
|
+
raise ValueError(
|
|
192
|
+
f"Plugin {plugin_class.__name__} must specify a handler type using @Plugin.for_handler decorator"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Find compatible handler (exact match or subclass)
|
|
196
|
+
handler_type = plugin_class.get_handler_type()
|
|
197
|
+
compatible_handler = None
|
|
198
|
+
compatible_registry = None
|
|
199
|
+
|
|
200
|
+
for registered_type, handler in self._handlers.items():
|
|
201
|
+
if issubclass(type(handler), handler_type):
|
|
202
|
+
compatible_handler = handler
|
|
203
|
+
compatible_registry = self._handler_registries[registered_type]
|
|
204
|
+
break
|
|
205
|
+
|
|
206
|
+
if not compatible_handler:
|
|
207
|
+
raise RuntimeError(
|
|
208
|
+
f"Plugin {plugin_class.__name__} requires handler {handler_type.__name__} or a subclass, "
|
|
209
|
+
"but none was provided"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
logging.info(f"Initializing plugin {plugin_class.__name__}")
|
|
213
|
+
|
|
214
|
+
# Validate plugin requirements before instantiation
|
|
215
|
+
hints = plugin_class.__annotations__ if hasattr(plugin_class, "__annotations__") else {}
|
|
216
|
+
|
|
217
|
+
# Check for Union types and required services against handler-specific registry
|
|
218
|
+
unsupported_unions = []
|
|
219
|
+
missing_services = []
|
|
220
|
+
|
|
221
|
+
for field_name, service in hints.items():
|
|
222
|
+
if isinstance(service, type) and service.__module__ == "builtins":
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
# Check for both Union[] syntax and | syntax (PEP 604)
|
|
226
|
+
is_union = (hasattr(service, "__origin__") and service.__origin__ is Union) or (
|
|
227
|
+
hasattr(service, "__or__")
|
|
228
|
+
and not isinstance(service, type)
|
|
229
|
+
and any(arg is type(None) for arg in service.__args__)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
if is_union:
|
|
233
|
+
unsupported_unions.append(field_name)
|
|
234
|
+
else:
|
|
235
|
+
target_type = get_origin(service) or service
|
|
236
|
+
|
|
237
|
+
service_found = False
|
|
238
|
+
for registered_type in compatible_registry._services:
|
|
239
|
+
registered_base = get_origin(registered_type) or registered_type
|
|
240
|
+
try:
|
|
241
|
+
if isinstance(registered_base, type) and issubclass(registered_base, target_type):
|
|
242
|
+
service_found = True
|
|
243
|
+
break
|
|
244
|
+
except TypeError:
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
if not service_found:
|
|
248
|
+
missing_services.append(getattr(service, "__name__", str(service)))
|
|
249
|
+
|
|
250
|
+
if unsupported_unions:
|
|
251
|
+
logging.warning(
|
|
252
|
+
f"Plugin {plugin_class.__name__} using handler {handler_type.__name__} has "
|
|
253
|
+
f"Union types which are not supported for injection: {', '.join(unsupported_unions)}",
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if missing_services:
|
|
257
|
+
logging.warning(
|
|
258
|
+
f"Plugin {plugin_class.__name__} using handler {handler_type.__name__} "
|
|
259
|
+
f"could not inject the following services: {', '.join(missing_services)}",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Set handler-specific registry and initialize plugin
|
|
263
|
+
plugin_class.set_registry(compatible_registry)
|
|
264
|
+
plugin = plugin_class(**additional_params) if additional_params else plugin_class()
|
|
265
|
+
self._plugins[plugin.name] = plugin
|
|
266
|
+
|
|
267
|
+
return compatible_handler, compatible_registry, handler_type, plugin
|
|
268
|
+
|
|
269
|
+
def register_plugin( # noqa: PLR0912
|
|
270
|
+
self,
|
|
271
|
+
plugin_class: Type[Plugin],
|
|
272
|
+
custom_initializer: Optional[Callable[[Plugin], None]] = None,
|
|
273
|
+
additional_params: Optional[Dict[str, Any]] = None,
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Register and initialize a plugin.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
plugin_class: Plugin class to register
|
|
279
|
+
custom_initializer: Optional callable that will be called with the plugin instance after initialization
|
|
280
|
+
additional_params: Optional dictionary of keyword arguments to pass to the plugin constructor
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
ValueError: If plugin doesn't specify a handler type
|
|
284
|
+
"""
|
|
285
|
+
compatible_handler, _, _, plugin = self._prepare_plugin_registration(plugin_class, additional_params)
|
|
286
|
+
|
|
287
|
+
# Initialize plugin with its specific handler
|
|
288
|
+
type(compatible_handler).initialize_plugin(compatible_handler, plugin)
|
|
289
|
+
|
|
290
|
+
# Call custom initializer if provided
|
|
291
|
+
if custom_initializer is not None:
|
|
292
|
+
custom_initializer(plugin)
|
|
293
|
+
|
|
294
|
+
async def aregister_plugin(
|
|
295
|
+
self,
|
|
296
|
+
plugin_class: Type[Plugin],
|
|
297
|
+
custom_initializer: Optional[Callable[[Plugin], Any]] = None,
|
|
298
|
+
additional_params: Optional[Dict[str, Any]] = None,
|
|
299
|
+
) -> None:
|
|
300
|
+
"""Register and initialize a plugin asynchronously.
|
|
301
|
+
|
|
302
|
+
This is backwards compatible with the synchronous version;
|
|
303
|
+
passing a synchronous plugin will be handled appropriately.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
plugin_class: Plugin class to register
|
|
307
|
+
custom_initializer: Optional callable that will be called with the plugin instance after initialization.
|
|
308
|
+
Can be either a synchronous or asynchronous function.
|
|
309
|
+
additional_params: Optional dictionary of keyword arguments to pass to the plugin constructor
|
|
310
|
+
|
|
311
|
+
Raises:
|
|
312
|
+
ValueError: If plugin doesn't specify a handler type
|
|
313
|
+
"""
|
|
314
|
+
compatible_handler, _, _, plugin = self._prepare_plugin_registration(plugin_class, additional_params)
|
|
315
|
+
|
|
316
|
+
if asyncio.iscoroutinefunction(type(compatible_handler).ainitialize_plugin):
|
|
317
|
+
await type(compatible_handler).ainitialize_plugin(compatible_handler, plugin)
|
|
318
|
+
else:
|
|
319
|
+
type(compatible_handler).initialize_plugin(compatible_handler, plugin)
|
|
320
|
+
|
|
321
|
+
# Call custom initializer if provided
|
|
322
|
+
if custom_initializer is not None:
|
|
323
|
+
if asyncio.iscoroutinefunction(custom_initializer):
|
|
324
|
+
await custom_initializer(plugin)
|
|
325
|
+
else:
|
|
326
|
+
custom_initializer(plugin)
|
|
327
|
+
|
|
328
|
+
def get_plugin(self, name: str) -> Optional[Plugin]:
|
|
329
|
+
"""Get a plugin by name.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
name: Name of plugin to get
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
Plugin instance if found, None otherwise
|
|
336
|
+
"""
|
|
337
|
+
return self._plugins.get(name)
|
|
338
|
+
|
|
339
|
+
def get_plugins(
|
|
340
|
+
self, handler_type: Optional[Type[PluginHandler]] = None, plugin_names: Optional[List[str]] = None
|
|
341
|
+
) -> List[Plugin]:
|
|
342
|
+
"""Get all registered plugins, optionally filtered by handler type and names.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
handler_type: Optional handler type to filter plugins by. If provided,
|
|
346
|
+
only returns plugins that have this handler type registered.
|
|
347
|
+
plugin_names: Optional list of plugin names to filter by. If provided,
|
|
348
|
+
only returns plugins whose names are in this list.
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
List of plugin instances
|
|
352
|
+
"""
|
|
353
|
+
plugins = list(self._plugins.values())
|
|
354
|
+
|
|
355
|
+
if handler_type is not None:
|
|
356
|
+
plugins = [
|
|
357
|
+
plugin for plugin in plugins if plugin.handler_type and issubclass(plugin.handler_type, handler_type)
|
|
358
|
+
]
|
|
359
|
+
|
|
360
|
+
if plugin_names is not None:
|
|
361
|
+
plugins = [plugin for plugin in plugins if plugin.name in plugin_names]
|
|
362
|
+
|
|
363
|
+
return plugins
|
|
364
|
+
|
|
365
|
+
def get_handlers(self, handler_type: Optional[Type[PluginHandler]] = None) -> List[PluginHandler]:
|
|
366
|
+
"""Get all registered handlers, optionally filtered by type.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
handler_type: Optional handler type to filter by. If provided,
|
|
370
|
+
only returns handlers that are instances of this type.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
List of handler instances
|
|
374
|
+
"""
|
|
375
|
+
if handler_type is None:
|
|
376
|
+
return list(self._handlers.values())
|
|
377
|
+
|
|
378
|
+
return [handler for handler in self._handlers.values() if isinstance(handler, handler_type)]
|
|
379
|
+
|
|
380
|
+
def get_handler(self, handler_type: Type[T]) -> T:
|
|
381
|
+
"""Get a handler of the specified type.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
handler_type: The type of handler to get
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
The handler instance of the specified type
|
|
388
|
+
|
|
389
|
+
Raises:
|
|
390
|
+
HandlerNotFoundError: If no handler of the specified type is found
|
|
391
|
+
"""
|
|
392
|
+
for handler in self._handlers.values():
|
|
393
|
+
if isinstance(handler, handler_type):
|
|
394
|
+
return handler # type: ignore
|
|
395
|
+
raise HandlerNotFoundError(f"No handler of type {handler_type.__name__} found")
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Plugin base class for the Plugin Architecture.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Samuel Lusandi (samuel.lusandi@gdplabs.id)
|
|
5
|
+
|
|
6
|
+
References:
|
|
7
|
+
None
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Callable, Optional, Type
|
|
11
|
+
|
|
12
|
+
from gl_plugin.plugin.handler import PluginHandler
|
|
13
|
+
from gl_plugin.plugin.registry import ServiceRegistry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Plugin:
|
|
17
|
+
"""Base class for BOSA Plugins."""
|
|
18
|
+
|
|
19
|
+
name: str = None
|
|
20
|
+
description: str = None
|
|
21
|
+
version: str = None
|
|
22
|
+
_handler_type: Optional[Type["PluginHandler"]] = None
|
|
23
|
+
|
|
24
|
+
_registry: Optional[ServiceRegistry] = None
|
|
25
|
+
|
|
26
|
+
def __init__(self, *args, **kwargs):
|
|
27
|
+
"""Initialize plugin instance.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
ValueError: If required class attributes are not set
|
|
31
|
+
"""
|
|
32
|
+
super().__init__(*args, **kwargs)
|
|
33
|
+
|
|
34
|
+
if not all([self.name, self.description, self.version]):
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"Plugin class {self.__class__.__name__} must set name, description, and version as class attributes"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def get_handler_type(cls) -> Optional[Type["PluginHandler"]]:
|
|
41
|
+
"""Get the handler type for this plugin class.
|
|
42
|
+
|
|
43
|
+
This method walks up the inheritance chain to find the handler_type.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The handler type for this plugin, or None if not set
|
|
47
|
+
"""
|
|
48
|
+
for base in cls.__mro__:
|
|
49
|
+
if "_handler_type" in base.__dict__:
|
|
50
|
+
return base._handler_type
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def for_handler(cls, handler_type: Type["PluginHandler"]) -> Callable[[Type["Plugin"]], Type["Plugin"]]:
|
|
55
|
+
"""Decorator to specify which handler this plugin is designed for.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
handler_type: The type of handler this plugin works with
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
A decorator function that sets the handler type on the plugin class
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def decorator(plugin_cls: Type["Plugin"]) -> Type["Plugin"]:
|
|
65
|
+
plugin_cls._handler_type = handler_type
|
|
66
|
+
return plugin_cls
|
|
67
|
+
|
|
68
|
+
return decorator
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def set_registry(cls, registry: ServiceRegistry) -> None:
|
|
72
|
+
"""Set the service registry for this plugin class.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
registry: Service registry to use for dependency injection
|
|
76
|
+
"""
|
|
77
|
+
cls._registry = registry
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def handler_type(self) -> Optional[Type["PluginHandler"]]:
|
|
81
|
+
"""Get the handler type for this plugin instance.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
The handler type for this plugin, or None if not set
|
|
85
|
+
"""
|
|
86
|
+
return self.__class__.get_handler_type()
|
|
87
|
+
|
|
88
|
+
def __new__(cls, *args, **kwargs):
|
|
89
|
+
"""Create a new plugin instance with injected services.
|
|
90
|
+
|
|
91
|
+
This is called before __init__ and allows us to inject services
|
|
92
|
+
before the instance is initialized.
|
|
93
|
+
"""
|
|
94
|
+
instance = super().__new__(cls)
|
|
95
|
+
|
|
96
|
+
if cls._registry is not None:
|
|
97
|
+
cls._registry.inject_services(instance)
|
|
98
|
+
|
|
99
|
+
return instance
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Service registry for core services that can be injected into plugins.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Samuel Lusandi (samuel.lusandi@gdplabs.id)
|
|
5
|
+
|
|
6
|
+
References:
|
|
7
|
+
None
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, Type, TypeVar, get_origin, get_type_hints
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ServiceRegistry:
|
|
16
|
+
"""Registry for core services that can be injected into plugins."""
|
|
17
|
+
|
|
18
|
+
def __init__(self):
|
|
19
|
+
"""Initialize an empty service registry."""
|
|
20
|
+
self._services: Dict[Type, Any] = {}
|
|
21
|
+
|
|
22
|
+
def register(self, service_type: Type[T], instance: T) -> None:
|
|
23
|
+
"""Register a service instance for a given type.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
service_type: The type of the service (usually its class)
|
|
27
|
+
instance: The service instance
|
|
28
|
+
"""
|
|
29
|
+
self._services[service_type] = instance
|
|
30
|
+
|
|
31
|
+
def get(self, service_type: Type[T]) -> T:
|
|
32
|
+
"""Get a service instance by its type.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
service_type: The type of service to retrieve
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
The service instance
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
TypeError: If the service type is a built-in type
|
|
42
|
+
KeyError: If the service type is not registered
|
|
43
|
+
"""
|
|
44
|
+
if isinstance(service_type, type) and service_type.__module__ == "builtins":
|
|
45
|
+
raise TypeError(
|
|
46
|
+
f"Built-in type {service_type.__name__} cannot be injected. Pass this value directly instead."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if service_type not in self._services:
|
|
50
|
+
raise KeyError(f"Service {getattr(service_type, '__name__', str(service_type))} not registered")
|
|
51
|
+
return self._services[service_type]
|
|
52
|
+
|
|
53
|
+
def inject_services(self, target: Any) -> None:
|
|
54
|
+
"""Inject registered services into an object based on its type hints.
|
|
55
|
+
|
|
56
|
+
This will look for class-level type hints and inject matching services.
|
|
57
|
+
If a service is registered that is a subclass of the requested type,
|
|
58
|
+
it will be injected. Handles both regular and generic types.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
target: The object to inject services into
|
|
62
|
+
"""
|
|
63
|
+
hints = get_type_hints(target.__class__)
|
|
64
|
+
|
|
65
|
+
for attr_name, attr_type in hints.items():
|
|
66
|
+
# First try exact type match
|
|
67
|
+
service = self._services.get(attr_type)
|
|
68
|
+
|
|
69
|
+
# If no exact match, look for a subclass that implements the interface
|
|
70
|
+
if service is None:
|
|
71
|
+
target_type = get_origin(attr_type) or attr_type
|
|
72
|
+
|
|
73
|
+
for registered_type, registered_service in self._services.items():
|
|
74
|
+
registered_base = get_origin(registered_type) or registered_type
|
|
75
|
+
try:
|
|
76
|
+
if isinstance(registered_base, type) and issubclass(registered_base, target_type):
|
|
77
|
+
service = registered_service
|
|
78
|
+
break
|
|
79
|
+
except TypeError:
|
|
80
|
+
# Skip if type checking fails (e.g. with complex generic types)
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
if service is not None:
|
|
84
|
+
# This is created in order to accommodate services that are to be injected
|
|
85
|
+
# as a new object. Usually, services are to be injected as a singleton or
|
|
86
|
+
# as a class method. However, some services are to be injected as a new
|
|
87
|
+
# object so their behavior is not shared. An example is the Router;
|
|
88
|
+
# each Plugin *must* have its own Router otherwise, a shared route can
|
|
89
|
+
# cause a conflict with another plugin.
|
|
90
|
+
if callable(service) and not isinstance(service, type):
|
|
91
|
+
service = service()
|
|
92
|
+
setattr(target, attr_name, service)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Configuration service for accessing environment variables."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Optional, Type, TypeVar, cast
|
|
5
|
+
|
|
6
|
+
U = TypeVar("U")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConfigService:
|
|
10
|
+
"""Service for accessing environment variables with type conversion."""
|
|
11
|
+
|
|
12
|
+
@staticmethod
|
|
13
|
+
def get_string(key: str, default: Optional[str] = None) -> Optional[str]:
|
|
14
|
+
"""Get environment variable as string."""
|
|
15
|
+
return os.environ.get(key, default)
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def get_int(key: str, default: Optional[int] = None) -> Optional[int]:
|
|
19
|
+
"""Get environment variable as integer."""
|
|
20
|
+
value = os.environ.get(key)
|
|
21
|
+
if value is None:
|
|
22
|
+
return default
|
|
23
|
+
try:
|
|
24
|
+
return int(value)
|
|
25
|
+
except ValueError:
|
|
26
|
+
return default
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def get_float(key: str, default: Optional[float] = None) -> Optional[float]:
|
|
30
|
+
"""Get environment variable as float."""
|
|
31
|
+
value = os.environ.get(key)
|
|
32
|
+
if value is None:
|
|
33
|
+
return default
|
|
34
|
+
try:
|
|
35
|
+
return float(value)
|
|
36
|
+
except ValueError:
|
|
37
|
+
return default
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def get_bool(key: str, default: Optional[bool] = None) -> Optional[bool]:
|
|
41
|
+
"""Get environment variable as boolean.
|
|
42
|
+
|
|
43
|
+
'true', 'yes', '1', 'on' are considered True
|
|
44
|
+
'false', 'no', '0', 'off' are considered False
|
|
45
|
+
"""
|
|
46
|
+
value = os.environ.get(key)
|
|
47
|
+
if value is None:
|
|
48
|
+
return default
|
|
49
|
+
|
|
50
|
+
value = value.lower()
|
|
51
|
+
if value in ("true", "yes", "1", "on"):
|
|
52
|
+
return True
|
|
53
|
+
if value in ("false", "no", "0", "off"):
|
|
54
|
+
return False
|
|
55
|
+
return default
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def get_list(key: str, separator: str = ",", default: Optional[list[str]] = None) -> Optional[list[str]]:
|
|
59
|
+
"""Get environment variable as list of strings."""
|
|
60
|
+
value = os.environ.get(key)
|
|
61
|
+
if value is None:
|
|
62
|
+
return default
|
|
63
|
+
return [item.strip() for item in value.split(separator)]
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def require(key: str) -> str:
|
|
67
|
+
"""Get required environment variable.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
ValueError: If environment variable is not set
|
|
71
|
+
"""
|
|
72
|
+
value = os.environ.get(key)
|
|
73
|
+
if value is None:
|
|
74
|
+
raise ValueError(f"Required environment variable {key} is not set")
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def require_as(key: str, type_: Type[U]) -> U:
|
|
79
|
+
"""Get required environment variable with type conversion.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
key: Environment variable key
|
|
83
|
+
type_: Type to convert to (int, float, bool)
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ValueError: If environment variable is not set or cannot be converted
|
|
87
|
+
"""
|
|
88
|
+
value = ConfigService.require(key)
|
|
89
|
+
try:
|
|
90
|
+
if type_ == bool: # noqa: E721 # pylint: disable=unidiomatic-typecheck
|
|
91
|
+
result = ConfigService.get_bool(key)
|
|
92
|
+
if result is None:
|
|
93
|
+
raise ValueError
|
|
94
|
+
return cast(U, result)
|
|
95
|
+
return type_(value) # type: ignore
|
|
96
|
+
except ValueError as exc:
|
|
97
|
+
raise ValueError(f"Environment variable {key} cannot be converted to {type_.__name__}") from exc
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gl-plugin
|
|
3
|
+
Version: 0.0.1b2
|
|
4
|
+
Summary: GL SDK's Plugin Architecture Implementation
|
|
5
|
+
Requires-Python: <3.14,>=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: python-dotenv>=1.2.1
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pre-commit<4.0.0,>=3.6.2; extra == "dev"
|
|
10
|
+
Requires-Dist: pytest<9.0.0,>=8.3.4; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest-mock<4.0.0,>=3.14.0; extra == "dev"
|
|
12
|
+
Requires-Dist: pytest-asyncio<1.0.0,>=0.25.3; extra == "dev"
|
|
13
|
+
Requires-Dist: coverage<8.0.0,>=7.6.10; extra == "dev"
|
|
14
|
+
Requires-Dist: mypy<2.0.0,>=1.11.2; extra == "dev"
|
|
15
|
+
Requires-Dist: ruff<1.0.0,>=0.9.9; extra == "dev"
|
|
16
|
+
Requires-Dist: pytest-cov<6.0.0,>=5.0.0; extra == "dev"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.py
|
|
4
|
+
gl_plugin/__init__.py
|
|
5
|
+
gl_plugin.egg-info/PKG-INFO
|
|
6
|
+
gl_plugin.egg-info/SOURCES.txt
|
|
7
|
+
gl_plugin.egg-info/dependency_links.txt
|
|
8
|
+
gl_plugin.egg-info/requires.txt
|
|
9
|
+
gl_plugin.egg-info/top_level.txt
|
|
10
|
+
gl_plugin/plugin/__init__.py
|
|
11
|
+
gl_plugin/plugin/handler.py
|
|
12
|
+
gl_plugin/plugin/manager.py
|
|
13
|
+
gl_plugin/plugin/plugin.py
|
|
14
|
+
gl_plugin/plugin/registry.py
|
|
15
|
+
gl_plugin/services/__init__.py
|
|
16
|
+
gl_plugin/services/config.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gl_plugin
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "gl-plugin"
|
|
3
|
+
version = "0.0.1b2"
|
|
4
|
+
description = "GL SDK's Plugin Architecture Implementation"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11,<3.14"
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
"python-dotenv>=1.2.1",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
dev = [
|
|
14
|
+
"pre-commit>=3.6.2,<4.0.0",
|
|
15
|
+
"pytest>=8.3.4,<9.0.0",
|
|
16
|
+
"pytest-mock>=3.14.0,<4.0.0",
|
|
17
|
+
"pytest-asyncio>=0.25.3,<1.0.0",
|
|
18
|
+
"coverage>=7.6.10,<8.0.0",
|
|
19
|
+
"mypy>=1.11.2,<2.0.0",
|
|
20
|
+
"ruff>=0.9.9,<1.0.0",
|
|
21
|
+
"pytest-cov>=5.0.0,<6.0.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
26
|
+
build-backend = "setuptools.build_meta"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools]
|
|
29
|
+
packages = { find = { include = ["gl_plugin*"] } }
|