pluginforge 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pluginforge-0.1.0/LICENSE +21 -0
- pluginforge-0.1.0/PKG-INFO +170 -0
- pluginforge-0.1.0/README.md +144 -0
- pluginforge-0.1.0/pluginforge/__init__.py +8 -0
- pluginforge-0.1.0/pluginforge/alembic_ext.py +33 -0
- pluginforge-0.1.0/pluginforge/base.py +57 -0
- pluginforge-0.1.0/pluginforge/config.py +71 -0
- pluginforge-0.1.0/pluginforge/discovery.py +124 -0
- pluginforge-0.1.0/pluginforge/fastapi_ext.py +37 -0
- pluginforge-0.1.0/pluginforge/i18n.py +82 -0
- pluginforge-0.1.0/pluginforge/lifecycle.py +124 -0
- pluginforge-0.1.0/pluginforge/manager.py +243 -0
- pluginforge-0.1.0/pyproject.toml +45 -0
|
@@ -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.
|
|
@@ -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,144 @@
|
|
|
1
|
+
# PluginForge
|
|
2
|
+
|
|
3
|
+
Application-agnostic Python plugin framework built on [pluggy](https://pluggy.readthedocs.io/).
|
|
4
|
+
|
|
5
|
+
PluginForge adds the layers that pluggy is missing: YAML configuration, plugin lifecycle management, enable/disable per config, dependency resolution, FastAPI integration, and i18n support.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install pluginforge
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
With optional FastAPI support:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install pluginforge[fastapi]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
### 1. Create a plugin
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from pluginforge import BasePlugin
|
|
25
|
+
|
|
26
|
+
class HelloPlugin(BasePlugin):
|
|
27
|
+
name = "hello"
|
|
28
|
+
version = "1.0.0"
|
|
29
|
+
description = "A hello world plugin"
|
|
30
|
+
|
|
31
|
+
def activate(self):
|
|
32
|
+
print(f"Hello plugin activated with config: {self.config}")
|
|
33
|
+
|
|
34
|
+
def get_routes(self):
|
|
35
|
+
from fastapi import APIRouter
|
|
36
|
+
router = APIRouter()
|
|
37
|
+
|
|
38
|
+
@router.get("/hello")
|
|
39
|
+
def hello():
|
|
40
|
+
return {"message": self.config.get("greeting", "Hello!")}
|
|
41
|
+
|
|
42
|
+
return [router]
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### 2. Configure your app
|
|
46
|
+
|
|
47
|
+
```yaml
|
|
48
|
+
# config/app.yaml
|
|
49
|
+
app:
|
|
50
|
+
name: "MyApp"
|
|
51
|
+
version: "1.0.0"
|
|
52
|
+
default_language: "en"
|
|
53
|
+
|
|
54
|
+
plugins:
|
|
55
|
+
entry_point_group: "myapp.plugins"
|
|
56
|
+
enabled:
|
|
57
|
+
- "hello"
|
|
58
|
+
disabled: []
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```yaml
|
|
62
|
+
# config/plugins/hello.yaml
|
|
63
|
+
greeting: "Hello from PluginForge!"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 3. Use PluginManager
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from pluginforge import PluginManager
|
|
70
|
+
|
|
71
|
+
pm = PluginManager("config/app.yaml")
|
|
72
|
+
|
|
73
|
+
# Register plugins directly (or use entry points for auto-discovery)
|
|
74
|
+
pm.register_plugins([HelloPlugin])
|
|
75
|
+
|
|
76
|
+
# Access plugins
|
|
77
|
+
for plugin in pm.get_active_plugins():
|
|
78
|
+
print(f"Active: {plugin.name} v{plugin.version}")
|
|
79
|
+
|
|
80
|
+
# Mount FastAPI routes
|
|
81
|
+
from fastapi import FastAPI
|
|
82
|
+
app = FastAPI()
|
|
83
|
+
pm.mount_routes(app) # Routes at /api/plugins/{name}/
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Features
|
|
87
|
+
|
|
88
|
+
- **YAML Configuration** - App config, per-plugin config, and i18n strings
|
|
89
|
+
- **Plugin Lifecycle** - init, activate, deactivate with error handling
|
|
90
|
+
- **Enable/Disable** - Control plugins via config lists
|
|
91
|
+
- **Dependency Resolution** - Topological sorting with circular dependency detection
|
|
92
|
+
- **FastAPI Integration** - Auto-mount plugin routes under `/api/plugins/{name}/`
|
|
93
|
+
- **Alembic Support** - Collect migration directories from plugins
|
|
94
|
+
- **i18n** - Multi-language strings from YAML with fallback
|
|
95
|
+
|
|
96
|
+
## Entry Point Discovery
|
|
97
|
+
|
|
98
|
+
Register plugins as entry points in your `pyproject.toml`:
|
|
99
|
+
|
|
100
|
+
```toml
|
|
101
|
+
[project.entry-points."myapp.plugins"]
|
|
102
|
+
hello = "myapp.plugins.hello:HelloPlugin"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Then use `discover_plugins()` instead of `register_plugins()`:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
pm = PluginManager("config/app.yaml")
|
|
109
|
+
pm.discover_plugins() # Auto-discovers from entry points
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## i18n
|
|
113
|
+
|
|
114
|
+
```yaml
|
|
115
|
+
# config/i18n/en.yaml
|
|
116
|
+
common:
|
|
117
|
+
save: "Save"
|
|
118
|
+
cancel: "Cancel"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
pm.get_text("common.save", "en") # "Save"
|
|
123
|
+
pm.get_text("common.save", "de") # "Speichern"
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Development
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
# Install dependencies
|
|
130
|
+
poetry install --with dev
|
|
131
|
+
|
|
132
|
+
# Run tests
|
|
133
|
+
poetry run pytest
|
|
134
|
+
|
|
135
|
+
# Lint
|
|
136
|
+
poetry run ruff check pluginforge/ tests/
|
|
137
|
+
|
|
138
|
+
# Format
|
|
139
|
+
poetry run ruff format pluginforge/ tests/
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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,45 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "pluginforge"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Application-agnostic plugin framework built on pluggy"
|
|
5
|
+
authors = ["Asterios Raptis"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
repository = "https://github.com/astrapi69/pluginforge"
|
|
9
|
+
keywords = ["plugin", "framework", "pluggy", "hooks", "yaml"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3.11",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
|
18
|
+
]
|
|
19
|
+
packages = [{ include = "pluginforge" }]
|
|
20
|
+
|
|
21
|
+
[tool.poetry.dependencies]
|
|
22
|
+
python = "^3.11"
|
|
23
|
+
pluggy = "^1.4.0"
|
|
24
|
+
pyyaml = "^6.0"
|
|
25
|
+
|
|
26
|
+
[tool.poetry.group.dev.dependencies]
|
|
27
|
+
pytest = "^8.0"
|
|
28
|
+
pytest-cov = "^5.0"
|
|
29
|
+
ruff = "^0.4"
|
|
30
|
+
|
|
31
|
+
[tool.poetry.extras]
|
|
32
|
+
fastapi = ["fastapi"]
|
|
33
|
+
alembic = ["alembic", "sqlalchemy"]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
testpaths = ["tests"]
|
|
37
|
+
addopts = "--cov=pluginforge --cov-report=term-missing"
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
target-version = "py311"
|
|
41
|
+
line-length = 100
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["poetry-core"]
|
|
45
|
+
build-backend = "poetry.core.masonry.api"
|