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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pluginforge
3
- Version: 0.2.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
- ## Development
153
+ ## Documentation
152
154
 
153
- ```bash
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
- # Run tests
158
- poetry run pytest
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
- # Lint
161
- poetry run ruff check pluginforge/ tests/
169
+ ## Development
162
170
 
163
- # Format
164
- poetry run ruff format pluginforge/ tests/
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
- ## Development
128
+ ## Documentation
127
129
 
128
- ```bash
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
- # Run tests
133
- poetry run pytest
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
- # Lint
136
- poetry run ruff check pluginforge/ tests/
144
+ ## Development
137
145
 
138
- # Format
139
- poetry run ruff format pluginforge/ tests/
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.2.0"
8
- __all__ = ["BasePlugin", "CircularDependencyError", "PluginManager"]
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
- migrations[plugin.name] = str(path)
32
- logger.info("Collected migrations for plugin '%s': %s", plugin.name, migrations_dir)
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
- Each plugin's routes are mounted under /api/plugins/{plugin_name}/.
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' at %s", plugin.name, prefix)
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,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "pluginforge"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "Application-agnostic plugin framework built on pluggy"
5
5
  authors = ["Asterios Raptis"]
6
6
  license = "MIT"
@@ -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