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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pluginforge
3
- Version: 0.1.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.4.0,<2.0.0)
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
- ## 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
@@ -4,5 +4,5 @@ from pluginforge.base import BasePlugin
4
4
  from pluginforge.discovery import CircularDependencyError
5
5
  from pluginforge.manager import PluginManager
6
6
 
7
- __version__ = "0.1.0"
7
+ __version__ = "0.2.0"
8
8
  __all__ = ["BasePlugin", "CircularDependencyError", "PluginManager"]
@@ -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
- 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
 
@@ -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__(self, config_path: str = "config/app.yaml") -> None:
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
- 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)
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 = plugins_map[name]
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.deactivate_all()
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.1.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.4.0"
23
+ pluggy = "^1.5.0"
24
24
  pyyaml = "^6.0"
25
25
 
26
26
  [tool.poetry.group.dev.dependencies]
File without changes