diff --git a/core/ez/__init__.py b/core/ez/__init__.py index c56908b..2ec32ed 100644 --- a/core/ez/__init__.py +++ b/core/ez/__init__.py @@ -25,7 +25,7 @@ PLUGINS_DIR: Path = SITE_DIR / "plugins" MODULE_DIR: Path = EZ_FRAMEWORK_DIR / "modules" PLUGIN_API_DIR: Path = SITE_DIR / "lib" / "public-api" / "plugins" - +EZ_ROUTE_ATTRIBUTE = "ez_web_route" #endregion @@ -42,30 +42,27 @@ from utilities.event import Event from utilities.event_emitter import EventEmitter from web.response import _EzResponse -from plugins.manager import PluginManager -from plugins.installer import PluginInstaller -from plugins.errors import UnknownPluginError -from plugins.plugin_info import PluginInfo - from web.app.app import EZApplication -sys.path.remove(str(MODULE_DIR)) -del sys +from ez.errors import EZError +sys.path.remove(str(MODULE_DIR)) #endregion #region EZ Internal + +__path__ = list(__path__) + + class _EZ: ez: "_EZ | None" = None ee: EventEmitter app: FastAPI plugin_events: dict[str, list[tuple[str, Callable]]] - plugin_manager: PluginManager - plugin_installer: PluginInstaller mm: ModuleManager @@ -78,8 +75,6 @@ def __init__(self, app: FastAPI | None = None, ee: EventEmitter | None = None) - self.app = app or EZApplication(redirect_slashes=True) self.plugin_events = {} - self.plugin_manager = PluginManager(PLUGINS_DIR) - self.plugin_installer = PluginInstaller(self.plugin_manager) self.mm = ModuleManager(MODULE_DIR) @@ -95,20 +90,24 @@ def remove_plugin_events(self, plugin: str): del self.plugin_events[plugin] def get_plugin_from_handler(self, handler: Callable, __Path=Path) -> str: - if not handler.__module__.startswith(self.plugin_manager.PLUGIN_PREFIX): + PLUGIN_PREFIX = "ez.current-site.plugins" + if not handler.__module__.startswith(PLUGIN_PREFIX): return None - name = handler.__module__.removeprefix(self.plugin_manager.PLUGIN_PREFIX + ".").split(".")[0] - return self.plugin_manager.get_plugin_info(name).dir_name + name = handler.__module__.removeprefix(PLUGIN_PREFIX + ".").split(".")[0] + return name _EZ() -def event_function(f: Callable, *, __wraps=wraps, __ez=_EZ.ez, UnkownPluginError=UnknownPluginError): +def event_function(f: Callable, *, __wraps=wraps, __ez=_EZ.ez): + import ez.plugins + from ez.plugins import UnknownPluginError + plugin = __ez.get_plugin_from_handler(f) if not plugin: return f try: - plugin = __ez.plugin_manager.get_plugin(plugin) + plugin = ez.plugins.get_plugin(plugin) except UnknownPluginError: return f else: @@ -117,7 +116,7 @@ def wrapper(*args, **kwargs): if plugin.enabled: return f(*args, **kwargs) return - f.__ez_plugin__ = wrapper.__ez_plugin__ = plugin.info + f.__ez_plugin__ = wrapper.__ez_plugin__ = plugin return wrapper @@ -125,12 +124,23 @@ def is_plugin_event_handler(f): return callable(f) and getattr(f, "__ez_plugin__", None) is not None -def get_plugin_event_handler_info(f) -> PluginInfo: +def get_plugin_event_handler_info(f): if not is_plugin_event_handler(f): raise ValueError(f"Function '{f.__qualname__}' is not a plugin event handler.") return getattr(f, "__ez_plugin__") +def extend_ez(module, alias: str = None, *, THIS=sys.modules[__name__]): + import sys + from pathlib import Path + + path = Path(module.__file__) + + name = alias or path.stem + setattr(THIS, alias or path.stem, module) + sys.modules[f"ez.{name}"] = module + + #endregion @@ -185,54 +195,6 @@ def emit(event: Event, *args, __ez=_EZ.ez, **kwargs): #endregion -#region Plugin System - - -def get_plugins(__ez=_EZ.ez): - return __ez.plugin_manager.get_plugins() - - -def load_plugin(plugin: str, __ez=_EZ.ez): - return __ez.plugin_manager.load_plugin(plugin) - - -def load_plugins(__ez=_EZ.ez): - from ez.database.models.plugin import PluginModel - - names = [plugin.dir_name for plugin in PluginModel.filter_by(enabled=True).all()] - - return __ez.plugin_manager.load_plugins(names) - - -def enable_plugin(plugin: str, __ez=_EZ.ez): - return __ez.plugin_manager.enable_plugin(plugin) - - -def disable_plugin(plugin: str, __ez=_EZ.ez): - plugin_info = __ez.plugin_manager.get_plugin_info(plugin) - __ez.remove_plugin_events(plugin_info.dir_name) - return __ez.plugin_manager.disable_plugin(plugin) - - -def install_plugin(path: str | Path, __Path=Path, __ez=_EZ.ez): - if isinstance(path, str): - path = __Path(path) - - if path.suffix == ".zip": - return __ez.plugin_installer.install_from_zip(path) - elif path.is_dir(): - return __ez.plugin_installer.install_from_path(path) - else: - raise ValueError("Invalid path to plugin: must be a directory or a zip file.") - - -def uninstall_plugin(plugin: str, __ez=_EZ.ez): - return __ez.plugin_installer.uninstall_plugin(plugin) - - -#endregion - - #region Module System @@ -268,6 +230,7 @@ async def wrapper(*args, **kwargs): result = await handler(*args, **kwargs) return response._auto_body(result) + setattr(wrapper, EZ_ROUTE_ATTRIBUTE, True) __ez.app.add_api_route(route, endpoint=wrapper, methods=methods) else: @@ -276,6 +239,7 @@ def wrapper(*args, **kwargs): result = handler(*args, **kwargs) return response._auto_body(result) + setattr(wrapper, EZ_ROUTE_ATTRIBUTE, True) __ez.app.add_api_route(route, endpoint=wrapper, methods=methods) log.debug(f"{methods} {route} -> {handler.__name__}") @@ -399,13 +363,17 @@ def _setup(__ez=_EZ.ez): del Modules - from ez.events import Plugins + from ez.plugins import PluginEvent, __pm - emit(Plugins.WillLoad) - load_plugins() - emit(Plugins.DidLoad) + plugins = [ + "test-plugin", + "title-changer" + ] + emit(PluginEvent.WillLoad, plugins) + __pm.load_plugins(*plugins) + emit(PluginEvent.DidLoad, plugins) - del Plugins + del PluginEvent def _run(setup=_setup): @@ -430,10 +398,10 @@ def run(_run=_run): del _EZ del EZApplication -del PluginManager +# del PluginManager del ModuleManager -del UnknownPluginError +# del UnknownPluginError del FastAPI @@ -447,7 +415,10 @@ def run(_run=_run): del Path +del sys + __all__ = [ + "EZError", "on", "once", "emit", diff --git a/core/ez/events/__init__.py b/core/ez/events/__init__.py index 0db9ebe..1b1d130 100644 --- a/core/ez/events/__init__.py +++ b/core/ez/events/__init__.py @@ -1,18 +1,18 @@ from utilities.event import Event -from .http import HTTP from .settings import Settings +from web.events import HTTP from web.app.events import App from modules.events import Modules -from plugins.events import Plugins -from ezjsx.events import TreeRenderer +# from plugins.events import Plugins +# from ezjsx.events import TreeRenderer __all__ = [ "Event", "HTTP", "App", "Modules", - "Plugins", + # "Plugins", "Settings", "TreeRenderer" ] diff --git a/modules/ezjsx/__main__.py b/modules/ezjsx/__main__.py index 8012122..364d0af 100644 --- a/modules/ezjsx/__main__.py +++ b/modules/ezjsx/__main__.py @@ -4,7 +4,9 @@ from jsx.renderer import render from jsx.components import Component from jsx.html import Element -from ez.events import HTTP, TreeRenderer +from ez.events import HTTP +from .events import TreeRenderer +from . import components @ez.on(HTTP.Out) @@ -12,17 +14,24 @@ def render_tree(_): if ez.request.method != "GET": return - if isinstance(ez.response.body, (Component, Element)): - ez.emit(TreeRenderer.WillRender, ez.response.body) - result = render(ez.response.body) - ez.emit(TreeRenderer.DidRender, ez.response.body, result) + body = ez.response.body + if isinstance(body, (Component, Element)): + if not isinstance(body, components.Page): + body = components.Page(body) + + ez.emit(TreeRenderer.WillRender, body) + result = render(body) + ez.emit(TreeRenderer.DidRender, body, result) ez.response.html(result) + mount(ez._app) -__title__ = "EZ JSX Integration" +ez.extend_ez(components, "jsx") + +__module_name__ = "EZ JSX Integration" __version__ = "1.0.0" -__description__ = \ +__doc__ = \ """ This module enables the jsx library for use in EZ Web Framework. """ diff --git a/modules/ezjsx/components/__init__.py b/modules/ezjsx/components/__init__.py new file mode 100644 index 0000000..716568d --- /dev/null +++ b/modules/ezjsx/components/__init__.py @@ -0,0 +1,2 @@ +from .page import Page +from .card import Card \ No newline at end of file diff --git a/modules/ezjsx/components/card.py b/modules/ezjsx/components/card.py new file mode 100644 index 0000000..9439b99 --- /dev/null +++ b/modules/ezjsx/components/card.py @@ -0,0 +1,22 @@ +from jsx.components import ContainerComponent +from jsx.html import Div + + +class Card(ContainerComponent): + def __init__(self, *children, **props): + super().__init__(*children) + + class_name = props.get("class_name", []) + if isinstance(class_name, list): + class_name.append("card") + else: + class_name = [class_name, "card"] + + props["class_name"] = class_name + self.props = props + + def render(self): + return Div( + *self.children, + **self.props, + ) diff --git a/modules/ezjsx/components/page.py b/modules/ezjsx/components/page.py new file mode 100644 index 0000000..bbe7988 --- /dev/null +++ b/modules/ezjsx/components/page.py @@ -0,0 +1,29 @@ +from jsx.components import ContainerComponent +from jsx.html import Fragment, Html, Head, Body, Title, Link, Script + + +class Page(ContainerComponent): + def __init__(self, *children, title="Ez Web"): + super().__init__(*children) + self.title = title + + def render(self): + return Fragment( + "", + Html( + Head( + Title(self.title), + Link( + rel="stylesheet", + href="https://unpkg.com/bootstrap@5.3.2/dist/css/bootstrap.min.css", + ), + Script(src="/_jsx/main.js"), + ), + Body( + self.body(), + ), + ), + ) + + def body(self): + return Fragment(*self.children) diff --git a/modules/modules/manager.py b/modules/modules/manager.py index 0117985..614d22e 100644 --- a/modules/modules/manager.py +++ b/modules/modules/manager.py @@ -1,3 +1,4 @@ +from types import ModuleType from utilities.semver import SemanticVersion from .module import Module @@ -15,11 +16,17 @@ class ModuleManager: PACKAGE_ENTRY_POINT = "__main__.py" MODULE_PREFIX = "ez.global.modules" + MODULE_NAME_ATTRIBUTE = "__module_name__" def __init__(self, module_dir: Path) -> None: self.module_dir = module_dir self._modules: list[Module] = [] + sys.modules[self.MODULE_PREFIX] = type("", (ModuleType,), { + "__path__": [str(module_dir)], + "__package__": str(self.MODULE_PREFIX) + })(self.MODULE_PREFIX) + def load_modules(self, *, reload: bool = True) -> bool: """ Load all modules from the path given in the constructor. @@ -42,6 +49,7 @@ def load_modules(self, *, reload: bool = True) -> bool: return False if not self.module_dir.is_dir(): return False + for item in self.module_dir.iterdir(): name = item.stem if item.is_dir(): @@ -53,30 +61,44 @@ def load_modules(self, *, reload: bool = True) -> bool: if item.suffix != ".py": continue self._load_module_from(name, item) + + for module in self._modules: + module.entry_point.__spec__.loader.exec_module(module.entry_point) + if self._modules: self._modules.append(Module( "Module Manager", THIS, Path(THIS.__file__), - SemanticVersion.parse(THIS.__version__), - "Module Manager by EZ Framework." )) - return True - return False + + return bool(self._modules) def _load_module_from(self, name: str, path: Path): - spec = spec_from_file_location(self.get_module_full_name(name), str(path)) + full_name = self.get_module_full_name(name) + package_dir = path.parent + + init_file = package_dir / "__init__.py" + if init_file.exists(): + spec = spec_from_file_location( + full_name, + init_file, + submodule_search_locations=[ + str(package_dir) + ]) + module = sys.modules[spec.name] = module_from_spec(spec) + spec.loader.exec_module(module) + + name = getattr(module, self.MODULE_NAME_ATTRIBUTE, name) + + spec = spec_from_file_location( + full_name + '.__main__', + str(path) + ) entry_point = module_from_spec(spec) - spec.loader.exec_module(entry_point) - - if not hasattr(entry_point, "__version__"): - raise Exception(f"Invalid module '{name}' at '{path}'") - - name = getattr(entry_point, "__title__", name) - version = SemanticVersion.parse(entry_point.__version__) - description = getattr(entry_point, "__description__", getattr(entry_point, "__doc__", "")) + sys.modules[spec.name] = entry_point - module = Module(name, entry_point, path, version, description) + module = Module(name, entry_point, path) self._modules.append(module) def reload_module(self, module: Module): diff --git a/modules/modules/module.py b/modules/modules/module.py index 655faee..1b2be26 100644 --- a/modules/modules/module.py +++ b/modules/modules/module.py @@ -2,16 +2,12 @@ from pathlib import Path from types import ModuleType -from utilities.semver import SemanticVersion - @dataclass class Module: name: str entry_point: ModuleType entry_point_path: Path - version: SemanticVersion - description: str def __str__(self) -> str: - return f"{self.name} v{self.version} @ {self.entry_point_path}" + return f"{self.name} @ {self.entry_point_path}" diff --git a/modules/plugins/__init__.py b/modules/plugins/__init__.py index e69de29..3fd3fd9 100644 --- a/modules/plugins/__init__.py +++ b/modules/plugins/__init__.py @@ -0,0 +1,6 @@ +from . import ez_plugins +import ez +ez.extend_ez(ez_plugins, "plugins") + + +__module_name__ = "Plugin Manager" diff --git a/modules/plugins/__main__.py b/modules/plugins/__main__.py index 856be4b..8cd13e5 100644 --- a/modules/plugins/__main__.py +++ b/modules/plugins/__main__.py @@ -1,2 +1 @@ -__title__ = "Plugin Manager" -__version__ = "1.0.0" +from . import router \ No newline at end of file diff --git a/modules/plugins/builtins/installer.py b/modules/plugins/builtins/installer.py new file mode 100644 index 0000000..e4445f2 --- /dev/null +++ b/modules/plugins/builtins/installer.py @@ -0,0 +1,83 @@ +import yaml +import shutil + +from pathlib import Path +from zipfile import ZipFile, BadZipFile +from pydantic import BaseModel, Field, ValidationError + +from utilities.version import Version + +from ..config import PLUGINS_PUBLIC_API_DIR +from ..errors import EZPluginError, PluginAlreadyInstalledError, UnknownPluginError +from ..plugin_info import PackageName +from ..machinery.installer import IPluginInstaller, PluginInstallationResult, PluginInstallerInfo + + +class EZPluginInstallerError(EZPluginError): + ... + + +class InvalidPluginArchive(EZPluginInstallerError): + ... + + +class InvalidPluginManifest(EZPluginInstallerError): + ... + + +class PluginManifest(BaseModel): + name: str + package_name: PackageName = Field(alias="package-name") + version: Version = Field(alias="version") + typing_file: str = Field(alias="typing-file") + + +class EZPluginInstaller(IPluginInstaller): + info = PluginInstallerInfo( + id="ez.plugins.installer", + name="EZ Plugin Installer", + ) + + EZ_PLUGIN_MANIFEST_FILENAME = "manifest.yaml" + + def install(self, path: str) -> PluginInstallationResult: + path: Path = Path(path) + + if not path.exists(): + raise FileNotFoundError(path) + + if not path.is_file(): + raise IsADirectoryError(path) + + try: + zip_file = ZipFile(path) + except BadZipFile as e: + raise InvalidPluginArchive(path) from e + + with zip_file: + manifest_file = zip_file.open(self.EZ_PLUGIN_MANIFEST_FILENAME) + manifest_data = yaml.safe_load(manifest_file) + try: + manifest = PluginManifest.model_validate(manifest_data) + except ValidationError: + raise InvalidPluginManifest(path) + + plugin_dir = self.plugin_dir / manifest.package_name + if plugin_dir.exists(): + raise PluginAlreadyInstalledError(manifest.package_name) + plugin_dir.mkdir() + + zip_file.extractall(str(plugin_dir)) + + if manifest.typing_file: + (plugin_dir / manifest.typing_file).rename() + + def uninstall(self, plugin_id: str) -> None: + plugin_dir = self.plugin_dir / plugin_id + if not plugin_dir.exists(): + raise UnknownPluginError(plugin_id) + + shutil.rmtree(str(plugin_dir)) + + typing_file = PLUGINS_PUBLIC_API_DIR / f"{plugin_id}.pyi" + typing_file.unlink(missing_ok=True) diff --git a/modules/plugins/builtins/loader.py b/modules/plugins/builtins/loader.py new file mode 100644 index 0000000..61e38e7 --- /dev/null +++ b/modules/plugins/builtins/loader.py @@ -0,0 +1,126 @@ +import sys + +from types import ModuleType +from importlib.util import spec_from_file_location, module_from_spec + +from utilities.version import Version + +from ..plugin import Plugin, PluginInfo, PluginId +from ..machinery.loader import IPluginLoader, PluginLoaderInfo + +from .plugin import EZPlugin +from .plugin_module_loader import PluginModuleLoader +from .plugin_module import PluginModule + + +class EZPluginLoader(IPluginLoader): + info = PluginLoaderInfo( + id="ez.plugins.loader", + name="EZ Plugin Loader", + ) + + EZ_PLUGIN_ENTRY_POINT_FILENAME = "plugin.py" + EZ_PLUGIN_API_ATTRIBUTE_NAME = "__api__" + EZ_PLUGIN_INFO_ATTRIBUTE_NAME = "__info__" + EZ_PLUGIN_MAIN_FUNCTION_NAME = "main" + EZ_PLUGIN_PREFIX = "ez.current-site.plugins" + + def __init__(self, plugin_dir: PluginId) -> None: + super().__init__(plugin_dir) + + self._loader = PluginModuleLoader() + + root = sys.modules[self.EZ_PLUGIN_PREFIX] = PluginModule(self.EZ_PLUGIN_PREFIX) + root.__path__ = [str(self.plugin_dir)] + + def load(self, plugin_id: PluginId, plugin: Plugin) -> EZPlugin | None: + if plugin is None: + return self._load_plugin(plugin_id) + plugin = self._assert_ez_plugin(plugin) + if plugin.is_loaded: + if plugin.enabled: + return None + self._reload_plugin(plugin) + else: + plugin.module = self._load_module(plugin.info.package_name) + + def _load_plugin(self, plugin_id: PluginId) -> EZPlugin: + module = self._load_module(plugin_id) + sys.modules[module.__name__] = module + + plugin_dir = self.plugin_dir / plugin_id + + with open(plugin_dir / "plugin-metadata") as file: + metadata = { + key.strip(): value.strip() + for key, value in (line.split(":", 1) for line in file) + } + metadata["version"] = Version.parse(metadata["version"]) + if "description" not in metadata: + metadata["description"] = module.__doc__ + info = PluginInfo.model_construct( + **metadata + ) + + module.__info__ = info + module.__loader__ = self.info.id + module.__path__.append(str(plugin_dir)) + + ez_plugin = EZPlugin( + info=info, + loader=self.info, + enabled=True, + api=None, + module=module + ) + + module.__plugin__ = ez_plugin + + self._execute_module(module) + + ez_plugin.api = getattr(module, self.get_api_attribute_name(), None) + + return ez_plugin + + def run_main(self, plugin: Plugin) -> None: + plugin = self._assert_ez_plugin(plugin) + if plugin.enabled: + main = getattr(plugin.module, self.EZ_PLUGIN_MAIN_FUNCTION_NAME, None) + if callable(main): + main() + + def _load_module(self, plugin_id: PluginId) -> PluginModule: + path = self._get_plugin_path(plugin_id) + spec = spec_from_file_location(self.get_module_full_name(plugin_id), str(path), loader=self._loader) + module = module_from_spec(spec) + return module + + def _execute_module(self, module: ModuleType) -> None: + module.__spec__.loader.exec_module(module) + + def _reload_plugin(self, plugin: EZPlugin) -> None: + self._execute_module(plugin.module) + + def _get_plugin_path(self, plugin_id: PluginId): + return self.plugin_dir / str(plugin_id) / self.get_entry_point_filename() + + def _assert_ez_plugin(self, plugin: Plugin) -> EZPlugin: + if not isinstance(plugin, EZPlugin): + raise TypeError(f"Expected EZPlugin, got {type(plugin)}") + return plugin + + @classmethod + def get_entry_point_filename(cls) -> str: + return cls.EZ_PLUGIN_ENTRY_POINT_FILENAME + + @classmethod + def get_api_attribute_name(cls) -> str: + return cls.EZ_PLUGIN_API_ATTRIBUTE_NAME + + @classmethod + def get_info_attribute_name(cls) -> str: + return cls.EZ_PLUGIN_INFO_ATTRIBUTE_NAME + + @classmethod + def get_module_full_name(cls, plugin_id: PluginId) -> str: + return f"{cls.EZ_PLUGIN_PREFIX}.{plugin_id}" diff --git a/modules/plugins/builtins/plugin.py b/modules/plugins/builtins/plugin.py new file mode 100644 index 0000000..6ad42ff --- /dev/null +++ b/modules/plugins/builtins/plugin.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass + +from ..plugin import Plugin +from .plugin_module import PluginModule + + +@dataclass +class EZPlugin(Plugin): + module: PluginModule | None + + @property + def is_loaded(self): + return self.module is not None diff --git a/modules/plugins/builtins/plugin_module.py b/modules/plugins/builtins/plugin_module.py new file mode 100644 index 0000000..881a33e --- /dev/null +++ b/modules/plugins/builtins/plugin_module.py @@ -0,0 +1,18 @@ +from types import ModuleType +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..plugin import Plugin, PluginInfo + + +class PluginModule(ModuleType): + __plugin__: "Plugin" + __info__: "PluginInfo" + + def __init__(self, name, doc=None): + super().__init__(name, doc) + self.__path__ = [] + self.__package__ = name + + def __repr__(self): + return f"" diff --git a/modules/plugins/builtins/plugin_module_loader.py b/modules/plugins/builtins/plugin_module_loader.py new file mode 100644 index 0000000..38b445a --- /dev/null +++ b/modules/plugins/builtins/plugin_module_loader.py @@ -0,0 +1,16 @@ +from importlib.abc import Loader + +from .plugin_module import PluginModule + + +class PluginModuleLoader(Loader): + def create_module(self, spec): + module = PluginModule(spec.name, None) + module.__spec__ = spec + return module + + def exec_module(self, module): + with open(module.__spec__.origin, "r") as file: + code = file.read() + + exec(code, vars(module)) diff --git a/modules/plugins/config.py b/modules/plugins/config.py new file mode 100644 index 0000000..07967cc --- /dev/null +++ b/modules/plugins/config.py @@ -0,0 +1,12 @@ +import ez + + +PLUGINS_DIRECTORY_NAME = "plugins" +PLUGINS_DIRECTORY = ez.SITE_DIR / PLUGINS_DIRECTORY_NAME + +PLUGINS_PUBLIC_API_DIRECTORY_NAME = "lib/public-api" +PLUGINS_PUBLIC_API_MODULE_NAME = "ez_plugins" + +PLUGINS_PUBLIC_API_DIR = ez.SITE_DIR / PLUGINS_PUBLIC_API_DIRECTORY_NAME / PLUGINS_PUBLIC_API_MODULE_NAME + +METADATA_FILENAME = "plugin-metadata" diff --git a/modules/plugins/errors.py b/modules/plugins/errors.py index 3eaecdd..1ca988b 100644 --- a/modules/plugins/errors.py +++ b/modules/plugins/errors.py @@ -1,11 +1,17 @@ -from ez.errors import EZError +from ez import EZError +from typing import TYPE_CHECKING -class EZPluginManagerError(EZError): +if TYPE_CHECKING: + from .machinery.installer import PluginInstallerId + from .machinery.loader import PluginLoaderId + + +class EZPluginError(EZError): ... -class UnknownPluginError(EZPluginManagerError): +class UnknownPluginError(EZPluginError): def __init__(self, plugin_name): self.plugin_name = plugin_name @@ -13,9 +19,44 @@ def __str__(self): return f"Unknown plugin: {self.plugin_name}" -class PluginAlreadyInstalledError(EZPluginManagerError): +class PluginAlreadyInstalledError(EZPluginError): def __init__(self, plugin_name): self.plugin_name = plugin_name def __str__(self): return f"Plugin already installed: {self.plugin_name}" + + +class DuplicateIDError(EZPluginError): + def __init__(self, item, id) -> None: + self.item = item + self.id = id + + def __str__(self): + return f"Duplicate ID of {type(self.item).__name__}: {self.id}" + + +class UnknownPluginInstallerError(EZPluginError): + def __init__(self, installer_id: "PluginInstallerId") -> None: + self._installer_id = installer_id + + def __str__(self): + return f"Unknown plugin installer: {self._installer_id}" + + +class UnknownPluginLoaderError(EZPluginError): + def __init__(self, loader_id: "PluginLoaderId") -> None: + self._loader_id = loader_id + + def __str__(self): + return f"Unknown plugin loader: {self._loader_id}" + + +__all__ = [ + "EZPluginError", + "UnknownPluginError", + "PluginAlreadyInstalledError", + "DuplicateIDError", + "UnknownPluginInstallerError", + "UnknownPluginLoaderError", +] diff --git a/modules/plugins/ez_plugins/__init__.py b/modules/plugins/ez_plugins/__init__.py new file mode 100644 index 0000000..a66f2d2 --- /dev/null +++ b/modules/plugins/ez_plugins/__init__.py @@ -0,0 +1,135 @@ +from typing import Callable + +from ..plugin import Plugin, PluginInfo, PluginId, PluginAPI +from ..machinery.installer import IPluginInstaller, PluginInstallerInfo, PluginInstallerId +from ..machinery.loader import IPluginLoader, PluginLoaderInfo + +from .errors import EZPluginError, UnknownPluginError, DuplicateIDError +from .events import Plugins as PluginEvent + +from ..manager import PLUGIN_MANAGER as __pm +from ..config import METADATA_FILENAME, PLUGINS_PUBLIC_API_MODULE_NAME + + +def get_plugins() -> list[Plugin]: + return __pm.get_plugins() + + +def get_plugin(plugin_id: PluginId) -> Plugin: + return __pm.get_plugin(plugin_id) + + +def install(path: str, *, installer: PluginInstallerId = None): + return __pm.install(path, installer=installer) + + +def uninstall(plugin_id: PluginId): + __pm.uninstall(plugin_id) + + +def enable(plugin_id: PluginId) -> bool: + if is_enabled(plugin_id): + return False + return __pm.enable(plugin_id) is None + + +def disable(plugin_id: PluginId) -> bool: + if is_disabled(plugin_id): + return False + return __pm.disable(plugin_id) is None + + +def is_enabled(plugin_id: PluginId) -> bool: + plugin = __pm.get_plugin(plugin_id) + return plugin.enabled + + +def is_disabled(plugin_id: PluginId) -> bool: + return not is_enabled(plugin_id) + + +def has_api(plugin_id: PluginId) -> bool: + return get_api(plugin_id) is not None + + +def get_api(plugin_id: PluginId) -> PluginAPI | None: + plugin = get_plugin(plugin_id) + return plugin.api + + +def get_plugins_directory() -> str: + return str(__pm.plugin_dir) + + +def get_metadata_filename() -> str: + return METADATA_FILENAME + + +def get_plugin_public_api_module_name() -> str: + return PLUGINS_PUBLIC_API_MODULE_NAME + + +def expose(plugin: Plugin, api: PluginAPI): + return __pm.expose(plugin, api) + + +def add_installer(installer: IPluginInstaller | Callable[[str], IPluginInstaller]) -> IPluginInstaller: + return __pm.add_installer(installer) + + +def remove_installer(installer: IPluginInstaller) -> bool: + return __pm.remove_installer(installer) + + +def add_loader(loader: IPluginLoader | Callable[[str], IPluginLoader]) -> IPluginLoader: + return __pm.add_loader(loader) + + +def remove_loader(loader: IPluginLoader) -> bool: + return __pm.remove_loader(loader) + + +def get_installers() -> list[PluginInstallerInfo]: + return [ + installer.info for installer in __pm.get_installers() + ] + + +def get_loaders() -> list[PluginLoaderInfo]: + return [ + loader.info for loader in __pm.get_loaders() + ] + + +__all__ = [ + "Plugin", + "IPluginInstaller", + "PluginInstallerInfo", + "IPluginLoader", + "PluginLoaderInfo", + "UnknownPluginError", + "DuplicateIDError", + "PluginInfo", + "EZPluginError", + "PluginEvent", + "get_plugins", + "get_plugin", + "install", + "uninstall", + "enable", + "disable", + "is_enabled", + "is_disabled", + "has_api", + "get_api", + "get_plugins_directory", + "get_metadata_filename", + "get_plugin_public_api_module_name", + "expose", + "add_installer", + "remove_installer", + "add_loader", + "remove_loader", + "get_installers", + "get_loaders", +] diff --git a/modules/plugins/ez_plugins/errors.py b/modules/plugins/ez_plugins/errors.py new file mode 100644 index 0000000..a11fc7d --- /dev/null +++ b/modules/plugins/ez_plugins/errors.py @@ -0,0 +1 @@ +from ..errors import * diff --git a/modules/plugins/events.py b/modules/plugins/ez_plugins/events.py similarity index 100% rename from modules/plugins/events.py rename to modules/plugins/ez_plugins/events.py diff --git a/modules/plugins/ez_plugins/machinery.py b/modules/plugins/ez_plugins/machinery.py new file mode 100644 index 0000000..013bb80 --- /dev/null +++ b/modules/plugins/ez_plugins/machinery.py @@ -0,0 +1,15 @@ +from ..machinery.installer import IPluginInstaller, PluginInstallerInfo, PluginInstallationResult +from ..machinery.loader import IPluginLoader, PluginLoaderInfo +from ..plugin import Plugin, PluginInfo, PluginId + + +__all__ = [ + "IPluginInstaller", + "PluginInstallerInfo", + "PluginInstallationResult", + "IPluginLoader", + "PluginLoaderInfo", + "Plugin", + "PluginInfo", + "PluginId", +] diff --git a/modules/plugins/installer.py b/modules/plugins/installer.py deleted file mode 100644 index cd36367..0000000 --- a/modules/plugins/installer.py +++ /dev/null @@ -1,128 +0,0 @@ -import ez - -from pathlib import Path -from zipfile import ZipFile -from yaml import load, Loader -from shutil import rmtree - -from pydantic import BaseModel - -from utilities.semver import SemanticVersion - -from .manager import PluginManager - - -def read_manifest(path: Path | str): - if isinstance(path, str): - path = Path(path) - manifest_file = path / PluginInstaller.MANIFEST_FILENAME - - if not manifest_file.exists(): - raise FileNotFoundError(f"Manifest file not found in {path}") - - with manifest_file.open("r") as file: - data = load(file, Loader=Loader) - for key in data: - if '-' in key: - new_key = key.replace('-', '_') - data[new_key] = data.pop(key) - manifest = PluginManifest.model_validate(data) - - return manifest - - -def move_file(src: Path, dest: Path, name: str = None): - dest.mkdir(parents=True, exist_ok=True) - if name is None: - name = src.name - src.rename(dest / name) - - -class PluginRepository(BaseModel): - type: str - url: str - - -class PluginManifest(BaseModel): - name: str - version: str - description: str - author: str - license: str - - homepage: str - repository: list[PluginRepository] - - typing_file: str | None - package_name: str - - @property - def semantic_version(self): - if not hasattr(self, "_semantic_version"): - self._semantic_version = SemanticVersion.parse(self.version) - return self._semantic_version - - -class PluginInstaller: - MANIFEST_FILENAME = "manifest.yaml" - - def __init__(self, manager: PluginManager) -> None: - self.manager = manager - - def install_from_path(self, path: str): - manifest = read_manifest(path) - - self.install_plugin(manifest, path) - - def install_plugin(self, manifest: PluginManifest, path: Path): - plugin_dir = ez.PLUGINS_DIR / manifest.package_name - if plugin_dir.exists(): - raise FileExistsError(f"Plugin {manifest.package_name} is already installed") - - plugin_content = path / manifest.package_name - - # TODO: make manifest more flexible in terms of file transfer - - if manifest.typing_file: - ez.PLUGIN_API_DIR.mkdir(parents=True, exist_ok=True) - - target_typing_file_name = manifest.package_name.replace("-", "_") + ".pyi" - move_file(plugin_content / manifest.typing_file, ez.PLUGIN_API_DIR, target_typing_file_name) - - plugin_dir.mkdir(parents=False, exist_ok=False) - for file in plugin_content.iterdir(): - move_file(file, plugin_dir) - - def install_from_zip(self, path: str): - path: Path = Path(path) - - with ZipFile(path) as zip_file: - for info in zip_file.infolist(): - if Path(info.filename).name == self.MANIFEST_FILENAME: - break - else: - raise FileNotFoundError(f"Manifest file not found in {path}") - - tmpdir = ez.EZ_FRAMEWORK_DIR / "temp" / "plugins" - tmpdir.mkdir(parents=True, exist_ok=True) - - tmpdir /= path.stem - if tmpdir.exists(): - rmtree(str(tmpdir)) - tmpdir.mkdir(parents=False, exist_ok=False) - - zip_file.extractall(tmpdir) - - self.install_from_path(tmpdir) - - rmtree(str(tmpdir)) - - def uninstall_plugin(self, name: str): - raise NotImplementedError("Uninstalling plugins is not yet supported") - # plugin_dir = ez.PLUGINS_DIR / name - # if not plugin_dir.exists(): - # raise FileNotFoundError(f"Plugin {name} is not installed") - - # for file in plugin_dir.iterdir(): - # file.unlink() - # plugin_dir.rmdir() diff --git a/modules/plugins/machinery/installer.py b/modules/plugins/machinery/installer.py new file mode 100644 index 0000000..bf9f292 --- /dev/null +++ b/modules/plugins/machinery/installer.py @@ -0,0 +1,44 @@ +from pydantic import BaseModel +from pathlib import Path +from typing import TypeAlias, ClassVar, TYPE_CHECKING + +from utilities.version import Version + +if TYPE_CHECKING: + from ..plugin import PluginId + + +PluginInstallerId: TypeAlias = str + + +class PluginInstallerInfo(BaseModel): + id: PluginInstallerId + name: str + + +class PluginInstallationResult(BaseModel): + installer_id: PluginInstallerId + package_name: str + version: Version + previous_version: Version | None + + @property + def is_upgrade(self) -> bool: + return self.previous_version is not None + + +class IPluginInstaller: + info: ClassVar[PluginInstallerInfo] + + def __init__(self, plugin_dir: str) -> None: + self._plugin_dir = Path(plugin_dir) + + @property + def plugin_dir(self) -> Path: + return self._plugin_dir + + def install(self, path: str) -> PluginInstallationResult: + raise NotImplementedError + + def uninstall(self, plugin_id: "PluginId") -> None: + raise NotImplementedError diff --git a/modules/plugins/machinery/loader.py b/modules/plugins/machinery/loader.py new file mode 100644 index 0000000..cf2b4c3 --- /dev/null +++ b/modules/plugins/machinery/loader.py @@ -0,0 +1,30 @@ +from pydantic import BaseModel +from pathlib import Path +from typing import TypeAlias, ClassVar + +from ..plugin import Plugin, PluginId + + +PluginLoaderId: TypeAlias = str + + +class PluginLoaderInfo(BaseModel): + id: PluginLoaderId + name: str + + +class IPluginLoader: + info: ClassVar[PluginLoaderInfo] + + def __init__(self, plugin_dir: str) -> None: + self._plugin_dir = Path(plugin_dir) + + @property + def plugin_dir(self) -> Path: + return self._plugin_dir + + def load(self, plugin_id: PluginId, plugin: Plugin | None) -> Plugin | None: + raise NotImplementedError + + def run_main(self, plugin: Plugin) -> None: + return None diff --git a/modules/plugins/machinery/manager.py b/modules/plugins/machinery/manager.py new file mode 100644 index 0000000..337c5e9 --- /dev/null +++ b/modules/plugins/machinery/manager.py @@ -0,0 +1,204 @@ +from typing import Callable +from types import ModuleType +from pathlib import Path + +from ..machinery.installer import PluginInstallerId +from ..machinery.loader import PluginLoaderId + +from ..plugin import ( + Plugin, + PluginInfo, + PluginId, +) + +from ..errors import ( + UnknownPluginError, + UnknownPluginInstallerError, + UnknownPluginLoaderError, + DuplicateIDError, +) + +from ..machinery.installer import IPluginInstaller +from ..machinery.loader import IPluginLoader + +from ..config import PLUGINS_PUBLIC_API_MODULE_NAME + + +class PluginManager: + EZ_PLUGIN_PREFIX = "ez.current-site.plugins" + + def __init__( + self, + plugin_dir: str, + default_installer: Callable[[str], IPluginInstaller] | IPluginInstaller, + default_loader: Callable[[str], IPluginLoader] | IPluginLoader, + public_api: ModuleType | None = None + ) -> None: + self.plugin_dir = Path(plugin_dir) + self._plugins: dict[PluginId, Plugin] = {} + self._plugin_installers: dict[PluginInstallerId, IPluginInstaller] = {} + self._plugin_loaders: dict[PluginLoaderId, IPluginLoader] = {} + + self._default_plugin_installer = self.add_installer(default_installer) + self._default_plugin_loader = self.add_loader(default_loader) + + self._public_api = public_api or ModuleType(PLUGINS_PUBLIC_API_MODULE_NAME) + + #region Plugin Public API + + @property + def public_api(self): + return self._public_api + + def enable_public_api(self, alias: str = None): + alias = alias or PLUGINS_PUBLIC_API_MODULE_NAME + + import sys + + sys.modules[alias] = self._public_api + self._public_api.__ez_name__ = alias + + def disable_public_api(self): + import sys + + del sys.modules[self._public_api.__ez_name__] + + def expose(self, plugin: Plugin, api: object): + if api is None: + raise ValueError("API cannot be None") + + api_name = plugin.info.package_name.replace("-", "_") + if hasattr(self._public_api, api_name): + raise ValueError(f"API name {api_name} already in use") + + plugin.api = api + setattr(self._public_api, api_name, api) + + #endregion + + def get_plugin(self, plugin_id: PluginId) -> Plugin: + try: + return self._plugins[plugin_id] + except KeyError: + raise UnknownPluginError(plugin_id) from None + + def get_plugins(self): + return list(self._plugins.values()) + + #region Installers & Loaders + + def add_installer(self, installer: IPluginInstaller | Callable[[str], IPluginInstaller]) -> IPluginInstaller: + if not isinstance(installer, IPluginInstaller): + installer = installer(self.plugin_dir) + if installer.info.id in self._plugin_installers: + raise DuplicateIDError(installer, installer.info.id) + self._plugin_installers[installer.info.id] = installer + return installer + + def add_loader(self, loader: IPluginLoader | Callable[[str], IPluginLoader]) -> IPluginLoader: + if not isinstance(loader, IPluginInstaller): + loader = loader(self.plugin_dir) + if loader.info.id in self._plugin_loaders: + raise DuplicateIDError(loader, loader.info.id) + self._plugin_loaders[loader.info.id] = loader + return loader + + def remove_installer(self, installer: IPluginInstaller) -> bool: + try: + del self._plugin_installers[installer.info.id] + except KeyError: + return False + return True + + def remove_loader(self, loader: IPluginLoader) -> bool: + try: + del self._plugin_loaders[loader.info.id] + except KeyError: + return False + return True + + def get_installer(self, installer: PluginInstallerId) -> IPluginInstaller: + try: + return self._plugin_installers[installer] + except KeyError: + raise UnknownPluginInstallerError(installer) from None + + def get_loader(self, loader: PluginLoaderId) -> IPluginLoader: + try: + return self._plugin_loaders[loader] + except KeyError: + raise UnknownPluginLoaderError(loader) from None + + def get_installers(self): + return list(self._plugin_installers.values()) + + def get_loaders(self): + return list(self._plugin_loaders.values()) + + #endregion + + #region Installation + + def install(self, path: str, *, installer: PluginInstallerId = None): + if installer is None: + _installer = self._default_plugin_installer + else: + _installer = self.get_installer(installer) + return _installer.install(path) + + def uninstall(self, plugin_id: PluginId): + plugin = self.get_plugin(plugin_id) + installer = self.get_installer(plugin.info.installer_id) + installer.uninstall(plugin_id) + + #endregion + + #region Plugin Status + + def enable(self, plugin_id: PluginId): + ... + + def disable(self, plugin_id: PluginId): + ... + + def load_plugin(self, plugin_id: PluginId): + loader = self._default_plugin_loader + + try: + plugin = self.get_plugin(plugin_id) + except UnknownPluginError: + plugin = loader.load(plugin_id, None) + + if plugin is None: + raise TypeError(f"Plugin loader {loader.info.id} returned None for plugin {plugin_id}") + self._plugins[plugin_id] = plugin + + if plugin.api is not None: + self.expose(plugin, plugin.api) + else: + loader.load(plugin_id, plugin) + + return plugin + + def load_plugins(self, *plugin_ids: PluginId, run_main: bool = True): + for plugin_id in plugin_ids: + self.load_plugin(plugin_id) + + if run_main: + self.run_plugins(*plugin_ids) + + def run_plugins(self, *plugin_ids: PluginId): + if not plugin_ids: + plugin_ids = self._plugins.keys() + for plugin_id in plugin_ids: + plugin = self.get_plugin(plugin_id) + loader = self.get_loader(plugin.loader.id) + loader.run_main(plugin) + + #endregion + + @classmethod + def get_plugin_full_name(cls, plugin: str | PluginInfo) -> str: + if isinstance(plugin, PluginInfo): + plugin = plugin.package_name + return f"{cls.EZ_PLUGIN_PREFIX}.{plugin}" diff --git a/modules/plugins/machinery/utils.py b/modules/plugins/machinery/utils.py new file mode 100644 index 0000000..58cf3fc --- /dev/null +++ b/modules/plugins/machinery/utils.py @@ -0,0 +1,36 @@ +from pathlib import Path + + +class InstallerHelper: + def __init__(self, root: str) -> None: + self._files: list[str] = [] + self.root = Path(root) + + def add_file(self, file: str): + self._files.append(file) + + def _move_file(self, src: str, dest: str, name: str = None, exclude: bool = False): + dest = Path(dest) + dest.mkdir(parents=True, exist_ok=True) + src = Path(src) + src.rename(dest / (name or src.name)) + if not exclude: + self.add_file(str(dest / (name or src.name))) + + + def install_file(self, src: str, dest: str, name: str = None, exclude: bool = False): + self._move_file(src, dest, name, exclude=exclude) + + def install_dir(self, src: str, dest: str, exclude: bool = False): + src = Path(src) + dest = Path(dest) + for file in src.iterdir(): + if file.is_dir(): + self.install_dir(file, dest / file.name, exclude) + else: + self._move_file(file, dest, exclude=exclude) + + def finalize(self, uninstallation_filename: str): + with open(self.root / uninstallation_filename, "w") as file: + for f in self._files: + file.write(f"{f}\n") diff --git a/modules/plugins/manager.py b/modules/plugins/manager.py index 3c0496a..67ebc11 100644 --- a/modules/plugins/manager.py +++ b/modules/plugins/manager.py @@ -1,127 +1,8 @@ -import sys -from utilities.semver import SemanticVersion +from .machinery.manager import PluginManager +from .builtins.installer import EZPluginInstaller +from .builtins.loader import EZPluginLoader -from .plugin_info import PluginInfo -from .model import PluginWebModel -from .plugin import Plugin -from .errors import UnknownPluginError +from .config import PLUGINS_DIRECTORY -from types import ModuleType -from pathlib import Path -from importlib.util import spec_from_file_location, module_from_spec -from importlib import reload - - -class PluginManager: - PLUGIN_PREFIX = "ez.current-site.plugins" - - def __init__(self, plugin_dir: Path) -> None: - self.plugin_dir = plugin_dir - self._plugins: dict[str, Plugin] = {} - - sys.modules[self.PLUGIN_PREFIX] = type("", (ModuleType,), { - "__path__": [str(plugin_dir)] - })(self.PLUGIN_PREFIX) - - def get_plugins(self): - return [ - PluginWebModel.model_validate({ - "name": plugin.name, - "id": plugin.info.dir_name, - "enabled": plugin.enabled, - "version": str(plugin.version), - "description": plugin.info.description - }) - for plugin in self._plugins.values() - ] - - def enable_plugin(self, plugin_name: str): - try: - plugin = self._plugins[plugin_name] - except KeyError: - raise UnknownPluginError(plugin_name) - else: - if plugin.enabled: - return - plugin.enable() - if plugin.is_loaded: - plugin.module.__spec__.loader.exec_module(plugin.module) - else: - self.load_plugin(plugin) - - def disable_plugin(self, plugin_name: str): - try: - plugin = self._plugins[plugin_name] - except KeyError: - raise UnknownPluginError(plugin_name) - else: - if not plugin.enabled: - return - plugin.disable() - - def get_plugin_info(self, plugin_name: str) -> PluginInfo: - return self.get_plugin(plugin_name).info - - def get_plugin(self, plugin_name): - try: - return self._plugins[plugin_name] - except KeyError: - raise UnknownPluginError(plugin_name) - - def add_plugin(self, plugin: PluginInfo) -> None: - self._plugins[plugin.dir_name] = Plugin(plugin, None) - - def load_plugin(self, plugin: str | PluginInfo, _plugin: Plugin | None = None) -> bool: - if isinstance(plugin, str): - try: - plugin = self.get_plugin_info(plugin) - except UnknownPluginError: - dir_name = plugin - else: - dir_name = plugin.dir_name - if dir_name in self._plugins and self._plugins[dir_name].is_loaded: - self.enable_plugin(dir_name) - return True - - spec = spec_from_file_location(self.get_plugin_full_name(dir_name), str(self.plugin_dir / dir_name / "plugin.py")) - module = module_from_spec(spec) - - if not hasattr(module, "__path__"): - module.__path__ = [] - module.__package__ = spec.name - module.__path__.append(str(self.plugin_dir / dir_name)) - sys.modules[module.__name__] = module - - if isinstance(plugin, str): - plugin = PluginInfo(plugin, SemanticVersion(0, 0, 0), "", dir_name) - _plugin = self._plugins[dir_name] = Plugin(plugin, None) - - try: - spec.loader.exec_module(module) - except Exception: - del sys.modules[module.__name__] - del self._plugins[dir_name] - raise - - if isinstance(plugin, str): - if not hasattr(module, "__version__"): - return False - plugin.version = SemanticVersion.parse(module.__version__) - plugin.description = getattr(module, "__description__", getattr(module, "__doc__", "")) - if _plugin is None: - _plugin = Plugin(plugin, None) - _plugin.module = module - _plugin.enable() - self._plugins[dir_name] = _plugin - - return True - - def load_plugins(self, dir_names: list[str]): - for dir_name in dir_names: - self.load_plugin(dir_name) - - @classmethod - def get_plugin_full_name(cls, plugin: str | PluginInfo) -> str: - if isinstance(plugin, PluginInfo): - plugin = plugin.dir_name - return f"{cls.PLUGIN_PREFIX}.{plugin}" +PLUGIN_MANAGER = PluginManager(PLUGINS_DIRECTORY, EZPluginInstaller, EZPluginLoader) +PLUGIN_MANAGER.enable_public_api() diff --git a/modules/plugins/model.py b/modules/plugins/model.py deleted file mode 100644 index 5c77c49..0000000 --- a/modules/plugins/model.py +++ /dev/null @@ -1,9 +0,0 @@ -from pydantic import BaseModel - - -class PluginWebModel(BaseModel): - name: str - id: str - version: str - description: str - enabled: bool diff --git a/modules/plugins/plugin.py b/modules/plugins/plugin.py index 014dd11..b4f3ad3 100644 --- a/modules/plugins/plugin.py +++ b/modules/plugins/plugin.py @@ -1,42 +1,29 @@ -from types import ModuleType -from .plugin_info import PluginInfo +from dataclasses import dataclass +from typing import TypeAlias, TYPE_CHECKING + +from .plugin_info import PluginInfo, PluginId + + +if TYPE_CHECKING: + from .machinery.loader import PluginLoaderInfo API_ATTRIBUTE_NAME = "__api__" +PluginAPI: TypeAlias = object + + +@dataclass class Plugin: info: PluginInfo - module: ModuleType | None + loader: "PluginLoaderInfo" enabled: bool + api: PluginAPI | None + - def __init__(self, info: PluginInfo, module: ModuleType | None): - self.info = info - self.module = module - self.enabled = False - - @property - def is_loaded(self) -> bool: - return self.module is not None - - @property - def name(self) -> str: - return self.info.name - - @property - def version(self) -> str: - return str(self.info.version) - - @property - def has_api(self) -> bool: - return hasattr(self.module, API_ATTRIBUTE_NAME) - - @property - def api(self): - return getattr(self.module, API_ATTRIBUTE_NAME, None) - - def enable(self): - self.enabled = True - - def disable(self): - self.enabled = False +__all__ = [ + "Plugin", + "PluginId", + "PluginInfo", +] diff --git a/modules/plugins/plugin_info.py b/modules/plugins/plugin_info.py index db540eb..3922d47 100644 --- a/modules/plugins/plugin_info.py +++ b/modules/plugins/plugin_info.py @@ -1,10 +1,29 @@ -from dataclasses import dataclass -from utilities.semver import SemanticVersion +from pydantic import BaseModel, Field +from pydantic.dataclasses import dataclass +from typing import TypeAlias, TYPE_CHECKING +from utilities.version import Version + + +from .machinery.installer import PluginInstallerId + + +PluginId: TypeAlias = str +PackageName: TypeAlias = str @dataclass -class PluginInfo: +class PluginInfo(BaseModel): name: str - version: SemanticVersion - description: str - dir_name: str + version: Version + description: str | None + installer_id: PluginInstallerId = Field(exclude=True) + package_name: str + author: str + + @property + def id(self) -> PluginId: + return self.package_name + + @property + def dir_name(self) -> PackageName: + return self.package_name diff --git a/modules/plugins/router.py b/modules/plugins/router.py new file mode 100644 index 0000000..b09f9ff --- /dev/null +++ b/modules/plugins/router.py @@ -0,0 +1,21 @@ +import ez +import ez.plugins + + +@ez.get("/api/plugins") +def get_plugins(): + return [plugin.info.model_dump() for plugin in ez.plugins.get_plugins()] + + +@ez.post("/api/plugins/{plugin_id}/enable") +def enable_plugin(plugin_id: str): + print(f"Enabling plugin {plugin_id}...") + ez.plugins.enable_plugin(plugin_id) + return "Plugin enabled!" + + +@ez.post("/api/plugins/{plugin_id}/disable") +def disable_plugin(plugin_id: str): + print(f"Disabling plugin {plugin_id}...") + ez.plugins.disable_plugin(plugin_id) + return "Plugin disabled!" diff --git a/modules/resources/__init__.py b/modules/resources/__init__.py new file mode 100644 index 0000000..4cf0262 --- /dev/null +++ b/modules/resources/__init__.py @@ -0,0 +1,5 @@ +from .resource import Resource, URI + + +def load_resource(uri: URI) -> Resource: + ... diff --git a/modules/resources/machinery/loader.py b/modules/resources/machinery/loader.py new file mode 100644 index 0000000..cdc0ff9 --- /dev/null +++ b/modules/resources/machinery/loader.py @@ -0,0 +1,17 @@ +from typing import TypeAlias + +from ..resource import Resource, ResourceType, URI + + +ResourceLoaderId: TypeAlias = str + + +class ResourceLoaderInfo: + id: ResourceLoaderId + name: str + + + +class IResourceLoader: + def load_resource(self, uri: URI, resource_type: ResourceType | None = None) -> Resource: + raise NotImplementedError diff --git a/modules/resources/manager.py b/modules/resources/manager.py new file mode 100644 index 0000000..e9ef2b3 --- /dev/null +++ b/modules/resources/manager.py @@ -0,0 +1,33 @@ +from .machinery.loader import IResourceLoader, ResourceLoaderId +from .resource import Resource, ResourceType, URI + + +class ResourceManager: + _loaders: dict[ResourceLoaderId, IResourceLoader] + _resources: dict[URI, Resource] + + def __init__(self) -> None: + self._loaders = {} + self._resources = {} + + def load_resource( + self, + uri: URI, + resource_type: ResourceType | None = None, + *, + enable_caching: bool = True + ): + try: + return ( + self._resources[uri] + if enable_caching + else self._load_resource(uri, resource_type) + ) + except KeyError: + resource = self._load_resource(uri, resource_type) + if enable_caching: + self._resources[uri] = resource + return resource + + def _load_resource(self, uri: URI, resource_type: ResourceType | None = None): + raise NotImplementedError diff --git a/modules/resources/resource.py b/modules/resources/resource.py new file mode 100644 index 0000000..3c4a077 --- /dev/null +++ b/modules/resources/resource.py @@ -0,0 +1,50 @@ +from typing import TypeAlias, TypeVar, Type + +from utilities.uri import UnifiedResourceIdentifier + + +ResourceTypeId: TypeAlias = str +URI: TypeAlias = UnifiedResourceIdentifier + +ResourceT = TypeVar("ResourceT", bound="Resource") + + +class ResourceType: + _id: ResourceTypeId + _name: str + + def __init__(self, name: str, id: ResourceTypeId) -> None: + self._name = name + self._id = id + + @property + def name(self): + return self._name + + @property + def id(self): + return self._id + + +class Resource: + _type: ResourceType + _uri: URI + + def __init__(self, resource_type: ResourceType, uri: str) -> None: + self._type = resource_type + self._uri = uri + + @property + def type(self): + return self._type + + @property + def uri(self): + return self._uri + + def cast(self, cls: Type[ResourceT], *, strict: bool = True) -> ResourceT: + if not strict: + return self + if not isinstance(self, cls): + raise TypeError(f"Resource.cast(strict=True) could not convert resource of type {type(self).__name__} to {cls.__name__}") + return self diff --git a/modules/utilities/semver.py b/modules/utilities/semver.py index 15f538a..d264cd1 100644 --- a/modules/utilities/semver.py +++ b/modules/utilities/semver.py @@ -1,16 +1,12 @@ -class SemanticVersion: +from pydantic import BaseModel, model_validator, model_serializer + + +class SemanticVersion(BaseModel): major: int minor: int patch: int - pre_release: str - build: str - - def __init__(self, major: int, minor: int, patch: int, pre_release: str = "", build: str = ""): - self.major = major - self.minor = minor - self.patch = patch - self.pre_release = pre_release - self.build = build + pre_release: str = "" + build: str = "" @classmethod def parse(cls, version: str): @@ -24,7 +20,24 @@ def parse(cls, version: str): if "+" in pre_release: pre_release, build = pre_release.split("+") - return cls(int(major), int(minor), int(patch), pre_release, build) + return cls.model_construct( + major=int(major), + minor=int(minor), + patch=int(patch), + pre_release=pre_release, + build=build + ) + + @model_validator(mode="before") + @classmethod + def validate(cls, data, _): + if isinstance(data, str): + return cls.parse(data) + return data + + @model_serializer + def serialize(self): + return str(self) def __str__(self): return f"{self.major}.{self.minor}.{self.patch}{f'-{self.pre_release}' if self.pre_release else ''}{f'+{self.build}' if self.build else ''}" diff --git a/modules/utilities/uri.py b/modules/utilities/uri.py new file mode 100644 index 0000000..f6925a1 --- /dev/null +++ b/modules/utilities/uri.py @@ -0,0 +1,147 @@ +from io import StringIO, SEEK_CUR + + +class URIParser: + def __init__(self, source: str) -> None: + self.source = source + self.stream = StringIO(source) + + def parse(self) -> "UnifiedResourceIdentifier": + scheme = self._parse_scheme() + if self.stream.read(2) == "//": + authority = self._parse_authority() + else: + authority = None + self.stream.seek(-1, SEEK_CUR) + path = self._parse_path() + query = self._parse_query() + fragment = self._parse_fragment() + + return UnifiedResourceIdentifier(scheme, authority, path, query, fragment, self.source) + + def _parse_scheme(self) -> str: + scheme = "" + while (char := self.stream.read(1)) != ":": + scheme += char + return scheme + + def _parse_authority(self) -> "URIAuthority": + userinfo = self._parse_userinfo() + host = self._parse_host() + port = self._parse_port() + return URIAuthority(userinfo, host, port) + + def _parse_userinfo(self) -> "URIUserInfo": + username = "" + while (char := self.stream.read(1)) != "@": + username += char + password = "" + while (char := self.stream.read(1)) != ":": + password += char + return URIUserInfo(username, password) + + def _parse_host(self) -> str: + host = "" + while (char := self.stream.read(1)) != ":" and char != "/": + host += char + return host + + def _parse_port(self) -> int: + port = "" + while (char := self.stream.read(1)) != "/": + port += char + return int(port) + + def _parse_path(self) -> str: + path = "" + while (char := self.stream.read(1)) != "?": + path += char + return path + + def _parse_query(self) -> str: + query = "" + while (char := self.stream.read(1)) != "#": + query += char + return query + + def _parse_fragment(self) -> str: + return self.stream.read() + + +class URIUserInfo: + username: str + password: str + + def __init__(self, username: str, password: str) -> None: + self.username = username + self.password = password + + +class URIAuthority: + userinfo: URIUserInfo + host: str + port: int + + def __init__(self, userinfo: URIUserInfo, host: str, port: int) -> None: + self.userinfo = userinfo + self.host = host + self.port = port + + +class URIPath: + SEPARATOR: str = "/" + + raw: str + segments: list[str] + + def __init__(self, raw: str) -> None: + self.raw = raw + self.segments = raw.split(self.SEPARATOR) + + @property + def name(self) -> str: + return self.segments[-1] + + @property + def parent(self) -> "URIPath": + return URIPath(self.SEPARATOR.join(self.segments[:-1])) + + def __str__(self) -> str: + return self.raw + + def __truediv__(self, other: str) -> "URIPath": + return URIPath(self.raw + self.SEPARATOR + other) + + def __rtruediv__(self, other: str) -> "URIPath": + return URIPath(other + self.SEPARATOR + self.raw) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, URIPath): + return NotImplemented + return self.raw == other.raw + + def __hash__(self) -> int: + return hash(self.raw) + + +class UnifiedResourceIdentifier: + scheme: str + authority: URIAuthority | None + path: str + query: str + fragment: str + + raw: str + + def __init__(self, scheme: str, authority: URIAuthority, path: str, query: str, fragment: str, raw: str) -> None: + self.scheme = scheme + self.authority = authority + self.path = path + self.query = query + self.fragment = fragment + self.raw = raw + + @classmethod + def parse(cls, uri: str) -> "UnifiedResourceIdentifier": + parser = URIParser(uri) + return parser.parse() diff --git a/modules/utilities/version.py b/modules/utilities/version.py new file mode 100644 index 0000000..280ed8f --- /dev/null +++ b/modules/utilities/version.py @@ -0,0 +1 @@ +from .semver import SemanticVersion as Version diff --git a/modules/web/app/app.py b/modules/web/app/app.py index b7b1f7c..7c07731 100644 --- a/modules/web/app/app.py +++ b/modules/web/app/app.py @@ -3,7 +3,8 @@ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint from ..response import _EzResponse -from ez.events import HTTP, App +from .events import App +from ..events import HTTP docs_urls = [ @@ -20,26 +21,17 @@ async def dispatch( ): import ez - if request.url.path in docs_urls: - return await call_next(request) - - if request.url.path.startswith("/socket.io"): - return await call_next(request) - - if request.url.path == STATIC_PATH or request.url.path.startswith( - f"{STATIC_PATH}/" - ): - result = await call_next(request) - if result.status_code < 400: - return result - ez.request = request ez.response = _EzResponse() + ez.emit(HTTP.In, request) result = await call_next(request) - if 300 <= result.status_code < 400: + + route = request.scope.get("endpoint") + if not route or not getattr(route, ez.EZ_ROUTE_ATTRIBUTE, False): return result + ez.emit(HTTP.Out, ez.response) @@ -66,4 +58,4 @@ def _exception_handler(self, request: Request, exc: Exception): """ import ez - return ez.response.text(str(exc)) + return str(exc) diff --git a/core/ez/events/http.py b/modules/web/events.py similarity index 100% rename from core/ez/events/http.py rename to modules/web/events.py diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 index f8d2e16..bf351d2 100644 --- a/scripts/setup.ps1 +++ b/scripts/setup.ps1 @@ -76,8 +76,8 @@ function Load { Write-Host `b"OK" -ForegroundColor $GREEN } else { Write-Host -NoNewLine `b"Failed" -ForegroundColor $RED - $error = Receive-Job $job - Write-Host $error + $jobError = Receive-Job $job + Write-Host $jobError return 1 } }