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.
Files changed (103) hide show
  1. fastpluggy/__init__.py +4 -0
  2. fastpluggy/core/__init__.py +0 -0
  3. fastpluggy/core/auth/__init__.py +34 -0
  4. fastpluggy/core/auth/auth_interface.py +78 -0
  5. fastpluggy/core/auth/middleware.py +31 -0
  6. fastpluggy/core/base_module.py +73 -0
  7. fastpluggy/core/base_module_manager.py +347 -0
  8. fastpluggy/core/config.py +38 -0
  9. fastpluggy/core/database.py +103 -0
  10. fastpluggy/core/dependency.py +23 -0
  11. fastpluggy/core/error/__init__.py +8 -0
  12. fastpluggy/core/error/degraded_mode_handler.py +55 -0
  13. fastpluggy/core/error/exception.py +21 -0
  14. fastpluggy/core/flash.py +51 -0
  15. fastpluggy/core/global_registry.py +26 -0
  16. fastpluggy/core/menu/__init__.py +0 -0
  17. fastpluggy/core/menu/decorator.py +47 -0
  18. fastpluggy/core/menu/menu_manager.py +171 -0
  19. fastpluggy/core/menu/schema.py +56 -0
  20. fastpluggy/core/models.py +26 -0
  21. fastpluggy/core/models_tools/__init__.py +0 -0
  22. fastpluggy/core/models_tools/base.py +16 -0
  23. fastpluggy/core/models_tools/callable.py +60 -0
  24. fastpluggy/core/models_tools/pydantic.py +87 -0
  25. fastpluggy/core/models_tools/shared.py +90 -0
  26. fastpluggy/core/models_tools/sqlalchemy.py +233 -0
  27. fastpluggy/core/module_base.py +79 -0
  28. fastpluggy/core/plugin/__init__.py +0 -0
  29. fastpluggy/core/plugin/dependency_resolver.py +151 -0
  30. fastpluggy/core/plugin/installer/__init__.py +24 -0
  31. fastpluggy/core/plugin/installer/git_installer.py +134 -0
  32. fastpluggy/core/plugin/installer/zip_installer.py +39 -0
  33. fastpluggy/core/plugin/repository.py +101 -0
  34. fastpluggy/core/plugin/service.py +59 -0
  35. fastpluggy/core/plugin_state.py +116 -0
  36. fastpluggy/core/repository/__init__.py +0 -0
  37. fastpluggy/core/repository/app_settings.py +79 -0
  38. fastpluggy/core/routers/__init__.py +0 -0
  39. fastpluggy/core/routers/actions/__init__.py +12 -0
  40. fastpluggy/core/routers/actions/fast_pluggy.py +0 -0
  41. fastpluggy/core/routers/actions/modules.py +152 -0
  42. fastpluggy/core/routers/admin.py +200 -0
  43. fastpluggy/core/routers/app_static.py +37 -0
  44. fastpluggy/core/routers/base_module.py +190 -0
  45. fastpluggy/core/routers/execute.py +127 -0
  46. fastpluggy/core/routers/home.py +20 -0
  47. fastpluggy/core/routers/settings.py +67 -0
  48. fastpluggy/core/tools/__init__.py +11 -0
  49. fastpluggy/core/tools/fastapi.py +105 -0
  50. fastpluggy/core/tools/fs_tools.py +53 -0
  51. fastpluggy/core/tools/git_tools.py +80 -0
  52. fastpluggy/core/tools/inspect_tools.py +262 -0
  53. fastpluggy/core/tools/install.py +19 -0
  54. fastpluggy/core/tools/serialize_tools.py +53 -0
  55. fastpluggy/core/tools/system.py +18 -0
  56. fastpluggy/core/tools/threads_tools.py +34 -0
  57. fastpluggy/core/view_builer/__init__.py +87 -0
  58. fastpluggy/core/view_builer/components/__init__.py +98 -0
  59. fastpluggy/core/view_builer/components/button.py +331 -0
  60. fastpluggy/core/view_builer/components/custom.py +47 -0
  61. fastpluggy/core/view_builer/components/debug.py +31 -0
  62. fastpluggy/core/view_builer/components/form.py +115 -0
  63. fastpluggy/core/view_builer/components/list.py +38 -0
  64. fastpluggy/core/view_builer/components/model.py +114 -0
  65. fastpluggy/core/view_builer/components/pagination.py +87 -0
  66. fastpluggy/core/view_builer/components/raw.py +23 -0
  67. fastpluggy/core/view_builer/components/render_field_tools.py +59 -0
  68. fastpluggy/core/view_builer/components/tabbed.py +35 -0
  69. fastpluggy/core/view_builer/components/table.py +207 -0
  70. fastpluggy/core/view_builer/components/table_model.py +222 -0
  71. fastpluggy/core/view_builer/form_builder.py +189 -0
  72. fastpluggy/fastpluggy.py +239 -0
  73. fastpluggy/static/css/__init__.py +0 -0
  74. fastpluggy/static/css/styles.css +25 -0
  75. fastpluggy/static/js/__init__.py +0 -0
  76. fastpluggy/static/js/scripts.js +1 -0
  77. fastpluggy/templates/__init__.py +0 -0
  78. fastpluggy/templates/admin/__init__.py +0 -0
  79. fastpluggy/templates/admin/install_module.html.j2 +60 -0
  80. fastpluggy/templates/auth/__init__.py +0 -0
  81. fastpluggy/templates/base.html.j2 +229 -0
  82. fastpluggy/templates/components/__init__.py +0 -0
  83. fastpluggy/templates/components/button_component.html.j2 +44 -0
  84. fastpluggy/templates/components/common.html.j2 +77 -0
  85. fastpluggy/templates/components/debug/json.html.j2 +12 -0
  86. fastpluggy/templates/components/form.html.j2 +30 -0
  87. fastpluggy/templates/components/form_component.html.j2 +16 -0
  88. fastpluggy/templates/components/generic_page.html.j2 +24 -0
  89. fastpluggy/templates/components/mime.html.j2 +14 -0
  90. fastpluggy/templates/components/model_view.html.j2 +29 -0
  91. fastpluggy/templates/components/pagination_macros.html.j2 +112 -0
  92. fastpluggy/templates/components/tabbed.html.j2 +29 -0
  93. fastpluggy/templates/components/table_component.html.j2 +80 -0
  94. fastpluggy/templates/components/table_filter_component.html.j2 +25 -0
  95. fastpluggy/templates/degraded_mode.html.j2 +63 -0
  96. fastpluggy/templates/error.html.j2 +56 -0
  97. fastpluggy/templates/flash_messages.html.j2 +10 -0
  98. fastpluggy/templates/index.html.j2 +16 -0
  99. fastpluggy/templates/menu.html.j2 +176 -0
  100. fastpluggy-0.2.7.dist-info/METADATA +17 -0
  101. fastpluggy-0.2.7.dist-info/RECORD +103 -0
  102. fastpluggy-0.2.7.dist-info/WHEEL +5 -0
  103. fastpluggy-0.2.7.dist-info/top_level.txt +1 -0
fastpluggy/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ import os
2
+
3
+ __version__ = "0.2.7" # Update this manually when needed
4
+ __SOURCE_COMMIT_CORE__ = os.getenv("SOURCE_COMMIT_CORE", "")
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
+