Refactored instances management

This commit is contained in:
2025-11-23 19:52:03 +01:00
parent 97247f824c
commit b1be747101
24 changed files with 783 additions and 216 deletions

View File

@@ -26,8 +26,8 @@ class Boundaries(SingleInstance):
Keep the boundaries updated
"""
def __init__(self, session, owner, container_id: str = None, on_resize=None):
super().__init__(session, Ids.Boundaries, owner)
def __init__(self, owner, container_id: str = None, on_resize=None, _id=None):
super().__init__(owner, _id=_id)
self._owner = owner
self._container_id = container_id or owner.get_id()
self._on_resize = on_resize

View File

@@ -1,13 +1,12 @@
from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.controls.helpers import Ids
from myfasthtml.core.commands import CommandsManager
from myfasthtml.core.instances import SingleInstance
from myfasthtml.core.network_utils import from_parent_child_list
class CommandsDebugger(SingleInstance):
def __init__(self, session, parent, _id=None):
super().__init__(session, Ids.CommandsDebugger, parent)
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
def render(self):
commands = self._get_commands()

View File

@@ -16,7 +16,7 @@ logger = logging.getLogger("FileUpload")
class FileUploadState(DbObject):
def __init__(self, owner):
super().__init__(owner.get_session(), owner.get_id())
super().__init__(owner)
with self.initializing():
# persisted in DB
@@ -37,7 +37,7 @@ class Commands(BaseCommands):
class FileUpload(MultipleInstance):
def __init__(self, parent, _id=None):
super().__init__(Ids.FileUpload, parent, _id=_id)
super().__init__(parent, _id=_id)
self.commands = Commands(self)
self._state = FileUploadState(self)

View File

@@ -1,12 +1,11 @@
from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.controls.helpers import Ids
from myfasthtml.core.instances import SingleInstance, InstancesManager
from myfasthtml.core.network_utils import from_parent_child_list
class InstancesDebugger(SingleInstance):
def __init__(self, session, parent, _id=None):
super().__init__(session, Ids.InstancesDebugger, parent)
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
def render(self):
instances = self._get_instances()
@@ -15,8 +14,15 @@ class InstancesDebugger(SingleInstance):
label_getter=lambda x: x.get_prefix(),
parent_getter=lambda x: x.get_parent().get_id() if x.get_parent() else None
)
for edge in edges:
edge["color"] = "green"
edge["arrows"] = {"to": {"enabled": False, "type": "circle"}}
for node in nodes:
node["shape"] = "box"
vis_network = VisNetwork(self, nodes=nodes, edges=edges)
#vis_network.add_to_options(physics={"wind": {"x": 0, "y": 1}})
return vis_network
def _get_instances(self):

View File

@@ -2,14 +2,13 @@ import json
from fasthtml.xtend import Script
from myfasthtml.controls.helpers import Ids
from myfasthtml.core.commands import BaseCommand
from myfasthtml.core.instances import MultipleInstance
class Keyboard(MultipleInstance):
def __init__(self, parent, _id=None, combinations=None):
super().__init__(Ids.Keyboard, parent)
super().__init__(parent, _id=_id)
self.combinations = combinations or {}
def add(self, sequence: str, command: BaseCommand):

View File

@@ -12,10 +12,10 @@ from fasthtml.common import *
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.Boundaries import Boundaries
from myfasthtml.controls.UserProfile import UserProfile
from myfasthtml.controls.helpers import mk, Ids
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import InstancesManager, SingleInstance
from myfasthtml.core.instances import SingleInstance
from myfasthtml.core.utils import get_id
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
@@ -25,7 +25,7 @@ logger = logging.getLogger("LayoutControl")
class LayoutState(DbObject):
def __init__(self, owner):
super().__init__(owner.get_session(), owner.get_id())
super().__init__(owner)
with self.initializing():
self.left_drawer_open: bool = True
self.right_drawer_open: bool = True
@@ -100,7 +100,7 @@ class Layout(SingleInstance):
def get_groups(self):
return self._groups
def __init__(self, session, app_name, parent=None):
def __init__(self, parent, app_name, _id=None):
"""
Initialize the Layout component.
@@ -109,13 +109,13 @@ class Layout(SingleInstance):
left_drawer (bool): Enable left drawer. Default is True.
right_drawer (bool): Enable right drawer. Default is True.
"""
super().__init__(session, Ids.Layout, parent)
super().__init__(parent, _id=_id)
self.app_name = app_name
# Content storage
self._main_content = None
self._state = LayoutState(self)
self._boundaries = Boundaries(session, self)
self._boundaries = Boundaries(self)
self.commands = Commands(self)
self.left_drawer = self.Content(self)
self.right_drawer = self.Content(self)
@@ -193,7 +193,7 @@ class Layout(SingleInstance):
),
Div( # right
*self.header_right.get_content(),
InstancesManager.get(self._session, Ids.UserProfile, UserProfile),
UserProfile(self),
cls="flex gap-1"
),
cls="mf-layout-header"

View File

@@ -35,14 +35,13 @@ class Search(MultipleInstance):
a callable for extracting a string value from items, and a template callable for rendering
the filtered items. It provides functionality to handle and organize item-based operations.
:param session: The session object to maintain state or context across operations.
:param _id: Optional identifier for the component.
:param items: An optional list of names for the items to be filtered.
:param get_attr: Callable function to extract a string value from an item for filtering. Defaults to a
function that returns the item as is.
:param template: Callable function to render the filtered items. Defaults to a Div rendering function.
"""
super().__init__(Ids.Search, parent, _id=_id)
super().__init__(parent, _id=_id)
self.items_names = items_names or ''
self.items = items or []
self.filtered = self.items.copy()

View File

@@ -9,7 +9,7 @@ from fasthtml.xtend import Script
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.Search import Search
from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance, BaseInstance
@@ -45,7 +45,7 @@ class Boundaries:
class TabsManagerState(DbObject):
def __init__(self, owner):
super().__init__(owner.get_session(), owner.get_id())
super().__init__(owner)
with self.initializing():
# persisted in DB
self.tabs: dict[str, Any] = {}
@@ -78,7 +78,7 @@ class TabsManager(MultipleInstance):
_tab_count = 0
def __init__(self, parent, _id=None):
super().__init__(Ids.TabsManager, parent, _id=_id)
super().__init__(parent, _id=_id)
self._state = TabsManagerState(self)
self.commands = Commands(self)
self._boundaries = Boundaries()

View File

@@ -1,9 +1,10 @@
from fasthtml.components import *
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.controls.helpers import mk
from myfasthtml.core.AuthProxy import AuthProxy
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import SingleInstance, InstancesManager
from myfasthtml.core.instances import SingleInstance, InstancesManager, RootInstance
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
@@ -25,7 +26,7 @@ class UserProfileState:
def save(self):
user_settings = {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
auth_proxy = InstancesManager.get_auth_proxy()
auth_proxy = AuthProxy(RootInstance)
auth_proxy.save_user_info(self._session["access_token"], {"user_settings": user_settings})
@@ -35,8 +36,8 @@ class Commands(BaseCommands):
class UserProfile(SingleInstance):
def __init__(self, session, parent=None):
super().__init__(session, Ids.UserProfile, parent)
def __init__(self, parent=None, _id=None):
super().__init__(parent, _id=_id)
self._state = UserProfileState(self)
self._commands = Commands(self)

View File

@@ -3,7 +3,6 @@ import logging
from fasthtml.components import Script, Div
from myfasthtml.controls.helpers import Ids
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance
@@ -12,7 +11,7 @@ logger = logging.getLogger("VisNetwork")
class VisNetworkState(DbObject):
def __init__(self, owner):
super().__init__(owner.get_session(), owner.get_id())
super().__init__(owner)
with self.initializing():
# persisted in DB
self.nodes: list = []
@@ -30,7 +29,7 @@ class VisNetworkState(DbObject):
class VisNetwork(MultipleInstance):
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None):
super().__init__(Ids.VisNetwork, parent, _id=_id)
super().__init__(parent, _id=_id)
logger.debug(f"VisNetwork created with id: {self._id}")
self._state = VisNetworkState(self)
@@ -50,7 +49,13 @@ class VisNetwork(MultipleInstance):
state.options = options
self._state.update(state)
def add_to_options(self, **kwargs):
logger.debug(f"add_to_options: {kwargs=}")
new_options = self._state.options.copy() | kwargs
self._update_state(None, None, new_options)
return self
def render(self):
# Serialize nodes and edges to JSON

View File

@@ -7,19 +7,8 @@ from myfasthtml.core.utils import merge_classes
class Ids:
# Please keep the alphabetical order
AuthProxy = "mf-auth-proxy"
Boundaries = "mf-boundaries"
CommandsDebugger = "mf-commands-debugger"
DbManager = "mf-dbmanager"
FileUpload = "mf-file-upload"
InstancesDebugger = "mf-instances-debugger"
Keyboard = "mf-keyboard"
Layout = "mf-layout"
Root = "mf-root"
Search = "mf-search"
TabsManager = "mf-tabs-manager"
UserProfile = "mf-user-profile"
VisNetwork = "mf-vis-network"
UserSession = "mf-user_session"
class mk:

View File

@@ -1,11 +1,10 @@
from myfasthtml.auth.utils import login_user, save_user_info, register_user
from myfasthtml.controls.helpers import Ids
from myfasthtml.core.instances import UniqueInstance, RootInstance
from myfasthtml.core.instances import SingleInstance
class AuthProxy(UniqueInstance):
def __init__(self, base_url: str = None):
super().__init__(Ids.AuthProxy, RootInstance)
class AuthProxy(SingleInstance):
def __init__(self, parent, base_url: str = None):
super().__init__(parent)
self._base_url = base_url
def login_user(self, email: str, password: str):

View File

@@ -3,14 +3,13 @@ from types import SimpleNamespace
from dbengine.dbengine import DbEngine
from myfasthtml.controls.helpers import Ids
from myfasthtml.core.instances import SingleInstance, InstancesManager
from myfasthtml.core.instances import SingleInstance, BaseInstance
from myfasthtml.core.utils import retrieve_user_info
class DbManager(SingleInstance):
def __init__(self, session, parent=None, root=".myFastHtmlDb", auto_register: bool = True):
super().__init__(session, Ids.DbManager, parent, auto_register=auto_register)
def __init__(self, parent, root=".myFastHtmlDb", auto_register: bool = True):
super().__init__(parent, auto_register=auto_register)
self.db = DbEngine(root=root)
def save(self, entry, obj):
@@ -35,12 +34,12 @@ class DbObject:
It loads from DB at startup
"""
_initializing = False
_forbidden_attrs = {"_initializing", "_db_manager", "_name", "_session", "_forbidden_attrs"}
_forbidden_attrs = {"_initializing", "_db_manager", "_name", "_owner", "_forbidden_attrs"}
def __init__(self, session, name=None, db_manager=None):
self._session = session
def __init__(self, owner: BaseInstance, name=None, db_manager=None):
self._owner = owner
self._name = name or self.__class__.__name__
self._db_manager = db_manager or InstancesManager.get(self._session, Ids.DbManager, DbManager)
self._db_manager = db_manager or DbManager(self._owner)
self._finalize_initialization()

View File

@@ -1,7 +1,8 @@
import uuid
from typing import Self
from typing import Optional
from myfasthtml.controls.helpers import Ids
from myfasthtml.core.utils import pascal_to_snake
special_session = {
"user_info": {"id": "** SPECIAL SESSION **"}
@@ -18,25 +19,76 @@ class BaseInstance:
Base class for all instances (manageable by InstancesManager)
"""
def __init__(self, session: dict, prefix: str, _id: str, parent: Self, auto_register: bool = True):
self._session = session
self._id = _id
self._prefix = prefix
def __new__(cls, *args, **kwargs):
# Extract arguments from both positional and keyword arguments
# Signature matches __init__: parent, session=None, _id=None, auto_register=True
parent = args[0] if len(args) > 0 and isinstance(args[0], BaseInstance) else kwargs.get("parent", None)
session = args[1] if len(args) > 1 and isinstance(args[1], dict) else kwargs.get("session", None)
_id = args[2] if len(args) > 2 and isinstance(args[2], str) else kwargs.get("_id", None)
# Compute _id if not provided
if _id is None:
_id = cls.compute_id()
if session is None:
if parent is not None:
session = parent.get_session()
else:
raise TypeError("Either session or parent must be provided")
session_id = InstancesManager.get_session_id(session)
key = (session_id, _id)
if key in InstancesManager.instances:
res = InstancesManager.instances[key]
if type(res) is not cls:
raise TypeError(f"Instance with id {_id} already exists, but is of type {type(res)}")
return res
# Otherwise create a new instance
instance = super().__new__(cls)
instance._is_new_instance = True # mark as fresh
return instance
def __init__(self, parent: Optional['BaseInstance'],
session: Optional[dict] = None,
_id: Optional[str] = None,
auto_register: bool = True):
if not getattr(self, "_is_new_instance", False):
# Skip __init__ if instance already existed
return
else:
# make sure that it's no longer considered as a new instance
self._is_new_instance = False
self._parent = parent
self._session = session or (parent.get_session() if parent else None)
self._id = _id or self.compute_id()
if auto_register:
InstancesManager.register(session, self)
InstancesManager.register(self._session, self)
def get_id(self):
return self._id
def get_session(self):
def get_session(self) -> dict:
return self._session
def get_prefix(self):
return self._prefix
def get_id(self) -> str:
return self._id
def get_parent(self):
def get_parent(self) -> Optional['BaseInstance']:
return self._parent
@classmethod
def get_prefix(cls):
return f"mf-{pascal_to_snake(cls.__name__)}"
@classmethod
def compute_id(cls):
prefix = cls.get_prefix()
if issubclass(cls, SingleInstance):
_id = prefix
else:
_id = f"{prefix}-{str(uuid.uuid4())}"
return _id
class SingleInstance(BaseInstance):
@@ -44,19 +96,12 @@ class SingleInstance(BaseInstance):
Base class for instances that can only have one instance at a time.
"""
def __init__(self, session: dict, prefix: str, parent, auto_register: bool = True):
super().__init__(session, prefix, prefix, parent, auto_register)
class UniqueInstance(BaseInstance):
"""
Base class for instances that can only have one instance at a time.
Does not throw exception if the instance already exists, it simply overwrites it.
"""
def __init__(self, prefix: str, parent: BaseInstance, auto_register: bool = True):
super().__init__(parent.get_session(), prefix, prefix, parent, auto_register)
self._prefix = prefix
def __init__(self,
parent: Optional[BaseInstance] = None,
session: Optional[dict] = None,
_id: Optional[str] = None,
auto_register: bool = True):
super().__init__(parent, session, _id, auto_register)
class MultipleInstance(BaseInstance):
@@ -64,9 +109,11 @@ class MultipleInstance(BaseInstance):
Base class for instances that can have multiple instances at a time.
"""
def __init__(self, prefix: str, parent: BaseInstance, auto_register: bool = True, _id=None):
super().__init__(parent.get_session(), prefix, _id or f"{prefix}-{str(uuid.uuid4())}", parent, auto_register)
self._prefix = prefix
def __init__(self, parent: BaseInstance,
session: Optional[dict] = None,
_id: Optional[str] = None,
auto_register: bool = True):
super().__init__(parent, session, _id, auto_register)
class InstancesManager:
@@ -80,7 +127,7 @@ class InstancesManager:
:param instance:
:return:
"""
key = (InstancesManager._get_session_id(session), instance.get_id())
key = (InstancesManager.get_session_id(session), instance.get_id())
if isinstance(instance, SingleInstance) and key in InstancesManager.instances:
raise DuplicateInstanceError(instance)
@@ -89,48 +136,27 @@ class InstancesManager:
return instance
@staticmethod
def get(session: dict, instance_id: str, instance_type: type = None, parent: BaseInstance = None, *args, **kwargs):
def get(session: dict, instance_id: str):
"""
Get or create an instance of the given type (from its id)
:param session:
:param instance_id:
:param instance_type:
:param parent:
:param args:
:param kwargs:
:return:
"""
try:
key = (InstancesManager._get_session_id(session), instance_id)
return InstancesManager.instances[key]
except KeyError:
if instance_type:
if not issubclass(instance_type, SingleInstance):
assert parent is not None, "Parent instance must be provided if not SingleInstance"
if isinstance(parent, MultipleInstance):
return instance_type(parent, _id=instance_id, *args, **kwargs)
else:
return instance_type(session, parent=parent, *args, **kwargs) # it will be automatically registered
else:
raise
key = (InstancesManager.get_session_id(session), instance_id)
return InstancesManager.instances[key]
@staticmethod
def _get_session_id(session):
if not session:
def get_session_id(session):
if session is None:
return "** NOT LOGGED IN **"
if "user_info" not in session:
return "** UNKNOWN USER **"
return session["user_info"].get("id", "** INVALID SESSION **")
@staticmethod
def get_auth_proxy():
return InstancesManager.get(special_session, Ids.AuthProxy)
@staticmethod
def reset():
return InstancesManager.instances.clear()
InstancesManager.instances.clear()
RootInstance = SingleInstance(special_session, Ids.Root, None)
RootInstance = SingleInstance(None, special_session, Ids.Root)

View File

@@ -144,50 +144,34 @@ def from_tree_with_metadata(
def from_parent_child_list(
items: list,
id_getter: callable = None,
label_getter: callable = None,
parent_getter: callable = None,
ghost_color: str = "#ff9999"
id_getter: Callable = None,
label_getter: Callable = None,
parent_getter: Callable = None,
ghost_color: str = "#ff9999",
root_color: str | None = "#ff9999"
) -> tuple[list, list]:
"""
Convert a list of items with parent references to vis.js nodes and edges format.
Args:
items: List of items (dicts or objects) with parent references
(e.g., [{"id": "child", "parent": "root", "label": "Child"}, ...])
id_getter: Optional callback to extract node ID from item
Default: lambda item: item.get("id")
label_getter: Optional callback to extract node label from item
Default: lambda item: item.get("label", "")
parent_getter: Optional callback to extract parent ID from item
Default: lambda item: item.get("parent")
ghost_color: Color to use for ghost nodes (nodes referenced as parents but not in list)
Default: "#ff9999" (light red)
id_getter: callback to extract node ID
label_getter: callback to extract node label
parent_getter: callback to extract parent ID
ghost_color: color for ghost nodes (referenced parents)
root_color: color for root nodes (nodes without parent)
Returns:
tuple: (nodes, edges) where:
- nodes: list of dicts with IDs from items, ghost nodes have color property
- edges: list of dicts with 'from' and 'to' keys
Note:
- Nodes with parent=None or parent="" are treated as root nodes
- If a parent is referenced but doesn't exist in items, a ghost node is created
with the ghost_color applied
Example:
>>> items = [
... {"id": "root", "label": "Root"},
... {"id": "child1", "parent": "root", "label": "Child 1"},
... {"id": "child2", "parent": "unknown", "label": "Child 2"}
... ]
>>> nodes, edges = from_parent_child_list(items)
>>> # "unknown" will be created as a ghost node with color="#ff9999"
tuple: (nodes, edges)
"""
# Default getters
if id_getter is None:
id_getter = lambda item: item.get("id")
if label_getter is None:
label_getter = lambda item: item.get("label", "")
if parent_getter is None:
parent_getter = lambda item: item.get("parent")
@@ -205,34 +189,48 @@ def from_parent_child_list(
existing_ids.add(node_id)
nodes.append({
"id": node_id,
"label": node_label
"label": node_label,
# root color assigned later
})
# Track ghost nodes to avoid duplicates
# Track ghost nodes
ghost_nodes = set()
# Second pass: create edges and identify ghost nodes
# Track which nodes have parents
nodes_with_parent = set()
# Second pass: create edges and detect ghost nodes
for item in items:
node_id = id_getter(item)
parent_id = parent_getter(item)
# Skip if no parent or parent is empty string or None
# Skip roots
if parent_id is None or parent_id == "":
continue
# Create edge from parent to child
# Child has a parent
nodes_with_parent.add(node_id)
# Create edge parent → child
edges.append({
"from": parent_id,
"to": node_id
})
# Check if parent exists, if not create ghost node
# Create ghost node if parent not found
if parent_id not in existing_ids and parent_id not in ghost_nodes:
ghost_nodes.add(parent_id)
nodes.append({
"id": parent_id,
"label": str(parent_id), # Use ID as label for ghost nodes
"label": str(parent_id),
"color": ghost_color
})
# Final pass: assign color to root nodes
if root_color is not None:
for node in nodes:
if node["id"] not in nodes_with_parent and node["id"] not in ghost_nodes:
# Root node
node["color"] = root_color
return nodes, edges

View File

@@ -1,4 +1,5 @@
import logging
import re
from bs4 import Tag
from fastcore.xml import FT
@@ -234,6 +235,18 @@ def get_id(obj):
return str(obj)
def pascal_to_snake(name: str) -> str:
"""Convert a PascalCase or CamelCase string to snake_case."""
if name is None:
return None
name = name.strip()
# Insert underscore before capital letters (except the first one)
s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name)
# Handle consecutive capital letters (like 'HTTPServer' -> 'http_server')
s2 = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', s1)
return s2.lower()
@utils_rt(Routes.Commands)
def post(session, c_id: str, client_response: dict = None):
"""

View File

@@ -11,6 +11,10 @@ import re
def pascal_to_snake(name: str) -> str:
"""Convert a PascalCase or CamelCase string to snake_case."""
if name is None:
return None
name = name.strip()
# Insert underscore before capital letters (except the first one)
s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name)
# Handle consecutive capital letters (like 'HTTPServer' -> 'http_server')

View File

@@ -10,6 +10,7 @@ from starlette.responses import Response
from myfasthtml.auth.routes import setup_auth_routes
from myfasthtml.auth.utils import create_auth_beforeware
from myfasthtml.core.AuthProxy import AuthProxy
from myfasthtml.core.instances import RootInstance
from myfasthtml.core.utils import utils_app
logger = logging.getLogger("MyFastHtml")
@@ -104,6 +105,6 @@ def create_app(daisyui: Optional[bool] = True,
setup_auth_routes(app, rt, base_url=base_url)
# create the AuthProxy instance
AuthProxy(base_url) # using the auto register mechanism to expose it
AuthProxy(RootInstance, base_url) # using the auto register mechanism to expose it
return app, rt