pluginforge 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,8 @@
1
+ """PluginForge - Application-agnostic plugin framework built on pluggy."""
2
+
3
+ from pluginforge.base import BasePlugin
4
+ from pluginforge.discovery import CircularDependencyError
5
+ from pluginforge.manager import PluginManager
6
+
7
+ __version__ = "0.1.0"
8
+ __all__ = ["BasePlugin", "CircularDependencyError", "PluginManager"]
@@ -0,0 +1,33 @@
1
+ """Alembic migration support for plugins."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ from pluginforge.base import BasePlugin
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def collect_migrations_dirs(plugins: list[BasePlugin]) -> dict[str, str]:
12
+ """Collect Alembic migration directories from all plugins.
13
+
14
+ Args:
15
+ plugins: List of active plugins.
16
+
17
+ Returns:
18
+ Dict mapping plugin name to migrations directory path.
19
+ """
20
+ migrations: dict[str, str] = {}
21
+ for plugin in plugins:
22
+ migrations_dir = plugin.get_migrations_dir()
23
+ if migrations_dir is None:
24
+ continue
25
+ path = Path(migrations_dir)
26
+ if not path.is_dir():
27
+ logger.warning(
28
+ "Plugin '%s' migrations dir does not exist: %s", plugin.name, migrations_dir
29
+ )
30
+ continue
31
+ migrations[plugin.name] = str(path)
32
+ logger.info("Collected migrations for plugin '%s': %s", plugin.name, migrations_dir)
33
+ return migrations
pluginforge/base.py ADDED
@@ -0,0 +1,57 @@
1
+ """Base plugin class for all PluginForge plugins."""
2
+
3
+ from abc import ABC
4
+ from typing import Any
5
+
6
+
7
+ class BasePlugin(ABC):
8
+ """Abstract base class for all PluginForge plugins.
9
+
10
+ Attributes:
11
+ name: Unique plugin identifier (e.g. "export").
12
+ version: Plugin version string.
13
+ api_version: Hook spec compatibility version.
14
+ description: Human-readable description.
15
+ author: Plugin author.
16
+ depends_on: List of plugin names this plugin depends on.
17
+ config: Plugin configuration, populated during init().
18
+ """
19
+
20
+ name: str
21
+ version: str = "0.1.0"
22
+ api_version: str = "1"
23
+ description: str = ""
24
+ author: str = ""
25
+ depends_on: list[str] = []
26
+ config: dict[str, Any] = {}
27
+
28
+ def init(self, app_config: dict[str, Any], plugin_config: dict[str, Any]) -> None:
29
+ """Called when the plugin is loaded. Receives app and plugin config.
30
+
31
+ Args:
32
+ app_config: The global application configuration.
33
+ plugin_config: Plugin-specific configuration from YAML.
34
+ """
35
+ self.config = plugin_config
36
+
37
+ def activate(self) -> None:
38
+ """Called when the plugin is activated."""
39
+
40
+ def deactivate(self) -> None:
41
+ """Called when the plugin is deactivated. Release resources here."""
42
+
43
+ def get_routes(self) -> list:
44
+ """Return FastAPI routers to be mounted. Optional.
45
+
46
+ Returns:
47
+ List of FastAPI APIRouter instances.
48
+ """
49
+ return []
50
+
51
+ def get_migrations_dir(self) -> str | None:
52
+ """Return path to Alembic migration scripts. Optional.
53
+
54
+ Returns:
55
+ Path string or None if no migrations.
56
+ """
57
+ return None
pluginforge/config.py ADDED
@@ -0,0 +1,71 @@
1
+ """YAML configuration loading and merging."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def load_yaml(path: str | Path) -> dict[str, Any]:
13
+ """Load a YAML file and return its contents as a dict.
14
+
15
+ Args:
16
+ path: Path to the YAML file.
17
+
18
+ Returns:
19
+ Parsed YAML content, or empty dict if file is missing or empty.
20
+ """
21
+ path = Path(path)
22
+ if not path.exists():
23
+ logger.debug("Config file not found, using empty defaults: %s", path)
24
+ return {}
25
+ try:
26
+ with open(path, encoding="utf-8") as f:
27
+ data = yaml.safe_load(f)
28
+ return data if isinstance(data, dict) else {}
29
+ except yaml.YAMLError as e:
30
+ logger.warning("Failed to parse YAML file %s: %s", path, e)
31
+ return {}
32
+
33
+
34
+ def load_app_config(config_path: str | Path) -> dict[str, Any]:
35
+ """Load the main application config.
36
+
37
+ Args:
38
+ config_path: Path to app.yaml.
39
+
40
+ Returns:
41
+ Application configuration dict.
42
+ """
43
+ return load_yaml(config_path)
44
+
45
+
46
+ def load_plugin_config(config_dir: str | Path, plugin_name: str) -> dict[str, Any]:
47
+ """Load plugin-specific configuration from config/plugins/{name}.yaml.
48
+
49
+ Args:
50
+ config_dir: Base config directory (parent of plugins/).
51
+ plugin_name: Name of the plugin.
52
+
53
+ Returns:
54
+ Plugin configuration dict.
55
+ """
56
+ path = Path(config_dir) / "plugins" / f"{plugin_name}.yaml"
57
+ return load_yaml(path)
58
+
59
+
60
+ def load_i18n(config_dir: str | Path, lang: str) -> dict[str, Any]:
61
+ """Load i18n strings for a specific language.
62
+
63
+ Args:
64
+ config_dir: Base config directory (parent of i18n/).
65
+ lang: Language code (e.g. "en", "de").
66
+
67
+ Returns:
68
+ i18n strings dict.
69
+ """
70
+ path = Path(config_dir) / "i18n" / f"{lang}.yaml"
71
+ return load_yaml(path)
@@ -0,0 +1,124 @@
1
+ """Plugin discovery via entry points and dependency resolution."""
2
+
3
+ import logging
4
+ from importlib.metadata import entry_points
5
+ from typing import Any
6
+
7
+ from pluginforge.base import BasePlugin
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class CircularDependencyError(Exception):
13
+ """Raised when a circular dependency is detected among plugins."""
14
+
15
+
16
+ def discover_entry_points(group: str) -> dict[str, type[BasePlugin]]:
17
+ """Load plugin classes from entry points.
18
+
19
+ Args:
20
+ group: Entry point group name (e.g. "myapp.plugins").
21
+
22
+ Returns:
23
+ Dict mapping plugin name to plugin class.
24
+ """
25
+ plugins: dict[str, type[BasePlugin]] = {}
26
+ eps = entry_points()
27
+ group_eps = eps.select(group=group) if hasattr(eps, "select") else eps.get(group, [])
28
+ for ep in group_eps:
29
+ try:
30
+ plugin_cls = ep.load()
31
+ if hasattr(plugin_cls, "name"):
32
+ plugins[plugin_cls.name] = plugin_cls
33
+ else:
34
+ logger.warning("Entry point %s has no 'name' attribute, skipping", ep.name)
35
+ except Exception as e:
36
+ logger.error("Failed to load entry point %s: %s", ep.name, e)
37
+ return plugins
38
+
39
+
40
+ def filter_plugins(
41
+ plugins: dict[str, type[BasePlugin]],
42
+ enabled: list[str] | None,
43
+ disabled: list[str] | None,
44
+ ) -> dict[str, type[BasePlugin]]:
45
+ """Filter plugins based on enabled/disabled lists.
46
+
47
+ If enabled list is provided, only those plugins are kept.
48
+ Disabled list always takes precedence (plugins in disabled are removed).
49
+
50
+ Args:
51
+ plugins: All discovered plugins.
52
+ enabled: List of enabled plugin names, or None for all.
53
+ disabled: List of disabled plugin names, or None.
54
+
55
+ Returns:
56
+ Filtered plugins dict.
57
+ """
58
+ if enabled is not None:
59
+ plugins = {name: cls for name, cls in plugins.items() if name in enabled}
60
+ if disabled:
61
+ plugins = {name: cls for name, cls in plugins.items() if name not in disabled}
62
+ return plugins
63
+
64
+
65
+ def resolve_dependencies(
66
+ plugins: dict[str, Any],
67
+ ) -> list[str]:
68
+ """Topologically sort plugins by their dependencies.
69
+
70
+ Args:
71
+ plugins: Dict mapping plugin name to plugin class (must have depends_on attribute).
72
+
73
+ Returns:
74
+ List of plugin names in dependency order.
75
+
76
+ Raises:
77
+ CircularDependencyError: If circular dependencies are detected.
78
+ """
79
+ graph: dict[str, list[str]] = {}
80
+ for name, cls in plugins.items():
81
+ deps = getattr(cls, "depends_on", []) or []
82
+ graph[name] = [d for d in deps if d in plugins]
83
+
84
+ visited: set[str] = set()
85
+ in_stack: set[str] = set()
86
+ order: list[str] = []
87
+
88
+ def visit(node: str) -> None:
89
+ if node in in_stack:
90
+ raise CircularDependencyError(f"Circular dependency detected involving plugin '{node}'")
91
+ if node in visited:
92
+ return
93
+ in_stack.add(node)
94
+ for dep in graph.get(node, []):
95
+ visit(dep)
96
+ in_stack.remove(node)
97
+ visited.add(node)
98
+ order.append(node)
99
+
100
+ for name in graph:
101
+ visit(name)
102
+
103
+ return order
104
+
105
+
106
+ def check_missing_dependencies(
107
+ plugins: dict[str, Any],
108
+ ) -> dict[str, list[str]]:
109
+ """Check which plugins have unresolved dependencies.
110
+
111
+ Args:
112
+ plugins: Dict mapping plugin name to plugin class.
113
+
114
+ Returns:
115
+ Dict mapping plugin name to list of missing dependency names.
116
+ """
117
+ missing: dict[str, list[str]] = {}
118
+ available = set(plugins.keys())
119
+ for name, cls in plugins.items():
120
+ deps = getattr(cls, "depends_on", []) or []
121
+ unresolved = [d for d in deps if d not in available]
122
+ if unresolved:
123
+ missing[name] = unresolved
124
+ return missing
@@ -0,0 +1,37 @@
1
+ """FastAPI integration for mounting plugin routes."""
2
+
3
+ import logging
4
+
5
+ from pluginforge.base import BasePlugin
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ def mount_plugin_routes(app: "object", plugins: list[BasePlugin]) -> None:
11
+ """Mount routes from all plugins onto a FastAPI app.
12
+
13
+ Each plugin's routes are mounted under /api/plugins/{plugin_name}/.
14
+
15
+ Args:
16
+ app: A FastAPI application instance.
17
+ plugins: List of active plugins.
18
+ """
19
+ try:
20
+ from fastapi import FastAPI
21
+ except ImportError:
22
+ raise ImportError(
23
+ "FastAPI is required for route mounting. "
24
+ "Install it with: pip install pluginforge[fastapi]"
25
+ )
26
+
27
+ if not isinstance(app, FastAPI):
28
+ raise TypeError(f"Expected FastAPI instance, got {type(app).__name__}")
29
+
30
+ for plugin in plugins:
31
+ routes = plugin.get_routes()
32
+ if not routes:
33
+ continue
34
+ for router in routes:
35
+ prefix = f"/api/plugins/{plugin.name}"
36
+ app.include_router(router, prefix=prefix)
37
+ logger.info("Mounted routes for plugin '%s' at %s", plugin.name, prefix)
pluginforge/i18n.py ADDED
@@ -0,0 +1,82 @@
1
+ """Internationalization support via YAML files."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from pluginforge.config import load_i18n
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class I18n:
13
+ """Manages internationalized strings loaded from YAML files.
14
+
15
+ Attributes:
16
+ config_dir: Base config directory containing i18n/ folder.
17
+ default_lang: Fallback language code.
18
+ """
19
+
20
+ def __init__(self, config_dir: str | Path, default_lang: str = "en") -> None:
21
+ self.config_dir = Path(config_dir)
22
+ self.default_lang = default_lang
23
+ self._strings: dict[str, dict[str, Any]] = {}
24
+
25
+ def load_language(self, lang: str) -> None:
26
+ """Load strings for a language if not already loaded.
27
+
28
+ Args:
29
+ lang: Language code (e.g. "en", "de").
30
+ """
31
+ if lang not in self._strings:
32
+ self._strings[lang] = load_i18n(self.config_dir, lang)
33
+
34
+ def get_text(self, key: str, lang: str | None = None) -> str:
35
+ """Get a translated string by dot-notation key.
36
+
37
+ Falls back to default language if key is not found in requested language.
38
+ Returns the key itself if not found in any language.
39
+
40
+ Args:
41
+ key: Dot-notation key (e.g. "common.save").
42
+ lang: Language code, or None for default.
43
+
44
+ Returns:
45
+ Translated string, or the key if not found.
46
+ """
47
+ lang = lang or self.default_lang
48
+ self.load_language(lang)
49
+
50
+ value = self._resolve_key(key, lang)
51
+ if value is not None:
52
+ return value
53
+
54
+ if lang != self.default_lang:
55
+ self.load_language(self.default_lang)
56
+ value = self._resolve_key(key, self.default_lang)
57
+ if value is not None:
58
+ return value
59
+
60
+ logger.debug("i18n key not found: %s (lang=%s)", key, lang)
61
+ return key
62
+
63
+ def _resolve_key(self, key: str, lang: str) -> str | None:
64
+ """Resolve a dot-notation key in a language's strings.
65
+
66
+ Args:
67
+ key: Dot-notation key.
68
+ lang: Language code.
69
+
70
+ Returns:
71
+ The resolved string or None.
72
+ """
73
+ data = self._strings.get(lang, {})
74
+ parts = key.split(".")
75
+ for part in parts:
76
+ if isinstance(data, dict):
77
+ data = data.get(part)
78
+ else:
79
+ return None
80
+ if data is None:
81
+ return None
82
+ return str(data) if not isinstance(data, dict) else None
@@ -0,0 +1,124 @@
1
+ """Plugin lifecycle management (init, activate, deactivate)."""
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from pluginforge.base import BasePlugin
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class PluginLifecycle:
12
+ """Manages plugin lifecycle transitions.
13
+
14
+ Tracks which plugins are initialized and active, handles
15
+ init/activate/deactivate calls with error handling.
16
+ """
17
+
18
+ def __init__(self) -> None:
19
+ self._initialized: dict[str, BasePlugin] = {}
20
+ self._active: dict[str, BasePlugin] = {}
21
+
22
+ def init_plugin(
23
+ self,
24
+ plugin: BasePlugin,
25
+ app_config: dict[str, Any],
26
+ plugin_config: dict[str, Any],
27
+ ) -> bool:
28
+ """Initialize a plugin with config.
29
+
30
+ Args:
31
+ plugin: The plugin instance to initialize.
32
+ app_config: Global application config.
33
+ plugin_config: Plugin-specific config.
34
+
35
+ Returns:
36
+ True if initialization succeeded, False otherwise.
37
+ """
38
+ try:
39
+ plugin.init(app_config, plugin_config)
40
+ self._initialized[plugin.name] = plugin
41
+ logger.info("Initialized plugin: %s", plugin.name)
42
+ return True
43
+ except Exception as e:
44
+ logger.error("Failed to initialize plugin %s: %s", plugin.name, e)
45
+ return False
46
+
47
+ def activate_plugin(self, plugin: BasePlugin) -> bool:
48
+ """Activate an initialized plugin.
49
+
50
+ Args:
51
+ plugin: The plugin instance to activate.
52
+
53
+ Returns:
54
+ True if activation succeeded, False otherwise.
55
+ """
56
+ if plugin.name not in self._initialized:
57
+ logger.warning("Cannot activate uninitialized plugin: %s", plugin.name)
58
+ return False
59
+ try:
60
+ plugin.activate()
61
+ self._active[plugin.name] = plugin
62
+ logger.info("Activated plugin: %s", plugin.name)
63
+ return True
64
+ except Exception as e:
65
+ logger.error("Failed to activate plugin %s: %s", plugin.name, e)
66
+ return False
67
+
68
+ def deactivate_plugin(self, plugin: BasePlugin) -> bool:
69
+ """Deactivate an active plugin.
70
+
71
+ Args:
72
+ plugin: The plugin instance to deactivate.
73
+
74
+ Returns:
75
+ True if deactivation succeeded, False otherwise.
76
+ """
77
+ if plugin.name not in self._active:
78
+ logger.warning("Cannot deactivate inactive plugin: %s", plugin.name)
79
+ return False
80
+ try:
81
+ plugin.deactivate()
82
+ del self._active[plugin.name]
83
+ logger.info("Deactivated plugin: %s", plugin.name)
84
+ return True
85
+ except Exception as e:
86
+ logger.error("Failed to deactivate plugin %s: %s", plugin.name, e)
87
+ return False
88
+
89
+ def deactivate_all(self) -> None:
90
+ """Deactivate all active plugins in reverse activation order."""
91
+ names = list(reversed(list(self._active.keys())))
92
+ for name in names:
93
+ plugin = self._active[name]
94
+ self.deactivate_plugin(plugin)
95
+
96
+ def get_active_plugins(self) -> list[BasePlugin]:
97
+ """Return list of currently active plugins.
98
+
99
+ Returns:
100
+ List of active BasePlugin instances.
101
+ """
102
+ return list(self._active.values())
103
+
104
+ def get_plugin(self, name: str) -> BasePlugin | None:
105
+ """Get an initialized plugin by name.
106
+
107
+ Args:
108
+ name: Plugin name.
109
+
110
+ Returns:
111
+ The plugin instance or None.
112
+ """
113
+ return self._initialized.get(name)
114
+
115
+ def is_active(self, name: str) -> bool:
116
+ """Check if a plugin is currently active.
117
+
118
+ Args:
119
+ name: Plugin name.
120
+
121
+ Returns:
122
+ True if the plugin is active.
123
+ """
124
+ return name in self._active
pluginforge/manager.py ADDED
@@ -0,0 +1,243 @@
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())
@@ -0,0 +1,170 @@
1
+ Metadata-Version: 2.4
2
+ Name: pluginforge
3
+ Version: 0.1.0
4
+ Summary: Application-agnostic plugin framework built on pluggy
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Keywords: plugin,framework,pluggy,hooks,yaml
8
+ Author: Asterios Raptis
9
+ Requires-Python: >=3.11,<4.0
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
19
+ Provides-Extra: alembic
20
+ Provides-Extra: fastapi
21
+ Requires-Dist: pluggy (>=1.4.0,<2.0.0)
22
+ Requires-Dist: pyyaml (>=6.0,<7.0)
23
+ Project-URL: Repository, https://github.com/astrapi69/pluginforge
24
+ Description-Content-Type: text/markdown
25
+
26
+ # PluginForge
27
+
28
+ Application-agnostic Python plugin framework built on [pluggy](https://pluggy.readthedocs.io/).
29
+
30
+ PluginForge adds the layers that pluggy is missing: YAML configuration, plugin lifecycle management, enable/disable per config, dependency resolution, FastAPI integration, and i18n support.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install pluginforge
36
+ ```
37
+
38
+ With optional FastAPI support:
39
+
40
+ ```bash
41
+ pip install pluginforge[fastapi]
42
+ ```
43
+
44
+ ## Quickstart
45
+
46
+ ### 1. Create a plugin
47
+
48
+ ```python
49
+ from pluginforge import BasePlugin
50
+
51
+ class HelloPlugin(BasePlugin):
52
+ name = "hello"
53
+ version = "1.0.0"
54
+ description = "A hello world plugin"
55
+
56
+ def activate(self):
57
+ print(f"Hello plugin activated with config: {self.config}")
58
+
59
+ def get_routes(self):
60
+ from fastapi import APIRouter
61
+ router = APIRouter()
62
+
63
+ @router.get("/hello")
64
+ def hello():
65
+ return {"message": self.config.get("greeting", "Hello!")}
66
+
67
+ return [router]
68
+ ```
69
+
70
+ ### 2. Configure your app
71
+
72
+ ```yaml
73
+ # config/app.yaml
74
+ app:
75
+ name: "MyApp"
76
+ version: "1.0.0"
77
+ default_language: "en"
78
+
79
+ plugins:
80
+ entry_point_group: "myapp.plugins"
81
+ enabled:
82
+ - "hello"
83
+ disabled: []
84
+ ```
85
+
86
+ ```yaml
87
+ # config/plugins/hello.yaml
88
+ greeting: "Hello from PluginForge!"
89
+ ```
90
+
91
+ ### 3. Use PluginManager
92
+
93
+ ```python
94
+ from pluginforge import PluginManager
95
+
96
+ pm = PluginManager("config/app.yaml")
97
+
98
+ # Register plugins directly (or use entry points for auto-discovery)
99
+ pm.register_plugins([HelloPlugin])
100
+
101
+ # Access plugins
102
+ for plugin in pm.get_active_plugins():
103
+ print(f"Active: {plugin.name} v{plugin.version}")
104
+
105
+ # Mount FastAPI routes
106
+ from fastapi import FastAPI
107
+ app = FastAPI()
108
+ pm.mount_routes(app) # Routes at /api/plugins/{name}/
109
+ ```
110
+
111
+ ## Features
112
+
113
+ - **YAML Configuration** - App config, per-plugin config, and i18n strings
114
+ - **Plugin Lifecycle** - init, activate, deactivate with error handling
115
+ - **Enable/Disable** - Control plugins via config lists
116
+ - **Dependency Resolution** - Topological sorting with circular dependency detection
117
+ - **FastAPI Integration** - Auto-mount plugin routes under `/api/plugins/{name}/`
118
+ - **Alembic Support** - Collect migration directories from plugins
119
+ - **i18n** - Multi-language strings from YAML with fallback
120
+
121
+ ## Entry Point Discovery
122
+
123
+ Register plugins as entry points in your `pyproject.toml`:
124
+
125
+ ```toml
126
+ [project.entry-points."myapp.plugins"]
127
+ hello = "myapp.plugins.hello:HelloPlugin"
128
+ ```
129
+
130
+ Then use `discover_plugins()` instead of `register_plugins()`:
131
+
132
+ ```python
133
+ pm = PluginManager("config/app.yaml")
134
+ pm.discover_plugins() # Auto-discovers from entry points
135
+ ```
136
+
137
+ ## i18n
138
+
139
+ ```yaml
140
+ # config/i18n/en.yaml
141
+ common:
142
+ save: "Save"
143
+ cancel: "Cancel"
144
+ ```
145
+
146
+ ```python
147
+ pm.get_text("common.save", "en") # "Save"
148
+ pm.get_text("common.save", "de") # "Speichern"
149
+ ```
150
+
151
+ ## Development
152
+
153
+ ```bash
154
+ # Install dependencies
155
+ poetry install --with dev
156
+
157
+ # Run tests
158
+ poetry run pytest
159
+
160
+ # Lint
161
+ poetry run ruff check pluginforge/ tests/
162
+
163
+ # Format
164
+ poetry run ruff format pluginforge/ tests/
165
+ ```
166
+
167
+ ## License
168
+
169
+ MIT
170
+
@@ -0,0 +1,13 @@
1
+ pluginforge/__init__.py,sha256=tCr-Ac9kjoLZES2FjGtSyGxWxYgZ-l_rP4M29fWF8W0,312
2
+ pluginforge/alembic_ext.py,sha256=X6_CN--jm33VEehdl9rnoo615KkNzxzWoE4dCCf4Lcw,994
3
+ pluginforge/base.py,sha256=4tqqZxj0v-_joJ-p9DeocSPP35V-Y9gw8kud3V8jiQo,1697
4
+ pluginforge/config.py,sha256=H7aomZ6w8FFQMEvcTTExJKYbhxgv_Fx68NdLt-_mLgw,1872
5
+ pluginforge/discovery.py,sha256=KcnlMJ1PdFczt32N77tnHLKRFlB82Wof5a6hLeLs2F4,3713
6
+ pluginforge/fastapi_ext.py,sha256=rMkEpSPJBAqE6UH_8QQbFYdt23cjZppejtQZi4KOMGM,1133
7
+ pluginforge/i18n.py,sha256=a6371SjtOe9oU8D9iUJ2XmFnPLLNoVt1hzd-9mqzfQw,2509
8
+ pluginforge/lifecycle.py,sha256=5DVaiXBxEFAZ5T0qocYoy4VhNj6VfQA3zvAPHgT1MtU,3786
9
+ pluginforge/manager.py,sha256=_yT_CuVfKqdGzwjdwVHCzLLNGaGQbGX7eqKqcuadGoE,7908
10
+ pluginforge-0.1.0.dist-info/METADATA,sha256=l-19tEfUpYzHujRW4-tW_pUJ00E_s7nojWUr_Vou6Fk,3955
11
+ pluginforge-0.1.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
12
+ pluginforge-0.1.0.dist-info/licenses/LICENSE,sha256=74BL02tnIOJWZLxUWBuum_9yFcMY70O7LXdhZ_kLN2Q,1072
13
+ pluginforge-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.3.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Asterios Raptis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.