From c641f3fd634b5f6722a0b75008c9ceac17ff5e44 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Mon, 10 Nov 2025 23:12:07 +0100 Subject: [PATCH] Added DbObjects --- .gitignore | 1 + requirements.txt | 3 + src/app.py | 4 +- src/myfasthtml/controls/Layout.py | 12 ++-- src/myfasthtml/controls/UserProfile.py | 4 +- src/myfasthtml/controls/helpers.py | 4 ++ src/myfasthtml/core/dbmanager.py | 62 ++++++++++++++++ src/myfasthtml/core/instances.py | 13 ++-- src/myfasthtml/core/utils.py | 2 +- tests/core/test_db_object.py | 98 ++++++++++++++++++++++++++ 10 files changed, 189 insertions(+), 14 deletions(-) create mode 100644 src/myfasthtml/core/dbmanager.py create mode 100644 tests/core/test_db_object.py diff --git a/.gitignore b/.gitignore index 2cfd859..5e0e0fc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ app.egg-info *.pyc .mypy_cache .coverage +.myFastHtmlDb htmlcov .cache .venv diff --git a/requirements.txt b/requirements.txt index e4dd7a5..c60a2ae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -36,6 +36,7 @@ markdown-it-py==4.0.0 mdurl==0.1.2 more-itertools==10.8.0 myauth==0.2.0 +mydbengine==0.1.0 myutils==0.4.0 nh3==0.3.1 oauthlib==3.3.1 @@ -64,11 +65,13 @@ rfc3986==2.0.0 rich==14.2.0 rsa==4.9.1 SecretStorage==3.4.0 +shellingham==1.5.4 six==1.17.0 sniffio==1.3.1 soupsieve==2.8 starlette==0.48.0 twine==6.2.0 +typer==0.20.0 typing-inspection==0.4.2 typing_extensions==4.15.0 urllib3==2.5.0 diff --git a/src/app.py b/src/app.py index df35d40..cbb8490 100644 --- a/src/app.py +++ b/src/app.py @@ -4,6 +4,8 @@ from fasthtml import serve from fasthtml.components import * from myfasthtml.controls.Layout import Layout +from myfasthtml.controls.helpers import Ids +from myfasthtml.core.instances import InstancesManager from myfasthtml.myfastapp import create_app logging.basicConfig( @@ -22,7 +24,7 @@ app, rt = create_app(protect_routes=True, @rt("/") def index(session): - layout = Layout(session, "Testing Layout") + layout = InstancesManager.get(session, Ids.Layout, Layout, "Testing Layout") layout.set_footer("Goodbye World") for i in range(1000): layout.left_drawer.append(Div(f"Left Drawer Item {i}")) diff --git a/src/myfasthtml/controls/Layout.py b/src/myfasthtml/controls/Layout.py index c1e302f..a38d0ef 100644 --- a/src/myfasthtml/controls/Layout.py +++ b/src/myfasthtml/controls/Layout.py @@ -13,7 +13,8 @@ from myfasthtml.controls.BaseCommands import BaseCommands from myfasthtml.controls.UserProfile import UserProfile from myfasthtml.controls.helpers import mk, Ids from myfasthtml.core.commands import Command -from myfasthtml.core.instances import MultipleInstance, InstancesManager +from myfasthtml.core.dbmanager import DbObject +from myfasthtml.core.instances import InstancesManager, SingleInstance from myfasthtml.icons.fluent import panel_left_expand20_regular as left_drawer_icon from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_icon @@ -21,7 +22,10 @@ logger = logging.getLogger("LayoutControl") @dataclass -class LayoutState: +class LayoutState(DbObject): + def __init__(self, session, owner): + super().__init__(session, owner.get_id()) + left_drawer_open: bool = True right_drawer_open: bool = False @@ -31,7 +35,7 @@ class Commands(BaseCommands): return Command("ToggleDrawer", "Toggle main layout drawer", self._owner.toggle_drawer, "left") -class Layout(MultipleInstance): +class Layout(SingleInstance): """ A responsive layout component with header, footer, main content area, and optional collapsible side drawers. @@ -70,7 +74,7 @@ class Layout(MultipleInstance): self._header_content = None self._footer_content = None self._main_content = None - self._state = LayoutState() + self._state = LayoutState(session, self) self.commands = Commands(self) self.left_drawer = self.DrawerContent(self, "left") self.right_drawer = self.DrawerContent(self, "right") diff --git a/src/myfasthtml/controls/UserProfile.py b/src/myfasthtml/controls/UserProfile.py index caf159d..754cb39 100644 --- a/src/myfasthtml/controls/UserProfile.py +++ b/src/myfasthtml/controls/UserProfile.py @@ -2,7 +2,7 @@ from fasthtml.components import * from myfasthtml.controls.helpers import Ids, mk from myfasthtml.core.instances import SingleInstance -from myfasthtml.core.utils import get_user_info +from myfasthtml.core.utils import retrieve_user_info from myfasthtml.icons.material import dark_mode_filled, person_outline_sharp from myfasthtml.icons.material_p1 import light_mode_filled, alternate_email_filled @@ -12,7 +12,7 @@ class UserProfile(SingleInstance): super().__init__(session, Ids.UserProfile) def render(self): - user_info = get_user_info(self._session) + user_info = retrieve_user_info(self._session) return Div( Div(user_info['username'], tabindex="0", diff --git a/src/myfasthtml/controls/helpers.py b/src/myfasthtml/controls/helpers.py index 095fa71..4c08acc 100644 --- a/src/myfasthtml/controls/helpers.py +++ b/src/myfasthtml/controls/helpers.py @@ -3,10 +3,14 @@ from fasthtml.components import * from myfasthtml.core.bindings import Binding from myfasthtml.core.commands import Command from myfasthtml.core.utils import merge_classes + + class Ids: + DbManager = "mf-dbmanager" Layout = "mf-layout" UserProfile = "mf-user-profile" + class mk: @staticmethod diff --git a/src/myfasthtml/core/dbmanager.py b/src/myfasthtml/core/dbmanager.py new file mode 100644 index 0000000..61772d7 --- /dev/null +++ b/src/myfasthtml/core/dbmanager.py @@ -0,0 +1,62 @@ +from dbengine.dbengine import DbEngine + +from myfasthtml.controls.helpers import Ids +from myfasthtml.core.instances import SingleInstance, InstancesManager +from myfasthtml.core.utils import retrieve_user_info + + +class DbManager(SingleInstance): + def __init__(self, session, root=".myFastHtmlDb", auto_register: bool = True): + super().__init__(session, Ids.DbManager, auto_register=auto_register) + self.db = DbEngine(root=root) + + def save(self, entry, obj): + self.db.save(self.get_tenant(), self.get_user(), entry, obj) + + def load(self, entry): + return self.db.load(self.get_tenant(), entry) + + def exists_entry(self, entry): + return self.db.exists(self.get_tenant(), entry) + + def get_tenant(self): + return retrieve_user_info(self._session)["id"] + + def get_user(self): + return retrieve_user_info(self._session)["email"] + + +class DbObject: + """ + When you set the attribute, it persists in DB + It loads from DB at startup + """ + + def __init__(self, session, name=None, db_manager=None): + self._session = session + self._name = name or self.__class__.__name__ + self._db_manager = db_manager or InstancesManager.get(self._session, Ids.DbManager, DbManager) + + # init is possible + if self._db_manager.exists_entry(self._name): + props = self._db_manager.load(self._name) + for k, v in props.items(): + setattr(self, k, v) + else: + self._save_self() + + def __setattr__(self, name: str, value: str): + if name.startswith("_"): + super().__setattr__(name, value) + return + + old_value = getattr(self, name, None) + if old_value == value: + return + + super().__setattr__(name, value) + self._save_self() + + def _save_self(self): + props = {k: getattr(self, k) for k, v in self.__class__.__dict__.items() if not k.startswith("_")} + self._db_manager.save(self._name, props) diff --git a/src/myfasthtml/core/instances.py b/src/myfasthtml/core/instances.py index f9d7123..a233a89 100644 --- a/src/myfasthtml/core/instances.py +++ b/src/myfasthtml/core/instances.py @@ -11,10 +11,11 @@ class BaseInstance: Base class for all instances (manageable by InstancesManager) """ - def __init__(self, session: dict, _id: str): + def __init__(self, session: dict, _id: str, auto_register: bool = True): self._session = session self._id = _id - InstancesManager.register(session, self) + if auto_register: + InstancesManager.register(session, self) def get_id(self): return self._id @@ -28,8 +29,8 @@ class SingleInstance(BaseInstance): Base class for instances that can only have one instance at a time. """ - def __init__(self, session: dict, prefix: str): - super().__init__(session, prefix) + def __init__(self, session: dict, prefix: str, auto_register: bool = True): + super().__init__(session, prefix, auto_register) self._instance = None @@ -38,8 +39,8 @@ class MultipleInstance(BaseInstance): Base class for instances that can have multiple instances at a time. """ - def __init__(self, session: dict, prefix: str): - super().__init__(session, f"{prefix}-{str(uuid.uuid4())}") + def __init__(self, session: dict, prefix: str, auto_register: bool = True): + super().__init__(session, f"{prefix}-{str(uuid.uuid4())}", auto_register) self._instance = None diff --git a/src/myfasthtml/core/utils.py b/src/myfasthtml/core/utils.py index 3850528..2393b9a 100644 --- a/src/myfasthtml/core/utils.py +++ b/src/myfasthtml/core/utils.py @@ -191,7 +191,7 @@ def quoted_str(s): return str(s) -def get_user_info(session: dict): +def retrieve_user_info(session: dict): if not session: return { "id": "** NOT LOGGED IN **", diff --git a/tests/core/test_db_object.py b/tests/core/test_db_object.py new file mode 100644 index 0000000..1f3d606 --- /dev/null +++ b/tests/core/test_db_object.py @@ -0,0 +1,98 @@ +import shutil +from dataclasses import dataclass + +import pytest + +from myfasthtml.core.dbmanager import DbManager, DbObject + + +@pytest.fixture(scope="session") +def session(): + return { + "user_info": { + "id": "test_tenant_id", + "email": "test@email.com", + "username": "test user", + "role": [], + } + } + + +@pytest.fixture +def db_manager(session): + shutil.rmtree("TestDb", ignore_errors=True) + db_manager_instance = DbManager(session, root="TestDb", auto_register=False) + + yield db_manager_instance + + shutil.rmtree("TestDb", ignore_errors=True) + + +def simplify(res: dict) -> dict: + return {k: v for k, v in res.items() if not k.startswith("_")} + + +def test_i_can_init(session, db_manager): + @dataclass + class DummyObject(DbObject): + def __init__(self, sess: dict): + super().__init__(sess, "DummyObject", db_manager) + + value: str = "hello" + number: int = 42 + + DummyObject(session) + + assert simplify(db_manager.load("DummyObject")) == {"value": "hello", "number": 42} + + +def test_i_can_init_from_db(session, db_manager): + @dataclass + class DummyObject(DbObject): + def __init__(self, sess: dict): + super().__init__(sess, "DummyObject", db_manager) + + value: str = "hello" + number: int = 42 + + # insert other values in db + db_manager.save("DummyObject", {"value": "other_value", "number": 34}) + + dummy = DummyObject(session) + + assert dummy.value == "other_value" + assert dummy.number == 34 + + +def test_db_is_updated_when_attribute_is_modified(session, db_manager): + @dataclass + class DummyObject(DbObject): + def __init__(self, sess: dict): + super().__init__(sess, "DummyObject", db_manager) + + value: str = "hello" + number: int = 42 + + dummy = DummyObject(session) + dummy.value = "other_value" + + assert simplify(db_manager.load("DummyObject")) == {"value": "other_value", "number": 42} + + +def test_i_do_not_save_in_db_when_value_is_the_same(session, db_manager): + @dataclass + class DummyObject(DbObject): + def __init__(self, sess: dict): + super().__init__(sess, "DummyObject", db_manager) + + value: str = "hello" + number: int = 42 + + dummy = DummyObject(session) + dummy.value = "other_value" + in_db_1 = db_manager.load("DummyObject") + + dummy.value = "other_value" + in_db_2 = db_manager.load("DummyObject") + + assert in_db_1["__parent__"] == in_db_2["__parent__"]