From 2ba3199bd96a852b06a396840acc62cf04a85cea Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Wed, 22 May 2024 19:10:58 +0300 Subject: [PATCH 01/26] Add user class --- modules/admin/user.py | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 modules/admin/user.py diff --git a/modules/admin/user.py b/modules/admin/user.py new file mode 100644 index 0000000..8e981d1 --- /dev/null +++ b/modules/admin/user.py @@ -0,0 +1,2 @@ +class User: + ... From 74f9d1ea1a521eec5fb43dc65451fc6928492183 Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Wed, 22 May 2024 19:11:14 +0300 Subject: [PATCH 02/26] Add dashboard context types --- modules/admin/context.py | 244 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 modules/admin/context.py diff --git a/modules/admin/context.py b/modules/admin/context.py new file mode 100644 index 0000000..c87a2b5 --- /dev/null +++ b/modules/admin/context.py @@ -0,0 +1,244 @@ +""" +The admin dashboard context module. + +The context for the dashboard is composed of multiple layers. + +First, the dashboard context is implemented by the ContextRoot class. + +The context root then contains a: +- shared context (SharedContext) +- user contexts (UserContext) +- sessions (Session) + +The shared context is data that is shared between all logged in users. +The user context is data for a specific user, but shared with all its sessions (open tabs, etc..) +The session context is data for a specific session (connection, tab in browser, etc..) +""" + +from typing import TypeAlias, overload +from utilities.utils import bind + +from .user import User + + +SessionID: TypeAlias = str + + +class SharedContext: + ... + + +class SessionContext: + ... + + +class UserContext: + def __init__(self, user: User) -> None: + self._user = user + self._sessions = [] + + @property + def user(self) -> User: + return self._user + + @property + def sessions(self) -> list["Session"]: + return self._sessions.copy() + + def create_session(self, session_id: str) -> "Session": + session = Session(self._user, session_id) + self._sessions.append(session) + return session + + def connect_session(self, session: "Session"): + if session.user != self._user: + raise ValueError("Session user does not match user context.") + self._sessions.append(session) + + def disconnect_session(self, session: "Session"): + try: + self._sessions.remove(session) + except ValueError: + return False + return True + + +class Session: + def __init__(self, user: User, session_id: SessionID) -> None: + self._id = session_id + self._user = user + self._context = SessionContext() + + @property + def id(self) -> SessionID: + return self._id + + @property + def user(self) -> User: + return self._user + + @property + def context(self) -> SessionContext: + return self._context + + +class ContextRoot: + _user_contexts: dict[User, UserContext] + _sessions: dict[SessionID, Session] + + def __init__(self) -> None: + self._global_context = SharedContext() + self._user_contexts = {} + self._sessions = {} + + @property + def global_context(self) -> SharedContext: + return self._global_context + + def user_context(self, user: User) -> UserContext: + if user not in self._user_contexts: + self._user_contexts[user] = UserContext(user) + return self._user_contexts[user] + + def get_user_context(self, user: User) -> UserContext | None: + try: + return self._user_contexts[user] + except KeyError: + return None + + def _clear_user_context(self, user: User) -> None: + try: + del self._user_contexts[user] + except KeyError: + return + + def session(self, session_id: SessionID) -> Session: + if session_id not in self._sessions: + raise KeyError(f"Session with ID '{session_id}' not found.") + return self._sessions[session_id] + + def create_session(self, user: User, session_id: str) -> Session: + session = self.user_context(user).create_session(session_id) + self._sessions[session_id] = session + return session + + def delete_session(self, session_id: SessionID) -> bool: + try: + session = self._sessions.pop(session_id) + except KeyError: + return False + context = self.get_user_context(session.user) + + if context is None: + return False + + result = context.disconnect_session(session) + + if not context.sessions: + self._clear_user_context(session.user) + + return result + + def disconnect_user(self, user: User): + context = self.get_user_context(user) + + if context is None: + return + + for session in context.sessions: + self.delete_session(session.id) + + +_SITE_CONTEXT = ContextRoot() + + +@bind(_SITE_CONTEXT) +def get_shared_context(ctx: ContextRoot): + def _get_global_context() -> SharedContext: + return ctx.global_context + + return _get_global_context + + +@bind(_SITE_CONTEXT) +def get_context_root(ctx: ContextRoot): + def _get_site_context() -> ContextRoot: + return ctx + + return _get_site_context + + +@bind(_SITE_CONTEXT) +def get_user_context(ctx: ContextRoot): + @overload + def _get_user_context(user: User, /) -> UserContext: ... + @overload + def _get_user_context(session: Session, /) -> UserContext: ... + @overload + def _get_user_context(session_id: SessionID, /) -> UserContext: ... + + def _get_user_context(value: User | Session | SessionID, /) -> UserContext: + match value: + case User() as user: + result = ctx.get_user_context(user) + if result is None: + raise ValueError("User context not found.") + return result + case Session() as session: + return _get_user_context(session.user) + case SessionID() as session_id: + return _get_user_context(ctx.session(session_id)) + case _: + raise TypeError("Invalid argument type.") + + return _get_user_context + + +@bind(_SITE_CONTEXT) +def create_session(ctx: ContextRoot): + def _create_session(user: User, session_id: SessionID) -> Session: + return ctx.create_session(user, session_id) + + return _create_session + + +@bind(_SITE_CONTEXT) +def get_session(ctx: ContextRoot): + def _get_session(session_id: SessionID) -> Session: + return ctx.session(session_id) + + return _get_session + + +@bind(_SITE_CONTEXT) +def close_session(ctx: ContextRoot): + @overload + def _close_session(session: Session, /) -> bool: ... + @overload + def _close_session(session_id: SessionID, /) -> bool: ... + + def _close_session(session_id: Session | SessionID, /) -> bool: + if isinstance(session_id, Session): + session_id = session_id.id + return ctx.delete_session(session_id) + + return _close_session + + +@bind(_SITE_CONTEXT) +def disconnect_user(ctx: ContextRoot): + def _disconnect_user(user: User): + ctx.disconnect_user(user) + + return _disconnect_user + + +@bind(_SITE_CONTEXT) +def get_connected_users(ctx: ContextRoot): + def _get_users() -> list[User]: + return list(ctx._user_contexts.keys()) + + return _get_users + + +del _SITE_CONTEXT From 88fa9e4bbdfbb65ad8ad93db3823f73a6e507892 Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Wed, 22 May 2024 19:11:50 +0300 Subject: [PATCH 03/26] Add admin module dependency --- modules/admin/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/admin/__init__.py b/modules/admin/__init__.py index e69de29..50018ff 100644 --- a/modules/admin/__init__.py +++ b/modules/admin/__init__.py @@ -0,0 +1,3 @@ +__deps__ = [ + "web", +] From b1e58148c9258d97b8fd34aa59900db27fc76720 Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Wed, 22 May 2024 19:12:38 +0300 Subject: [PATCH 04/26] Add some notifications API signatures --- modules/admin/__api__/notifications.py | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 modules/admin/__api__/notifications.py diff --git a/modules/admin/__api__/notifications.py b/modules/admin/__api__/notifications.py new file mode 100644 index 0000000..2415add --- /dev/null +++ b/modules/admin/__api__/notifications.py @@ -0,0 +1,33 @@ +from typing import Any, Iterable, overload + +from seamless.types import Renderable + +from ..user import User + + +_EMPTY: Any = object() + + +@overload +def push(users: Iterable[User], message: str, /) -> None: ... +@overload +def push(users: Iterable[User], message: str, /, *, title: str) -> None: ... +@overload +def push(users: Iterable[User], custom: Renderable, /) -> None: ... + +def push(users: Iterable[User], message: str | Renderable, /, *, title: str = _EMPTY): + raise NotImplementedError + + +@overload +def push_all(message: str, /, *, exclude: Iterable[User] | None = None) -> None: ... +@overload +def push_all(message: str, /, *, title: str, exclude: Iterable[User] | None = None) -> None: ... +@overload +def push_all(custom: Renderable, /, *, exclude: Iterable[User] | None = None) -> None: ... + +def push_all(message: str | Renderable, /, *, title: str = _EMPTY, exclude: Iterable[User] | None = None): + if exclude is None: + exclude = [] + + raise NotImplementedError From 4ae4f4a98f8c88e4dbd334f98c9078f4e32e4dc7 Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Wed, 22 May 2024 23:58:31 +0300 Subject: [PATCH 05/26] Fix routing API --- modules/web/__api__/http.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/web/__api__/http.py b/modules/web/__api__/http.py index 97e38c4..cfaaa7f 100644 --- a/modules/web/__api__/http.py +++ b/modules/web/__api__/http.py @@ -23,7 +23,7 @@ def _current_router(): @bind(API_ROUTER) def router(api_router: "PluginRouter"): - def _router(route: str = _EMPTY): + def _router(route: str = _EMPTY, **kwargs): plugin = current_plugin() if not plugin.is_root: @@ -38,7 +38,7 @@ def _router(route: str = _EMPTY): if route is _EMPTY: route = '/' + plugin.oid - return api_router.add_router(plugin, route) + return api_router.add_router(plugin, route, **kwargs) return _router From b2f45d634edcb6604412abc54e63cdf5aa030d8a Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Wed, 22 May 2024 23:58:47 +0300 Subject: [PATCH 06/26] Add request wrapper --- modules/web/request.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 modules/web/request.py diff --git a/modules/web/request.py b/modules/web/request.py new file mode 100644 index 0000000..d49d9c1 --- /dev/null +++ b/modules/web/request.py @@ -0,0 +1,18 @@ +""" +Defines the Request class, which represents an incoming HTTP request. + +As of v1, this module is a thin wrapper around Starlette's Request class. + +When we move to use our own web app framework, or to a different ASGI framework, +we will replace this module with the appropriate request class. + +However, the basic API should remain the same, so the rest of the application +should not have to change. +""" + +from starlette.requests import Request + + +__all__ = [ + "Request" +] From ac96638028b1bd554c44549683e4d21f2080d3cc Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Wed, 22 May 2024 23:59:01 +0300 Subject: [PATCH 07/26] Fix routing API --- modules/web/routing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/web/routing.py b/modules/web/routing.py index f8b4a90..e2e338a 100644 --- a/modules/web/routing.py +++ b/modules/web/routing.py @@ -21,11 +21,11 @@ def __init__(self) -> None: def router(self): return self._router - def add_router(self, app: Application, route: str, *, cls: type[Router] = EZRouter) -> Router: + def add_router(self, app: Application, route: str, *args, cls: type[Router] = EZRouter, **kwargs) -> Router: if app in self._mounts: return cast(Router, self._mounts[app].app) - router = cls() + router = cls(*args, **kwargs) mount = Mount(route, app=router) if not app.is_root: From 0927d1fc98c2d191383cabfbbe1fed39f4d4bd25 Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Wed, 22 May 2024 23:59:23 +0300 Subject: [PATCH 08/26] Add http request context --- modules/web/__api__/http.py | 16 ++++++--- modules/web/__main__.py | 2 +- modules/web/context.py | 70 +++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 modules/web/context.py diff --git a/modules/web/__api__/http.py b/modules/web/__api__/http.py index cfaaa7f..d25968b 100644 --- a/modules/web/__api__/http.py +++ b/modules/web/__api__/http.py @@ -3,11 +3,15 @@ from sandbox import current_plugin from utilities.utils import bind -from ..http import HTTPException, HTTPMethod, HTTPStatus -from ..routing import API_ROUTER - if TYPE_CHECKING: - from ..routing import PluginRouter + from modules.web.routing import PluginRouter + from modules.web.context import get_request, set_request, request + from modules.web.http import HTTPException, HTTPMethod, HTTPStatus + from modules.web.routing import API_ROUTER +else: + from ..context import get_request, set_request, request + from ..http import HTTPException, HTTPMethod, HTTPStatus + from ..routing import API_ROUTER _EMPTY: Any = object() @@ -107,4 +111,8 @@ def head(_route: str, **kwargs): "HTTPException", "HTTPMethod", "HTTPStatus", + + "get_request", + "set_request", + "request", ] diff --git a/modules/web/__main__.py b/modules/web/__main__.py index b9512d8..27f6850 100644 --- a/modules/web/__main__.py +++ b/modules/web/__main__.py @@ -1,4 +1,4 @@ -from . import routing +from . import routing, context routing.add_route(routing.API_MOUNT) diff --git a/modules/web/context.py b/modules/web/context.py new file mode 100644 index 0000000..dbc3a75 --- /dev/null +++ b/modules/web/context.py @@ -0,0 +1,70 @@ +from contextvars import ContextVar +from typing import Any, TypeAlias, overload + +from utilities.utils import bind + +from .request import Request + +from starlette.middleware.base import BaseHTTPMiddleware + +import ez + + +RequestVar: TypeAlias = ContextVar[Request | None] +_REQUEST: RequestVar = ContextVar("request", default=None) + + +@bind(_REQUEST) +def get_request(var: RequestVar): + def _get_request() -> Request: + return var.get() + + return _get_request + + +def has_request(): + return get_request() is not None + + +@bind(_REQUEST) +def set_request(var: RequestVar): + def _set_request(request: Request | None): + var.set(request) + + return _set_request + + +@bind() +def request(): + _EMPTY: Any = object() + + @overload + def _request() -> Request: ... + @overload + def _request(request: Request) -> None: ... + + def _request(request: Request = _EMPTY): + if request is _EMPTY: + return get_request() + set_request(request) + + return _request + + +async def _on_request(request, call_next): + set_request(Request(request)) + response = await call_next(request) + set_request(None) + return response + + +ez.lowlevel.WEB_APP.add_middleware(BaseHTTPMiddleware, dispatch=_on_request) + + +del _REQUEST + +__all__ = [ + "get_request", + "set_request", + "request", +] From aeec7b3519ed5b8f527fee6d15e1d3de78aee331 Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Wed, 22 May 2024 23:59:48 +0300 Subject: [PATCH 09/26] Add plugin logging on finished load --- modules/plugins/__main__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/modules/plugins/__main__.py b/modules/plugins/__main__.py index 2c17ba4..848b410 100644 --- a/modules/plugins/__main__.py +++ b/modules/plugins/__main__.py @@ -79,6 +79,11 @@ def load_plugins(): ) plugin_manager.run_plugins(*plugin_ids) + plugins = plugin_manager.get_plugins() + ez.log.info(f"Loaded {len(plugins)} plugins:") + for plugin in plugins: + ez.log.info(f"\tLoaded plugin: {plugin.info.package_name}") + ez.events.emit(Plugins.DidLoad, plugins) From 92081f97098e9fa6f3ce6ce1831eed6c0bdaa5df Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Thu, 23 May 2024 00:00:03 +0300 Subject: [PATCH 10/26] Fix typing --- modules/plugins/__api__/events.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/modules/plugins/__api__/events.py b/modules/plugins/__api__/events.py index 804fea8..1bbfa38 100644 --- a/modules/plugins/__api__/events.py +++ b/modules/plugins/__api__/events.py @@ -1 +1,7 @@ -from ..events import Plugins +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from modules.plugins.events import Plugins +else: + from ..events import Plugins From 23b30c133c8960ce62e3fadb5d821ea83afcd685 Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Thu, 23 May 2024 00:00:22 +0300 Subject: [PATCH 11/26] Fix typing --- modules/plugins/__api__/__init__.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/modules/plugins/__api__/__init__.py b/modules/plugins/__api__/__init__.py index e8876af..777e222 100644 --- a/modules/plugins/__api__/__init__.py +++ b/modules/plugins/__api__/__init__.py @@ -1,14 +1,27 @@ -from typing import Callable +from typing import Callable, TYPE_CHECKING -from ..plugin import Plugin, PluginInfo, PluginId, PluginAPI -from ..machinery.installer import IPluginInstaller, PluginInstallerInfo, PluginInstallerId -from ..machinery.loader import IPluginLoader, PluginLoaderInfo +if TYPE_CHECKING: + from modules.plugins.plugin import Plugin, PluginInfo, PluginId, PluginAPI + from modules.plugins.machinery.installer import IPluginInstaller, PluginInstallerInfo, PluginInstallerId + from modules.plugins.machinery.loader import IPluginLoader, PluginLoaderInfo -from .errors import EZPluginError, UnknownPluginError, DuplicateIDError -from .events import Plugins + from modules.plugins.errors import EZPluginError, UnknownPluginError, DuplicateIDError + from .events import Plugins -from ..manager import PLUGIN_MANAGER as __pm -from ..config import PLUGINS_PUBLIC_API_MODULE_NAME + from modules.plugins.manager import PLUGIN_MANAGER as __pm + from modules.plugins.config import PLUGINS_PUBLIC_API_MODULE_NAME + + from modules.plugins.framework.settings import Settings +else: + 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 + + from ..manager import PLUGIN_MANAGER as __pm + from ..config import PLUGINS_PUBLIC_API_MODULE_NAME def get_plugins() -> list[Plugin]: From 94ec1fbc75287d7c3a9cc97072fe1f46b141586f Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Thu, 23 May 2024 00:00:38 +0300 Subject: [PATCH 12/26] Add plugin settings base class --- modules/plugins/__api__/__init__.py | 3 +++ modules/plugins/framework/settings.py | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 modules/plugins/framework/settings.py diff --git a/modules/plugins/__api__/__init__.py b/modules/plugins/__api__/__init__.py index 777e222..3622beb 100644 --- a/modules/plugins/__api__/__init__.py +++ b/modules/plugins/__api__/__init__.py @@ -23,6 +23,8 @@ from ..manager import PLUGIN_MANAGER as __pm from ..config import PLUGINS_PUBLIC_API_MODULE_NAME + from ..framework.settings import Settings + def get_plugins() -> list[Plugin]: return __pm.get_plugins() @@ -121,6 +123,7 @@ def get_loaders() -> list[PluginLoaderInfo]: "PluginInfo", "EZPluginError", "Plugins", + "Settings", "get_plugins", "get_plugin", "install", diff --git a/modules/plugins/framework/settings.py b/modules/plugins/framework/settings.py new file mode 100644 index 0000000..39aee85 --- /dev/null +++ b/modules/plugins/framework/settings.py @@ -0,0 +1,27 @@ +from typing import Any, ClassVar, Unpack + +from pydantic import BaseModel, ConfigDict + +from utilities.utils import spacify + + +_EMPTY: Any = object() + + +class Settings(BaseModel): + __ez_section_title__: ClassVar[str | None] + __ez_section_id__: ClassVar[str | None] + + def __init_subclass__(cls, *, section: str = _EMPTY, section_id: str = _EMPTY, **kwargs: Unpack[ConfigDict]): + super().__init_subclass__(**kwargs) + + if section is _EMPTY and section_id is _EMPTY: + section = spacify(cls.__name__) + + if section_id is _EMPTY: + section_id = section.lower().replace(' ', '-') + elif section is _EMPTY: + section = section_id.replace('-', ' ').title() + + cls.__ez_section_title__ = section + cls.__ez_section_id__ = section_id From 89b56be436bd5696b224d431170206e38ae76516 Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Thu, 23 May 2024 00:01:02 +0300 Subject: [PATCH 13/26] Add spacify tool --- include/utilities/utils.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/include/utilities/utils.py b/include/utilities/utils.py index 9e62e94..51f01b7 100644 --- a/include/utilities/utils.py +++ b/include/utilities/utils.py @@ -16,3 +16,14 @@ def wrapper(fn: Callable[B, Callable[P, T]]) -> Callable[P, T]: return fn(*args, **kwargs) return wrapper + + +def spacify(text: str, sep: str = ' ') -> str: + """ + Takes in a string of either PascalCase or camelCase and return it split to words + with the given separator. + + Default separator is a single space. + """ + return ''.join(sep + char if char.isupper() else char.strip() for char in text).strip() + From 96cd155b81ae81f26b8acfce85f6a8fbfca4fae2 Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Thu, 23 May 2024 00:01:27 +0300 Subject: [PATCH 14/26] Add admin to core API --- include/ez/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/include/ez/__init__.py b/include/ez/__init__.py index edd51f9..8294ff7 100644 --- a/include/ez/__init__.py +++ b/include/ez/__init__.py @@ -7,6 +7,7 @@ from pathlib import Path from . import ( + admin, data, database, events, @@ -37,6 +38,7 @@ "EZ_PYTHON_EXECUTABLE", "SITE_DIR", + "admin", "data", "database", "events", From a7475e0df15cd3b929d7f654884a3f7633b9e17c Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Thu, 23 May 2024 00:02:21 +0300 Subject: [PATCH 15/26] Add settings viewer renderer --- modules/admin/__api__/__init__.py | 2 ++ modules/admin/__api__/view.py | 1 + modules/admin/views/settings_viewer.py | 40 ++++++++++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 modules/admin/__api__/view.py create mode 100644 modules/admin/views/settings_viewer.py diff --git a/modules/admin/__api__/__init__.py b/modules/admin/__api__/__init__.py index fd13de2..d739524 100644 --- a/modules/admin/__api__/__init__.py +++ b/modules/admin/__api__/__init__.py @@ -1,4 +1,6 @@ from ..menu import MENU +from . import view + def add_item(name: str): MENU.add_item(name) diff --git a/modules/admin/__api__/view.py b/modules/admin/__api__/view.py new file mode 100644 index 0000000..37821bf --- /dev/null +++ b/modules/admin/__api__/view.py @@ -0,0 +1 @@ +from ..views.settings_viewer import render_settings_viewer diff --git a/modules/admin/views/settings_viewer.py b/modules/admin/views/settings_viewer.py new file mode 100644 index 0000000..6af4c6c --- /dev/null +++ b/modules/admin/views/settings_viewer.py @@ -0,0 +1,40 @@ +import ez + +from pydantic.fields import FieldInfo + +from ez.plugins import Settings + +from seamless.html import Div +from seamless.styling import Style + + +def render_settings_viewer(settings: Settings): + def render_section(section: type[Settings], value: Settings): + return Div()( + Div()(section.__ez_section_title__), + render_settings_viewer(value) + ) + + def render_field(name: str, field: FieldInfo, value): + annotation = field.annotation + if not isinstance(annotation, type): + raise TypeError + provider_type = ez.data.providers.get_provider_type(annotation) + provider = provider_type.load(value) + return Div(style=Style( + display="flex", + flexDirection="column" + + ))( + (field.title or name) + ": ", + provider.render_input() + ) + + return Div()( + *[ + render_section(field.annotation, getattr(settings, name)) + if isinstance(field.annotation, type) and issubclass(field.annotation, Settings) + else render_field(name, field, getattr(settings, name)) + for name, field in settings.model_fields.items() + ] + ) From bb84a6a8a19ed76c6fb9a6cdaafd06bfb8409ef0 Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Thu, 23 May 2024 00:03:42 +0300 Subject: [PATCH 16/26] Add int provider --- modules/data/providers/primitives.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/modules/data/providers/primitives.py b/modules/data/providers/primitives.py index 0b725ac..9d0cdd0 100644 --- a/modules/data/providers/primitives.py +++ b/modules/data/providers/primitives.py @@ -41,6 +41,13 @@ class StringProvider(_ValueProvider[str], data_type=str): def render_input(self): return Input(value=self.value) + + +class IntegerProvider(_ValueProvider[int], data_type=int): + DEFAULT = 0 + + def render_input(self): + return Input(type="number", value=self.value) class BooleanProvider(_ValueProvider[bool], data_type=bool): From 411f83ae9f50e7d7c0cad6691bef99d60e3f6def Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Thu, 23 May 2024 00:04:49 +0300 Subject: [PATCH 17/26] Add admin module dependency --- modules/admin/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/admin/__init__.py b/modules/admin/__init__.py index 50018ff..d5eccf2 100644 --- a/modules/admin/__init__.py +++ b/modules/admin/__init__.py @@ -1,3 +1,4 @@ __deps__ = [ + "plugins", "web", ] From c99b665e57542b6ba07d381ecbfee498792032d7 Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Thu, 23 May 2024 00:05:13 +0300 Subject: [PATCH 18/26] Add admin context --- modules/admin/context.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/modules/admin/context.py b/modules/admin/context.py index c87a2b5..e77537a 100644 --- a/modules/admin/context.py +++ b/modules/admin/context.py @@ -15,6 +15,8 @@ The session context is data for a specific session (connection, tab in browser, etc..) """ +import ez + from typing import TypeAlias, overload from utilities.utils import bind @@ -241,4 +243,19 @@ def _get_users() -> list[User]: return _get_users +def get_current_session() -> Session: + request = ez.web.http.request() + if request is None: + raise ValueError("No request found") + try: + sid = request.session["ez-admin:sid"] + except KeyError: + raise ValueError("Missing session id") + return get_session(sid) + + +def get_current_user(): + return get_current_session().user + + del _SITE_CONTEXT From 3d97f61beb7710023cdda6ca6ec1bfd6d38a8e57 Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Thu, 23 May 2024 00:05:38 +0300 Subject: [PATCH 19/26] Add super simple authentication mechanism --- modules/admin/authentication.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 modules/admin/authentication.py diff --git a/modules/admin/authentication.py b/modules/admin/authentication.py new file mode 100644 index 0000000..9b8e735 --- /dev/null +++ b/modules/admin/authentication.py @@ -0,0 +1,21 @@ +from uuid import uuid4 +from .user import User + +from . import context + +USERNAME = "admin" +PASSWORD = "pwd" + + +ADMIN_USER = User() + + +def authenticate(username: str, password: str) -> context.Session | None: + if username != USERNAME or password != PASSWORD: + return None + + user = ADMIN_USER + + session = context.create_session(user, uuid4().hex) + + return session From 36ad1dcbdae6b2766bf09073d191ef8327bdd864 Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Thu, 23 May 2024 00:06:31 +0300 Subject: [PATCH 20/26] Add login request form --- modules/admin/requests.py | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 modules/admin/requests.py diff --git a/modules/admin/requests.py b/modules/admin/requests.py new file mode 100644 index 0000000..188a8bd --- /dev/null +++ b/modules/admin/requests.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class LoginRequest(BaseModel): + username: str + password: str From 95821cf79b295a62aa68c256fc503266d931c3f6 Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Thu, 23 May 2024 00:06:50 +0300 Subject: [PATCH 21/26] Add admin API router --- modules/admin/__main__.py | 1 + modules/admin/router.py | 45 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 modules/admin/router.py diff --git a/modules/admin/__main__.py b/modules/admin/__main__.py index e69de29..4ef953c 100644 --- a/modules/admin/__main__.py +++ b/modules/admin/__main__.py @@ -0,0 +1 @@ +from . import router diff --git a/modules/admin/router.py b/modules/admin/router.py new file mode 100644 index 0000000..bcd7e9b --- /dev/null +++ b/modules/admin/router.py @@ -0,0 +1,45 @@ +from starlette.requests import Request +from starlette.responses import JSONResponse, PlainTextResponse + +import ez + +from starlette.middleware.sessions import SessionMiddleware +from starlette.middleware import Middleware + +from .context import get_current_session, close_session +from .requests import LoginRequest + +from .authentication import authenticate + + +admin = ez.web.http.router("/admin", middleware=[Middleware(SessionMiddleware, secret_key="ez-admin")]) + + +@admin.post("/login") +async def login(request: Request): + async with request.form() as form: + login = LoginRequest.model_validate(form) + + session = authenticate(login.username, login.password) + if session is None: + raise ez.web.http.HTTPException(ez.web.http.HTTPStatus.UNAUTHORIZED, "Invalid credentials") + + request.session["ez-admin:sid"] = session.id + + return JSONResponse({"message": "Logged in"}) + + +@admin.post("/logout") +def logout(request: Request): + session = get_current_session() + if close_session(session): + del request.session["ez-admin:sid"] + return JSONResponse({"message": "Logged out"}) + + +@admin.get("/test") +def test(request: Request): + session = get_current_session() + return PlainTextResponse( + session.id + " :: " + str(session.user) + ) From 4fabe585571284294b2112c6e7003427a3cb6ee1 Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Thu, 23 May 2024 16:14:27 +0300 Subject: [PATCH 22/26] Add Menu and MenuEntry types --- modules/admin/ui/menu.py | 207 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 modules/admin/ui/menu.py diff --git a/modules/admin/ui/menu.py b/modules/admin/ui/menu.py new file mode 100644 index 0000000..940dde9 --- /dev/null +++ b/modules/admin/ui/menu.py @@ -0,0 +1,207 @@ +from typing import Any, Callable, Concatenate, ParamSpec, TypeAlias, TypeVar, SupportsIndex, overload + + +_EMPTY: Any = object() + +MenuID: TypeAlias = str + +P = ParamSpec("P") +MenuEntryT = TypeVar("MenuEntryT", bound="MenuEntry") +MenuEntryCls = Callable[Concatenate["Menu", MenuID, P], MenuEntryT] + + + +class MenuEntry: + _parent: "Menu | None" + + def __init__(self, parent: "Menu | None", id: str | None = None): + self._parent = parent + self._id = id + + @property + def menu(self): + return self._parent + + @property + def id(self): + return self._id + + +class Menu(MenuEntry): + _entries: list[MenuEntry] + _mapping: dict[MenuID, MenuEntry] + + def __init__(self, parent: "Menu | None"): + super().__init__(parent) + + self._entries = [] + self._mapping = {} + + def add_entry(self, entry: MenuEntry) -> None: + self._entries.append(entry) + if entry.id is not None: + self._mapping[entry.id] = entry + + def add_entry_at(self, index: SupportsIndex, entry: MenuEntry) -> None: + self._entries.insert(index, entry) + if entry.id is not None: + self._mapping[entry.id] = entry + + def add_entries(self, *entries: MenuEntry) -> None: + for entry in entries: + self.add_entry(entry) + + def add_entries_at(self, index: SupportsIndex, *entries: MenuEntry) -> None: + index = index.__index__() + for i, entry in enumerate(entries): + self.add_entry_at(index + i, entry) + + @overload + def add_entry_before(self, target: MenuEntry, entry: MenuEntry, /) -> None: ... + @overload + def add_entry_before(self, entry_id: MenuID, entry: MenuEntry, /) -> None: ... + + def add_entry_before(self, target: MenuEntry | MenuID, entry: MenuEntry, /): + if isinstance(target, MenuID): + target = self._mapping[target] + index = self._entries.index(target) + self.add_entry_at(index - 1, entry) + + @overload + def add_entry_after(self, target: MenuEntry, entry: MenuEntry, /) -> None: ... + @overload + def add_entry_after(self, entry_id: MenuID, entry: MenuEntry, /) -> None: ... + + def add_entry_after(self, target: MenuEntry | MenuID, entry: MenuEntry, /): + if isinstance(target, MenuID): + target = self._mapping[target] + index = self._entries.index(target) + self.add_entry_at(index, entry) + + @overload + def add_entries_before(self, target: MenuEntry, /, *entries: MenuEntry) -> None: ... + @overload + def add_entries_before(self, entry_id: MenuID, /, *entries: MenuEntry) -> None: ... + + def add_entries_before(self, target: MenuEntry | MenuID, /, *entries: MenuEntry): + if isinstance(target, MenuID): + target = self._mapping[target] + index = self._entries.index(target) + self.add_entries_at(index - 1, *entries) + + @overload + def add_entries_after(self, target: MenuEntry, /, *entries: MenuEntry) -> None: ... + @overload + def add_entries_after(self, entry_id: MenuID, /, *entries: MenuEntry) -> None: ... + + def add_entries_after(self, target: MenuEntry | MenuID, /, *entries: MenuEntry): + if isinstance(target, MenuID): + target = self._mapping[target] + index = self._entries.index(target) + self.add_entries_at(index, *entries) + + def create_entry( + self, + cls: MenuEntryCls[P, MenuEntryT], + *args: P.args, + **kwargs: P.kwargs + ) -> MenuEntryT: + entry = cls(self, *args, **kwargs) + self.add_entry(entry) + return entry + + def create_entry_at( + self, + index: SupportsIndex, + cls: MenuEntryCls[P, MenuEntryT], + *args: P.args, + **kwargs: P.kwargs + ) -> MenuEntryT: + entry = cls(self, *args, **kwargs) + self._entries.insert(index, entry) + return entry + + @overload + def entry_at(self, index: SupportsIndex, /) -> MenuEntry: ... + @overload + def entry_at(self, index: SupportsIndex, entry: MenuEntry, /) -> None: ... + + def entry_at(self, index: SupportsIndex, entry: MenuEntry | None = _EMPTY, /): + if entry is _EMPTY: + return self[index] + if entry is None: + del self[index] + else: + self[index] = entry + + def remove_entry(self, entry: MenuEntry) -> None: + del self[entry] + + def remove_entry_at(self, index: SupportsIndex) -> None: + del self[index] + + @overload + def index(self, entry: MenuEntry, /) -> int: ... + @overload + def index(self, entry_id: MenuID, /) -> int: ... + + def index(self, entry: MenuEntry | MenuID, /) -> int: + if isinstance(entry, MenuID): + entry = self._mapping[entry] + return self._entries.index(entry) + + @overload + def __contains__(self, entry: MenuEntry, /) -> bool: ... + @overload + def __contains__(self, entry_id: MenuID, /) -> bool: ... + + def __contains__(self, entry: MenuEntry | MenuID, /) -> bool: + if isinstance(entry, MenuID): + return entry in self._mapping + return entry in self._entries + + @overload + def __getitem__(self, index: SupportsIndex, /) -> MenuEntry: ... + @overload + def __getitem__(self, entry_id: MenuID, /) -> MenuEntry: ... + + def __getitem__(self, index_or_id: SupportsIndex | MenuID) -> MenuEntry: + if isinstance(index_or_id, MenuID): + return self._mapping[index_or_id] + return self._entries[index_or_id] + + @overload + def __setitem__(self, index: SupportsIndex, entry: MenuEntry, /) -> None: ... + @overload + def __setitem__(self, entry_id: MenuID, entry: MenuEntry, /) -> None: ... + + def __setitem__(self, index: SupportsIndex | MenuID, entry: MenuEntry, /): + if isinstance(index, MenuID): + index = self.index(index) + self._entries[index] = entry + + @overload + def __delitem__(self, index: SupportsIndex, /) -> None: ... + @overload + def __delitem__(self, entry_id: MenuID, /) -> None: ... + @overload + def __delitem__(self, entry: MenuEntry, /) -> None: ... + + def __delitem__(self, entry: SupportsIndex | MenuEntry | MenuID, /): + if not isinstance(entry, MenuEntry): + entry = self[entry] + self._entries.remove(entry) + if entry.id is not None: + del self._mapping[entry.id] + + def __iter__(self): + return iter(self._entries) + + def __len__(self): + return len(self._entries) + + +__all__ = [ + "Menu", + "MenuEntry" +] From a54e116722c408eb45904f69d48013c34db9dece Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Thu, 23 May 2024 16:15:44 +0300 Subject: [PATCH 23/26] Prettify --- modules/admin/ui/menu.py | 44 +++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/modules/admin/ui/menu.py b/modules/admin/ui/menu.py index 940dde9..6c9384a 100644 --- a/modules/admin/ui/menu.py +++ b/modules/admin/ui/menu.py @@ -1,4 +1,13 @@ -from typing import Any, Callable, Concatenate, ParamSpec, TypeAlias, TypeVar, SupportsIndex, overload +from typing import ( + Any, + Callable, + Concatenate, + ParamSpec, + TypeAlias, + TypeVar, + SupportsIndex, + overload, +) _EMPTY: Any = object() @@ -10,7 +19,6 @@ MenuEntryCls = Callable[Concatenate["Menu", MenuID, P], MenuEntryT] - class MenuEntry: _parent: "Menu | None" @@ -21,7 +29,7 @@ def __init__(self, parent: "Menu | None", id: str | None = None): @property def menu(self): return self._parent - + @property def id(self): return self._id @@ -101,26 +109,23 @@ def add_entries_after(self, target: MenuEntry | MenuID, /, *entries: MenuEntry): self.add_entries_at(index, *entries) def create_entry( - self, - cls: MenuEntryCls[P, MenuEntryT], - *args: P.args, - **kwargs: P.kwargs - ) -> MenuEntryT: + self, cls: MenuEntryCls[P, MenuEntryT], *args: P.args, **kwargs: P.kwargs + ) -> MenuEntryT: entry = cls(self, *args, **kwargs) self.add_entry(entry) return entry - + def create_entry_at( - self, - index: SupportsIndex, - cls: MenuEntryCls[P, MenuEntryT], - *args: P.args, - **kwargs: P.kwargs - ) -> MenuEntryT: + self, + index: SupportsIndex, + cls: MenuEntryCls[P, MenuEntryT], + *args: P.args, + **kwargs: P.kwargs, + ) -> MenuEntryT: entry = cls(self, *args, **kwargs) self._entries.insert(index, entry) return entry - + @overload def entry_at(self, index: SupportsIndex, /) -> MenuEntry: ... @overload @@ -169,7 +174,7 @@ def __getitem__(self, index_or_id: SupportsIndex | MenuID) -> MenuEntry: if isinstance(index_or_id, MenuID): return self._mapping[index_or_id] return self._entries[index_or_id] - + @overload def __setitem__(self, index: SupportsIndex, entry: MenuEntry, /) -> None: ... @overload @@ -201,7 +206,4 @@ def __len__(self): return len(self._entries) -__all__ = [ - "Menu", - "MenuEntry" -] +__all__ = ["Menu", "MenuEntry"] From 2308a568c83fadbffbed0e1e691c8a7916da899a Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Thu, 23 May 2024 16:18:26 +0300 Subject: [PATCH 24/26] Remove entry construction code --- modules/admin/ui/menu.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/modules/admin/ui/menu.py b/modules/admin/ui/menu.py index 6c9384a..c3578a2 100644 --- a/modules/admin/ui/menu.py +++ b/modules/admin/ui/menu.py @@ -1,10 +1,6 @@ from typing import ( Any, - Callable, - Concatenate, - ParamSpec, TypeAlias, - TypeVar, SupportsIndex, overload, ) @@ -14,10 +10,6 @@ MenuID: TypeAlias = str -P = ParamSpec("P") -MenuEntryT = TypeVar("MenuEntryT", bound="MenuEntry") -MenuEntryCls = Callable[Concatenate["Menu", MenuID, P], MenuEntryT] - class MenuEntry: _parent: "Menu | None" @@ -108,24 +100,6 @@ def add_entries_after(self, target: MenuEntry | MenuID, /, *entries: MenuEntry): index = self._entries.index(target) self.add_entries_at(index, *entries) - def create_entry( - self, cls: MenuEntryCls[P, MenuEntryT], *args: P.args, **kwargs: P.kwargs - ) -> MenuEntryT: - entry = cls(self, *args, **kwargs) - self.add_entry(entry) - return entry - - def create_entry_at( - self, - index: SupportsIndex, - cls: MenuEntryCls[P, MenuEntryT], - *args: P.args, - **kwargs: P.kwargs, - ) -> MenuEntryT: - entry = cls(self, *args, **kwargs) - self._entries.insert(index, entry) - return entry - @overload def entry_at(self, index: SupportsIndex, /) -> MenuEntry: ... @overload From d0c7076c216af4ab6e921b04bee35570e85f1592 Mon Sep 17 00:00:00 2001 From: Binyamin Y Cohen Date: Thu, 23 May 2024 17:23:30 +0300 Subject: [PATCH 25/26] Remove session middleware --- modules/admin/router.py | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/modules/admin/router.py b/modules/admin/router.py index bcd7e9b..557f816 100644 --- a/modules/admin/router.py +++ b/modules/admin/router.py @@ -3,16 +3,13 @@ import ez -from starlette.middleware.sessions import SessionMiddleware -from starlette.middleware import Middleware - from .context import get_current_session, close_session from .requests import LoginRequest from .authentication import authenticate -admin = ez.web.http.router("/admin", middleware=[Middleware(SessionMiddleware, secret_key="ez-admin")]) +admin = ez.web.http.router("/admin") @admin.post("/login") @@ -20,26 +17,12 @@ async def login(request: Request): async with request.form() as form: login = LoginRequest.model_validate(form) - session = authenticate(login.username, login.password) - if session is None: - raise ez.web.http.HTTPException(ez.web.http.HTTPStatus.UNAUTHORIZED, "Invalid credentials") - - request.session["ez-admin:sid"] = session.id - return JSONResponse({"message": "Logged in"}) @admin.post("/logout") def logout(request: Request): session = get_current_session() - if close_session(session): - del request.session["ez-admin:sid"] + close_session(session) return JSONResponse({"message": "Logged out"}) - -@admin.get("/test") -def test(request: Request): - session = get_current_session() - return PlainTextResponse( - session.id + " :: " + str(session.user) - ) From d56843d029cfc0860161c3523420fc8724409bb8 Mon Sep 17 00:00:00 2001 From: Neriya Cohen Date: Sun, 2 Jun 2024 13:22:17 +0300 Subject: [PATCH 26/26] Fix error message --- modules/templates/template.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/templates/template.py b/modules/templates/template.py index 9140e67..2db50b1 100644 --- a/modules/templates/template.py +++ b/modules/templates/template.py @@ -16,7 +16,7 @@ class Template(TemplateBase, Generic[T]): def __init__(self, name: str, params: type[T] | type, parent: "TemplatePack | None" = None): if not isinstance(params, type) or not issubclass(params, TemplateParams): - raise TypeError(f"Functional template parameter must be a subclass of BaseModel. Got {params}.") + raise TypeError(f"Functional template parameter must be a subclass of TemplateParams. Got {params}.") super().__init__(name, parent)