pluginforge 0.1.0__tar.gz → 0.3.0__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.
- {pluginforge-0.1.0 → pluginforge-0.3.0}/PKG-INFO +25 -12
- {pluginforge-0.1.0 → pluginforge-0.3.0}/README.md +23 -10
- {pluginforge-0.1.0 → pluginforge-0.3.0}/pluginforge/__init__.py +1 -1
- {pluginforge-0.1.0 → pluginforge-0.3.0}/pluginforge/base.py +24 -0
- {pluginforge-0.1.0 → pluginforge-0.3.0}/pluginforge/fastapi_ext.py +5 -4
- {pluginforge-0.1.0 → pluginforge-0.3.0}/pluginforge/lifecycle.py +23 -0
- {pluginforge-0.1.0 → pluginforge-0.3.0}/pluginforge/manager.py +137 -20
- {pluginforge-0.1.0 → pluginforge-0.3.0}/pyproject.toml +2 -2
- {pluginforge-0.1.0 → pluginforge-0.3.0}/LICENSE +0 -0
- {pluginforge-0.1.0 → pluginforge-0.3.0}/pluginforge/alembic_ext.py +0 -0
- {pluginforge-0.1.0 → pluginforge-0.3.0}/pluginforge/config.py +0 -0
- {pluginforge-0.1.0 → pluginforge-0.3.0}/pluginforge/discovery.py +0 -0
- {pluginforge-0.1.0 → pluginforge-0.3.0}/pluginforge/i18n.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pluginforge
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Application-agnostic plugin framework built on pluggy
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
18
18
|
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
19
19
|
Provides-Extra: alembic
|
|
20
20
|
Provides-Extra: fastapi
|
|
21
|
-
Requires-Dist: pluggy (>=1.
|
|
21
|
+
Requires-Dist: pluggy (>=1.5.0,<2.0.0)
|
|
22
22
|
Requires-Dist: pyyaml (>=6.0,<7.0)
|
|
23
23
|
Project-URL: Repository, https://github.com/astrapi69/pluginforge
|
|
24
24
|
Description-Content-Type: text/markdown
|
|
@@ -118,6 +118,8 @@ pm.mount_routes(app) # Routes at /api/plugins/{name}/
|
|
|
118
118
|
- **Alembic Support** - Collect migration directories from plugins
|
|
119
119
|
- **i18n** - Multi-language strings from YAML with fallback
|
|
120
120
|
|
|
121
|
+
For detailed documentation, see the [Wiki](https://github.com/astrapi69/pluginforge/wiki).
|
|
122
|
+
|
|
121
123
|
## Entry Point Discovery
|
|
122
124
|
|
|
123
125
|
Register plugins as entry points in your `pyproject.toml`:
|
|
@@ -148,20 +150,31 @@ pm.get_text("common.save", "en") # "Save"
|
|
|
148
150
|
pm.get_text("common.save", "de") # "Speichern"
|
|
149
151
|
```
|
|
150
152
|
|
|
151
|
-
##
|
|
153
|
+
## Documentation
|
|
152
154
|
|
|
153
|
-
|
|
154
|
-
# Install dependencies
|
|
155
|
-
poetry install --with dev
|
|
155
|
+
The full documentation is available in the [Wiki](https://github.com/astrapi69/pluginforge/wiki):
|
|
156
156
|
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
- [Getting Started](https://github.com/astrapi69/pluginforge/wiki/Getting-Started)
|
|
158
|
+
- [BasePlugin](https://github.com/astrapi69/pluginforge/wiki/BasePlugin)
|
|
159
|
+
- [PluginManager](https://github.com/astrapi69/pluginforge/wiki/PluginManager)
|
|
160
|
+
- [Configuration](https://github.com/astrapi69/pluginforge/wiki/Configuration)
|
|
161
|
+
- [Discovery and Dependencies](https://github.com/astrapi69/pluginforge/wiki/Discovery-and-Dependencies)
|
|
162
|
+
- [Lifecycle](https://github.com/astrapi69/pluginforge/wiki/Lifecycle)
|
|
163
|
+
- [Hooks](https://github.com/astrapi69/pluginforge/wiki/Hooks)
|
|
164
|
+
- [FastAPI Integration](https://github.com/astrapi69/pluginforge/wiki/FastAPI-Integration)
|
|
165
|
+
- [Alembic Integration](https://github.com/astrapi69/pluginforge/wiki/Alembic-Integration)
|
|
166
|
+
- [i18n](https://github.com/astrapi69/pluginforge/wiki/i18n)
|
|
167
|
+
- [Examples](https://github.com/astrapi69/pluginforge/wiki/Examples)
|
|
159
168
|
|
|
160
|
-
|
|
161
|
-
poetry run ruff check pluginforge/ tests/
|
|
169
|
+
## Development
|
|
162
170
|
|
|
163
|
-
|
|
164
|
-
|
|
171
|
+
```bash
|
|
172
|
+
make install-dev # Install with dev dependencies
|
|
173
|
+
make test # Run tests
|
|
174
|
+
make lint # Run ruff linter
|
|
175
|
+
make format # Format code
|
|
176
|
+
make ci # Full CI pipeline (lint + format-check + test)
|
|
177
|
+
make help # Show all available targets
|
|
165
178
|
```
|
|
166
179
|
|
|
167
180
|
## License
|
|
@@ -93,6 +93,8 @@ pm.mount_routes(app) # Routes at /api/plugins/{name}/
|
|
|
93
93
|
- **Alembic Support** - Collect migration directories from plugins
|
|
94
94
|
- **i18n** - Multi-language strings from YAML with fallback
|
|
95
95
|
|
|
96
|
+
For detailed documentation, see the [Wiki](https://github.com/astrapi69/pluginforge/wiki).
|
|
97
|
+
|
|
96
98
|
## Entry Point Discovery
|
|
97
99
|
|
|
98
100
|
Register plugins as entry points in your `pyproject.toml`:
|
|
@@ -123,20 +125,31 @@ pm.get_text("common.save", "en") # "Save"
|
|
|
123
125
|
pm.get_text("common.save", "de") # "Speichern"
|
|
124
126
|
```
|
|
125
127
|
|
|
126
|
-
##
|
|
128
|
+
## Documentation
|
|
127
129
|
|
|
128
|
-
|
|
129
|
-
# Install dependencies
|
|
130
|
-
poetry install --with dev
|
|
130
|
+
The full documentation is available in the [Wiki](https://github.com/astrapi69/pluginforge/wiki):
|
|
131
131
|
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
- [Getting Started](https://github.com/astrapi69/pluginforge/wiki/Getting-Started)
|
|
133
|
+
- [BasePlugin](https://github.com/astrapi69/pluginforge/wiki/BasePlugin)
|
|
134
|
+
- [PluginManager](https://github.com/astrapi69/pluginforge/wiki/PluginManager)
|
|
135
|
+
- [Configuration](https://github.com/astrapi69/pluginforge/wiki/Configuration)
|
|
136
|
+
- [Discovery and Dependencies](https://github.com/astrapi69/pluginforge/wiki/Discovery-and-Dependencies)
|
|
137
|
+
- [Lifecycle](https://github.com/astrapi69/pluginforge/wiki/Lifecycle)
|
|
138
|
+
- [Hooks](https://github.com/astrapi69/pluginforge/wiki/Hooks)
|
|
139
|
+
- [FastAPI Integration](https://github.com/astrapi69/pluginforge/wiki/FastAPI-Integration)
|
|
140
|
+
- [Alembic Integration](https://github.com/astrapi69/pluginforge/wiki/Alembic-Integration)
|
|
141
|
+
- [i18n](https://github.com/astrapi69/pluginforge/wiki/i18n)
|
|
142
|
+
- [Examples](https://github.com/astrapi69/pluginforge/wiki/Examples)
|
|
134
143
|
|
|
135
|
-
|
|
136
|
-
poetry run ruff check pluginforge/ tests/
|
|
144
|
+
## Development
|
|
137
145
|
|
|
138
|
-
|
|
139
|
-
|
|
146
|
+
```bash
|
|
147
|
+
make install-dev # Install with dev dependencies
|
|
148
|
+
make test # Run tests
|
|
149
|
+
make lint # Run ruff linter
|
|
150
|
+
make format # Format code
|
|
151
|
+
make ci # Full CI pipeline (lint + format-check + test)
|
|
152
|
+
make help # Show all available targets
|
|
140
153
|
```
|
|
141
154
|
|
|
142
155
|
## License
|
|
@@ -14,7 +14,10 @@ class BasePlugin(ABC):
|
|
|
14
14
|
description: Human-readable description.
|
|
15
15
|
author: Plugin author.
|
|
16
16
|
depends_on: List of plugin names this plugin depends on.
|
|
17
|
+
app_config: Global application configuration, populated during init().
|
|
17
18
|
config: Plugin configuration, populated during init().
|
|
19
|
+
config_schema: Optional dict mapping config keys to expected types.
|
|
20
|
+
If set, config is validated during init().
|
|
18
21
|
"""
|
|
19
22
|
|
|
20
23
|
name: str
|
|
@@ -23,7 +26,9 @@ class BasePlugin(ABC):
|
|
|
23
26
|
description: str = ""
|
|
24
27
|
author: str = ""
|
|
25
28
|
depends_on: list[str] = []
|
|
29
|
+
app_config: dict[str, Any] = {}
|
|
26
30
|
config: dict[str, Any] = {}
|
|
31
|
+
config_schema: dict[str, type] | None = None
|
|
27
32
|
|
|
28
33
|
def init(self, app_config: dict[str, Any], plugin_config: dict[str, Any]) -> None:
|
|
29
34
|
"""Called when the plugin is loaded. Receives app and plugin config.
|
|
@@ -32,6 +37,7 @@ class BasePlugin(ABC):
|
|
|
32
37
|
app_config: The global application configuration.
|
|
33
38
|
plugin_config: Plugin-specific configuration from YAML.
|
|
34
39
|
"""
|
|
40
|
+
self.app_config = app_config
|
|
35
41
|
self.config = plugin_config
|
|
36
42
|
|
|
37
43
|
def activate(self) -> None:
|
|
@@ -48,6 +54,24 @@ class BasePlugin(ABC):
|
|
|
48
54
|
"""
|
|
49
55
|
return []
|
|
50
56
|
|
|
57
|
+
def get_frontend_manifest(self) -> dict[str, Any] | None:
|
|
58
|
+
"""Return manifest for frontend UI components. Optional.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Frontend manifest dict or None.
|
|
62
|
+
"""
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
def health(self) -> dict[str, Any]:
|
|
66
|
+
"""Return plugin health status. Optional.
|
|
67
|
+
|
|
68
|
+
Override to check external dependencies (APIs, databases, etc.).
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Dict with at least a "status" key ("ok" or "error").
|
|
72
|
+
"""
|
|
73
|
+
return {"status": "ok"}
|
|
74
|
+
|
|
51
75
|
def get_migrations_dir(self) -> str | None:
|
|
52
76
|
"""Return path to Alembic migration scripts. Optional.
|
|
53
77
|
|
|
@@ -7,14 +7,16 @@ from pluginforge.base import BasePlugin
|
|
|
7
7
|
logger = logging.getLogger(__name__)
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
def mount_plugin_routes(app: "object", plugins: list[BasePlugin]) -> None:
|
|
10
|
+
def mount_plugin_routes(app: "object", plugins: list[BasePlugin], prefix: str = "/api") -> None:
|
|
11
11
|
"""Mount routes from all plugins onto a FastAPI app.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Plugins bring their own route prefixes via their routers.
|
|
14
|
+
The prefix parameter is prepended to all plugin routes.
|
|
14
15
|
|
|
15
16
|
Args:
|
|
16
17
|
app: A FastAPI application instance.
|
|
17
18
|
plugins: List of active plugins.
|
|
19
|
+
prefix: URL prefix for all plugin routes (default: "/api").
|
|
18
20
|
"""
|
|
19
21
|
try:
|
|
20
22
|
from fastapi import FastAPI
|
|
@@ -32,6 +34,5 @@ def mount_plugin_routes(app: "object", plugins: list[BasePlugin]) -> None:
|
|
|
32
34
|
if not routes:
|
|
33
35
|
continue
|
|
34
36
|
for router in routes:
|
|
35
|
-
prefix = f"/api/plugins/{plugin.name}"
|
|
36
37
|
app.include_router(router, prefix=prefix)
|
|
37
|
-
logger.info("Mounted routes for plugin '%s'
|
|
38
|
+
logger.info("Mounted routes for plugin '%s' under %s", plugin.name, prefix)
|
|
@@ -37,6 +37,7 @@ class PluginLifecycle:
|
|
|
37
37
|
"""
|
|
38
38
|
try:
|
|
39
39
|
plugin.init(app_config, plugin_config)
|
|
40
|
+
self._validate_config(plugin)
|
|
40
41
|
self._initialized[plugin.name] = plugin
|
|
41
42
|
logger.info("Initialized plugin: %s", plugin.name)
|
|
42
43
|
return True
|
|
@@ -44,6 +45,28 @@ class PluginLifecycle:
|
|
|
44
45
|
logger.error("Failed to initialize plugin %s: %s", plugin.name, e)
|
|
45
46
|
return False
|
|
46
47
|
|
|
48
|
+
@staticmethod
|
|
49
|
+
def _validate_config(plugin: BasePlugin) -> None:
|
|
50
|
+
"""Validate plugin config against its config_schema if defined.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
plugin: The plugin whose config to validate.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
TypeError: If a config value has the wrong type.
|
|
57
|
+
"""
|
|
58
|
+
if plugin.config_schema is None:
|
|
59
|
+
return
|
|
60
|
+
for key, expected_type in plugin.config_schema.items():
|
|
61
|
+
if key not in plugin.config:
|
|
62
|
+
continue
|
|
63
|
+
value = plugin.config[key]
|
|
64
|
+
if not isinstance(value, expected_type):
|
|
65
|
+
raise TypeError(
|
|
66
|
+
f"Plugin '{plugin.name}' config '{key}': "
|
|
67
|
+
f"expected {expected_type.__name__}, got {type(value).__name__}"
|
|
68
|
+
)
|
|
69
|
+
|
|
47
70
|
def activate_plugin(self, plugin: BasePlugin) -> bool:
|
|
48
71
|
"""Activate an initialized plugin.
|
|
49
72
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
from pathlib import Path
|
|
5
|
+
from collections.abc import Callable
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
import pluggy
|
|
@@ -29,12 +30,23 @@ class PluginManager:
|
|
|
29
30
|
|
|
30
31
|
Args:
|
|
31
32
|
config_path: Path to app.yaml configuration file.
|
|
33
|
+
pre_activate: Optional callback called before plugin activation.
|
|
34
|
+
Receives (plugin, config) and must return True to allow activation.
|
|
35
|
+
api_version: Current hook spec version. Plugins with a different
|
|
36
|
+
api_version will log a warning but still load.
|
|
32
37
|
"""
|
|
33
38
|
|
|
34
|
-
def __init__(
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
config_path: str = "config/app.yaml",
|
|
42
|
+
pre_activate: Callable[[BasePlugin, dict[str, Any]], bool] | None = None,
|
|
43
|
+
api_version: str = "1",
|
|
44
|
+
) -> None:
|
|
35
45
|
self._config_path = Path(config_path)
|
|
36
46
|
self._config_dir = self._config_path.parent
|
|
37
47
|
self._app_config = load_app_config(self._config_path)
|
|
48
|
+
self._pre_activate = pre_activate
|
|
49
|
+
self._api_version = api_version
|
|
38
50
|
|
|
39
51
|
plugins_config = self._app_config.get("plugins", {})
|
|
40
52
|
group = plugins_config.get("entry_point_group", "pluginforge.plugins")
|
|
@@ -42,6 +54,7 @@ class PluginManager:
|
|
|
42
54
|
|
|
43
55
|
self._pm = pluggy.PluginManager(group)
|
|
44
56
|
self._lifecycle = PluginLifecycle()
|
|
57
|
+
self._load_errors: dict[str, str] = {}
|
|
45
58
|
|
|
46
59
|
default_lang = self._app_config.get("app", {}).get("default_language", "en")
|
|
47
60
|
self._i18n = I18n(self._config_dir, default_lang=default_lang)
|
|
@@ -65,6 +78,26 @@ class PluginManager:
|
|
|
65
78
|
"""
|
|
66
79
|
return load_plugin_config(self._config_dir, plugin_name)
|
|
67
80
|
|
|
81
|
+
def reload_config(self) -> None:
|
|
82
|
+
"""Reload application config from disk.
|
|
83
|
+
|
|
84
|
+
Reloads app.yaml and clears the i18n cache. Active plugins are
|
|
85
|
+
not affected - call deactivate_all() + discover_plugins() to
|
|
86
|
+
fully restart with new config.
|
|
87
|
+
"""
|
|
88
|
+
self._app_config = load_app_config(self._config_path)
|
|
89
|
+
default_lang = self._app_config.get("app", {}).get("default_language", "en")
|
|
90
|
+
self._i18n = I18n(self._config_dir, default_lang=default_lang)
|
|
91
|
+
logger.info("Reloaded config from %s", self._config_path)
|
|
92
|
+
|
|
93
|
+
def list_available_plugins(self) -> list[str]:
|
|
94
|
+
"""Return names of all discoverable plugins from entry points.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
List of plugin names without loading them.
|
|
98
|
+
"""
|
|
99
|
+
return list(discover_entry_points(self._entry_point_group).keys())
|
|
100
|
+
|
|
68
101
|
def discover_plugins(self) -> None:
|
|
69
102
|
"""Discover, filter, resolve dependencies, and activate plugins.
|
|
70
103
|
|
|
@@ -81,7 +114,9 @@ class PluginManager:
|
|
|
81
114
|
|
|
82
115
|
missing = check_missing_dependencies(plugins)
|
|
83
116
|
for name, deps in missing.items():
|
|
117
|
+
msg = f"Missing dependencies: {deps}"
|
|
84
118
|
logger.warning("Plugin '%s' has missing dependencies %s, skipping", name, deps)
|
|
119
|
+
self._load_errors[name] = msg
|
|
85
120
|
del plugins[name]
|
|
86
121
|
|
|
87
122
|
try:
|
|
@@ -89,18 +124,7 @@ class PluginManager:
|
|
|
89
124
|
except CircularDependencyError as e:
|
|
90
125
|
raise e
|
|
91
126
|
|
|
92
|
-
|
|
93
|
-
cls = plugins[name]
|
|
94
|
-
plugin = cls()
|
|
95
|
-
plugin_config = self.get_plugin_config(name)
|
|
96
|
-
|
|
97
|
-
if not self._lifecycle.init_plugin(plugin, self._app_config, plugin_config):
|
|
98
|
-
continue
|
|
99
|
-
|
|
100
|
-
self._pm.register(plugin, name=name)
|
|
101
|
-
|
|
102
|
-
if not self._lifecycle.activate_plugin(plugin):
|
|
103
|
-
self._pm.unregister(name=name)
|
|
127
|
+
self._activate_ordered(plugins, order)
|
|
104
128
|
|
|
105
129
|
def register_plugins(self, plugin_classes: list[type[BasePlugin]]) -> None:
|
|
106
130
|
"""Register plugin classes directly (without entry point discovery).
|
|
@@ -121,22 +145,89 @@ class PluginManager:
|
|
|
121
145
|
|
|
122
146
|
missing = check_missing_dependencies(plugins_map)
|
|
123
147
|
for name, deps in missing.items():
|
|
148
|
+
msg = f"Missing dependencies: {deps}"
|
|
124
149
|
logger.warning("Plugin '%s' has missing dependencies %s, skipping", name, deps)
|
|
150
|
+
self._load_errors[name] = msg
|
|
125
151
|
del plugins_map[name]
|
|
126
152
|
|
|
127
153
|
order = resolve_dependencies(plugins_map)
|
|
128
154
|
|
|
155
|
+
self._activate_ordered(plugins_map, order)
|
|
156
|
+
|
|
157
|
+
def register_plugin(
|
|
158
|
+
self, plugin: BasePlugin, plugin_config: dict[str, Any] | None = None
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Register a single pre-instantiated plugin.
|
|
161
|
+
|
|
162
|
+
Useful for tests or dynamically created plugins.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
plugin: An already instantiated plugin.
|
|
166
|
+
plugin_config: Optional config dict. If None, loaded from YAML.
|
|
167
|
+
"""
|
|
168
|
+
if plugin_config is None:
|
|
169
|
+
plugin_config = self.get_plugin_config(plugin.name)
|
|
170
|
+
|
|
171
|
+
self._check_api_version(plugin)
|
|
172
|
+
|
|
173
|
+
if not self._lifecycle.init_plugin(plugin, self._app_config, plugin_config):
|
|
174
|
+
self._load_errors[plugin.name] = "Failed to initialize"
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
if self._pre_activate is not None:
|
|
178
|
+
if not self._pre_activate(plugin, plugin_config):
|
|
179
|
+
logger.info("Pre-activate check rejected plugin '%s'", plugin.name)
|
|
180
|
+
self._load_errors[plugin.name] = "Rejected by pre-activate check"
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
self._pm.register(plugin, name=plugin.name)
|
|
184
|
+
|
|
185
|
+
if not self._lifecycle.activate_plugin(plugin):
|
|
186
|
+
self._load_errors[plugin.name] = "Failed to activate"
|
|
187
|
+
self._pm.unregister(name=plugin.name)
|
|
188
|
+
|
|
189
|
+
def _check_api_version(self, plugin: BasePlugin) -> None:
|
|
190
|
+
"""Log a warning if the plugin's api_version differs from the manager's.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
plugin: The plugin to check.
|
|
194
|
+
"""
|
|
195
|
+
if plugin.api_version != self._api_version:
|
|
196
|
+
logger.warning(
|
|
197
|
+
"Plugin '%s' has api_version '%s', expected '%s'",
|
|
198
|
+
plugin.name,
|
|
199
|
+
plugin.api_version,
|
|
200
|
+
self._api_version,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def _activate_ordered(self, plugins: dict[str, type[BasePlugin]], order: list[str]) -> None:
|
|
204
|
+
"""Initialize and activate plugins in dependency order.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
plugins: Map of plugin name to plugin class.
|
|
208
|
+
order: Topologically sorted list of plugin names.
|
|
209
|
+
"""
|
|
129
210
|
for name in order:
|
|
130
|
-
cls =
|
|
211
|
+
cls = plugins[name]
|
|
131
212
|
plugin = cls()
|
|
132
213
|
plugin_config = self.get_plugin_config(name)
|
|
133
214
|
|
|
215
|
+
self._check_api_version(plugin)
|
|
216
|
+
|
|
134
217
|
if not self._lifecycle.init_plugin(plugin, self._app_config, plugin_config):
|
|
218
|
+
self._load_errors[name] = "Failed to initialize"
|
|
135
219
|
continue
|
|
136
220
|
|
|
221
|
+
if self._pre_activate is not None:
|
|
222
|
+
if not self._pre_activate(plugin, plugin_config):
|
|
223
|
+
logger.info("Pre-activate check rejected plugin '%s'", name)
|
|
224
|
+
self._load_errors[name] = "Rejected by pre-activate check"
|
|
225
|
+
continue
|
|
226
|
+
|
|
137
227
|
self._pm.register(plugin, name=name)
|
|
138
228
|
|
|
139
229
|
if not self._lifecycle.activate_plugin(plugin):
|
|
230
|
+
self._load_errors[name] = "Failed to activate"
|
|
140
231
|
self._pm.unregister(name=name)
|
|
141
232
|
|
|
142
233
|
def activate_plugin(self, name: str) -> None:
|
|
@@ -152,7 +243,7 @@ class PluginManager:
|
|
|
152
243
|
self._lifecycle.activate_plugin(plugin)
|
|
153
244
|
|
|
154
245
|
def deactivate_plugin(self, name: str) -> None:
|
|
155
|
-
"""Deactivate a specific active plugin.
|
|
246
|
+
"""Deactivate a specific active plugin and unregister its hooks.
|
|
156
247
|
|
|
157
248
|
Args:
|
|
158
249
|
name: Plugin name.
|
|
@@ -161,7 +252,8 @@ class PluginManager:
|
|
|
161
252
|
if plugin is None:
|
|
162
253
|
logger.warning("Plugin '%s' not found", name)
|
|
163
254
|
return
|
|
164
|
-
self._lifecycle.deactivate_plugin(plugin)
|
|
255
|
+
if self._lifecycle.deactivate_plugin(plugin):
|
|
256
|
+
self._pm.unregister(name=name)
|
|
165
257
|
|
|
166
258
|
def get_plugin(self, name: str) -> BasePlugin | None:
|
|
167
259
|
"""Get a plugin instance by name.
|
|
@@ -182,9 +274,19 @@ class PluginManager:
|
|
|
182
274
|
"""
|
|
183
275
|
return self._lifecycle.get_active_plugins()
|
|
184
276
|
|
|
277
|
+
def get_load_errors(self) -> dict[str, str]:
|
|
278
|
+
"""Return errors from plugin loading/activation.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
Dict mapping plugin name to error message for failed plugins.
|
|
282
|
+
"""
|
|
283
|
+
return dict(self._load_errors)
|
|
284
|
+
|
|
185
285
|
def deactivate_all(self) -> None:
|
|
186
|
-
"""Deactivate all active plugins in reverse order."""
|
|
187
|
-
self._lifecycle.
|
|
286
|
+
"""Deactivate all active plugins in reverse order and unregister hooks."""
|
|
287
|
+
for plugin in reversed(self._lifecycle.get_active_plugins()):
|
|
288
|
+
if self._lifecycle.deactivate_plugin(plugin):
|
|
289
|
+
self._pm.unregister(name=plugin.name)
|
|
188
290
|
|
|
189
291
|
def register_hookspecs(self, spec_module: object) -> None:
|
|
190
292
|
"""Register hook specifications from a module.
|
|
@@ -210,15 +312,16 @@ class PluginManager:
|
|
|
210
312
|
return []
|
|
211
313
|
return hook(**kwargs)
|
|
212
314
|
|
|
213
|
-
def mount_routes(self, app: object) -> None:
|
|
315
|
+
def mount_routes(self, app: object, prefix: str = "/api") -> None:
|
|
214
316
|
"""Mount FastAPI routes from all active plugins.
|
|
215
317
|
|
|
216
318
|
Args:
|
|
217
319
|
app: A FastAPI application instance.
|
|
320
|
+
prefix: URL prefix for all plugin routes (default: "/api").
|
|
218
321
|
"""
|
|
219
322
|
from pluginforge.fastapi_ext import mount_plugin_routes
|
|
220
323
|
|
|
221
|
-
mount_plugin_routes(app, self.get_active_plugins())
|
|
324
|
+
mount_plugin_routes(app, self.get_active_plugins(), prefix=prefix)
|
|
222
325
|
|
|
223
326
|
def get_text(self, key: str, lang: str | None = None) -> str:
|
|
224
327
|
"""Get an internationalized string.
|
|
@@ -241,3 +344,17 @@ class PluginManager:
|
|
|
241
344
|
from pluginforge.alembic_ext import collect_migrations_dirs
|
|
242
345
|
|
|
243
346
|
return collect_migrations_dirs(self.get_active_plugins())
|
|
347
|
+
|
|
348
|
+
def health_check(self) -> dict[str, dict[str, Any]]:
|
|
349
|
+
"""Run health checks on all active plugins.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
Dict mapping plugin name to health status dict.
|
|
353
|
+
"""
|
|
354
|
+
results: dict[str, dict[str, Any]] = {}
|
|
355
|
+
for plugin in self.get_active_plugins():
|
|
356
|
+
try:
|
|
357
|
+
results[plugin.name] = plugin.health()
|
|
358
|
+
except Exception as e:
|
|
359
|
+
results[plugin.name] = {"status": "error", "error": str(e)}
|
|
360
|
+
return results
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "pluginforge"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Application-agnostic plugin framework built on pluggy"
|
|
5
5
|
authors = ["Asterios Raptis"]
|
|
6
6
|
license = "MIT"
|
|
@@ -20,7 +20,7 @@ packages = [{ include = "pluginforge" }]
|
|
|
20
20
|
|
|
21
21
|
[tool.poetry.dependencies]
|
|
22
22
|
python = "^3.11"
|
|
23
|
-
pluggy = "^1.
|
|
23
|
+
pluggy = "^1.5.0"
|
|
24
24
|
pyyaml = "^6.0"
|
|
25
25
|
|
|
26
26
|
[tool.poetry.group.dev.dependencies]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|