FastPluggy 0.2.7__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.
- fastpluggy/__init__.py +4 -0
- fastpluggy/core/__init__.py +0 -0
- fastpluggy/core/auth/__init__.py +34 -0
- fastpluggy/core/auth/auth_interface.py +78 -0
- fastpluggy/core/auth/middleware.py +31 -0
- fastpluggy/core/base_module.py +73 -0
- fastpluggy/core/base_module_manager.py +347 -0
- fastpluggy/core/config.py +38 -0
- fastpluggy/core/database.py +103 -0
- fastpluggy/core/dependency.py +23 -0
- fastpluggy/core/error/__init__.py +8 -0
- fastpluggy/core/error/degraded_mode_handler.py +55 -0
- fastpluggy/core/error/exception.py +21 -0
- fastpluggy/core/flash.py +51 -0
- fastpluggy/core/global_registry.py +26 -0
- fastpluggy/core/menu/__init__.py +0 -0
- fastpluggy/core/menu/decorator.py +47 -0
- fastpluggy/core/menu/menu_manager.py +171 -0
- fastpluggy/core/menu/schema.py +56 -0
- fastpluggy/core/models.py +26 -0
- fastpluggy/core/models_tools/__init__.py +0 -0
- fastpluggy/core/models_tools/base.py +16 -0
- fastpluggy/core/models_tools/callable.py +60 -0
- fastpluggy/core/models_tools/pydantic.py +87 -0
- fastpluggy/core/models_tools/shared.py +90 -0
- fastpluggy/core/models_tools/sqlalchemy.py +233 -0
- fastpluggy/core/module_base.py +79 -0
- fastpluggy/core/plugin/__init__.py +0 -0
- fastpluggy/core/plugin/dependency_resolver.py +151 -0
- fastpluggy/core/plugin/installer/__init__.py +24 -0
- fastpluggy/core/plugin/installer/git_installer.py +134 -0
- fastpluggy/core/plugin/installer/zip_installer.py +39 -0
- fastpluggy/core/plugin/repository.py +101 -0
- fastpluggy/core/plugin/service.py +59 -0
- fastpluggy/core/plugin_state.py +116 -0
- fastpluggy/core/repository/__init__.py +0 -0
- fastpluggy/core/repository/app_settings.py +79 -0
- fastpluggy/core/routers/__init__.py +0 -0
- fastpluggy/core/routers/actions/__init__.py +12 -0
- fastpluggy/core/routers/actions/fast_pluggy.py +0 -0
- fastpluggy/core/routers/actions/modules.py +152 -0
- fastpluggy/core/routers/admin.py +200 -0
- fastpluggy/core/routers/app_static.py +37 -0
- fastpluggy/core/routers/base_module.py +190 -0
- fastpluggy/core/routers/execute.py +127 -0
- fastpluggy/core/routers/home.py +20 -0
- fastpluggy/core/routers/settings.py +67 -0
- fastpluggy/core/tools/__init__.py +11 -0
- fastpluggy/core/tools/fastapi.py +105 -0
- fastpluggy/core/tools/fs_tools.py +53 -0
- fastpluggy/core/tools/git_tools.py +80 -0
- fastpluggy/core/tools/inspect_tools.py +262 -0
- fastpluggy/core/tools/install.py +19 -0
- fastpluggy/core/tools/serialize_tools.py +53 -0
- fastpluggy/core/tools/system.py +18 -0
- fastpluggy/core/tools/threads_tools.py +34 -0
- fastpluggy/core/view_builer/__init__.py +87 -0
- fastpluggy/core/view_builer/components/__init__.py +98 -0
- fastpluggy/core/view_builer/components/button.py +331 -0
- fastpluggy/core/view_builer/components/custom.py +47 -0
- fastpluggy/core/view_builer/components/debug.py +31 -0
- fastpluggy/core/view_builer/components/form.py +115 -0
- fastpluggy/core/view_builer/components/list.py +38 -0
- fastpluggy/core/view_builer/components/model.py +114 -0
- fastpluggy/core/view_builer/components/pagination.py +87 -0
- fastpluggy/core/view_builer/components/raw.py +23 -0
- fastpluggy/core/view_builer/components/render_field_tools.py +59 -0
- fastpluggy/core/view_builer/components/tabbed.py +35 -0
- fastpluggy/core/view_builer/components/table.py +207 -0
- fastpluggy/core/view_builer/components/table_model.py +222 -0
- fastpluggy/core/view_builer/form_builder.py +189 -0
- fastpluggy/fastpluggy.py +239 -0
- fastpluggy/static/css/__init__.py +0 -0
- fastpluggy/static/css/styles.css +25 -0
- fastpluggy/static/js/__init__.py +0 -0
- fastpluggy/static/js/scripts.js +1 -0
- fastpluggy/templates/__init__.py +0 -0
- fastpluggy/templates/admin/__init__.py +0 -0
- fastpluggy/templates/admin/install_module.html.j2 +60 -0
- fastpluggy/templates/auth/__init__.py +0 -0
- fastpluggy/templates/base.html.j2 +229 -0
- fastpluggy/templates/components/__init__.py +0 -0
- fastpluggy/templates/components/button_component.html.j2 +44 -0
- fastpluggy/templates/components/common.html.j2 +77 -0
- fastpluggy/templates/components/debug/json.html.j2 +12 -0
- fastpluggy/templates/components/form.html.j2 +30 -0
- fastpluggy/templates/components/form_component.html.j2 +16 -0
- fastpluggy/templates/components/generic_page.html.j2 +24 -0
- fastpluggy/templates/components/mime.html.j2 +14 -0
- fastpluggy/templates/components/model_view.html.j2 +29 -0
- fastpluggy/templates/components/pagination_macros.html.j2 +112 -0
- fastpluggy/templates/components/tabbed.html.j2 +29 -0
- fastpluggy/templates/components/table_component.html.j2 +80 -0
- fastpluggy/templates/components/table_filter_component.html.j2 +25 -0
- fastpluggy/templates/degraded_mode.html.j2 +63 -0
- fastpluggy/templates/error.html.j2 +56 -0
- fastpluggy/templates/flash_messages.html.j2 +10 -0
- fastpluggy/templates/index.html.j2 +16 -0
- fastpluggy/templates/menu.html.j2 +176 -0
- fastpluggy-0.2.7.dist-info/METADATA +17 -0
- fastpluggy-0.2.7.dist-info/RECORD +103 -0
- fastpluggy-0.2.7.dist-info/WHEEL +5 -0
- fastpluggy-0.2.7.dist-info/top_level.txt +1 -0
fastpluggy/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# core/dependency.py
|
|
2
|
+
from fastapi import Request, HTTPException, Depends
|
|
3
|
+
from starlette import status
|
|
4
|
+
from starlette.authentication import UnauthenticatedUser
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def require_authentication(request: Request):
|
|
8
|
+
auth_manager = request.app.state.fastpluggy.auth_manager
|
|
9
|
+
if auth_manager:
|
|
10
|
+
if isinstance(request.user, UnauthenticatedUser):
|
|
11
|
+
# If a custom error handler is defined, call it.
|
|
12
|
+
if hasattr(auth_manager, "on_authenticate_error") and callable(auth_manager.on_authenticate_error):
|
|
13
|
+
result = await auth_manager.on_authenticate_error(request)
|
|
14
|
+
if result is not None:
|
|
15
|
+
return result
|
|
16
|
+
|
|
17
|
+
# Fallback to default behavior if no custom error handling was provided.
|
|
18
|
+
raise HTTPException(
|
|
19
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
20
|
+
detail="Not authenticated"
|
|
21
|
+
)
|
|
22
|
+
return request.user
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def require_role(role: str):
|
|
26
|
+
async def dependency(request: Request):
|
|
27
|
+
# your `require_authentication` should already have populated request.user & request.auth
|
|
28
|
+
# (e.g. via an AuthenticationMiddleware + your backend)
|
|
29
|
+
auth_manager = request.app.state.fastpluggy.auth_manager
|
|
30
|
+
if auth_manager:
|
|
31
|
+
scopes = getattr(request.auth, "scopes", [])
|
|
32
|
+
if role not in scopes:
|
|
33
|
+
raise HTTPException(status_code=403, detail=f"'{role}' role required")
|
|
34
|
+
return Depends(dependency)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from abc import abstractmethod
|
|
2
|
+
from typing import Type
|
|
3
|
+
|
|
4
|
+
from fastapi import Request
|
|
5
|
+
from starlette.authentication import BaseUser, AuthenticationBackend, AuthCredentials
|
|
6
|
+
from starlette.requests import HTTPConnection
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuthInterface(AuthenticationBackend):
|
|
10
|
+
|
|
11
|
+
async def on_authenticate_error(self, request: Request):
|
|
12
|
+
"""
|
|
13
|
+
Handle authentication errors when a user fails to authenticate.
|
|
14
|
+
|
|
15
|
+
This method is invoked when authentication fails (e.g., due to missing or invalid credentials).
|
|
16
|
+
It examines the request's 'Accept' header and, if the client expects HTML and login redirection is enabled,
|
|
17
|
+
returns a redirect response to the login page. Otherwise, it raises an HTTP 401 Unauthorized exception with
|
|
18
|
+
a WWW-Authenticate header, which can trigger the browser's Basic Authentication popup.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
request (Request): The incoming FastAPI request object containing details such as headers.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
A RedirectResponse if HTML is accepted and redirection is enabled. If not, this method raises an
|
|
25
|
+
HTTPException and does not return normally.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
HTTPException: Raised with status code 401 if authentication fails and the client does not accept HTML,
|
|
29
|
+
prompting the Basic Auth dialog.
|
|
30
|
+
"""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def user_model(self) -> Type[BaseUser]:
|
|
36
|
+
"""
|
|
37
|
+
Returns the SQLAlchemy model class representing a user.
|
|
38
|
+
Implementations can override this property to use a different model.
|
|
39
|
+
"""
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, BaseUser] | None:
|
|
44
|
+
"""
|
|
45
|
+
Check if the user is authenticated.
|
|
46
|
+
Should return a User object if authenticated.
|
|
47
|
+
Otherwise, it can either raise an HTTPException or return a Response (e.g. a redirect).
|
|
48
|
+
"""
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
def get_user_menu_entries(self, request: Request) -> list:
|
|
52
|
+
"""
|
|
53
|
+
Returns menu items for the user dropdown based on whether a user is logged in.
|
|
54
|
+
If a user is logged in, the menu is obtained from the FastPluggy menu manager (using the "user" menu).
|
|
55
|
+
Otherwise, a default login entry is returned.
|
|
56
|
+
|
|
57
|
+
Each menu item should be a dictionary containing keys such as:
|
|
58
|
+
- "name": the display text
|
|
59
|
+
- "icon": (optional) an icon class
|
|
60
|
+
- "url": the destination URL
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
"""
|
|
64
|
+
# Check if a user is attached to request.state (set by middleware)
|
|
65
|
+
user = getattr(request.state, "current_user", None)
|
|
66
|
+
if user:
|
|
67
|
+
from fastpluggy.fastpluggy import FastPluggy
|
|
68
|
+
|
|
69
|
+
# Retrieve the menu manager from the app state.
|
|
70
|
+
fast_pluggy: FastPluggy = request.app.state.fastpluggy
|
|
71
|
+
|
|
72
|
+
menu_manager = fast_pluggy.menu_manager
|
|
73
|
+
# Try to get the "user" menu from the manager.
|
|
74
|
+
user_menu = menu_manager.get_menu("user")
|
|
75
|
+
return user_menu
|
|
76
|
+
else:
|
|
77
|
+
# If no user is logged in, return a default login entry.
|
|
78
|
+
return [{'router': {"name": "Login", "icon": "fa-solid fa-sign-in-alt", "url": "/login"}}]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from fastapi import Request
|
|
2
|
+
from loguru import logger
|
|
3
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CurrentUserMiddleware(BaseHTTPMiddleware):
|
|
7
|
+
async def dispatch(self, request: Request, call_next):
|
|
8
|
+
# Obtain a DB session; in production you might want a better scoped session
|
|
9
|
+
|
|
10
|
+
from fastpluggy.fastpluggy import FastPluggy
|
|
11
|
+
|
|
12
|
+
# Retrieve the FastPluggy instance (assuming it's stored in app.state)
|
|
13
|
+
fast_pluggy: FastPluggy = request.app.state.fastpluggy
|
|
14
|
+
try:
|
|
15
|
+
# Use the current auth manager to authenticate the user
|
|
16
|
+
if fast_pluggy.auth_manager:
|
|
17
|
+
user = await fast_pluggy.auth_manager.authenticate(request)
|
|
18
|
+
else:
|
|
19
|
+
user = None
|
|
20
|
+
except Exception as e:
|
|
21
|
+
logger.exception(e)
|
|
22
|
+
user = None
|
|
23
|
+
# Attach the user to request.state
|
|
24
|
+
request.state.current_user = user[1] if user else None
|
|
25
|
+
request.state.roles = user[0] if user else None
|
|
26
|
+
if hasattr(fast_pluggy.auth_manager, 'get_user_menu_entries'):
|
|
27
|
+
request.state.user_menu = fast_pluggy.auth_manager.get_user_menu_entries(request)
|
|
28
|
+
else:
|
|
29
|
+
request.state.user_menu = []
|
|
30
|
+
response = await call_next(request)
|
|
31
|
+
return response
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from loguru import logger
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BaseModule:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self.requirements_installed = False
|
|
10
|
+
|
|
11
|
+
# self.package_name = f"{self.module_type}s.{name}"
|
|
12
|
+
self.url = "#"
|
|
13
|
+
|
|
14
|
+
def common_module_load_param(self):
|
|
15
|
+
|
|
16
|
+
# self.files = list_modules_in_package(self.module.__path__)
|
|
17
|
+
|
|
18
|
+
# Detect models
|
|
19
|
+
self.discover_models()
|
|
20
|
+
|
|
21
|
+
def discover_models(self, base_class_name="Base"):
|
|
22
|
+
"""
|
|
23
|
+
Discover SQLAlchemy models in a plugin's models.py file using AST.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
base_class_name (str): Name of the SQLAlchemy base class (default is 'Base').
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
# Construct the path to models.py
|
|
30
|
+
models_path = os.path.join(self.path, "models.py")
|
|
31
|
+
if not os.path.exists(models_path):
|
|
32
|
+
logger.debug(f"No models.py file found in module '{self.name}'.")
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
# Parse the models.py file using AST
|
|
36
|
+
with open(models_path, "r") as file:
|
|
37
|
+
tree = ast.parse(file.read())
|
|
38
|
+
|
|
39
|
+
for node in ast.walk(tree):
|
|
40
|
+
# Look for class definitions
|
|
41
|
+
if isinstance(node, ast.ClassDef):
|
|
42
|
+
# Check if the class inherits from the specified base class
|
|
43
|
+
for base in node.bases:
|
|
44
|
+
if (
|
|
45
|
+
isinstance(base, ast.Name) and base.id == base_class_name
|
|
46
|
+
) or (
|
|
47
|
+
isinstance(base, ast.Attribute) and base.attr == base_class_name
|
|
48
|
+
):
|
|
49
|
+
# Check for __tablename__ attribute
|
|
50
|
+
tablename = None
|
|
51
|
+
for body_item in node.body:
|
|
52
|
+
if isinstance(body_item, ast.Assign):
|
|
53
|
+
for target in body_item.targets:
|
|
54
|
+
if (
|
|
55
|
+
isinstance(target, ast.Name)
|
|
56
|
+
and target.id == "__tablename__"
|
|
57
|
+
):
|
|
58
|
+
if isinstance(body_item.value, ast.Constant):
|
|
59
|
+
tablename = body_item.value.value
|
|
60
|
+
|
|
61
|
+
# Add the discovered model to the module's models
|
|
62
|
+
self.models.append(
|
|
63
|
+
{"class_name": node.name, "table_name": tablename,
|
|
64
|
+
"module_name": '.'.join([self.package_name, 'models'])}
|
|
65
|
+
)
|
|
66
|
+
logger.info(
|
|
67
|
+
f"Discovered model '{node.name}' with table '{tablename}' in module '{self.name}'"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if not self.models:
|
|
71
|
+
logger.debug(f"No SQLAlchemy models discovered in module '{self.name}'.")
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.error(f"Error discovering models in module '{self.name}': {e}")
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import shutil
|
|
3
|
+
import sys
|
|
4
|
+
import traceback
|
|
5
|
+
import logging
|
|
6
|
+
import types
|
|
7
|
+
from abc import ABC
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, Any, List
|
|
10
|
+
|
|
11
|
+
from fastapi import FastAPI
|
|
12
|
+
from jinja2 import FileSystemLoader
|
|
13
|
+
from loguru import logger
|
|
14
|
+
from sqlalchemy.orm import Session
|
|
15
|
+
|
|
16
|
+
from fastpluggy.core.database import get_db
|
|
17
|
+
from fastpluggy.core.module_base import FastPluggyBaseModule
|
|
18
|
+
from fastpluggy.core.plugin.dependency_resolver import PluginDependencyResolver
|
|
19
|
+
from fastpluggy.core.plugin.repository import get_all_modules_status
|
|
20
|
+
from fastpluggy.core.plugin.service import PluginService
|
|
21
|
+
from fastpluggy.core.plugin_state import PluginState
|
|
22
|
+
from fastpluggy.core.tools.fs_tools import create_init_file, is_python_module
|
|
23
|
+
from fastpluggy.core.tools.git_tools import get_git_info_for_module
|
|
24
|
+
from fastpluggy.core.tools.inspect_tools import get_module, call_with_injection
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BaseModuleManager(ABC):
|
|
28
|
+
def __init__(self, app: FastAPI, fast_pluggy):
|
|
29
|
+
self.app = app
|
|
30
|
+
self.fast_pluggy = fast_pluggy
|
|
31
|
+
|
|
32
|
+
self.modules: Dict[str, PluginState] = {}
|
|
33
|
+
|
|
34
|
+
self.template_loaders: Dict[str, FileSystemLoader] = {}
|
|
35
|
+
|
|
36
|
+
self.db_session: Session = next(get_db())
|
|
37
|
+
self.plugin_states: Dict[str, bool] = {}
|
|
38
|
+
|
|
39
|
+
self.refresh_plugins_states()
|
|
40
|
+
|
|
41
|
+
def refresh_plugins_states(self):
|
|
42
|
+
self.plugin_states = get_all_modules_status(db=self.db_session)
|
|
43
|
+
|
|
44
|
+
def get_template_loaders(self):
|
|
45
|
+
template_loaders = {}
|
|
46
|
+
for item_info in self.modules.values():
|
|
47
|
+
if self.is_module_loaded(item_info) and item_info.plugin.templates_dir:
|
|
48
|
+
name = item_info.plugin.module_name
|
|
49
|
+
template_loader = FileSystemLoader(str(item_info.plugin.templates_dir))
|
|
50
|
+
template_loaders[name] = template_loader
|
|
51
|
+
logger.info(f"Added templates for module '{name}' from {item_info.plugin.templates_dir}")
|
|
52
|
+
|
|
53
|
+
return template_loaders
|
|
54
|
+
|
|
55
|
+
def register_all_routes(self):
|
|
56
|
+
for name, item_info in self.modules.items():
|
|
57
|
+
if self.is_module_loaded(item_info):
|
|
58
|
+
self.include_routes(item_info)
|
|
59
|
+
|
|
60
|
+
def include_routes(self, module_info: PluginState):
|
|
61
|
+
router = module_info.plugin.module_router
|
|
62
|
+
if isinstance(router, types.FunctionType):
|
|
63
|
+
try:
|
|
64
|
+
module_info.plugin.module_router = module_info.plugin.module_router()
|
|
65
|
+
logger.debug(f"📡 Router resolved for plugin '{module_info.plugin.module_name}'")
|
|
66
|
+
except Exception as e:
|
|
67
|
+
module_info.plugin.module_router = None
|
|
68
|
+
module_info.error.append(f"Router loading failed: {e}")
|
|
69
|
+
module_info.traceback.append(traceback.format_exc())
|
|
70
|
+
logger.warning(f"⚠️ Could not resolve module_router for '{module_info.plugin.module_name}': {e}")
|
|
71
|
+
|
|
72
|
+
router = module_info.plugin.module_router
|
|
73
|
+
if router: # todo : check it's a fast api router
|
|
74
|
+
url = f"/{module_info.module_type}/{module_info.plugin.module_name}" if module_info.plugin.module_mount_url is None else module_info.plugin.module_mount_url
|
|
75
|
+
|
|
76
|
+
if len(router.tags) == 0:
|
|
77
|
+
router.tags.append(module_info.plugin.module_name)
|
|
78
|
+
|
|
79
|
+
# Patch operation_id to avoid duplicates in OpenAPI
|
|
80
|
+
for route in router.routes:
|
|
81
|
+
if hasattr(route, "operation_id") and route.operation_id:
|
|
82
|
+
continue
|
|
83
|
+
if hasattr(route, "name") and route.name:
|
|
84
|
+
safe_module = module_info.plugin.module_name.replace(".", "_") if module_info.plugin.module_name else "unknown"
|
|
85
|
+
route.operation_id = f"{safe_module}_{route.name}"
|
|
86
|
+
|
|
87
|
+
self.fast_pluggy.app.include_router(
|
|
88
|
+
router, prefix=url,
|
|
89
|
+
tags=router.tags
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
logger.info(f"Router for module '{module_info.plugin.module_name}' included with prefix '{url}'")
|
|
93
|
+
|
|
94
|
+
def is_module_loaded(self, module_or_name):
|
|
95
|
+
"""
|
|
96
|
+
Checks if a module exists and is loaded correctly.
|
|
97
|
+
Accepts either the module name or a PluginState instance.
|
|
98
|
+
|
|
99
|
+
:param module_or_name: PluginState object or module name string.
|
|
100
|
+
:return: True if the module is enabled and loaded, False otherwise.
|
|
101
|
+
"""
|
|
102
|
+
if isinstance(module_or_name, str):
|
|
103
|
+
module_info = self.modules.get(module_or_name)
|
|
104
|
+
else:
|
|
105
|
+
module_info = module_or_name
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
module_info is not None
|
|
109
|
+
and isinstance(module_info, PluginState)
|
|
110
|
+
and module_info.plugin is not None
|
|
111
|
+
and module_info.enabled
|
|
112
|
+
and module_info.loaded
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def module_directory_exists(self, plugin_name: str) -> bool:
|
|
116
|
+
"""
|
|
117
|
+
Checks if a plugin exists in the plugin directory.
|
|
118
|
+
"""
|
|
119
|
+
module_info = self.modules.get(plugin_name)
|
|
120
|
+
plugin_path = module_info.path
|
|
121
|
+
#plugin_path = Path(os.path.join(self.fast_pluggy.get_folder_by_module_type('plugin'), plugin_name))
|
|
122
|
+
exists = plugin_path.is_dir() and any(plugin_path.glob("*.py"))
|
|
123
|
+
if exists:
|
|
124
|
+
logger.info(f"Plugin '{plugin_name}' exists.")
|
|
125
|
+
else:
|
|
126
|
+
logger.warning(f"Plugin '{plugin_name}' does not exist.")
|
|
127
|
+
return exists
|
|
128
|
+
|
|
129
|
+
def remove_plugin(self, plugin_name: str):
|
|
130
|
+
"""
|
|
131
|
+
Removes a plugin directory and refreshes the plugins.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
if not self.module_directory_exists(plugin_name):
|
|
135
|
+
logger.error(f"Plugin '{plugin_name}' not found for removal.")
|
|
136
|
+
raise FileNotFoundError(f"Plugin '{plugin_name}' not found.")
|
|
137
|
+
|
|
138
|
+
PluginService.disable_plugin(plugin_name=plugin_name, fast_pluggy=self.fast_pluggy)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
#plugin_path = Path(os.path.join(self.fast_pluggy.get_folder_by_module_type('plugin'), plugin_name))
|
|
142
|
+
module_info = self.modules.get(plugin_name)
|
|
143
|
+
plugin_path = module_info.path
|
|
144
|
+
|
|
145
|
+
shutil.rmtree(plugin_path)
|
|
146
|
+
logger.info(f"Plugin directory '{plugin_path}' removed.")
|
|
147
|
+
if plugin_name in sys.modules:
|
|
148
|
+
del sys.modules[plugin_name]
|
|
149
|
+
logger.info(f"Module '{plugin_name}' removed from sys.modules.")
|
|
150
|
+
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.error(f"Error removing plugin '{plugin_name}': {e}")
|
|
153
|
+
raise e
|
|
154
|
+
|
|
155
|
+
self.fast_pluggy.load_app()
|
|
156
|
+
|
|
157
|
+
def _get_extra_files(self, attribute) -> List[Any]:
|
|
158
|
+
files = []
|
|
159
|
+
for name,module_info in self.modules.items():
|
|
160
|
+
if not module_info.loaded:
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
plugin_attr = getattr(module_info.plugin, attribute, None)
|
|
164
|
+
if plugin_attr:
|
|
165
|
+
# plugin_attr is expected to be a list of paths
|
|
166
|
+
files.extend(plugin_attr)
|
|
167
|
+
|
|
168
|
+
return list(dict.fromkeys(files))
|
|
169
|
+
|
|
170
|
+
def get_extra_js_files(self):
|
|
171
|
+
return self._get_extra_files("extra_js_files")
|
|
172
|
+
|
|
173
|
+
def get_extra_css_files(self):
|
|
174
|
+
return self._get_extra_files("extra_css_files")
|
|
175
|
+
|
|
176
|
+
def discover_plugins(self, folder: str, module_type: str):
|
|
177
|
+
folder_path = Path(folder)
|
|
178
|
+
|
|
179
|
+
if not folder_path.exists():
|
|
180
|
+
folder_path.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
logger.info(f"Created missing domain directory at {folder}")
|
|
182
|
+
|
|
183
|
+
create_init_file(folder)
|
|
184
|
+
|
|
185
|
+
# Add folder to module search path
|
|
186
|
+
domain_path = folder_path.resolve()
|
|
187
|
+
sys.path.append(str(domain_path))
|
|
188
|
+
|
|
189
|
+
logger.info(f"🔍 Discovering {module_type} modules in {domain_path}")
|
|
190
|
+
|
|
191
|
+
for plugin_dir in folder_path.iterdir():
|
|
192
|
+
if not plugin_dir.is_dir() or plugin_dir.name.startswith('.') \
|
|
193
|
+
or not is_python_module(plugin_dir):
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
plugin_file = plugin_dir / "plugin.py"
|
|
197
|
+
plugin_dir_name = plugin_dir.name
|
|
198
|
+
#module_name = f"{module_type}s.{plugin_dir_name}"
|
|
199
|
+
|
|
200
|
+
plugin_state = PluginState(
|
|
201
|
+
module_type=module_type,
|
|
202
|
+
path=plugin_dir,
|
|
203
|
+
)
|
|
204
|
+
logging.info(f"plugin file : {plugin_file}")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if not plugin_file.exists():
|
|
208
|
+
msg = f"Missing plugin.py in {plugin_dir_name}"
|
|
209
|
+
logger.warning(f"❌ {msg}")
|
|
210
|
+
plugin_state.error.append(msg)
|
|
211
|
+
plugin_state.plugin = FastPluggyBaseModule(module_name=plugin_dir_name)
|
|
212
|
+
self.modules[plugin_dir_name] = plugin_state
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
# STEP 1: Import module
|
|
217
|
+
package = get_module(module_name=plugin_dir_name, module_path=str(plugin_dir.resolve()))
|
|
218
|
+
plugin_module = getattr(package, "plugin", package)
|
|
219
|
+
|
|
220
|
+
# STEP 2: Get plugin class
|
|
221
|
+
plugin_class = next(
|
|
222
|
+
(cls for _, cls in inspect.getmembers(plugin_module)
|
|
223
|
+
if inspect.isclass(cls)
|
|
224
|
+
and issubclass(cls, FastPluggyBaseModule)
|
|
225
|
+
and cls is not FastPluggyBaseModule),
|
|
226
|
+
None
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if not plugin_class:
|
|
230
|
+
msg = f"No valid plugin class in {plugin_dir_name}"
|
|
231
|
+
logger.warning(f"⚠️ {msg}")
|
|
232
|
+
plugin_state.error.append(msg)
|
|
233
|
+
plugin_state.plugin = FastPluggyBaseModule(module_name=plugin_dir_name)
|
|
234
|
+
self.modules[plugin_dir_name] = plugin_state
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
# STEP 3: Instantiate
|
|
238
|
+
plugin_instance = plugin_class()
|
|
239
|
+
plugin_instance.module_path = plugin_dir
|
|
240
|
+
|
|
241
|
+
plugin_state.plugin = plugin_instance
|
|
242
|
+
plugin_state.initialized = True
|
|
243
|
+
plugin_state.enabled=self.plugin_states.get(plugin_instance.module_name, True)
|
|
244
|
+
|
|
245
|
+
self.modules[plugin_instance.module_name] = plugin_state
|
|
246
|
+
|
|
247
|
+
logger.info(f"Plugin '{plugin_instance.module_name}' discovered in {folder}")
|
|
248
|
+
#if plugin_dir_name != plugin_instance.module_name:
|
|
249
|
+
# logger.info(f"Re-key the discovered plugin '{plugin_dir_name}' to '{plugin_instance.module_name}'")
|
|
250
|
+
# self.modules[plugin_instance.module_name] = plugin_state
|
|
251
|
+
|
|
252
|
+
except Exception as e:
|
|
253
|
+
err_msg = f"Failed to load plugin '{plugin_dir_name}': {str(e)}"
|
|
254
|
+
logger.exception(f"🚨 {err_msg}")
|
|
255
|
+
plugin_state.plugin = FastPluggyBaseModule(module_name=plugin_dir_name)
|
|
256
|
+
plugin_state.plugin.module_menu_icon = "fas fa-exclamation-triangle"
|
|
257
|
+
plugin_state.error.append(err_msg)
|
|
258
|
+
plugin_state.traceback.append(traceback.format_exc())
|
|
259
|
+
plugin_state.initialized = False
|
|
260
|
+
plugin_state.process()
|
|
261
|
+
self.modules[plugin_dir_name] = plugin_state
|
|
262
|
+
|
|
263
|
+
def initialize_plugins_in_order(self):
|
|
264
|
+
result = PluginDependencyResolver.get_sorted_modules_by_dependency(self.modules)
|
|
265
|
+
PluginDependencyResolver.update_plugin_states_from_result(result, self.modules)
|
|
266
|
+
|
|
267
|
+
if not result["success"]:
|
|
268
|
+
logger.error(f"❌ Dependency resolution failed: {result['error']}")
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
for name in result["sorted_modules"]:
|
|
272
|
+
plugin_state = self.modules.get(name)
|
|
273
|
+
if not plugin_state or not plugin_state.enabled and plugin_state.initialized:
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
# STEP 4: Install deps, git info, etc.
|
|
278
|
+
if self.fast_pluggy.settings.install_module_requirement_at_start:
|
|
279
|
+
try:
|
|
280
|
+
from fastpluggy.core.routers.actions.modules import install_module_requirements
|
|
281
|
+
install_module_requirements(module_name=name, fast_pluggy=self.fast_pluggy)
|
|
282
|
+
except Exception as install_err:
|
|
283
|
+
plugin_state.error.append(f"Failed to install requirements: {install_err}")
|
|
284
|
+
plugin_state.traceback.append(traceback.format_exc())
|
|
285
|
+
|
|
286
|
+
get_git_info_for_module(module=plugin_state) # todo make async and avoid fail
|
|
287
|
+
plugin_state.loaded = True
|
|
288
|
+
logger.success(f"✅ Plugin initialized: {plugin_state.plugin.display_name} ({plugin_state.plugin.module_name})")
|
|
289
|
+
|
|
290
|
+
except Exception as e:
|
|
291
|
+
logger.exception(f"🚨 Error loading plugin '{name}': {e}")
|
|
292
|
+
plugin_state.error.append(str(e))
|
|
293
|
+
plugin_state.traceback.append(traceback.format_exc())
|
|
294
|
+
plugin_state.plugin = FastPluggyBaseModule(module_name=name)
|
|
295
|
+
plugin_state.plugin.module_menu_icon = "fas fa-exclamation-triangle"
|
|
296
|
+
plugin_state.loaded = False
|
|
297
|
+
|
|
298
|
+
finally:
|
|
299
|
+
try:
|
|
300
|
+
plugin_state.process()
|
|
301
|
+
except Exception as process_err:
|
|
302
|
+
plugin_state.error.append(f"Error in process(): {process_err}")
|
|
303
|
+
plugin_state.traceback.append(traceback.format_exc())
|
|
304
|
+
plugin_state.loaded = False
|
|
305
|
+
|
|
306
|
+
return result
|
|
307
|
+
|
|
308
|
+
def execute_all_module_hook(self, hook_name: str, require_loaded: bool = True):
|
|
309
|
+
logger.info(f"Executing hook '{hook_name}' (require_loaded:{require_loaded}) for all modules...")
|
|
310
|
+
for module_name, module_info in self.modules.items():
|
|
311
|
+
if not module_info.enabled:
|
|
312
|
+
continue
|
|
313
|
+
|
|
314
|
+
if require_loaded and not module_info.loaded:
|
|
315
|
+
logger.warning(f"Skipping hook '{hook_name}' for module '{module_name}' because it is not loaded.")
|
|
316
|
+
continue
|
|
317
|
+
|
|
318
|
+
self.call_plugin_hook(plugin_state=module_info, hook_name=hook_name)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def call_plugin_hook(self, plugin_state, hook_name: str, **kwargs):
|
|
322
|
+
plugin = plugin_state.plugin
|
|
323
|
+
if not hasattr(plugin, hook_name):
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
hook = getattr(plugin, hook_name)
|
|
327
|
+
if not callable(hook):
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
logger.debug(f"🔧 Calling '{hook_name}' for plugin '{plugin.module_name}'")
|
|
332
|
+
|
|
333
|
+
from fastpluggy.fastpluggy import FastPluggy
|
|
334
|
+
call_with_injection(
|
|
335
|
+
func=hook,
|
|
336
|
+
context_dict={
|
|
337
|
+
FastPluggy: self.fast_pluggy,
|
|
338
|
+
# BaseModuleManager: self,
|
|
339
|
+
},
|
|
340
|
+
user_kwargs=kwargs
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
except Exception as e:
|
|
344
|
+
logger.exception(f"❌ Error in '{hook_name}' of plugin '{plugin.module_name}': {e}")
|
|
345
|
+
plugin_state.error.append(str(e))
|
|
346
|
+
plugin_state.traceback.append(traceback.format_exc())
|
|
347
|
+
plugin_state.plugin.module_menu_icon = "fas fa-exclamation-triangle"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from pydantic.v1 import BaseSettings
|
|
4
|
+
|
|
5
|
+
from fastpluggy.core.repository.app_settings import database_settings_source
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseDatabaseSettings(BaseSettings):
|
|
9
|
+
class Config:
|
|
10
|
+
@classmethod
|
|
11
|
+
def customise_sources(
|
|
12
|
+
cls,
|
|
13
|
+
init_settings,
|
|
14
|
+
env_settings,
|
|
15
|
+
file_secret_settings
|
|
16
|
+
):
|
|
17
|
+
return (
|
|
18
|
+
init_settings, # 1. Explicitly passed settings
|
|
19
|
+
env_settings, # 2. Environment variables
|
|
20
|
+
file_secret_settings, # 3. Secrets from files
|
|
21
|
+
database_settings_source, # 4. Custom database source
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
class FastPluggyConfig(BaseDatabaseSettings):
|
|
25
|
+
app_name : Optional[str] = 'FastPluggy'
|
|
26
|
+
|
|
27
|
+
# admin config
|
|
28
|
+
admin_enabled:Optional[bool] = True
|
|
29
|
+
fp_admin_base_url : Optional[str] = '/admin'
|
|
30
|
+
plugin_list_url : Optional[str] = 'https://registry.fastpluggy.xyz/plugins.json'
|
|
31
|
+
|
|
32
|
+
show_empty_menu_entries: Optional[bool] = True
|
|
33
|
+
install_module_requirement_at_start:Optional[bool] = False
|
|
34
|
+
check_all_plugin_updates_at_start:Optional[bool] = False
|
|
35
|
+
# thread_pool_max_workers:int=50
|
|
36
|
+
|
|
37
|
+
session_secret_key:str = "your-secret-key"
|
|
38
|
+
|