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.
Files changed (78) hide show
  1. pyview/__init__.py +16 -6
  2. pyview/assets/js/app.js +1 -0
  3. pyview/assets/js/uploaders.js +221 -0
  4. pyview/assets/package-lock.json +16 -14
  5. pyview/assets/package.json +2 -2
  6. pyview/async_stream_runner.py +2 -1
  7. pyview/auth/__init__.py +3 -1
  8. pyview/auth/provider.py +6 -6
  9. pyview/auth/required.py +7 -10
  10. pyview/binding/__init__.py +47 -0
  11. pyview/binding/binder.py +134 -0
  12. pyview/binding/context.py +33 -0
  13. pyview/binding/converters.py +191 -0
  14. pyview/binding/helpers.py +78 -0
  15. pyview/binding/injectables.py +119 -0
  16. pyview/binding/params.py +105 -0
  17. pyview/binding/result.py +32 -0
  18. pyview/changesets/__init__.py +2 -0
  19. pyview/changesets/changesets.py +8 -3
  20. pyview/cli/commands/create_view.py +4 -3
  21. pyview/cli/main.py +1 -1
  22. pyview/components/__init__.py +72 -0
  23. pyview/components/base.py +212 -0
  24. pyview/components/lifecycle.py +85 -0
  25. pyview/components/manager.py +366 -0
  26. pyview/components/renderer.py +14 -0
  27. pyview/components/slots.py +73 -0
  28. pyview/csrf.py +4 -2
  29. pyview/events/AutoEventDispatch.py +98 -0
  30. pyview/events/BaseEventHandler.py +51 -8
  31. pyview/events/__init__.py +2 -1
  32. pyview/instrumentation/__init__.py +3 -3
  33. pyview/instrumentation/interfaces.py +57 -33
  34. pyview/instrumentation/noop.py +21 -18
  35. pyview/js.py +20 -23
  36. pyview/live_routes.py +5 -3
  37. pyview/live_socket.py +167 -44
  38. pyview/live_view.py +24 -12
  39. pyview/meta.py +14 -2
  40. pyview/phx_message.py +7 -8
  41. pyview/playground/__init__.py +10 -0
  42. pyview/playground/builder.py +118 -0
  43. pyview/playground/favicon.py +39 -0
  44. pyview/pyview.py +54 -20
  45. pyview/session.py +2 -0
  46. pyview/static/assets/app.js +2088 -806
  47. pyview/static/assets/uploaders.js +221 -0
  48. pyview/stream.py +308 -0
  49. pyview/template/__init__.py +11 -1
  50. pyview/template/live_template.py +12 -8
  51. pyview/template/live_view_template.py +338 -0
  52. pyview/template/render_diff.py +33 -7
  53. pyview/template/root_template.py +21 -9
  54. pyview/template/serializer.py +2 -5
  55. pyview/template/template_view.py +170 -0
  56. pyview/template/utils.py +3 -2
  57. pyview/uploads.py +344 -55
  58. pyview/vendor/flet/pubsub/__init__.py +3 -1
  59. pyview/vendor/flet/pubsub/pub_sub.py +10 -18
  60. pyview/vendor/ibis/__init__.py +3 -7
  61. pyview/vendor/ibis/compiler.py +25 -32
  62. pyview/vendor/ibis/context.py +13 -15
  63. pyview/vendor/ibis/errors.py +0 -6
  64. pyview/vendor/ibis/filters.py +70 -76
  65. pyview/vendor/ibis/loaders.py +6 -7
  66. pyview/vendor/ibis/nodes.py +40 -42
  67. pyview/vendor/ibis/template.py +4 -5
  68. pyview/vendor/ibis/tree.py +62 -3
  69. pyview/vendor/ibis/utils.py +14 -15
  70. pyview/ws_handler.py +116 -86
  71. {pyview_web-0.3.0.dist-info → pyview_web-0.8.0a2.dist-info}/METADATA +39 -33
  72. pyview_web-0.8.0a2.dist-info/RECORD +80 -0
  73. pyview_web-0.8.0a2.dist-info/WHEEL +4 -0
  74. pyview_web-0.8.0a2.dist-info/entry_points.txt +3 -0
  75. pyview_web-0.3.0.dist-info/LICENSE +0 -21
  76. pyview_web-0.3.0.dist-info/RECORD +0 -58
  77. pyview_web-0.3.0.dist-info/WHEEL +0 -4
  78. 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)