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]
class Component:
 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:
  1. __init__(**params)
  2. mount() OR hydrate(state)
  3. handle_event(event, payload)
  4. before_render()
  5. render()
  6. dehydrate()
Slots:

Components can declare named slots via the slots class variable. Child components are assigned to slots with fill_slot() and their rendered HTML is available in the template context under slots.

Component(**params)
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] = {}
template_name: str | None = None
renderer = None
permission_classes: ClassVar[list[type[BasePermission]]] = []
slots: ClassVar[list[str]] = []

Slot names this component accepts. Empty list means any slot name is accepted.

params
state: dict[str, typing.Any]
errors: dict[str, str]
id
def mount(self):
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.

def hydrate(self, state: dict):
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.

def dehydrate(self) -> dict:
74    def dehydrate(self) -> dict:
75        """Serialize component state for persistence."""
76        return self.state.copy()

Serialize component state for persistence.

def before_render(self):
78    def before_render(self):
79        """Called before rendering. Use for derived state computation."""
80        pass

Called before rendering. Use for derived state computation.

def fill_slot( self, slot_name: str, component: Component) -> None:
 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 slots list.
def render_slots(self) -> dict[str, str]:
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.

def get_optimistic_patch(self, event: str, payload: dict) -> dict | None:
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
def handle_event(self, event: str, payload: dict):
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
def get_context(self) -> dict:
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.

def render(self) -> str:
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.

def dispatch( self, event: str | None = None, payload: dict | None = None, state: dict | None = None) -> dict:
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

class ComponentError(builtins.Exception):
15class ComponentError(Exception):
16    """Base exception for component errors."""
17
18    pass

Base exception for component errors.

class EventNotFoundError(component_framework.core.ComponentError):
21class EventNotFoundError(ComponentError):
22    """Raised when an event handler is not found."""
23
24    pass

Raised when an event handler is not found.

class StateSerializer:
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.

@staticmethod
def serialize(state: dict) -> str:
252    @staticmethod
253    def serialize(state: dict) -> str:
254        """Serialize state to JSON string."""
255        return json.dumps(state, default=str)

Serialize state to JSON string.

@staticmethod
def deserialize(data: str) -> dict:
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)

Deserialize state from JSON string.

def compose( parent_cls: type[Component], *, params: dict[str, typing.Any] | None = None, **slot_components: Component) -> Component:
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 Component instances.
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()
class SlotRenderer:
 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)
@staticmethod
def merge_slot_context( context: dict[str, typing.Any], slots: dict[str, str]) -> dict[str, typing.Any]:
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.

def render_with_slots( self, template_name: str, context: dict[str, typing.Any], slots: dict[str, str]) -> str:
 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.

class FormComponent(component_framework.core.Component):
 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
FormComponent(**params)
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]] = {}
schema: ClassVar[type[pydantic.main.BaseModel] | None] = None
validated_data: dict[str, typing.Any] | None
field_errors: dict[str, list[str]]
def validate(self, data: dict) -> bool:
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

def get_field_errors(self, field_name: str) -> list[str]:
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.

def has_field_error(self, field_name: str) -> bool:
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.

def mount(self):
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.

def get_field_value(self, field_name: str, default: Any = '') -> Any:
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.

def set_field_value(self, field_name: str, value: Any):
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.

def on_submit(self):
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.

def on_field_change(self, field: str, value: Any):
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.

def handle_event(self, event: str, payload: dict):
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.

def get_context(self) -> dict:
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.

class ModelFormComponent(component_framework.core.FormComponent):
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.

instance: Any = None
state_fields: ClassVar[list[str]] = []
def mount(self):
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.

def populate_form_from_instance(self):
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.

def on_submit(self):
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.

class AllowAny(component_framework.core.BasePermission):
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.

def has_permission(self, request: Any, component_cls: type) -> bool:
34    def has_permission(self, request: Any, component_cls: type) -> bool:
35        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.

class BasePermission:
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.

def has_permission(self, request: Any, component_cls: type) -> bool:
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.

class DjangoModelPermission(component_framework.core.BasePermission):
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"
def has_permission(self, request: Any, component_cls: type) -> bool:
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.

class IsAuthenticated(component_framework.core.BasePermission):
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.

def has_permission(self, request: Any, component_cls: type) -> bool:
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.

class IsStaff(component_framework.core.BasePermission):
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.

def has_permission(self, request: Any, component_cls: type) -> bool:
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.

class IsSuperuser(component_framework.core.BasePermission):
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.

def has_permission(self, request: Any, component_cls: type) -> bool:
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.

class ComponentRegistry:
 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.

def register(self, name: str):
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): ...

def get( self, name: str) -> type[Component] | None:
36    def get(self, name: str) -> type[Component] | None:
37        """Get component class by name."""
38        return self._registry.get(name)

Get component class by name.

def list(self) -> list[str]:
44    def list(self) -> list[str]:
45        """List all registered component names."""
46        return list(self._registry.keys())

List all registered component names.

registry = <ComponentRegistry object>
class Renderer(abc.ABC):
 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.

@abstractmethod
def render(self, template_name: str, context: dict) -> str:
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

class StateStore(abc.ABC):
 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.

@abstractmethod
def save(self, component_id: str, state: dict):
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
@abstractmethod
def load(self, component_id: str) -> dict | None:
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

@abstractmethod
def delete(self, component_id: str):
34    @abstractmethod
35    def delete(self, component_id: str):
36        """Delete component state."""
37        raise NotImplementedError

Delete component state.

class InMemoryStateStore(component_framework.core.StateStore):
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.

def save(self, component_id: str, state: dict):
46    def save(self, component_id: str, state: dict):
47        self._store[component_id] = state.copy()

Save component state.

Arguments:
  • component_id: Unique component identifier
  • state: Component state to persist
def load(self, component_id: str) -> dict | None:
49    def load(self, component_id: str) -> dict | None:
50        return self._store.get(component_id)

Load component state.

Arguments:
  • component_id: Unique component identifier
Returns:

Component state or None if not found

def delete(self, component_id: str):
52    def delete(self, component_id: str):
53        self._store.pop(component_id, None)

Delete component state.

def clear(self):
55    def clear(self):
56        """Clear all stored state (useful for testing)."""
57        self._store.clear()

Clear all stored state (useful for testing).

class ComponentWebSocketManager:
 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
connections: dict[str, list[WebSocketConnection]]
component_subscribers: dict[str, set[str]]
async def connect( self, connection: WebSocketConnection, connection_id: str):
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.

async def disconnect(self, connection_id: str):
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.

def subscribe(self, connection_id: str, component_id: str):
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.

def unsubscribe(self, connection_id: str, component_id: str):
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.

async def handle_message( self, connection: WebSocketConnection, connection_id: str, message: dict):
 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" }

async def broadcast_update(self, component_id: str, update: dict):
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.)
async def push_update(self, component_id: str, html: str, state: dict):
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.)

class WebSocketConnection(abc.ABC):
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.

@abstractmethod
async def send(self, data: dict):
17    @abstractmethod
18    async def send(self, data: dict):
19        """Send data to client."""
20        raise NotImplementedError

Send data to client.

@abstractmethod
async def receive(self) -> dict:
22    @abstractmethod
23    async def receive(self) -> dict:
24        """Receive data from client."""
25        raise NotImplementedError

Receive data from client.

@abstractmethod
async def close(self):
27    @abstractmethod
28    async def close(self):
29        """Close connection."""
30        raise NotImplementedError

Close connection.

ws_manager = <ComponentWebSocketManager object>