pluginforge 0.2.0__tar.gz → 0.4.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.2.0 → pluginforge-0.4.0}/PKG-INFO +24 -11
- {pluginforge-0.2.0 → pluginforge-0.4.0}/README.md +23 -10
- {pluginforge-0.2.0 → pluginforge-0.4.0}/pluginforge/__init__.py +8 -2
- {pluginforge-0.2.0 → pluginforge-0.4.0}/pluginforge/alembic_ext.py +11 -2
- {pluginforge-0.2.0 → pluginforge-0.4.0}/pluginforge/base.py +21 -0
- {pluginforge-0.2.0 → pluginforge-0.4.0}/pluginforge/config.py +10 -0
- {pluginforge-0.2.0 → pluginforge-0.4.0}/pluginforge/fastapi_ext.py +5 -4
- {pluginforge-0.2.0 → pluginforge-0.4.0}/pluginforge/lifecycle.py +34 -0
- pluginforge-0.4.0/pluginforge/manager.py +442 -0
- pluginforge-0.4.0/pluginforge/security.py +55 -0
- {pluginforge-0.2.0 → pluginforge-0.4.0}/pyproject.toml +1 -1
- pluginforge-0.2.0/pluginforge/manager.py +0 -243
- {pluginforge-0.2.0 → pluginforge-0.4.0}/LICENSE +0 -0
- {pluginforge-0.2.0 → pluginforge-0.4.0}/pluginforge/discovery.py +0 -0
- {pluginforge-0.2.0 → pluginforge-0.4.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.4.0
|
|
4
4
|
Summary: Application-agnostic plugin framework built on pluggy
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -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
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
from pluginforge.base import BasePlugin
|
|
4
4
|
from pluginforge.discovery import CircularDependencyError
|
|
5
5
|
from pluginforge.manager import PluginManager
|
|
6
|
+
from pluginforge.security import InvalidPluginNameError
|
|
6
7
|
|
|
7
|
-
__version__ = "0.
|
|
8
|
-
__all__ = [
|
|
8
|
+
__version__ = "0.3.0"
|
|
9
|
+
__all__ = [
|
|
10
|
+
"BasePlugin",
|
|
11
|
+
"CircularDependencyError",
|
|
12
|
+
"InvalidPluginNameError",
|
|
13
|
+
"PluginManager",
|
|
14
|
+
]
|
|
@@ -28,6 +28,15 @@ def collect_migrations_dirs(plugins: list[BasePlugin]) -> dict[str, str]:
|
|
|
28
28
|
"Plugin '%s' migrations dir does not exist: %s", plugin.name, migrations_dir
|
|
29
29
|
)
|
|
30
30
|
continue
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
# Ensure resolved path doesn't escape via symlinks
|
|
32
|
+
resolved = path.resolve()
|
|
33
|
+
if not resolved.is_dir():
|
|
34
|
+
logger.warning(
|
|
35
|
+
"Plugin '%s' migrations dir resolves to non-directory: %s",
|
|
36
|
+
plugin.name,
|
|
37
|
+
resolved,
|
|
38
|
+
)
|
|
39
|
+
continue
|
|
40
|
+
migrations[plugin.name] = str(resolved)
|
|
41
|
+
logger.info("Collected migrations for plugin '%s': %s", plugin.name, resolved)
|
|
33
42
|
return migrations
|
|
@@ -16,6 +16,8 @@ class BasePlugin(ABC):
|
|
|
16
16
|
depends_on: List of plugin names this plugin depends on.
|
|
17
17
|
app_config: Global application configuration, populated during init().
|
|
18
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().
|
|
19
21
|
"""
|
|
20
22
|
|
|
21
23
|
name: str
|
|
@@ -26,6 +28,7 @@ class BasePlugin(ABC):
|
|
|
26
28
|
depends_on: list[str] = []
|
|
27
29
|
app_config: dict[str, Any] = {}
|
|
28
30
|
config: dict[str, Any] = {}
|
|
31
|
+
config_schema: dict[str, type] | None = None
|
|
29
32
|
|
|
30
33
|
def init(self, app_config: dict[str, Any], plugin_config: dict[str, Any]) -> None:
|
|
31
34
|
"""Called when the plugin is loaded. Receives app and plugin config.
|
|
@@ -51,6 +54,24 @@ class BasePlugin(ABC):
|
|
|
51
54
|
"""
|
|
52
55
|
return []
|
|
53
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
|
+
|
|
54
75
|
def get_migrations_dir(self) -> str | None:
|
|
55
76
|
"""Return path to Alembic migration scripts. Optional.
|
|
56
77
|
|
|
@@ -46,6 +46,8 @@ def load_app_config(config_path: str | Path) -> dict[str, Any]:
|
|
|
46
46
|
def load_plugin_config(config_dir: str | Path, plugin_name: str) -> dict[str, Any]:
|
|
47
47
|
"""Load plugin-specific configuration from config/plugins/{name}.yaml.
|
|
48
48
|
|
|
49
|
+
Validates the plugin name to prevent path traversal attacks.
|
|
50
|
+
|
|
49
51
|
Args:
|
|
50
52
|
config_dir: Base config directory (parent of plugins/).
|
|
51
53
|
plugin_name: Name of the plugin.
|
|
@@ -53,6 +55,9 @@ def load_plugin_config(config_dir: str | Path, plugin_name: str) -> dict[str, An
|
|
|
53
55
|
Returns:
|
|
54
56
|
Plugin configuration dict.
|
|
55
57
|
"""
|
|
58
|
+
from pluginforge.security import validate_plugin_name
|
|
59
|
+
|
|
60
|
+
validate_plugin_name(plugin_name)
|
|
56
61
|
path = Path(config_dir) / "plugins" / f"{plugin_name}.yaml"
|
|
57
62
|
return load_yaml(path)
|
|
58
63
|
|
|
@@ -60,6 +65,8 @@ def load_plugin_config(config_dir: str | Path, plugin_name: str) -> dict[str, An
|
|
|
60
65
|
def load_i18n(config_dir: str | Path, lang: str) -> dict[str, Any]:
|
|
61
66
|
"""Load i18n strings for a specific language.
|
|
62
67
|
|
|
68
|
+
Validates the language code to prevent path traversal attacks.
|
|
69
|
+
|
|
63
70
|
Args:
|
|
64
71
|
config_dir: Base config directory (parent of i18n/).
|
|
65
72
|
lang: Language code (e.g. "en", "de").
|
|
@@ -67,5 +74,8 @@ def load_i18n(config_dir: str | Path, lang: str) -> dict[str, Any]:
|
|
|
67
74
|
Returns:
|
|
68
75
|
i18n strings dict.
|
|
69
76
|
"""
|
|
77
|
+
from pluginforge.security import validate_plugin_name
|
|
78
|
+
|
|
79
|
+
validate_plugin_name(lang)
|
|
70
80
|
path = Path(config_dir) / "i18n" / f"{lang}.yaml"
|
|
71
81
|
return load_yaml(path)
|
|
@@ -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
|
|
|
@@ -112,6 +135,17 @@ class PluginLifecycle:
|
|
|
112
135
|
"""
|
|
113
136
|
return self._initialized.get(name)
|
|
114
137
|
|
|
138
|
+
def remove_plugin(self, name: str) -> None:
|
|
139
|
+
"""Remove a plugin from all lifecycle tracking.
|
|
140
|
+
|
|
141
|
+
Used during hot-reload to clean up before re-instantiation.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
name: Plugin name.
|
|
145
|
+
"""
|
|
146
|
+
self._initialized.pop(name, None)
|
|
147
|
+
self._active.pop(name, None)
|
|
148
|
+
|
|
115
149
|
def is_active(self, name: str) -> bool:
|
|
116
150
|
"""Check if a plugin is currently active.
|
|
117
151
|
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""Central PluginManager that orchestrates config, discovery, lifecycle, and hooks."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import pluggy
|
|
11
|
+
|
|
12
|
+
from pluginforge.base import BasePlugin
|
|
13
|
+
from pluginforge.config import load_app_config, load_plugin_config
|
|
14
|
+
from pluginforge.discovery import (
|
|
15
|
+
CircularDependencyError,
|
|
16
|
+
check_missing_dependencies,
|
|
17
|
+
discover_entry_points,
|
|
18
|
+
filter_plugins,
|
|
19
|
+
resolve_dependencies,
|
|
20
|
+
)
|
|
21
|
+
from pluginforge.i18n import I18n
|
|
22
|
+
from pluginforge.lifecycle import PluginLifecycle
|
|
23
|
+
from pluginforge.security import validate_plugin_name
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PluginManager:
|
|
29
|
+
"""Central manager for plugin discovery, lifecycle, and hooks.
|
|
30
|
+
|
|
31
|
+
Wraps pluggy.PluginManager and adds YAML config, lifecycle management,
|
|
32
|
+
dependency resolution, and i18n support.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
config_path: Path to app.yaml configuration file.
|
|
36
|
+
pre_activate: Optional callback called before plugin activation.
|
|
37
|
+
Receives (plugin, config) and must return True to allow activation.
|
|
38
|
+
api_version: Current hook spec version. Plugins with a different
|
|
39
|
+
api_version will log a warning but still load.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
config_path: str = "config/app.yaml",
|
|
45
|
+
pre_activate: Callable[[BasePlugin, dict[str, Any]], bool] | None = None,
|
|
46
|
+
api_version: str = "1",
|
|
47
|
+
) -> None:
|
|
48
|
+
self._config_path = Path(config_path)
|
|
49
|
+
self._config_dir = self._config_path.parent
|
|
50
|
+
self._app_config = load_app_config(self._config_path)
|
|
51
|
+
self._pre_activate = pre_activate
|
|
52
|
+
self._api_version = api_version
|
|
53
|
+
|
|
54
|
+
plugins_config = self._app_config.get("plugins", {})
|
|
55
|
+
group = plugins_config.get("entry_point_group", "pluginforge.plugins")
|
|
56
|
+
self._entry_point_group = group
|
|
57
|
+
|
|
58
|
+
self._pm = pluggy.PluginManager(group)
|
|
59
|
+
self._lifecycle = PluginLifecycle()
|
|
60
|
+
self._load_errors: dict[str, str] = {}
|
|
61
|
+
|
|
62
|
+
default_lang = self._app_config.get("app", {}).get("default_language", "en")
|
|
63
|
+
self._i18n = I18n(self._config_dir, default_lang=default_lang)
|
|
64
|
+
|
|
65
|
+
def get_app_config(self) -> dict[str, Any]:
|
|
66
|
+
"""Return the loaded application configuration.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
App config dict.
|
|
70
|
+
"""
|
|
71
|
+
return self._app_config
|
|
72
|
+
|
|
73
|
+
def get_plugin_config(self, plugin_name: str) -> dict[str, Any]:
|
|
74
|
+
"""Load and return config for a specific plugin.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
plugin_name: Name of the plugin.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Plugin configuration dict.
|
|
81
|
+
"""
|
|
82
|
+
return load_plugin_config(self._config_dir, plugin_name)
|
|
83
|
+
|
|
84
|
+
def reload_config(self) -> None:
|
|
85
|
+
"""Reload application config from disk.
|
|
86
|
+
|
|
87
|
+
Reloads app.yaml and clears the i18n cache. Active plugins are
|
|
88
|
+
not affected - call deactivate_all() + discover_plugins() to
|
|
89
|
+
fully restart with new config.
|
|
90
|
+
"""
|
|
91
|
+
self._app_config = load_app_config(self._config_path)
|
|
92
|
+
default_lang = self._app_config.get("app", {}).get("default_language", "en")
|
|
93
|
+
self._i18n = I18n(self._config_dir, default_lang=default_lang)
|
|
94
|
+
logger.info("Reloaded config from %s", self._config_path)
|
|
95
|
+
|
|
96
|
+
def list_available_plugins(self) -> list[str]:
|
|
97
|
+
"""Return names of all discoverable plugins from entry points.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
List of plugin names without loading them.
|
|
101
|
+
"""
|
|
102
|
+
return list(discover_entry_points(self._entry_point_group).keys())
|
|
103
|
+
|
|
104
|
+
def discover_plugins(self) -> None:
|
|
105
|
+
"""Discover, filter, resolve dependencies, and activate plugins.
|
|
106
|
+
|
|
107
|
+
Loads plugins from entry points, filters by enabled/disabled config,
|
|
108
|
+
checks dependencies, sorts topologically, then initializes and
|
|
109
|
+
activates each plugin.
|
|
110
|
+
"""
|
|
111
|
+
plugins = discover_entry_points(self._entry_point_group)
|
|
112
|
+
|
|
113
|
+
plugins_config = self._app_config.get("plugins", {})
|
|
114
|
+
enabled = plugins_config.get("enabled")
|
|
115
|
+
disabled = plugins_config.get("disabled")
|
|
116
|
+
plugins = filter_plugins(plugins, enabled, disabled)
|
|
117
|
+
|
|
118
|
+
missing = check_missing_dependencies(plugins)
|
|
119
|
+
for name, deps in missing.items():
|
|
120
|
+
msg = f"Missing dependencies: {deps}"
|
|
121
|
+
logger.warning("Plugin '%s' has missing dependencies %s, skipping", name, deps)
|
|
122
|
+
self._load_errors[name] = msg
|
|
123
|
+
del plugins[name]
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
order = resolve_dependencies(plugins)
|
|
127
|
+
except CircularDependencyError as e:
|
|
128
|
+
raise e
|
|
129
|
+
|
|
130
|
+
self._activate_ordered(plugins, order)
|
|
131
|
+
|
|
132
|
+
def register_plugins(self, plugin_classes: list[type[BasePlugin]]) -> None:
|
|
133
|
+
"""Register plugin classes directly (without entry point discovery).
|
|
134
|
+
|
|
135
|
+
Useful for testing or programmatic plugin registration.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
plugin_classes: List of plugin classes to register.
|
|
139
|
+
"""
|
|
140
|
+
plugins_map: dict[str, type[BasePlugin]] = {}
|
|
141
|
+
for cls in plugin_classes:
|
|
142
|
+
plugins_map[cls.name] = cls
|
|
143
|
+
|
|
144
|
+
plugins_config = self._app_config.get("plugins", {})
|
|
145
|
+
enabled = plugins_config.get("enabled")
|
|
146
|
+
disabled = plugins_config.get("disabled")
|
|
147
|
+
plugins_map = filter_plugins(plugins_map, enabled, disabled)
|
|
148
|
+
|
|
149
|
+
missing = check_missing_dependencies(plugins_map)
|
|
150
|
+
for name, deps in missing.items():
|
|
151
|
+
msg = f"Missing dependencies: {deps}"
|
|
152
|
+
logger.warning("Plugin '%s' has missing dependencies %s, skipping", name, deps)
|
|
153
|
+
self._load_errors[name] = msg
|
|
154
|
+
del plugins_map[name]
|
|
155
|
+
|
|
156
|
+
order = resolve_dependencies(plugins_map)
|
|
157
|
+
|
|
158
|
+
self._activate_ordered(plugins_map, order)
|
|
159
|
+
|
|
160
|
+
def register_plugin(
|
|
161
|
+
self, plugin: BasePlugin, plugin_config: dict[str, Any] | None = None
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Register a single pre-instantiated plugin.
|
|
164
|
+
|
|
165
|
+
Useful for tests or dynamically created plugins.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
plugin: An already instantiated plugin.
|
|
169
|
+
plugin_config: Optional config dict. If None, loaded from YAML.
|
|
170
|
+
"""
|
|
171
|
+
validate_plugin_name(plugin.name)
|
|
172
|
+
|
|
173
|
+
if plugin_config is None:
|
|
174
|
+
plugin_config = self.get_plugin_config(plugin.name)
|
|
175
|
+
|
|
176
|
+
self._check_api_version(plugin)
|
|
177
|
+
|
|
178
|
+
if not self._lifecycle.init_plugin(plugin, self._app_config, plugin_config):
|
|
179
|
+
self._load_errors[plugin.name] = "Failed to initialize"
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
if self._pre_activate is not None:
|
|
183
|
+
if not self._pre_activate(plugin, plugin_config):
|
|
184
|
+
logger.info("Pre-activate check rejected plugin '%s'", plugin.name)
|
|
185
|
+
self._load_errors[plugin.name] = "Rejected by pre-activate check"
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
self._pm.register(plugin, name=plugin.name)
|
|
189
|
+
|
|
190
|
+
if not self._lifecycle.activate_plugin(plugin):
|
|
191
|
+
self._load_errors[plugin.name] = "Failed to activate"
|
|
192
|
+
self._pm.unregister(name=plugin.name)
|
|
193
|
+
|
|
194
|
+
def _check_api_version(self, plugin: BasePlugin) -> None:
|
|
195
|
+
"""Log a warning if the plugin's api_version differs from the manager's.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
plugin: The plugin to check.
|
|
199
|
+
"""
|
|
200
|
+
if plugin.api_version != self._api_version:
|
|
201
|
+
logger.warning(
|
|
202
|
+
"Plugin '%s' has api_version '%s', expected '%s'",
|
|
203
|
+
plugin.name,
|
|
204
|
+
plugin.api_version,
|
|
205
|
+
self._api_version,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def _activate_ordered(self, plugins: dict[str, type[BasePlugin]], order: list[str]) -> None:
|
|
209
|
+
"""Initialize and activate plugins in dependency order.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
plugins: Map of plugin name to plugin class.
|
|
213
|
+
order: Topologically sorted list of plugin names.
|
|
214
|
+
"""
|
|
215
|
+
for name in order:
|
|
216
|
+
validate_plugin_name(name)
|
|
217
|
+
cls = plugins[name]
|
|
218
|
+
plugin = cls()
|
|
219
|
+
plugin_config = self.get_plugin_config(name)
|
|
220
|
+
|
|
221
|
+
self._check_api_version(plugin)
|
|
222
|
+
|
|
223
|
+
if not self._lifecycle.init_plugin(plugin, self._app_config, plugin_config):
|
|
224
|
+
self._load_errors[name] = "Failed to initialize"
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
if self._pre_activate is not None:
|
|
228
|
+
if not self._pre_activate(plugin, plugin_config):
|
|
229
|
+
logger.info("Pre-activate check rejected plugin '%s'", name)
|
|
230
|
+
self._load_errors[name] = "Rejected by pre-activate check"
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
self._pm.register(plugin, name=name)
|
|
234
|
+
|
|
235
|
+
if not self._lifecycle.activate_plugin(plugin):
|
|
236
|
+
self._load_errors[name] = "Failed to activate"
|
|
237
|
+
self._pm.unregister(name=name)
|
|
238
|
+
|
|
239
|
+
def activate_plugin(self, name: str) -> None:
|
|
240
|
+
"""Activate a specific initialized plugin.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
name: Plugin name.
|
|
244
|
+
"""
|
|
245
|
+
plugin = self._lifecycle.get_plugin(name)
|
|
246
|
+
if plugin is None:
|
|
247
|
+
logger.warning("Plugin '%s' not found", name)
|
|
248
|
+
return
|
|
249
|
+
self._lifecycle.activate_plugin(plugin)
|
|
250
|
+
|
|
251
|
+
def deactivate_plugin(self, name: str) -> None:
|
|
252
|
+
"""Deactivate a specific active plugin and unregister its hooks.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
name: Plugin name.
|
|
256
|
+
"""
|
|
257
|
+
plugin = self._lifecycle.get_plugin(name)
|
|
258
|
+
if plugin is None:
|
|
259
|
+
logger.warning("Plugin '%s' not found", name)
|
|
260
|
+
return
|
|
261
|
+
if self._lifecycle.deactivate_plugin(plugin):
|
|
262
|
+
self._pm.unregister(name=name)
|
|
263
|
+
|
|
264
|
+
def get_plugin(self, name: str) -> BasePlugin | None:
|
|
265
|
+
"""Get a plugin instance by name.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
name: Plugin name.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
Plugin instance or None.
|
|
272
|
+
"""
|
|
273
|
+
return self._lifecycle.get_plugin(name)
|
|
274
|
+
|
|
275
|
+
def get_active_plugins(self) -> list[BasePlugin]:
|
|
276
|
+
"""Return all currently active plugins.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
List of active plugins.
|
|
280
|
+
"""
|
|
281
|
+
return self._lifecycle.get_active_plugins()
|
|
282
|
+
|
|
283
|
+
def get_load_errors(self) -> dict[str, str]:
|
|
284
|
+
"""Return errors from plugin loading/activation.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Dict mapping plugin name to error message for failed plugins.
|
|
288
|
+
"""
|
|
289
|
+
return dict(self._load_errors)
|
|
290
|
+
|
|
291
|
+
def deactivate_all(self) -> None:
|
|
292
|
+
"""Deactivate all active plugins in reverse order and unregister hooks."""
|
|
293
|
+
for plugin in reversed(self._lifecycle.get_active_plugins()):
|
|
294
|
+
if self._lifecycle.deactivate_plugin(plugin):
|
|
295
|
+
self._pm.unregister(name=plugin.name)
|
|
296
|
+
|
|
297
|
+
def register_hookspecs(self, spec_module: object) -> None:
|
|
298
|
+
"""Register hook specifications from a module.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
spec_module: Module containing hookspec-decorated functions.
|
|
302
|
+
"""
|
|
303
|
+
self._pm.add_hookspecs(spec_module)
|
|
304
|
+
|
|
305
|
+
def call_hook(self, hook_name: str, **kwargs: Any) -> list[Any]:
|
|
306
|
+
"""Call a named hook on all registered plugins.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
hook_name: Name of the hook to call.
|
|
310
|
+
**kwargs: Arguments to pass to hook implementations.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
List of results from all hook implementations.
|
|
314
|
+
"""
|
|
315
|
+
hook = getattr(self._pm.hook, hook_name, None)
|
|
316
|
+
if hook is None:
|
|
317
|
+
logger.warning("Hook '%s' not found", hook_name)
|
|
318
|
+
return []
|
|
319
|
+
return hook(**kwargs)
|
|
320
|
+
|
|
321
|
+
def mount_routes(self, app: object, prefix: str = "/api") -> None:
|
|
322
|
+
"""Mount FastAPI routes from all active plugins.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
app: A FastAPI application instance.
|
|
326
|
+
prefix: URL prefix for all plugin routes (default: "/api").
|
|
327
|
+
"""
|
|
328
|
+
from pluginforge.fastapi_ext import mount_plugin_routes
|
|
329
|
+
|
|
330
|
+
mount_plugin_routes(app, self.get_active_plugins(), prefix=prefix)
|
|
331
|
+
|
|
332
|
+
def get_text(self, key: str, lang: str | None = None) -> str:
|
|
333
|
+
"""Get an internationalized string.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
key: Dot-notation i18n key.
|
|
337
|
+
lang: Language code, or None for default.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
Translated string.
|
|
341
|
+
"""
|
|
342
|
+
return self._i18n.get_text(key, lang)
|
|
343
|
+
|
|
344
|
+
def collect_migrations(self) -> dict[str, str]:
|
|
345
|
+
"""Collect Alembic migration directories from all active plugins.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
Dict mapping plugin name to migrations directory path.
|
|
349
|
+
"""
|
|
350
|
+
from pluginforge.alembic_ext import collect_migrations_dirs
|
|
351
|
+
|
|
352
|
+
return collect_migrations_dirs(self.get_active_plugins())
|
|
353
|
+
|
|
354
|
+
def health_check(self) -> dict[str, dict[str, Any]]:
|
|
355
|
+
"""Run health checks on all active plugins.
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
Dict mapping plugin name to health status dict.
|
|
359
|
+
"""
|
|
360
|
+
results: dict[str, dict[str, Any]] = {}
|
|
361
|
+
for plugin in self.get_active_plugins():
|
|
362
|
+
try:
|
|
363
|
+
results[plugin.name] = plugin.health()
|
|
364
|
+
except Exception as e:
|
|
365
|
+
results[plugin.name] = {"status": "error", "error": str(e)}
|
|
366
|
+
return results
|
|
367
|
+
|
|
368
|
+
def reload_plugin(self, name: str) -> bool:
|
|
369
|
+
"""Hot-reload a plugin: deactivate, re-import module, re-init, activate.
|
|
370
|
+
|
|
371
|
+
The plugin's module is reloaded from disk so code changes take effect
|
|
372
|
+
without restarting the application.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
name: Name of the plugin to reload.
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
True if reload succeeded, False otherwise.
|
|
379
|
+
"""
|
|
380
|
+
plugin = self._lifecycle.get_plugin(name)
|
|
381
|
+
if plugin is None:
|
|
382
|
+
logger.warning("Cannot reload unknown plugin '%s'", name)
|
|
383
|
+
return False
|
|
384
|
+
|
|
385
|
+
plugin_cls = type(plugin)
|
|
386
|
+
module_name = plugin_cls.__module__
|
|
387
|
+
|
|
388
|
+
# Deactivate and unregister
|
|
389
|
+
if self._lifecycle.is_active(name):
|
|
390
|
+
self._lifecycle.deactivate_plugin(plugin)
|
|
391
|
+
self._pm.unregister(name=name)
|
|
392
|
+
|
|
393
|
+
# Remove from lifecycle tracking
|
|
394
|
+
self._lifecycle.remove_plugin(name)
|
|
395
|
+
|
|
396
|
+
# Reload the module
|
|
397
|
+
try:
|
|
398
|
+
module = sys.modules.get(module_name)
|
|
399
|
+
if module is not None:
|
|
400
|
+
module = importlib.reload(module)
|
|
401
|
+
plugin_cls = getattr(module, plugin_cls.__name__)
|
|
402
|
+
except Exception as e:
|
|
403
|
+
logger.error("Failed to reload module '%s': %s", module_name, e)
|
|
404
|
+
self._load_errors[name] = f"Failed to reload module: {e}"
|
|
405
|
+
return False
|
|
406
|
+
|
|
407
|
+
# Re-instantiate and activate
|
|
408
|
+
new_plugin = plugin_cls()
|
|
409
|
+
plugin_config = self.get_plugin_config(name)
|
|
410
|
+
|
|
411
|
+
if not self._lifecycle.init_plugin(new_plugin, self._app_config, plugin_config):
|
|
412
|
+
self._load_errors[name] = "Failed to initialize after reload"
|
|
413
|
+
return False
|
|
414
|
+
|
|
415
|
+
if self._pre_activate is not None:
|
|
416
|
+
if not self._pre_activate(new_plugin, plugin_config):
|
|
417
|
+
self._load_errors[name] = "Rejected by pre-activate check after reload"
|
|
418
|
+
return False
|
|
419
|
+
|
|
420
|
+
self._pm.register(new_plugin, name=name)
|
|
421
|
+
|
|
422
|
+
if not self._lifecycle.activate_plugin(new_plugin):
|
|
423
|
+
self._load_errors[name] = "Failed to activate after reload"
|
|
424
|
+
self._pm.unregister(name=name)
|
|
425
|
+
return False
|
|
426
|
+
|
|
427
|
+
logger.info("Reloaded plugin '%s'", name)
|
|
428
|
+
return True
|
|
429
|
+
|
|
430
|
+
def get_extensions(self, extension_point: type) -> list[BasePlugin]:
|
|
431
|
+
"""Return all active plugins that implement a given extension point.
|
|
432
|
+
|
|
433
|
+
An extension point is any class or ABC. This method returns all active
|
|
434
|
+
plugins that are instances of that type.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
extension_point: The extension point class to filter by.
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
List of active plugins implementing the extension point.
|
|
441
|
+
"""
|
|
442
|
+
return [p for p in self.get_active_plugins() if isinstance(p, extension_point)]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Security utilities for plugin name validation and path traversal prevention."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
# Plugin names must be alphanumeric with underscores/hyphens, max 64 chars
|
|
9
|
+
_PLUGIN_NAME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]{0,63}$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InvalidPluginNameError(ValueError):
|
|
13
|
+
"""Raised when a plugin name contains invalid characters."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_plugin_name(name: str) -> None:
|
|
17
|
+
"""Validate a plugin name for safety.
|
|
18
|
+
|
|
19
|
+
Plugin names are used to construct file paths (config/plugins/{name}.yaml),
|
|
20
|
+
so they must not contain path separators or traversal sequences.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
name: The plugin name to validate.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
InvalidPluginNameError: If the name is invalid.
|
|
27
|
+
"""
|
|
28
|
+
if not _PLUGIN_NAME_RE.match(name):
|
|
29
|
+
raise InvalidPluginNameError(
|
|
30
|
+
f"Invalid plugin name '{name}'. "
|
|
31
|
+
"Names must start with a letter, contain only letters, digits, "
|
|
32
|
+
"underscores, or hyphens, and be at most 64 characters."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_safe_path(path: str, allowed_base: str) -> bool:
|
|
37
|
+
"""Check that a resolved path stays within the allowed base directory.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
path: The path to validate.
|
|
41
|
+
allowed_base: The base directory that the path must stay within.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
True if the path is safe, False otherwise.
|
|
45
|
+
"""
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
resolved = Path(path).resolve()
|
|
49
|
+
base = Path(allowed_base).resolve()
|
|
50
|
+
try:
|
|
51
|
+
resolved.relative_to(base)
|
|
52
|
+
return True
|
|
53
|
+
except ValueError:
|
|
54
|
+
logger.warning("Path traversal detected: '%s' escapes base '%s'", path, allowed_base)
|
|
55
|
+
return False
|
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
"""Central PluginManager that orchestrates config, discovery, lifecycle, and hooks."""
|
|
2
|
-
|
|
3
|
-
import logging
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from typing import Any
|
|
6
|
-
|
|
7
|
-
import pluggy
|
|
8
|
-
|
|
9
|
-
from pluginforge.base import BasePlugin
|
|
10
|
-
from pluginforge.config import load_app_config, load_plugin_config
|
|
11
|
-
from pluginforge.discovery import (
|
|
12
|
-
CircularDependencyError,
|
|
13
|
-
check_missing_dependencies,
|
|
14
|
-
discover_entry_points,
|
|
15
|
-
filter_plugins,
|
|
16
|
-
resolve_dependencies,
|
|
17
|
-
)
|
|
18
|
-
from pluginforge.i18n import I18n
|
|
19
|
-
from pluginforge.lifecycle import PluginLifecycle
|
|
20
|
-
|
|
21
|
-
logger = logging.getLogger(__name__)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class PluginManager:
|
|
25
|
-
"""Central manager for plugin discovery, lifecycle, and hooks.
|
|
26
|
-
|
|
27
|
-
Wraps pluggy.PluginManager and adds YAML config, lifecycle management,
|
|
28
|
-
dependency resolution, and i18n support.
|
|
29
|
-
|
|
30
|
-
Args:
|
|
31
|
-
config_path: Path to app.yaml configuration file.
|
|
32
|
-
"""
|
|
33
|
-
|
|
34
|
-
def __init__(self, config_path: str = "config/app.yaml") -> None:
|
|
35
|
-
self._config_path = Path(config_path)
|
|
36
|
-
self._config_dir = self._config_path.parent
|
|
37
|
-
self._app_config = load_app_config(self._config_path)
|
|
38
|
-
|
|
39
|
-
plugins_config = self._app_config.get("plugins", {})
|
|
40
|
-
group = plugins_config.get("entry_point_group", "pluginforge.plugins")
|
|
41
|
-
self._entry_point_group = group
|
|
42
|
-
|
|
43
|
-
self._pm = pluggy.PluginManager(group)
|
|
44
|
-
self._lifecycle = PluginLifecycle()
|
|
45
|
-
|
|
46
|
-
default_lang = self._app_config.get("app", {}).get("default_language", "en")
|
|
47
|
-
self._i18n = I18n(self._config_dir, default_lang=default_lang)
|
|
48
|
-
|
|
49
|
-
def get_app_config(self) -> dict[str, Any]:
|
|
50
|
-
"""Return the loaded application configuration.
|
|
51
|
-
|
|
52
|
-
Returns:
|
|
53
|
-
App config dict.
|
|
54
|
-
"""
|
|
55
|
-
return self._app_config
|
|
56
|
-
|
|
57
|
-
def get_plugin_config(self, plugin_name: str) -> dict[str, Any]:
|
|
58
|
-
"""Load and return config for a specific plugin.
|
|
59
|
-
|
|
60
|
-
Args:
|
|
61
|
-
plugin_name: Name of the plugin.
|
|
62
|
-
|
|
63
|
-
Returns:
|
|
64
|
-
Plugin configuration dict.
|
|
65
|
-
"""
|
|
66
|
-
return load_plugin_config(self._config_dir, plugin_name)
|
|
67
|
-
|
|
68
|
-
def discover_plugins(self) -> None:
|
|
69
|
-
"""Discover, filter, resolve dependencies, and activate plugins.
|
|
70
|
-
|
|
71
|
-
Loads plugins from entry points, filters by enabled/disabled config,
|
|
72
|
-
checks dependencies, sorts topologically, then initializes and
|
|
73
|
-
activates each plugin.
|
|
74
|
-
"""
|
|
75
|
-
plugins = discover_entry_points(self._entry_point_group)
|
|
76
|
-
|
|
77
|
-
plugins_config = self._app_config.get("plugins", {})
|
|
78
|
-
enabled = plugins_config.get("enabled")
|
|
79
|
-
disabled = plugins_config.get("disabled")
|
|
80
|
-
plugins = filter_plugins(plugins, enabled, disabled)
|
|
81
|
-
|
|
82
|
-
missing = check_missing_dependencies(plugins)
|
|
83
|
-
for name, deps in missing.items():
|
|
84
|
-
logger.warning("Plugin '%s' has missing dependencies %s, skipping", name, deps)
|
|
85
|
-
del plugins[name]
|
|
86
|
-
|
|
87
|
-
try:
|
|
88
|
-
order = resolve_dependencies(plugins)
|
|
89
|
-
except CircularDependencyError as e:
|
|
90
|
-
raise e
|
|
91
|
-
|
|
92
|
-
for name in order:
|
|
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)
|
|
104
|
-
|
|
105
|
-
def register_plugins(self, plugin_classes: list[type[BasePlugin]]) -> None:
|
|
106
|
-
"""Register plugin classes directly (without entry point discovery).
|
|
107
|
-
|
|
108
|
-
Useful for testing or programmatic plugin registration.
|
|
109
|
-
|
|
110
|
-
Args:
|
|
111
|
-
plugin_classes: List of plugin classes to register.
|
|
112
|
-
"""
|
|
113
|
-
plugins_map: dict[str, type[BasePlugin]] = {}
|
|
114
|
-
for cls in plugin_classes:
|
|
115
|
-
plugins_map[cls.name] = cls
|
|
116
|
-
|
|
117
|
-
plugins_config = self._app_config.get("plugins", {})
|
|
118
|
-
enabled = plugins_config.get("enabled")
|
|
119
|
-
disabled = plugins_config.get("disabled")
|
|
120
|
-
plugins_map = filter_plugins(plugins_map, enabled, disabled)
|
|
121
|
-
|
|
122
|
-
missing = check_missing_dependencies(plugins_map)
|
|
123
|
-
for name, deps in missing.items():
|
|
124
|
-
logger.warning("Plugin '%s' has missing dependencies %s, skipping", name, deps)
|
|
125
|
-
del plugins_map[name]
|
|
126
|
-
|
|
127
|
-
order = resolve_dependencies(plugins_map)
|
|
128
|
-
|
|
129
|
-
for name in order:
|
|
130
|
-
cls = plugins_map[name]
|
|
131
|
-
plugin = cls()
|
|
132
|
-
plugin_config = self.get_plugin_config(name)
|
|
133
|
-
|
|
134
|
-
if not self._lifecycle.init_plugin(plugin, self._app_config, plugin_config):
|
|
135
|
-
continue
|
|
136
|
-
|
|
137
|
-
self._pm.register(plugin, name=name)
|
|
138
|
-
|
|
139
|
-
if not self._lifecycle.activate_plugin(plugin):
|
|
140
|
-
self._pm.unregister(name=name)
|
|
141
|
-
|
|
142
|
-
def activate_plugin(self, name: str) -> None:
|
|
143
|
-
"""Activate a specific initialized plugin.
|
|
144
|
-
|
|
145
|
-
Args:
|
|
146
|
-
name: Plugin name.
|
|
147
|
-
"""
|
|
148
|
-
plugin = self._lifecycle.get_plugin(name)
|
|
149
|
-
if plugin is None:
|
|
150
|
-
logger.warning("Plugin '%s' not found", name)
|
|
151
|
-
return
|
|
152
|
-
self._lifecycle.activate_plugin(plugin)
|
|
153
|
-
|
|
154
|
-
def deactivate_plugin(self, name: str) -> None:
|
|
155
|
-
"""Deactivate a specific active plugin.
|
|
156
|
-
|
|
157
|
-
Args:
|
|
158
|
-
name: Plugin name.
|
|
159
|
-
"""
|
|
160
|
-
plugin = self._lifecycle.get_plugin(name)
|
|
161
|
-
if plugin is None:
|
|
162
|
-
logger.warning("Plugin '%s' not found", name)
|
|
163
|
-
return
|
|
164
|
-
self._lifecycle.deactivate_plugin(plugin)
|
|
165
|
-
|
|
166
|
-
def get_plugin(self, name: str) -> BasePlugin | None:
|
|
167
|
-
"""Get a plugin instance by name.
|
|
168
|
-
|
|
169
|
-
Args:
|
|
170
|
-
name: Plugin name.
|
|
171
|
-
|
|
172
|
-
Returns:
|
|
173
|
-
Plugin instance or None.
|
|
174
|
-
"""
|
|
175
|
-
return self._lifecycle.get_plugin(name)
|
|
176
|
-
|
|
177
|
-
def get_active_plugins(self) -> list[BasePlugin]:
|
|
178
|
-
"""Return all currently active plugins.
|
|
179
|
-
|
|
180
|
-
Returns:
|
|
181
|
-
List of active plugins.
|
|
182
|
-
"""
|
|
183
|
-
return self._lifecycle.get_active_plugins()
|
|
184
|
-
|
|
185
|
-
def deactivate_all(self) -> None:
|
|
186
|
-
"""Deactivate all active plugins in reverse order."""
|
|
187
|
-
self._lifecycle.deactivate_all()
|
|
188
|
-
|
|
189
|
-
def register_hookspecs(self, spec_module: object) -> None:
|
|
190
|
-
"""Register hook specifications from a module.
|
|
191
|
-
|
|
192
|
-
Args:
|
|
193
|
-
spec_module: Module containing hookspec-decorated functions.
|
|
194
|
-
"""
|
|
195
|
-
self._pm.add_hookspecs(spec_module)
|
|
196
|
-
|
|
197
|
-
def call_hook(self, hook_name: str, **kwargs: Any) -> list[Any]:
|
|
198
|
-
"""Call a named hook on all registered plugins.
|
|
199
|
-
|
|
200
|
-
Args:
|
|
201
|
-
hook_name: Name of the hook to call.
|
|
202
|
-
**kwargs: Arguments to pass to hook implementations.
|
|
203
|
-
|
|
204
|
-
Returns:
|
|
205
|
-
List of results from all hook implementations.
|
|
206
|
-
"""
|
|
207
|
-
hook = getattr(self._pm.hook, hook_name, None)
|
|
208
|
-
if hook is None:
|
|
209
|
-
logger.warning("Hook '%s' not found", hook_name)
|
|
210
|
-
return []
|
|
211
|
-
return hook(**kwargs)
|
|
212
|
-
|
|
213
|
-
def mount_routes(self, app: object) -> None:
|
|
214
|
-
"""Mount FastAPI routes from all active plugins.
|
|
215
|
-
|
|
216
|
-
Args:
|
|
217
|
-
app: A FastAPI application instance.
|
|
218
|
-
"""
|
|
219
|
-
from pluginforge.fastapi_ext import mount_plugin_routes
|
|
220
|
-
|
|
221
|
-
mount_plugin_routes(app, self.get_active_plugins())
|
|
222
|
-
|
|
223
|
-
def get_text(self, key: str, lang: str | None = None) -> str:
|
|
224
|
-
"""Get an internationalized string.
|
|
225
|
-
|
|
226
|
-
Args:
|
|
227
|
-
key: Dot-notation i18n key.
|
|
228
|
-
lang: Language code, or None for default.
|
|
229
|
-
|
|
230
|
-
Returns:
|
|
231
|
-
Translated string.
|
|
232
|
-
"""
|
|
233
|
-
return self._i18n.get_text(key, lang)
|
|
234
|
-
|
|
235
|
-
def collect_migrations(self) -> dict[str, str]:
|
|
236
|
-
"""Collect Alembic migration directories from all active plugins.
|
|
237
|
-
|
|
238
|
-
Returns:
|
|
239
|
-
Dict mapping plugin name to migrations directory path.
|
|
240
|
-
"""
|
|
241
|
-
from pluginforge.alembic_ext import collect_migrations_dirs
|
|
242
|
-
|
|
243
|
-
return collect_migrations_dirs(self.get_active_plugins())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|