pyview-web 0.3.0__py3-none-any.whl → 0.8.0a2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyview/__init__.py +16 -6
- pyview/assets/js/app.js +1 -0
- pyview/assets/js/uploaders.js +221 -0
- pyview/assets/package-lock.json +16 -14
- pyview/assets/package.json +2 -2
- pyview/async_stream_runner.py +2 -1
- pyview/auth/__init__.py +3 -1
- pyview/auth/provider.py +6 -6
- pyview/auth/required.py +7 -10
- pyview/binding/__init__.py +47 -0
- pyview/binding/binder.py +134 -0
- pyview/binding/context.py +33 -0
- pyview/binding/converters.py +191 -0
- pyview/binding/helpers.py +78 -0
- pyview/binding/injectables.py +119 -0
- pyview/binding/params.py +105 -0
- pyview/binding/result.py +32 -0
- pyview/changesets/__init__.py +2 -0
- pyview/changesets/changesets.py +8 -3
- pyview/cli/commands/create_view.py +4 -3
- pyview/cli/main.py +1 -1
- pyview/components/__init__.py +72 -0
- pyview/components/base.py +212 -0
- pyview/components/lifecycle.py +85 -0
- pyview/components/manager.py +366 -0
- pyview/components/renderer.py +14 -0
- pyview/components/slots.py +73 -0
- pyview/csrf.py +4 -2
- pyview/events/AutoEventDispatch.py +98 -0
- pyview/events/BaseEventHandler.py +51 -8
- pyview/events/__init__.py +2 -1
- pyview/instrumentation/__init__.py +3 -3
- pyview/instrumentation/interfaces.py +57 -33
- pyview/instrumentation/noop.py +21 -18
- pyview/js.py +20 -23
- pyview/live_routes.py +5 -3
- pyview/live_socket.py +167 -44
- pyview/live_view.py +24 -12
- pyview/meta.py +14 -2
- pyview/phx_message.py +7 -8
- pyview/playground/__init__.py +10 -0
- pyview/playground/builder.py +118 -0
- pyview/playground/favicon.py +39 -0
- pyview/pyview.py +54 -20
- pyview/session.py +2 -0
- pyview/static/assets/app.js +2088 -806
- pyview/static/assets/uploaders.js +221 -0
- pyview/stream.py +308 -0
- pyview/template/__init__.py +11 -1
- pyview/template/live_template.py +12 -8
- pyview/template/live_view_template.py +338 -0
- pyview/template/render_diff.py +33 -7
- pyview/template/root_template.py +21 -9
- pyview/template/serializer.py +2 -5
- pyview/template/template_view.py +170 -0
- pyview/template/utils.py +3 -2
- pyview/uploads.py +344 -55
- pyview/vendor/flet/pubsub/__init__.py +3 -1
- pyview/vendor/flet/pubsub/pub_sub.py +10 -18
- pyview/vendor/ibis/__init__.py +3 -7
- pyview/vendor/ibis/compiler.py +25 -32
- pyview/vendor/ibis/context.py +13 -15
- pyview/vendor/ibis/errors.py +0 -6
- pyview/vendor/ibis/filters.py +70 -76
- pyview/vendor/ibis/loaders.py +6 -7
- pyview/vendor/ibis/nodes.py +40 -42
- pyview/vendor/ibis/template.py +4 -5
- pyview/vendor/ibis/tree.py +62 -3
- pyview/vendor/ibis/utils.py +14 -15
- pyview/ws_handler.py +116 -86
- {pyview_web-0.3.0.dist-info → pyview_web-0.8.0a2.dist-info}/METADATA +39 -33
- pyview_web-0.8.0a2.dist-info/RECORD +80 -0
- pyview_web-0.8.0a2.dist-info/WHEEL +4 -0
- pyview_web-0.8.0a2.dist-info/entry_points.txt +3 -0
- pyview_web-0.3.0.dist-info/LICENSE +0 -21
- pyview_web-0.3.0.dist-info/RECORD +0 -58
- pyview_web-0.3.0.dist-info/WHEEL +0 -4
- pyview_web-0.3.0.dist-info/entry_points.txt +0 -3
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base classes for PyView LiveComponents.
|
|
3
|
+
|
|
4
|
+
LiveComponents are stateful, reusable UI components that can handle their own
|
|
5
|
+
events and maintain isolated state. They are inspired by Phoenix LiveComponents.
|
|
6
|
+
|
|
7
|
+
Key concepts:
|
|
8
|
+
- LiveComponent: Base class defining component behavior (lifecycle, events, template)
|
|
9
|
+
- ComponentMeta: Metadata passed to template(), includes `myself` (CID) for event targeting
|
|
10
|
+
- ComponentSocket: Handle passed to lifecycle methods for state access
|
|
11
|
+
|
|
12
|
+
CID (Component ID) is stored externally in ComponentsManager, not on the component
|
|
13
|
+
instance. This keeps components clean and testable.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Generic, Optional, TypeVar
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from string.templatelib import Template # type: ignore[import-not-found]
|
|
21
|
+
|
|
22
|
+
from pyview.meta import PyViewMeta
|
|
23
|
+
|
|
24
|
+
# Avoid circular import - manager imports this module
|
|
25
|
+
from .manager import ComponentsManager
|
|
26
|
+
|
|
27
|
+
T = TypeVar("T")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ComponentMeta:
|
|
32
|
+
"""
|
|
33
|
+
Metadata passed to component's template() method.
|
|
34
|
+
|
|
35
|
+
Contains the component's CID (Component ID) which is used for event targeting
|
|
36
|
+
via phx-target={meta.myself}, and any slots passed from the parent.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
cid: The component's unique identifier (assigned by ComponentsManager)
|
|
40
|
+
parent_meta: The parent LiveView's PyViewMeta
|
|
41
|
+
slots: Dictionary of slot content passed from parent template
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
cid: int
|
|
45
|
+
parent_meta: "PyViewMeta"
|
|
46
|
+
slots: "dict[str, Template]" = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def myself(self) -> int:
|
|
50
|
+
"""
|
|
51
|
+
Returns the CID for use in phx-target.
|
|
52
|
+
|
|
53
|
+
This is the Python equivalent of Phoenix's @myself.
|
|
54
|
+
Use it in templates: phx-target="{meta.myself}"
|
|
55
|
+
"""
|
|
56
|
+
return self.cid
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class ComponentSocket(Generic[T]):
|
|
61
|
+
"""
|
|
62
|
+
Socket passed to component lifecycle methods.
|
|
63
|
+
|
|
64
|
+
This is an ephemeral handle created for each lifecycle call (mount, update,
|
|
65
|
+
handle_event). The context is the persistent state, stored externally in
|
|
66
|
+
ComponentsManager.
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
context: The component's current state (read/write)
|
|
70
|
+
cid: The component's unique identifier
|
|
71
|
+
manager: Reference to the ComponentsManager for advanced operations
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
context: T
|
|
75
|
+
cid: int
|
|
76
|
+
manager: "ComponentsManager"
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def myself(self) -> int:
|
|
80
|
+
"""Returns the CID for use in templates/events."""
|
|
81
|
+
return self.cid
|
|
82
|
+
|
|
83
|
+
async def send_parent(self, event: str, payload: Optional[dict[str, Any]] = None) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Send an event to the parent LiveView.
|
|
86
|
+
|
|
87
|
+
This allows components to communicate upward to their parent.
|
|
88
|
+
The parent's handle_event will be called with this event.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
event: Event name
|
|
92
|
+
payload: Optional event payload
|
|
93
|
+
"""
|
|
94
|
+
if payload is None:
|
|
95
|
+
payload = {}
|
|
96
|
+
await self.manager.send_to_parent(event, payload)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class LiveComponent(Generic[T]):
|
|
100
|
+
"""
|
|
101
|
+
Base class for stateful live components.
|
|
102
|
+
|
|
103
|
+
LiveComponents have their own state and can handle events targeted at them
|
|
104
|
+
via phx-target={meta.myself}. They are identified by a unique CID assigned
|
|
105
|
+
by the ComponentsManager.
|
|
106
|
+
|
|
107
|
+
Lifecycle:
|
|
108
|
+
1. mount(socket, assigns) - Called once when component first appears
|
|
109
|
+
2. update(socket, assigns) - Called when parent passes new assigns
|
|
110
|
+
3. template(assigns, meta) - Called to render the component
|
|
111
|
+
4. handle_event(event, payload, socket) - Called for targeted events
|
|
112
|
+
|
|
113
|
+
Error Handling:
|
|
114
|
+
Errors in lifecycle methods (mount, update, handle_event) are logged with
|
|
115
|
+
full context (component class name, CID, method) and then re-raised. This
|
|
116
|
+
matches Phoenix LiveView behavior where component errors crash the parent
|
|
117
|
+
LiveView process. The client-side JavaScript will automatically attempt
|
|
118
|
+
to reconnect and remount the view.
|
|
119
|
+
|
|
120
|
+
This "let it crash" approach ensures:
|
|
121
|
+
- Errors are visible during development
|
|
122
|
+
- No partial/inconsistent UI state
|
|
123
|
+
- Automatic recovery via reconnection
|
|
124
|
+
|
|
125
|
+
Example:
|
|
126
|
+
class Counter(LiveComponent[CounterContext]):
|
|
127
|
+
async def mount(self, socket: ComponentSocket[CounterContext], assigns: dict):
|
|
128
|
+
# Initialize state from parent assigns
|
|
129
|
+
socket.context = {
|
|
130
|
+
"count": assigns.get("initial", 0),
|
|
131
|
+
"label": assigns.get("label", "Counter")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async def update(self, socket: ComponentSocket[CounterContext], assigns: dict):
|
|
135
|
+
# React to changed assigns from parent (e.g., label updates)
|
|
136
|
+
if "label" in assigns:
|
|
137
|
+
socket.context["label"] = assigns["label"]
|
|
138
|
+
|
|
139
|
+
def template(self, assigns: CounterContext, meta: ComponentMeta):
|
|
140
|
+
return t'''
|
|
141
|
+
<div>
|
|
142
|
+
<span>{assigns["count"]}</span>
|
|
143
|
+
<button phx-click="increment" phx-target="{meta.myself}">+</button>
|
|
144
|
+
</div>
|
|
145
|
+
'''
|
|
146
|
+
|
|
147
|
+
async def handle_event(self, event: str, payload: dict, socket: ComponentSocket):
|
|
148
|
+
if event == "increment":
|
|
149
|
+
socket.context["count"] += 1
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
async def mount(self, socket: ComponentSocket[T], assigns: dict[str, Any]) -> None:
|
|
153
|
+
"""
|
|
154
|
+
Called once when the component is first added to the page.
|
|
155
|
+
|
|
156
|
+
Use this to initialize the component's state (socket.context) from
|
|
157
|
+
the initial assigns passed by the parent.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
socket: ComponentSocket for state access
|
|
161
|
+
assigns: Initial assigns from parent (e.g., label, initial values)
|
|
162
|
+
"""
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
async def update(self, socket: ComponentSocket[T], assigns: dict[str, Any]) -> None:
|
|
166
|
+
"""
|
|
167
|
+
Called after mount() and on subsequent renders with new assigns.
|
|
168
|
+
|
|
169
|
+
This is called:
|
|
170
|
+
1. Immediately after mount() with the initial assigns
|
|
171
|
+
2. On re-renders when the parent passes new assigns via live_component()
|
|
172
|
+
|
|
173
|
+
Override to handle assigns that should update component state.
|
|
174
|
+
The default is a no-op - components explicitly decide what affects their state.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
socket: ComponentSocket for state access
|
|
178
|
+
assigns: Assigns passed from parent via live_component()
|
|
179
|
+
"""
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
def template(self, assigns: T, meta: ComponentMeta) -> Any:
|
|
183
|
+
"""
|
|
184
|
+
Render the component's template.
|
|
185
|
+
|
|
186
|
+
Must return a Template (t-string). The meta parameter provides
|
|
187
|
+
access to `myself` for event targeting.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
assigns: The component's current context/state
|
|
191
|
+
meta: ComponentMeta with cid/myself for event targeting
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
A t-string Template
|
|
195
|
+
"""
|
|
196
|
+
raise NotImplementedError("LiveComponent subclasses must implement template()")
|
|
197
|
+
|
|
198
|
+
async def handle_event(
|
|
199
|
+
self, event: str, payload: dict[str, Any], socket: ComponentSocket[T]
|
|
200
|
+
) -> None:
|
|
201
|
+
"""
|
|
202
|
+
Handle events targeted at this component.
|
|
203
|
+
|
|
204
|
+
Events are targeted via phx-target="{meta.myself}" in templates.
|
|
205
|
+
Without phx-target, events go to the parent LiveView instead.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
event: Event name (e.g., "increment", "submit")
|
|
209
|
+
payload: Event payload/value
|
|
210
|
+
socket: ComponentSocket for state access
|
|
211
|
+
"""
|
|
212
|
+
pass
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Helper for running component lifecycle with nested component discovery.
|
|
3
|
+
|
|
4
|
+
This module handles nested component discovery (e.g., components inside slots),
|
|
5
|
+
which requires iterating lifecycle + template rendering until no new components
|
|
6
|
+
are discovered. Used by both HTTP (unconnected) and WebSocket (connected) flows.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from pyview.components import SocketWithComponents
|
|
14
|
+
from pyview.meta import PyViewMeta
|
|
15
|
+
|
|
16
|
+
MAX_COMPONENT_ITERATIONS = 10
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def run_nested_component_lifecycle(
|
|
20
|
+
socket: "SocketWithComponents",
|
|
21
|
+
meta: "PyViewMeta",
|
|
22
|
+
max_iterations: int = MAX_COMPONENT_ITERATIONS,
|
|
23
|
+
) -> dict[int, dict[str, Any]]:
|
|
24
|
+
"""
|
|
25
|
+
Run component lifecycle, discovering nested components and returning rendered trees.
|
|
26
|
+
|
|
27
|
+
On Python 3.14+, this iterates to discover components nested inside
|
|
28
|
+
other components' templates. On earlier versions, it just runs the
|
|
29
|
+
initial lifecycle (no t-string support means no nested components).
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
socket: Socket with components manager
|
|
33
|
+
meta: PyViewMeta for component rendering
|
|
34
|
+
max_iterations: Maximum iteration limit to catch circular dependencies
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Dictionary mapping CID to rendered wire format tree
|
|
38
|
+
"""
|
|
39
|
+
await socket.components.run_pending_lifecycle()
|
|
40
|
+
|
|
41
|
+
# Nested component discovery only works with t-strings (Python 3.14+)
|
|
42
|
+
if sys.version_info < (3, 14):
|
|
43
|
+
return {}
|
|
44
|
+
|
|
45
|
+
# Import t-string support (guarded by version check above)
|
|
46
|
+
from string.templatelib import Template
|
|
47
|
+
|
|
48
|
+
from pyview.template.live_view_template import LiveViewTemplate
|
|
49
|
+
|
|
50
|
+
# Track which CIDs we've already discovered nested components for
|
|
51
|
+
discovered_cids: set[int] = set()
|
|
52
|
+
rendered_trees: dict[int, dict[str, Any]] = {}
|
|
53
|
+
iterations = 0
|
|
54
|
+
|
|
55
|
+
while True:
|
|
56
|
+
# Only process CIDs that were seen (registered) in the current render cycle.
|
|
57
|
+
# This prevents rendering stale components from previous renders, which could
|
|
58
|
+
# resurrect their nested children and leave orphaned components in the response.
|
|
59
|
+
seen_cids = socket.components.get_seen_cids()
|
|
60
|
+
new_cids = seen_cids - discovered_cids
|
|
61
|
+
|
|
62
|
+
# Exit if no new components and no pending lifecycle
|
|
63
|
+
if not new_cids and not socket.components.has_pending_lifecycle():
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
# Render new component templates to discover nested components
|
|
67
|
+
for cid in new_cids:
|
|
68
|
+
discovered_cids.add(cid)
|
|
69
|
+
template: Any = socket.components.render_component(cid, meta)
|
|
70
|
+
if isinstance(template, Template):
|
|
71
|
+
tree = LiveViewTemplate.process(template, socket=socket)
|
|
72
|
+
tree["r"] = 1 # ROOT flag for Phoenix.js
|
|
73
|
+
rendered_trees[cid] = tree
|
|
74
|
+
|
|
75
|
+
# If nested components were discovered, run their lifecycle
|
|
76
|
+
if socket.components.has_pending_lifecycle():
|
|
77
|
+
if iterations >= max_iterations:
|
|
78
|
+
raise RuntimeError(
|
|
79
|
+
f"Component lifecycle exceeded {max_iterations} iterations. "
|
|
80
|
+
"Check for circular component dependencies."
|
|
81
|
+
)
|
|
82
|
+
await socket.components.run_pending_lifecycle()
|
|
83
|
+
iterations += 1
|
|
84
|
+
|
|
85
|
+
return rendered_trees
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ComponentsManager - Manages component lifecycle and state.
|
|
3
|
+
|
|
4
|
+
The manager is attached to a ConnectedLiveViewSocket and handles:
|
|
5
|
+
- Component registration and CID assignment
|
|
6
|
+
- External storage of component state (contexts)
|
|
7
|
+
- Lifecycle orchestration (mount, update)
|
|
8
|
+
- Event routing to specific components
|
|
9
|
+
- Component rendering
|
|
10
|
+
|
|
11
|
+
Key design: Components are identified by (module, id) tuple.
|
|
12
|
+
Same module + id = same component instance across re-renders.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
19
|
+
|
|
20
|
+
from .base import ComponentMeta, ComponentSocket, LiveComponent
|
|
21
|
+
from .slots import Slots
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from pyview.meta import PyViewMeta
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LiveViewProtocol(Protocol):
|
|
30
|
+
"""Protocol for LiveView's handle_event method."""
|
|
31
|
+
|
|
32
|
+
async def handle_event(self, event: str, payload: dict[str, Any], socket: Any) -> None: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ParentSocketProtocol(Protocol):
|
|
36
|
+
"""Protocol defining what ComponentsManager needs from its parent socket.
|
|
37
|
+
|
|
38
|
+
ComponentsManager only uses parent_socket.liveview.handle_event() for
|
|
39
|
+
sending events from components to their parent LiveView.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def liveview(self) -> LiveViewProtocol: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ComponentsManager:
|
|
47
|
+
"""
|
|
48
|
+
Manages LiveComponent instances, state, and lifecycle.
|
|
49
|
+
|
|
50
|
+
Components are stored by CID (Component ID), an integer assigned
|
|
51
|
+
on first registration. The (component_class, component_id) tuple
|
|
52
|
+
maps to a CID, allowing components to persist across re-renders.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
parent_socket: The parent LiveView's socket
|
|
56
|
+
_components: CID -> LiveComponent instance mapping
|
|
57
|
+
_contexts: CID -> component context (state) mapping
|
|
58
|
+
_by_key: (class, id) -> CID mapping for identity
|
|
59
|
+
_pending_mounts: CIDs that need mount() called
|
|
60
|
+
_pending_updates: CIDs that need update() called with new assigns
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, parent_socket: ParentSocketProtocol):
|
|
64
|
+
self.parent_socket = parent_socket
|
|
65
|
+
self._components: dict[int, LiveComponent] = {}
|
|
66
|
+
self._contexts: dict[int, Any] = {}
|
|
67
|
+
self._slots: dict[int, Slots] = {} # Slot content keyed by CID
|
|
68
|
+
self._by_key: dict[tuple[type, str], int] = {}
|
|
69
|
+
self._pending_mounts: list[tuple[int, dict[str, Any]]] = []
|
|
70
|
+
self._pending_updates: list[tuple[int, dict[str, Any]]] = []
|
|
71
|
+
self._next_cid = 1
|
|
72
|
+
self._seen_this_render: set[int] = set() # Track CIDs seen during current render
|
|
73
|
+
|
|
74
|
+
def register(
|
|
75
|
+
self, component_class: type[LiveComponent], component_id: str, assigns: dict[str, Any]
|
|
76
|
+
) -> int:
|
|
77
|
+
"""
|
|
78
|
+
Register a component, returning its CID.
|
|
79
|
+
|
|
80
|
+
If this (class, id) combination already exists, returns the existing CID
|
|
81
|
+
and queues an update. Otherwise, creates a new component and queues mount.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
component_class: The LiveComponent subclass
|
|
85
|
+
component_id: User-provided unique ID for this component instance
|
|
86
|
+
assigns: Props/assigns passed from parent template
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The component's CID (integer)
|
|
90
|
+
"""
|
|
91
|
+
key = (component_class, component_id)
|
|
92
|
+
|
|
93
|
+
# Extract slots from assigns (if present) without mutating caller's dict
|
|
94
|
+
component_slots = assigns.get("slots", {})
|
|
95
|
+
if "slots" in assigns:
|
|
96
|
+
assigns = {k: v for k, v in assigns.items() if k != "slots"}
|
|
97
|
+
|
|
98
|
+
if key in self._by_key:
|
|
99
|
+
# Existing component - queue update with new assigns
|
|
100
|
+
cid = self._by_key[key]
|
|
101
|
+
self._slots[cid] = component_slots # Update slots
|
|
102
|
+
self._pending_updates.append((cid, assigns))
|
|
103
|
+
self._seen_this_render.add(cid)
|
|
104
|
+
logger.debug(
|
|
105
|
+
f"Component {component_class.__name__}:{component_id} (cid={cid}) queued for update"
|
|
106
|
+
)
|
|
107
|
+
return cid
|
|
108
|
+
|
|
109
|
+
# New component - assign CID and create instance
|
|
110
|
+
cid = self._next_cid
|
|
111
|
+
self._next_cid += 1
|
|
112
|
+
|
|
113
|
+
component = component_class()
|
|
114
|
+
self._components[cid] = component
|
|
115
|
+
self._contexts[cid] = {} # Empty initial context
|
|
116
|
+
self._slots[cid] = component_slots # Store slots
|
|
117
|
+
self._by_key[key] = cid
|
|
118
|
+
|
|
119
|
+
# Queue mount with initial assigns
|
|
120
|
+
self._pending_mounts.append((cid, assigns))
|
|
121
|
+
self._seen_this_render.add(cid)
|
|
122
|
+
logger.debug(
|
|
123
|
+
f"Component {component_class.__name__}:{component_id} (cid={cid}) registered and queued for mount"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return cid
|
|
127
|
+
|
|
128
|
+
def get_component(self, cid: int) -> tuple[LiveComponent, Any] | None:
|
|
129
|
+
"""
|
|
130
|
+
Get a component and its context by CID.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
cid: Component ID
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Tuple of (component, context) or None if not found
|
|
137
|
+
"""
|
|
138
|
+
if cid not in self._components:
|
|
139
|
+
return None
|
|
140
|
+
return (self._components[cid], self._contexts[cid])
|
|
141
|
+
|
|
142
|
+
def get_context(self, cid: int) -> Any:
|
|
143
|
+
"""Get a component's context by CID."""
|
|
144
|
+
return self._contexts.get(cid)
|
|
145
|
+
|
|
146
|
+
def set_context(self, cid: int, context: Any) -> None:
|
|
147
|
+
"""Set a component's context by CID."""
|
|
148
|
+
self._contexts[cid] = context
|
|
149
|
+
|
|
150
|
+
async def run_pending_lifecycle(self) -> None:
|
|
151
|
+
"""
|
|
152
|
+
Execute pending mount and update calls.
|
|
153
|
+
|
|
154
|
+
Should be called after template rendering to process any
|
|
155
|
+
components that were registered during the render.
|
|
156
|
+
"""
|
|
157
|
+
# Process mounts first
|
|
158
|
+
while self._pending_mounts:
|
|
159
|
+
cid, assigns = self._pending_mounts.pop(0)
|
|
160
|
+
await self._run_mount(cid, assigns)
|
|
161
|
+
|
|
162
|
+
# Then process updates
|
|
163
|
+
while self._pending_updates:
|
|
164
|
+
cid, assigns = self._pending_updates.pop(0)
|
|
165
|
+
await self._run_update(cid, assigns)
|
|
166
|
+
|
|
167
|
+
async def _run_mount(self, cid: int, assigns: dict[str, Any]) -> None:
|
|
168
|
+
"""Run mount lifecycle for a component."""
|
|
169
|
+
if cid not in self._components:
|
|
170
|
+
logger.warning(f"Cannot mount component cid={cid}: not found")
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
component = self._components[cid]
|
|
174
|
+
socket = self._create_socket(cid)
|
|
175
|
+
|
|
176
|
+
component_name = component.__class__.__name__
|
|
177
|
+
try:
|
|
178
|
+
# Pass assigns to mount so component can initialize from parent props
|
|
179
|
+
await component.mount(socket, assigns)
|
|
180
|
+
# Persist context changes
|
|
181
|
+
self._contexts[cid] = socket.context
|
|
182
|
+
logger.debug(f"Component {component_name} (cid={cid}) mounted successfully")
|
|
183
|
+
except Exception as e:
|
|
184
|
+
logger.error(f"Error in {component_name}.mount() (cid={cid}): {e}", exc_info=True)
|
|
185
|
+
raise
|
|
186
|
+
|
|
187
|
+
# After mount, run update with initial assigns (Phoenix pattern)
|
|
188
|
+
await self._run_update(cid, assigns)
|
|
189
|
+
|
|
190
|
+
async def _run_update(self, cid: int, assigns: dict[str, Any]) -> None:
|
|
191
|
+
"""Run update lifecycle for a component."""
|
|
192
|
+
if cid not in self._components:
|
|
193
|
+
logger.warning(f"Cannot update component cid={cid}: not found")
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
component = self._components[cid]
|
|
197
|
+
socket = self._create_socket(cid)
|
|
198
|
+
|
|
199
|
+
component_name = component.__class__.__name__
|
|
200
|
+
try:
|
|
201
|
+
await component.update(socket, assigns)
|
|
202
|
+
# Persist context changes
|
|
203
|
+
self._contexts[cid] = socket.context
|
|
204
|
+
logger.debug(
|
|
205
|
+
f"Component {component_name} (cid={cid}) updated with assigns: {list(assigns.keys())}"
|
|
206
|
+
)
|
|
207
|
+
except Exception as e:
|
|
208
|
+
logger.error(f"Error in {component_name}.update() (cid={cid}): {e}", exc_info=True)
|
|
209
|
+
raise
|
|
210
|
+
|
|
211
|
+
async def handle_event(self, cid: int, event: str, payload: dict[str, Any]) -> None:
|
|
212
|
+
"""
|
|
213
|
+
Route an event to a specific component.
|
|
214
|
+
|
|
215
|
+
Called when an event has phx-target pointing to this component's CID.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
cid: Target component's CID
|
|
219
|
+
event: Event name
|
|
220
|
+
payload: Event payload
|
|
221
|
+
"""
|
|
222
|
+
if cid not in self._components:
|
|
223
|
+
logger.warning(f"Event '{event}' targeted non-existent component cid={cid}")
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
component = self._components[cid]
|
|
227
|
+
socket = self._create_socket(cid)
|
|
228
|
+
|
|
229
|
+
component_name = component.__class__.__name__
|
|
230
|
+
try:
|
|
231
|
+
await component.handle_event(event, payload, socket)
|
|
232
|
+
# Persist context changes
|
|
233
|
+
self._contexts[cid] = socket.context
|
|
234
|
+
logger.debug(f"Component {component_name} (cid={cid}) handled event '{event}'")
|
|
235
|
+
except Exception as e:
|
|
236
|
+
logger.error(
|
|
237
|
+
f"Error in {component_name}.handle_event('{event}') (cid={cid}): {e}", exc_info=True
|
|
238
|
+
)
|
|
239
|
+
raise
|
|
240
|
+
|
|
241
|
+
def render_component(self, cid: int, parent_meta: PyViewMeta) -> Any:
|
|
242
|
+
"""
|
|
243
|
+
Render a component and return its template result.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
cid: Component's CID
|
|
247
|
+
parent_meta: Parent LiveView's meta
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
The result of component.template()
|
|
251
|
+
"""
|
|
252
|
+
if cid not in self._components:
|
|
253
|
+
logger.warning(f"Cannot render component cid={cid}: not found")
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
component = self._components[cid]
|
|
257
|
+
context = self._contexts[cid]
|
|
258
|
+
component_slots = self._slots.get(cid, {})
|
|
259
|
+
meta = ComponentMeta(cid=cid, parent_meta=parent_meta, slots=component_slots)
|
|
260
|
+
|
|
261
|
+
return component.template(context, meta)
|
|
262
|
+
|
|
263
|
+
async def send_to_parent(self, event: str, payload: dict[str, Any]) -> None:
|
|
264
|
+
"""
|
|
265
|
+
Send an event to the parent LiveView.
|
|
266
|
+
|
|
267
|
+
Called by ComponentSocket.send_parent().
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
event: Event name
|
|
271
|
+
payload: Event payload
|
|
272
|
+
"""
|
|
273
|
+
await self.parent_socket.liveview.handle_event(event, payload, self.parent_socket)
|
|
274
|
+
|
|
275
|
+
def _create_socket(self, cid: int) -> ComponentSocket:
|
|
276
|
+
"""Create a ComponentSocket for lifecycle/event calls."""
|
|
277
|
+
return ComponentSocket(
|
|
278
|
+
context=self._contexts.get(cid, {}),
|
|
279
|
+
cid=cid,
|
|
280
|
+
manager=self,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
def unregister(self, cid: int) -> None:
|
|
284
|
+
"""
|
|
285
|
+
Remove a component from the manager.
|
|
286
|
+
|
|
287
|
+
Called when a component is removed from the DOM.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
cid: Component's CID
|
|
291
|
+
"""
|
|
292
|
+
if cid in self._components:
|
|
293
|
+
del self._components[cid]
|
|
294
|
+
if cid in self._contexts:
|
|
295
|
+
del self._contexts[cid]
|
|
296
|
+
if cid in self._slots:
|
|
297
|
+
del self._slots[cid]
|
|
298
|
+
|
|
299
|
+
# Remove from key mapping
|
|
300
|
+
key_to_remove = None
|
|
301
|
+
for key, stored_cid in self._by_key.items():
|
|
302
|
+
if stored_cid == cid:
|
|
303
|
+
key_to_remove = key
|
|
304
|
+
break
|
|
305
|
+
if key_to_remove:
|
|
306
|
+
del self._by_key[key_to_remove]
|
|
307
|
+
|
|
308
|
+
logger.debug(f"Component cid={cid} unregistered")
|
|
309
|
+
|
|
310
|
+
def clear(self) -> None:
|
|
311
|
+
"""Clear all components. Called on socket close."""
|
|
312
|
+
self._components.clear()
|
|
313
|
+
self._contexts.clear()
|
|
314
|
+
self._slots.clear()
|
|
315
|
+
self._by_key.clear()
|
|
316
|
+
self._pending_mounts.clear()
|
|
317
|
+
self._pending_updates.clear()
|
|
318
|
+
self._seen_this_render.clear()
|
|
319
|
+
logger.debug("ComponentsManager cleared")
|
|
320
|
+
|
|
321
|
+
def begin_render(self) -> None:
|
|
322
|
+
"""
|
|
323
|
+
Start a new render cycle.
|
|
324
|
+
|
|
325
|
+
Clears the set of seen components. Call this before rendering
|
|
326
|
+
the parent LiveView template.
|
|
327
|
+
"""
|
|
328
|
+
self._seen_this_render.clear()
|
|
329
|
+
|
|
330
|
+
def prune_stale_components(self) -> list[int]:
|
|
331
|
+
"""
|
|
332
|
+
Remove components that weren't seen during this render cycle.
|
|
333
|
+
|
|
334
|
+
Components not referenced in the current render are considered
|
|
335
|
+
removed from the DOM and should be cleaned up.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
List of CIDs that were pruned
|
|
339
|
+
"""
|
|
340
|
+
all_cids = set(self._components.keys())
|
|
341
|
+
stale_cids = all_cids - self._seen_this_render
|
|
342
|
+
|
|
343
|
+
for cid in stale_cids:
|
|
344
|
+
self.unregister(cid)
|
|
345
|
+
|
|
346
|
+
if stale_cids:
|
|
347
|
+
logger.debug(f"Pruned {len(stale_cids)} stale components: {stale_cids}")
|
|
348
|
+
|
|
349
|
+
return list(stale_cids)
|
|
350
|
+
|
|
351
|
+
@property
|
|
352
|
+
def component_count(self) -> int:
|
|
353
|
+
"""Number of registered components."""
|
|
354
|
+
return len(self._components)
|
|
355
|
+
|
|
356
|
+
def get_all_cids(self) -> list[int]:
|
|
357
|
+
"""Get all registered CIDs."""
|
|
358
|
+
return list(self._components.keys())
|
|
359
|
+
|
|
360
|
+
def get_seen_cids(self) -> set[int]:
|
|
361
|
+
"""Get CIDs that were seen (registered) during the current render cycle."""
|
|
362
|
+
return self._seen_this_render.copy()
|
|
363
|
+
|
|
364
|
+
def has_pending_lifecycle(self) -> bool:
|
|
365
|
+
"""Check if there are components waiting for mount/update."""
|
|
366
|
+
return bool(self._pending_mounts) or bool(self._pending_updates)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Component rendering utilities.
|
|
3
|
+
|
|
4
|
+
This module requires Python 3.14+ for t-string support.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pyview.template.live_view_template import LiveViewTemplate
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def render_component_tree(template: Any, socket: Any) -> dict[str, Any]:
|
|
13
|
+
"""Process a component template into Phoenix wire format."""
|
|
14
|
+
return LiveViewTemplate.process(template, socket=socket)
|