component_framework.core
Core framework components.
1"""Core framework components.""" 2 3from .component import Component, ComponentError, EventNotFoundError, StateSerializer 4from .composition import SlotRenderer, compose 5from .form import FormComponent, ModelFormComponent 6from .permissions import ( 7 AllowAny, 8 BasePermission, 9 DjangoModelPermission, 10 IsAuthenticated, 11 IsStaff, 12 IsSuperuser, 13) 14from .registry import ComponentRegistry, registry 15from .renderer import Renderer 16from .state import InMemoryStateStore, StateStore 17from .websocket import ComponentWebSocketManager, WebSocketConnection, ws_manager 18 19__all__ = [ 20 "Component", 21 "ComponentError", 22 "EventNotFoundError", 23 "StateSerializer", 24 "compose", 25 "SlotRenderer", 26 "FormComponent", 27 "ModelFormComponent", 28 "AllowAny", 29 "BasePermission", 30 "DjangoModelPermission", 31 "IsAuthenticated", 32 "IsStaff", 33 "IsSuperuser", 34 "ComponentRegistry", 35 "registry", 36 "Renderer", 37 "StateStore", 38 "InMemoryStateStore", 39 "ComponentWebSocketManager", 40 "WebSocketConnection", 41 "ws_manager", 42]
27class Component: 28 """ 29 Base component class with lifecycle management. 30 31 Lifecycle: 32 1. __init__(**params) 33 2. mount() OR hydrate(state) 34 3. handle_event(event, payload) 35 4. before_render() 36 5. render() 37 6. dehydrate() 38 39 Slots: 40 Components can declare named slots via the ``slots`` class variable. 41 Child components are assigned to slots with ``fill_slot()`` and their 42 rendered HTML is available in the template context under ``slots``. 43 """ 44 45 template_name: str | None = None 46 renderer = None 47 permission_classes: ClassVar[list[type["BasePermission"]]] = [] 48 slots: ClassVar[list[str]] = [] 49 """Slot names this component accepts. Empty list means *any* slot name is accepted.""" 50 51 def __init__(self, **params): 52 self.params = params 53 self.state: dict[str, Any] = {} 54 self.errors: dict[str, str] = {} 55 self.id = params.get("component_id") or self._generate_id() 56 self._mounted = False 57 self._slot_components: dict[str, Component] = {} 58 59 # ---------- Lifecycle ---------- 60 61 def _generate_id(self) -> str: 62 """Generate unique component ID.""" 63 return f"component-{uuid4().hex[:8]}" 64 65 def mount(self): 66 """Initialize component on first load. Override in subclasses.""" 67 self._mounted = True 68 69 def hydrate(self, state: dict): 70 """Restore component from serialized state.""" 71 self.state.update(state) 72 self._mounted = True 73 74 def dehydrate(self) -> dict: 75 """Serialize component state for persistence.""" 76 return self.state.copy() 77 78 def before_render(self): 79 """Called before rendering. Use for derived state computation.""" 80 pass 81 82 # ---------- Slots / Composition ---------- 83 84 def fill_slot(self, slot_name: str, component: "Component") -> None: 85 """ 86 Assign a child component to a named slot. 87 88 If the component declares specific ``slots``, *slot_name* must be one of 89 them. If ``slots`` is empty (the default), any name is accepted 90 (permissive mode). 91 92 Args: 93 slot_name: Target slot identifier. 94 component: Child component instance to render in the slot. 95 96 Raises: 97 ComponentError: If *slot_name* is not in the declared ``slots`` list. 98 """ 99 if self.slots and slot_name not in self.slots: 100 raise ComponentError(f"Unknown slot '{slot_name}'. Available: {self.slots}") 101 self._slot_components[slot_name] = component 102 103 def render_slots(self) -> dict[str, str]: 104 """ 105 Render all filled slot components. 106 107 Returns: 108 Dict mapping slot names to their rendered HTML strings. 109 """ 110 rendered: dict[str, str] = {} 111 for name, child in self._slot_components.items(): 112 rendered[name] = child.render() 113 return rendered 114 115 # ---------- Optimistic UI ---------- 116 117 def get_optimistic_patch(self, event: str, payload: dict) -> dict | None: 118 """ 119 Return a partial state dict that the client can apply immediately before the server 120 responds, for the given event and payload. 121 122 This enables optimistic UI updates: the client shows the expected result right away 123 and reconciles with the actual server state once the response arrives. On error, the 124 client can roll back to the previous state. 125 126 Override in subclasses to enable optimistic updates for specific events. Return None 127 (the default) to disable optimistic updates for a given event. 128 129 Args: 130 event: The event name being dispatched (e.g., "increment"). 131 payload: The event payload dict. 132 133 Returns: 134 A partial state dict to apply optimistically, or None to skip. 135 136 Example:: 137 138 def get_optimistic_patch(self, event: str, payload: dict) -> dict | None: 139 if event == "increment": 140 return {"count": self.state.get("count", 0) + payload.get("amount", 1)} 141 return None 142 """ 143 return None 144 145 # ---------- Events ---------- 146 147 def handle_event(self, event: str, payload: dict): 148 """ 149 Route event to handler method. 150 151 Args: 152 event: Event name (e.g., "increment") 153 payload: Event data 154 155 Raises: 156 EventNotFoundError: If handler not found 157 ComponentError: If handler raises exception 158 """ 159 handler = getattr(self, f"on_{event}", None) 160 161 if not handler: 162 raise EventNotFoundError(f"No handler for event: {event}") 163 164 try: 165 handler(**payload) 166 except TypeError as e: 167 raise ComponentError(f"Invalid payload for {event}: {e}") from e 168 except Exception as e: 169 logger.exception(f"Error handling {event} in {self.__class__.__name__}") 170 raise ComponentError(f"Error handling {event}") from e 171 172 # ---------- Rendering ---------- 173 174 def get_context(self) -> dict: 175 """ 176 Build template context. Does not expose full component. 177 178 Override to add custom context variables. 179 The returned dict always includes a ``slots`` key containing the 180 rendered HTML of any filled child components. 181 """ 182 return { 183 "state": self.state, 184 "errors": self.errors, 185 "component_id": self.id, 186 "slots": self.render_slots(), 187 } 188 189 def render(self) -> str: 190 """Render component to HTML.""" 191 if not self.renderer: 192 raise ComponentError("No renderer configured") 193 194 if not self.template_name: 195 raise ComponentError("No template_name specified") 196 197 self.before_render() 198 199 return self.renderer.render( 200 self.template_name, 201 self.get_context(), 202 ) 203 204 # ---------- Dispatch ---------- 205 206 def dispatch( 207 self, 208 event: str | None = None, 209 payload: dict | None = None, 210 state: dict | None = None, 211 ) -> dict: 212 """ 213 Main entry point for component execution. 214 215 Args: 216 event: Event name to handle 217 payload: Event data 218 state: Serialized state to restore 219 220 Returns: 221 Dict with 'html' and 'state' keys 222 """ 223 try: 224 # Lifecycle: mount or hydrate 225 if state: 226 self.hydrate(state) 227 else: 228 self.mount() 229 230 # Handle event if provided 231 if event: 232 self.handle_event(event, payload or {}) 233 234 # Render 235 html = self.render() 236 237 return { 238 "html": html, 239 "state": self.dehydrate(), 240 "component_id": self.id, 241 "slots": self.render_slots(), 242 } 243 244 except Exception: 245 logger.exception(f"Error in {self.__class__.__name__}.dispatch()") 246 raise
Base component class with lifecycle management.
Lifecycle:
- __init__(**params)
- mount() OR hydrate(state)
- handle_event(event, payload)
- before_render()
- render()
- dehydrate()
Slots:
Components can declare named slots via the
slotsclass variable. Child components are assigned to slots withfill_slot()and their rendered HTML is available in the template context underslots.
Slot names this component accepts. Empty list means any slot name is accepted.
65 def mount(self): 66 """Initialize component on first load. Override in subclasses.""" 67 self._mounted = True
Initialize component on first load. Override in subclasses.
69 def hydrate(self, state: dict): 70 """Restore component from serialized state.""" 71 self.state.update(state) 72 self._mounted = True
Restore component from serialized state.
74 def dehydrate(self) -> dict: 75 """Serialize component state for persistence.""" 76 return self.state.copy()
Serialize component state for persistence.
78 def before_render(self): 79 """Called before rendering. Use for derived state computation.""" 80 pass
Called before rendering. Use for derived state computation.
84 def fill_slot(self, slot_name: str, component: "Component") -> None: 85 """ 86 Assign a child component to a named slot. 87 88 If the component declares specific ``slots``, *slot_name* must be one of 89 them. If ``slots`` is empty (the default), any name is accepted 90 (permissive mode). 91 92 Args: 93 slot_name: Target slot identifier. 94 component: Child component instance to render in the slot. 95 96 Raises: 97 ComponentError: If *slot_name* is not in the declared ``slots`` list. 98 """ 99 if self.slots and slot_name not in self.slots: 100 raise ComponentError(f"Unknown slot '{slot_name}'. Available: {self.slots}") 101 self._slot_components[slot_name] = component
Assign a child component to a named slot.
If the component declares specific slots, slot_name must be one of
them. If slots is empty (the default), any name is accepted
(permissive mode).
Arguments:
- slot_name: Target slot identifier.
- component: Child component instance to render in the slot.
Raises:
- ComponentError: If slot_name is not in the declared
slotslist.
103 def render_slots(self) -> dict[str, str]: 104 """ 105 Render all filled slot components. 106 107 Returns: 108 Dict mapping slot names to their rendered HTML strings. 109 """ 110 rendered: dict[str, str] = {} 111 for name, child in self._slot_components.items(): 112 rendered[name] = child.render() 113 return rendered
Render all filled slot components.
Returns:
Dict mapping slot names to their rendered HTML strings.
117 def get_optimistic_patch(self, event: str, payload: dict) -> dict | None: 118 """ 119 Return a partial state dict that the client can apply immediately before the server 120 responds, for the given event and payload. 121 122 This enables optimistic UI updates: the client shows the expected result right away 123 and reconciles with the actual server state once the response arrives. On error, the 124 client can roll back to the previous state. 125 126 Override in subclasses to enable optimistic updates for specific events. Return None 127 (the default) to disable optimistic updates for a given event. 128 129 Args: 130 event: The event name being dispatched (e.g., "increment"). 131 payload: The event payload dict. 132 133 Returns: 134 A partial state dict to apply optimistically, or None to skip. 135 136 Example:: 137 138 def get_optimistic_patch(self, event: str, payload: dict) -> dict | None: 139 if event == "increment": 140 return {"count": self.state.get("count", 0) + payload.get("amount", 1)} 141 return None 142 """ 143 return None
Return a partial state dict that the client can apply immediately before the server responds, for the given event and payload.
This enables optimistic UI updates: the client shows the expected result right away and reconciles with the actual server state once the response arrives. On error, the client can roll back to the previous state.
Override in subclasses to enable optimistic updates for specific events. Return None (the default) to disable optimistic updates for a given event.
Arguments:
- event: The event name being dispatched (e.g., "increment").
- payload: The event payload dict.
Returns:
A partial state dict to apply optimistically, or None to skip.
Example::
def get_optimistic_patch(self, event: str, payload: dict) -> dict | None:
if event == "increment":
return {"count": self.state.get("count", 0) + payload.get("amount", 1)}
return None
147 def handle_event(self, event: str, payload: dict): 148 """ 149 Route event to handler method. 150 151 Args: 152 event: Event name (e.g., "increment") 153 payload: Event data 154 155 Raises: 156 EventNotFoundError: If handler not found 157 ComponentError: If handler raises exception 158 """ 159 handler = getattr(self, f"on_{event}", None) 160 161 if not handler: 162 raise EventNotFoundError(f"No handler for event: {event}") 163 164 try: 165 handler(**payload) 166 except TypeError as e: 167 raise ComponentError(f"Invalid payload for {event}: {e}") from e 168 except Exception as e: 169 logger.exception(f"Error handling {event} in {self.__class__.__name__}") 170 raise ComponentError(f"Error handling {event}") from e
Route event to handler method.
Arguments:
- event: Event name (e.g., "increment")
- payload: Event data
Raises:
- EventNotFoundError: If handler not found
- ComponentError: If handler raises exception
174 def get_context(self) -> dict: 175 """ 176 Build template context. Does not expose full component. 177 178 Override to add custom context variables. 179 The returned dict always includes a ``slots`` key containing the 180 rendered HTML of any filled child components. 181 """ 182 return { 183 "state": self.state, 184 "errors": self.errors, 185 "component_id": self.id, 186 "slots": self.render_slots(), 187 }
Build template context. Does not expose full component.
Override to add custom context variables.
The returned dict always includes a slots key containing the
rendered HTML of any filled child components.
189 def render(self) -> str: 190 """Render component to HTML.""" 191 if not self.renderer: 192 raise ComponentError("No renderer configured") 193 194 if not self.template_name: 195 raise ComponentError("No template_name specified") 196 197 self.before_render() 198 199 return self.renderer.render( 200 self.template_name, 201 self.get_context(), 202 )
Render component to HTML.
206 def dispatch( 207 self, 208 event: str | None = None, 209 payload: dict | None = None, 210 state: dict | None = None, 211 ) -> dict: 212 """ 213 Main entry point for component execution. 214 215 Args: 216 event: Event name to handle 217 payload: Event data 218 state: Serialized state to restore 219 220 Returns: 221 Dict with 'html' and 'state' keys 222 """ 223 try: 224 # Lifecycle: mount or hydrate 225 if state: 226 self.hydrate(state) 227 else: 228 self.mount() 229 230 # Handle event if provided 231 if event: 232 self.handle_event(event, payload or {}) 233 234 # Render 235 html = self.render() 236 237 return { 238 "html": html, 239 "state": self.dehydrate(), 240 "component_id": self.id, 241 "slots": self.render_slots(), 242 } 243 244 except Exception: 245 logger.exception(f"Error in {self.__class__.__name__}.dispatch()") 246 raise
Main entry point for component execution.
Arguments:
- event: Event name to handle
- payload: Event data
- state: Serialized state to restore
Returns:
Dict with 'html' and 'state' keys
Base exception for component errors.
21class EventNotFoundError(ComponentError): 22 """Raised when an event handler is not found.""" 23 24 pass
Raised when an event handler is not found.
249class StateSerializer: 250 """Handles safe serialization/deserialization of component state.""" 251 252 @staticmethod 253 def serialize(state: dict) -> str: 254 """Serialize state to JSON string.""" 255 return json.dumps(state, default=str) 256 257 @staticmethod 258 def deserialize(data: str) -> dict: 259 """Deserialize state from JSON string.""" 260 if not data: 261 return {} 262 return json.loads(data)
Handles safe serialization/deserialization of component state.
11def compose( 12 parent_cls: type[Component], 13 *, 14 params: dict[str, Any] | None = None, 15 **slot_components: Component, 16) -> Component: 17 """ 18 Convenience function: instantiate a parent component and fill its slots in one call. 19 20 Keyword arguments whose values are ``Component`` instances are treated as 21 slot assignments; everything else is forwarded to the parent constructor 22 via *params*. 23 24 Args: 25 parent_cls: The component class to instantiate. 26 params: Explicit keyword arguments forwarded to the parent constructor. 27 **slot_components: Mapping of slot names to child ``Component`` instances. 28 29 Returns: 30 The fully-assembled parent component (not yet dispatched). 31 32 Raises: 33 ComponentError: If a slot name is not accepted by the parent. 34 35 Example:: 36 37 card = compose( 38 CardComponent, 39 params={"title": "My Card"}, 40 header=HeaderComponent(text="Title"), 41 body=BodyComponent(items=[1, 2, 3]), 42 ) 43 result = card.dispatch() 44 """ 45 parent = parent_cls(**(params or {})) 46 for slot_name, child in slot_components.items(): 47 parent.fill_slot(slot_name, child) 48 return parent
Convenience function: instantiate a parent component and fill its slots in one call.
Keyword arguments whose values are Component instances are treated as
slot assignments; everything else is forwarded to the parent constructor
via params.
Arguments:
- parent_cls: The component class to instantiate.
- params: Explicit keyword arguments forwarded to the parent constructor.
- **slot_components: Mapping of slot names to child
Componentinstances.
Returns:
The fully-assembled parent component (not yet dispatched).
Raises:
- ComponentError: If a slot name is not accepted by the parent.
Example::
card = compose(
CardComponent,
params={"title": "My Card"},
header=HeaderComponent(text="Title"),
body=BodyComponent(items=[1, 2, 3]),
)
result = card.dispatch()
51class SlotRenderer: 52 """ 53 Mixin for renderers that support slot injection into templates. 54 55 Subclass your concrete ``Renderer`` together with this mixin to gain a 56 ``render_with_slots`` helper that merges pre-rendered slot HTML into the 57 template context before rendering. 58 59 Example:: 60 61 class MyRenderer(SlotRenderer, Renderer): 62 def render(self, template_name, context): 63 ... 64 65 def render_with_slots(self, template_name, context, slots): 66 ctx = self.merge_slot_context(context, slots) 67 return self.render(template_name, ctx) 68 """ 69 70 @staticmethod 71 def merge_slot_context( 72 context: dict[str, Any], 73 slots: dict[str, str], 74 ) -> dict[str, Any]: 75 """ 76 Return a *new* context dict with ``slots`` merged in. 77 78 If the context already contains a ``slots`` key it will be overwritten 79 with the provided *slots* mapping. 80 81 Args: 82 context: Original template context. 83 slots: Mapping of slot names to rendered HTML. 84 85 Returns: 86 A new dict combining *context* and the slot HTML. 87 """ 88 merged = {**context, "slots": slots} 89 return merged 90 91 def render_with_slots( 92 self, 93 template_name: str, 94 context: dict[str, Any], 95 slots: dict[str, str], 96 ) -> str: 97 """ 98 Render a template with slot HTML injected into the context. 99 100 The default implementation merges the slots into the context and 101 delegates to ``self.render()``. Override for custom behaviour. 102 103 Args: 104 template_name: Name/path of the template. 105 context: Template variables. 106 slots: Pre-rendered slot HTML keyed by slot name. 107 108 Returns: 109 Rendered HTML string. 110 """ 111 merged = self.merge_slot_context(context, slots) 112 # Delegate to the concrete Renderer.render() provided by the subclass. 113 return self.render(template_name, merged) # type: ignore[attr-defined]
Mixin for renderers that support slot injection into templates.
Subclass your concrete Renderer together with this mixin to gain a
render_with_slots helper that merges pre-rendered slot HTML into the
template context before rendering.
Example::
class MyRenderer(SlotRenderer, Renderer):
def render(self, template_name, context):
...
def render_with_slots(self, template_name, context, slots):
ctx = self.merge_slot_context(context, slots)
return self.render(template_name, ctx)
70 @staticmethod 71 def merge_slot_context( 72 context: dict[str, Any], 73 slots: dict[str, str], 74 ) -> dict[str, Any]: 75 """ 76 Return a *new* context dict with ``slots`` merged in. 77 78 If the context already contains a ``slots`` key it will be overwritten 79 with the provided *slots* mapping. 80 81 Args: 82 context: Original template context. 83 slots: Mapping of slot names to rendered HTML. 84 85 Returns: 86 A new dict combining *context* and the slot HTML. 87 """ 88 merged = {**context, "slots": slots} 89 return merged
Return a new context dict with slots merged in.
If the context already contains a slots key it will be overwritten
with the provided slots mapping.
Arguments:
- context: Original template context.
- slots: Mapping of slot names to rendered HTML.
Returns:
A new dict combining context and the slot HTML.
91 def render_with_slots( 92 self, 93 template_name: str, 94 context: dict[str, Any], 95 slots: dict[str, str], 96 ) -> str: 97 """ 98 Render a template with slot HTML injected into the context. 99 100 The default implementation merges the slots into the context and 101 delegates to ``self.render()``. Override for custom behaviour. 102 103 Args: 104 template_name: Name/path of the template. 105 context: Template variables. 106 slots: Pre-rendered slot HTML keyed by slot name. 107 108 Returns: 109 Rendered HTML string. 110 """ 111 merged = self.merge_slot_context(context, slots) 112 # Delegate to the concrete Renderer.render() provided by the subclass. 113 return self.render(template_name, merged) # type: ignore[attr-defined]
Render a template with slot HTML injected into the context.
The default implementation merges the slots into the context and
delegates to self.render(). Override for custom behaviour.
Arguments:
- template_name: Name/path of the template.
- context: Template variables.
- slots: Pre-rendered slot HTML keyed by slot name.
Returns:
Rendered HTML string.
11class FormComponent(Component): 12 """ 13 Form component with Pydantic validation. 14 15 Usage: 16 class UserFormSchema(BaseModel): 17 name: str 18 email: EmailStr 19 age: int = Field(ge=0, le=120) 20 21 @registry.register("user_form") 22 class UserForm(FormComponent): 23 schema = UserFormSchema 24 template_name = "user_form" 25 26 def on_submit(self): 27 # Validation happens automatically 28 # Access validated data in self.validated_data 29 pass 30 """ 31 32 # Pydantic model for validation 33 schema: ClassVar[type[BaseModel] | None] = None 34 35 def __init__(self, **params): 36 super().__init__(**params) 37 self.validated_data: dict[str, Any] | None = None 38 self.field_errors: dict[str, list[str]] = {} 39 40 # ---------- Validation ---------- 41 42 def validate(self, data: dict) -> bool: 43 """ 44 Validate data against schema. 45 46 Args: 47 data: Form data to validate 48 49 Returns: 50 True if valid, False otherwise 51 """ 52 if not self.schema: 53 # No schema, skip validation 54 self.validated_data = data 55 return True 56 57 try: 58 validated = self.schema(**data) 59 self.validated_data = validated.model_dump() 60 self.errors = {} 61 self.field_errors = {} 62 return True 63 except ValidationError as e: 64 self.validated_data = None 65 self.errors["form"] = "Validation failed" 66 67 # Convert Pydantic errors to field errors 68 self.field_errors = {} 69 for error in e.errors(): 70 field = str(error["loc"][0]) if error["loc"] else "form" 71 message = error["msg"] 72 73 if field not in self.field_errors: 74 self.field_errors[field] = [] 75 self.field_errors[field].append(message) 76 77 return False 78 79 def get_field_errors(self, field_name: str) -> list[str]: 80 """Get validation errors for a specific field.""" 81 return self.field_errors.get(field_name, []) 82 83 def has_field_error(self, field_name: str) -> bool: 84 """Check if field has validation errors.""" 85 return field_name in self.field_errors 86 87 # ---------- Form State ---------- 88 89 def mount(self): 90 """Initialize form state.""" 91 super().mount() 92 self.state["form_data"] = {} 93 self.state["submitted"] = False 94 self.state["is_valid"] = False 95 96 def get_field_value(self, field_name: str, default: Any = "") -> Any: 97 """Get current value of form field.""" 98 return self.state.get("form_data", {}).get(field_name, default) 99 100 def set_field_value(self, field_name: str, value: Any): 101 """Set value of form field.""" 102 if "form_data" not in self.state: 103 self.state["form_data"] = {} 104 self.state["form_data"][field_name] = value 105 106 # ---------- Event Handlers ---------- 107 108 def on_submit(self): 109 """ 110 Handle form submission. 111 112 Override in subclasses to add custom logic. 113 Validation happens automatically before this is called. 114 """ 115 pass 116 117 def on_field_change(self, field: str, value: Any): 118 """Handle field value change.""" 119 self.set_field_value(field, value) 120 121 # Clear field error on change 122 if field in self.field_errors: 123 del self.field_errors[field] 124 125 def handle_event(self, event: str, payload: dict): 126 """Handle events with automatic validation for submit.""" 127 if event == "submit": 128 # Extract form data 129 form_data = payload.get("form_data", self.state.get("form_data", {})) 130 self.state["form_data"] = form_data 131 self.state["submitted"] = True 132 133 # Validate 134 is_valid = self.validate(form_data) 135 self.state["is_valid"] = is_valid 136 137 if is_valid: 138 # Call submit handler 139 self.on_submit() 140 else: 141 # Regular event handling 142 super().handle_event(event, payload) 143 144 # ---------- Context ---------- 145 146 def get_context(self) -> dict: 147 """Add form-specific context.""" 148 context = super().get_context() 149 context.update( 150 { 151 "form_data": self.state.get("form_data", {}), 152 "field_errors": self.field_errors, 153 "is_valid": self.state.get("is_valid", False), 154 "submitted": self.state.get("submitted", False), 155 } 156 ) 157 return context
Form component with Pydantic validation.
Usage:
class UserFormSchema(BaseModel): name: str email: EmailStr age: int = Field(ge=0, le=120)
@registry.register("user_form") class UserForm(FormComponent): schema = UserFormSchema template_name = "user_form"
def on_submit(self): # Validation happens automatically # Access validated data in self.validated_data pass
42 def validate(self, data: dict) -> bool: 43 """ 44 Validate data against schema. 45 46 Args: 47 data: Form data to validate 48 49 Returns: 50 True if valid, False otherwise 51 """ 52 if not self.schema: 53 # No schema, skip validation 54 self.validated_data = data 55 return True 56 57 try: 58 validated = self.schema(**data) 59 self.validated_data = validated.model_dump() 60 self.errors = {} 61 self.field_errors = {} 62 return True 63 except ValidationError as e: 64 self.validated_data = None 65 self.errors["form"] = "Validation failed" 66 67 # Convert Pydantic errors to field errors 68 self.field_errors = {} 69 for error in e.errors(): 70 field = str(error["loc"][0]) if error["loc"] else "form" 71 message = error["msg"] 72 73 if field not in self.field_errors: 74 self.field_errors[field] = [] 75 self.field_errors[field].append(message) 76 77 return False
Validate data against schema.
Arguments:
- data: Form data to validate
Returns:
True if valid, False otherwise
79 def get_field_errors(self, field_name: str) -> list[str]: 80 """Get validation errors for a specific field.""" 81 return self.field_errors.get(field_name, [])
Get validation errors for a specific field.
83 def has_field_error(self, field_name: str) -> bool: 84 """Check if field has validation errors.""" 85 return field_name in self.field_errors
Check if field has validation errors.
89 def mount(self): 90 """Initialize form state.""" 91 super().mount() 92 self.state["form_data"] = {} 93 self.state["submitted"] = False 94 self.state["is_valid"] = False
Initialize form state.
96 def get_field_value(self, field_name: str, default: Any = "") -> Any: 97 """Get current value of form field.""" 98 return self.state.get("form_data", {}).get(field_name, default)
Get current value of form field.
100 def set_field_value(self, field_name: str, value: Any): 101 """Set value of form field.""" 102 if "form_data" not in self.state: 103 self.state["form_data"] = {} 104 self.state["form_data"][field_name] = value
Set value of form field.
108 def on_submit(self): 109 """ 110 Handle form submission. 111 112 Override in subclasses to add custom logic. 113 Validation happens automatically before this is called. 114 """ 115 pass
Handle form submission.
Override in subclasses to add custom logic. Validation happens automatically before this is called.
117 def on_field_change(self, field: str, value: Any): 118 """Handle field value change.""" 119 self.set_field_value(field, value) 120 121 # Clear field error on change 122 if field in self.field_errors: 123 del self.field_errors[field]
Handle field value change.
125 def handle_event(self, event: str, payload: dict): 126 """Handle events with automatic validation for submit.""" 127 if event == "submit": 128 # Extract form data 129 form_data = payload.get("form_data", self.state.get("form_data", {})) 130 self.state["form_data"] = form_data 131 self.state["submitted"] = True 132 133 # Validate 134 is_valid = self.validate(form_data) 135 self.state["is_valid"] = is_valid 136 137 if is_valid: 138 # Call submit handler 139 self.on_submit() 140 else: 141 # Regular event handling 142 super().handle_event(event, payload)
Handle events with automatic validation for submit.
146 def get_context(self) -> dict: 147 """Add form-specific context.""" 148 context = super().get_context() 149 context.update( 150 { 151 "form_data": self.state.get("form_data", {}), 152 "field_errors": self.field_errors, 153 "is_valid": self.state.get("is_valid", False), 154 "submitted": self.state.get("submitted", False), 155 } 156 ) 157 return context
Add form-specific context.
160class ModelFormComponent(FormComponent): 161 """ 162 Form component with model instance support. 163 164 Combines FormComponent validation with model persistence. 165 Works with DjangoModelMixin or similar patterns. 166 """ 167 168 # Provided by DjangoModelMixin or subclass 169 instance: Any = None 170 state_fields: ClassVar[list[str]] = [] 171 172 def mount(self): 173 """Initialize form with instance data.""" 174 super().mount() 175 176 # Populate form from instance if available 177 if self.instance: 178 self.populate_form_from_instance() 179 180 def populate_form_from_instance(self): 181 """Populate form data from model instance.""" 182 form_data = {} 183 for field_name in self.state_fields: 184 value = getattr(self.instance, field_name, None) 185 form_data[field_name] = value 186 187 self.state["form_data"] = form_data 188 189 def on_submit(self): 190 """Save validated data to model instance.""" 191 if not self.validated_data: 192 return 193 194 # Update instance from validated data 195 for field_name, value in self.validated_data.items(): 196 if hasattr(self.instance, field_name): 197 setattr(self.instance, field_name, value) 198 199 # Save instance (if method exists) 200 if hasattr(self, "save_instance"): 201 self.save_instance() # type: ignore[call-non-callable] # provided by DjangoModelMixin via MRO
Form component with model instance support.
Combines FormComponent validation with model persistence. Works with DjangoModelMixin or similar patterns.
172 def mount(self): 173 """Initialize form with instance data.""" 174 super().mount() 175 176 # Populate form from instance if available 177 if self.instance: 178 self.populate_form_from_instance()
Initialize form with instance data.
180 def populate_form_from_instance(self): 181 """Populate form data from model instance.""" 182 form_data = {} 183 for field_name in self.state_fields: 184 value = getattr(self.instance, field_name, None) 185 form_data[field_name] = value 186 187 self.state["form_data"] = form_data
Populate form data from model instance.
189 def on_submit(self): 190 """Save validated data to model instance.""" 191 if not self.validated_data: 192 return 193 194 # Update instance from validated data 195 for field_name, value in self.validated_data.items(): 196 if hasattr(self.instance, field_name): 197 setattr(self.instance, field_name, value) 198 199 # Save instance (if method exists) 200 if hasattr(self, "save_instance"): 201 self.save_instance() # type: ignore[call-non-callable] # provided by DjangoModelMixin via MRO
Save validated data to model instance.
31class AllowAny(BasePermission): 32 """Allow unrestricted access. Default permission class.""" 33 34 def has_permission(self, request: Any, component_cls: type) -> bool: 35 return True
Allow unrestricted access. Default permission class.
10class BasePermission: 11 """ 12 Base permission class. All permission classes must inherit from this. 13 14 Subclasses must implement `has_permission`. 15 """ 16 17 def has_permission(self, request: Any, component_cls: type) -> bool: 18 """ 19 Return True if the request has permission to access the component. 20 21 Args: 22 request: The incoming request object. 23 component_cls: The component class being accessed. 24 25 Returns: 26 True if permission is granted, False otherwise. 27 """ 28 return True
Base permission class. All permission classes must inherit from this.
Subclasses must implement has_permission.
17 def has_permission(self, request: Any, component_cls: type) -> bool: 18 """ 19 Return True if the request has permission to access the component. 20 21 Args: 22 request: The incoming request object. 23 component_cls: The component class being accessed. 24 25 Returns: 26 True if permission is granted, False otherwise. 27 """ 28 return True
Return True if the request has permission to access the component.
Arguments:
- request: The incoming request object.
- component_cls: The component class being accessed.
Returns:
True if permission is granted, False otherwise.
59class DjangoModelPermission(BasePermission): 60 """ 61 Allow access based on a specific Django model permission. 62 63 Set `permission_required` on the component class: 64 65 @registry.register("my_component") 66 class MyComponent(Component): 67 permission_classes = [DjangoModelPermission] 68 permission_required = "myapp.change_mymodel" 69 """ 70 71 def has_permission(self, request: Any, component_cls: type) -> bool: 72 permission = getattr(component_cls, "permission_required", "") 73 if not permission: 74 return True 75 user = getattr(request, "user", None) 76 if not user: 77 return False 78 if isinstance(permission, str): 79 return user.has_perm(permission) 80 # Iterable of permission strings — all must be held 81 return all(user.has_perm(p) for p in permission)
Allow access based on a specific Django model permission.
Set permission_required on the component class:
@registry.register("my_component")
class MyComponent(Component):
permission_classes = [DjangoModelPermission]
permission_required = "myapp.change_mymodel"
71 def has_permission(self, request: Any, component_cls: type) -> bool: 72 permission = getattr(component_cls, "permission_required", "") 73 if not permission: 74 return True 75 user = getattr(request, "user", None) 76 if not user: 77 return False 78 if isinstance(permission, str): 79 return user.has_perm(permission) 80 # Iterable of permission strings — all must be held 81 return all(user.has_perm(p) for p in permission)
Return True if the request has permission to access the component.
Arguments:
- request: The incoming request object.
- component_cls: The component class being accessed.
Returns:
True if permission is granted, False otherwise.
38class IsAuthenticated(BasePermission): 39 """Allow access only to authenticated users.""" 40 41 def has_permission(self, request: Any, component_cls: type) -> bool: 42 return bool(getattr(request, "user", None) and request.user.is_authenticated)
Allow access only to authenticated users.
41 def has_permission(self, request: Any, component_cls: type) -> bool: 42 return bool(getattr(request, "user", None) and request.user.is_authenticated)
Return True if the request has permission to access the component.
Arguments:
- request: The incoming request object.
- component_cls: The component class being accessed.
Returns:
True if permission is granted, False otherwise.
45class IsStaff(BasePermission): 46 """Allow access only to staff users.""" 47 48 def has_permission(self, request: Any, component_cls: type) -> bool: 49 return bool(getattr(request, "user", None) and request.user.is_staff)
Allow access only to staff users.
48 def has_permission(self, request: Any, component_cls: type) -> bool: 49 return bool(getattr(request, "user", None) and request.user.is_staff)
Return True if the request has permission to access the component.
Arguments:
- request: The incoming request object.
- component_cls: The component class being accessed.
Returns:
True if permission is granted, False otherwise.
52class IsSuperuser(BasePermission): 53 """Allow access only to superusers.""" 54 55 def has_permission(self, request: Any, component_cls: type) -> bool: 56 return bool(getattr(request, "user", None) and request.user.is_superuser)
Allow access only to superusers.
55 def has_permission(self, request: Any, component_cls: type) -> bool: 56 return bool(getattr(request, "user", None) and request.user.is_superuser)
Return True if the request has permission to access the component.
Arguments:
- request: The incoming request object.
- component_cls: The component class being accessed.
Returns:
True if permission is granted, False otherwise.
7class ComponentRegistry: 8 """Registry for component classes.""" 9 10 def __init__(self): 11 self._registry: dict[str, type[Component]] = {} 12 13 def register(self, name: str): 14 """ 15 Decorator to register a component class. 16 17 Usage: 18 @registry.register("counter") 19 class Counter(Component): 20 ... 21 """ 22 23 def decorator(cls: type[Component]): 24 if name in self._registry: 25 if self._registry[name] is cls: 26 return cls # idempotent: same class re-imported, no-op 27 raise ValueError( 28 f"Component '{name}' already registered by a different class " 29 f"({self._registry[name].__qualname__!r})" 30 ) 31 self._registry[name] = cls 32 return cls 33 34 return decorator 35 36 def get(self, name: str) -> type[Component] | None: 37 """Get component class by name.""" 38 return self._registry.get(name) 39 40 def __getitem__(self, name: str) -> type[Component]: 41 """Get component class by name, raises KeyError if not found.""" 42 return self._registry[name] 43 44 def list(self) -> list[str]: 45 """List all registered component names.""" 46 return list(self._registry.keys())
Registry for component classes.
13 def register(self, name: str): 14 """ 15 Decorator to register a component class. 16 17 Usage: 18 @registry.register("counter") 19 class Counter(Component): 20 ... 21 """ 22 23 def decorator(cls: type[Component]): 24 if name in self._registry: 25 if self._registry[name] is cls: 26 return cls # idempotent: same class re-imported, no-op 27 raise ValueError( 28 f"Component '{name}' already registered by a different class " 29 f"({self._registry[name].__qualname__!r})" 30 ) 31 self._registry[name] = cls 32 return cls 33 34 return decorator
Decorator to register a component class.
Usage:
@registry.register("counter") class Counter(Component): ...
7class Renderer(ABC): 8 """Abstract base class for template renderers.""" 9 10 @abstractmethod 11 def render(self, template_name: str, context: dict) -> str: 12 """ 13 Render template with context. 14 15 Args: 16 template_name: Name/path of template 17 context: Template variables 18 19 Returns: 20 Rendered HTML string 21 """ 22 raise NotImplementedError
Abstract base class for template renderers.
10 @abstractmethod 11 def render(self, template_name: str, context: dict) -> str: 12 """ 13 Render template with context. 14 15 Args: 16 template_name: Name/path of template 17 context: Template variables 18 19 Returns: 20 Rendered HTML string 21 """ 22 raise NotImplementedError
Render template with context.
Arguments:
- template_name: Name/path of template
- context: Template variables
Returns:
Rendered HTML string
7class StateStore(ABC): 8 """Abstract base class for state persistence.""" 9 10 @abstractmethod 11 def save(self, component_id: str, state: dict): 12 """ 13 Save component state. 14 15 Args: 16 component_id: Unique component identifier 17 state: Component state to persist 18 """ 19 raise NotImplementedError 20 21 @abstractmethod 22 def load(self, component_id: str) -> dict | None: 23 """ 24 Load component state. 25 26 Args: 27 component_id: Unique component identifier 28 29 Returns: 30 Component state or None if not found 31 """ 32 raise NotImplementedError 33 34 @abstractmethod 35 def delete(self, component_id: str): 36 """Delete component state.""" 37 raise NotImplementedError
Abstract base class for state persistence.
10 @abstractmethod 11 def save(self, component_id: str, state: dict): 12 """ 13 Save component state. 14 15 Args: 16 component_id: Unique component identifier 17 state: Component state to persist 18 """ 19 raise NotImplementedError
Save component state.
Arguments:
- component_id: Unique component identifier
- state: Component state to persist
21 @abstractmethod 22 def load(self, component_id: str) -> dict | None: 23 """ 24 Load component state. 25 26 Args: 27 component_id: Unique component identifier 28 29 Returns: 30 Component state or None if not found 31 """ 32 raise NotImplementedError
Load component state.
Arguments:
- component_id: Unique component identifier
Returns:
Component state or None if not found
40class InMemoryStateStore(StateStore): 41 """Simple in-memory state storage for development.""" 42 43 def __init__(self): 44 self._store: dict[str, dict] = {} 45 46 def save(self, component_id: str, state: dict): 47 self._store[component_id] = state.copy() 48 49 def load(self, component_id: str) -> dict | None: 50 return self._store.get(component_id) 51 52 def delete(self, component_id: str): 53 self._store.pop(component_id, None) 54 55 def clear(self): 56 """Clear all stored state (useful for testing).""" 57 self._store.clear()
Simple in-memory state storage for development.
Save component state.
Arguments:
- component_id: Unique component identifier
- state: Component state to persist
Load component state.
Arguments:
- component_id: Unique component identifier
Returns:
Component state or None if not found
33class ComponentWebSocketManager: 34 """ 35 Manages WebSocket connections for components. 36 37 Handles: 38 - Connection lifecycle 39 - Message routing 40 - Component updates 41 - Broadcasting 42 """ 43 44 def __init__(self): 45 self.connections: dict[str, list[WebSocketConnection]] = {} 46 self.component_subscribers: dict[str, set[str]] = {} 47 48 async def connect(self, connection: WebSocketConnection, connection_id: str): 49 """Register new WebSocket connection.""" 50 if connection_id not in self.connections: 51 self.connections[connection_id] = [] 52 self.connections[connection_id].append(connection) 53 logger.info(f"WebSocket connected: {connection_id}") 54 55 async def disconnect(self, connection_id: str): 56 """Remove WebSocket connection.""" 57 if connection_id in self.connections: 58 del self.connections[connection_id] 59 60 # Clean up subscriptions 61 for component_id, subscribers in self.component_subscribers.items(): 62 subscribers.discard(connection_id) 63 64 logger.info(f"WebSocket disconnected: {connection_id}") 65 66 def subscribe(self, connection_id: str, component_id: str): 67 """Subscribe connection to component updates.""" 68 if component_id not in self.component_subscribers: 69 self.component_subscribers[component_id] = set() 70 self.component_subscribers[component_id].add(connection_id) 71 logger.debug(f"Subscribed {connection_id} to component {component_id}") 72 73 def unsubscribe(self, connection_id: str, component_id: str): 74 """Unsubscribe connection from component updates.""" 75 if component_id in self.component_subscribers: 76 self.component_subscribers[component_id].discard(connection_id) 77 78 async def handle_message( 79 self, connection: WebSocketConnection, connection_id: str, message: dict 80 ): 81 """ 82 Handle incoming WebSocket message. 83 84 Message format: 85 { 86 "type": "component_event", 87 "component": "component_name", 88 "component_id": "component-123", 89 "event": "event_name", 90 "payload": {...}, 91 "state": "serialized_state" 92 } 93 """ 94 msg_type = message.get("type") 95 96 if msg_type == "component_event": 97 await self._handle_component_event(connection, connection_id, message) 98 elif msg_type == "subscribe": 99 component_id = message.get("component_id") 100 if component_id: 101 self.subscribe(connection_id, component_id) 102 await connection.send({"type": "subscribed", "component_id": component_id}) 103 elif msg_type == "unsubscribe": 104 component_id = message.get("component_id") 105 if component_id: 106 self.unsubscribe(connection_id, component_id) 107 await connection.send({"type": "unsubscribed", "component_id": component_id}) 108 else: 109 logger.warning(f"Unknown message type: {msg_type}") 110 111 async def _handle_component_event( 112 self, connection: WebSocketConnection, connection_id: str, message: dict 113 ): 114 """Handle component event from WebSocket.""" 115 try: 116 component_name: str = message.get("component", "") 117 component_id: str | None = message.get("component_id") 118 event: str | None = message.get("event") 119 payload: dict = message.get("payload", {}) 120 state_str: str | None = message.get("state") 121 122 # Get component class 123 component_cls = registry.get(component_name) 124 if not component_cls: 125 await connection.send( 126 { 127 "type": "error", 128 "error": f"Component '{component_name}' not found", 129 } 130 ) 131 return 132 133 # Deserialize state 134 state = None 135 if state_str: 136 state = StateSerializer.deserialize(state_str) 137 138 # Create and dispatch component 139 params = {"component_id": component_id} 140 component = component_cls(**params) 141 result = component.dispatch(event=event, payload=payload, state=state) 142 143 # Serialize state 144 result["state"] = StateSerializer.serialize(result["state"]) 145 146 # Send update to requesting connection 147 await connection.send({"type": "component_update", **result}) 148 149 # Broadcast to subscribers (optional) 150 if message.get("broadcast", False) and component_id: 151 await self.broadcast_update(component_id, result) 152 153 except Exception as e: 154 logger.exception("Error handling component event") 155 await connection.send({"type": "error", "error": str(e)}) 156 157 async def broadcast_update(self, component_id: str, update: dict): 158 """ 159 Broadcast component update to all subscribers. 160 161 Args: 162 component_id: Component to broadcast update for 163 update: Update data (html, state, etc.) 164 """ 165 if component_id not in self.component_subscribers: 166 return 167 168 subscribers = self.component_subscribers[component_id] 169 message = {"type": "component_update", **update} 170 171 # Send to all subscribers 172 tasks = [] 173 for connection_id in subscribers: 174 if connection_id in self.connections: 175 for conn in self.connections[connection_id]: 176 tasks.append(conn.send(message)) 177 178 if tasks: 179 await asyncio.gather(*tasks, return_exceptions=True) 180 logger.debug(f"Broadcasted update for {component_id} to {len(tasks)} connections") 181 182 async def push_update(self, component_id: str, html: str, state: dict): 183 """ 184 Push server-initiated update to component subscribers. 185 186 Useful for external events (model updates, background jobs, etc.) 187 """ 188 serialized_state = StateSerializer.serialize(state) 189 update = { 190 "component_id": component_id, 191 "html": html, 192 "state": serialized_state, 193 } 194 await self.broadcast_update(component_id, update)
Manages WebSocket connections for components.
Handles:
- Connection lifecycle
- Message routing
- Component updates
- Broadcasting
48 async def connect(self, connection: WebSocketConnection, connection_id: str): 49 """Register new WebSocket connection.""" 50 if connection_id not in self.connections: 51 self.connections[connection_id] = [] 52 self.connections[connection_id].append(connection) 53 logger.info(f"WebSocket connected: {connection_id}")
Register new WebSocket connection.
55 async def disconnect(self, connection_id: str): 56 """Remove WebSocket connection.""" 57 if connection_id in self.connections: 58 del self.connections[connection_id] 59 60 # Clean up subscriptions 61 for component_id, subscribers in self.component_subscribers.items(): 62 subscribers.discard(connection_id) 63 64 logger.info(f"WebSocket disconnected: {connection_id}")
Remove WebSocket connection.
66 def subscribe(self, connection_id: str, component_id: str): 67 """Subscribe connection to component updates.""" 68 if component_id not in self.component_subscribers: 69 self.component_subscribers[component_id] = set() 70 self.component_subscribers[component_id].add(connection_id) 71 logger.debug(f"Subscribed {connection_id} to component {component_id}")
Subscribe connection to component updates.
73 def unsubscribe(self, connection_id: str, component_id: str): 74 """Unsubscribe connection from component updates.""" 75 if component_id in self.component_subscribers: 76 self.component_subscribers[component_id].discard(connection_id)
Unsubscribe connection from component updates.
78 async def handle_message( 79 self, connection: WebSocketConnection, connection_id: str, message: dict 80 ): 81 """ 82 Handle incoming WebSocket message. 83 84 Message format: 85 { 86 "type": "component_event", 87 "component": "component_name", 88 "component_id": "component-123", 89 "event": "event_name", 90 "payload": {...}, 91 "state": "serialized_state" 92 } 93 """ 94 msg_type = message.get("type") 95 96 if msg_type == "component_event": 97 await self._handle_component_event(connection, connection_id, message) 98 elif msg_type == "subscribe": 99 component_id = message.get("component_id") 100 if component_id: 101 self.subscribe(connection_id, component_id) 102 await connection.send({"type": "subscribed", "component_id": component_id}) 103 elif msg_type == "unsubscribe": 104 component_id = message.get("component_id") 105 if component_id: 106 self.unsubscribe(connection_id, component_id) 107 await connection.send({"type": "unsubscribed", "component_id": component_id}) 108 else: 109 logger.warning(f"Unknown message type: {msg_type}")
Handle incoming WebSocket message.
Message format: { "type": "component_event", "component": "component_name", "component_id": "component-123", "event": "event_name", "payload": {...}, "state": "serialized_state" }
157 async def broadcast_update(self, component_id: str, update: dict): 158 """ 159 Broadcast component update to all subscribers. 160 161 Args: 162 component_id: Component to broadcast update for 163 update: Update data (html, state, etc.) 164 """ 165 if component_id not in self.component_subscribers: 166 return 167 168 subscribers = self.component_subscribers[component_id] 169 message = {"type": "component_update", **update} 170 171 # Send to all subscribers 172 tasks = [] 173 for connection_id in subscribers: 174 if connection_id in self.connections: 175 for conn in self.connections[connection_id]: 176 tasks.append(conn.send(message)) 177 178 if tasks: 179 await asyncio.gather(*tasks, return_exceptions=True) 180 logger.debug(f"Broadcasted update for {component_id} to {len(tasks)} connections")
Broadcast component update to all subscribers.
Arguments:
- component_id: Component to broadcast update for
- update: Update data (html, state, etc.)
182 async def push_update(self, component_id: str, html: str, state: dict): 183 """ 184 Push server-initiated update to component subscribers. 185 186 Useful for external events (model updates, background jobs, etc.) 187 """ 188 serialized_state = StateSerializer.serialize(state) 189 update = { 190 "component_id": component_id, 191 "html": html, 192 "state": serialized_state, 193 } 194 await self.broadcast_update(component_id, update)
Push server-initiated update to component subscribers.
Useful for external events (model updates, background jobs, etc.)
14class WebSocketConnection(ABC): 15 """Abstract WebSocket connection.""" 16 17 @abstractmethod 18 async def send(self, data: dict): 19 """Send data to client.""" 20 raise NotImplementedError 21 22 @abstractmethod 23 async def receive(self) -> dict: 24 """Receive data from client.""" 25 raise NotImplementedError 26 27 @abstractmethod 28 async def close(self): 29 """Close connection.""" 30 raise NotImplementedError
Abstract WebSocket connection.
17 @abstractmethod 18 async def send(self, data: dict): 19 """Send data to client.""" 20 raise NotImplementedError
Send data to client.