pulse-framework 0.1.75__py3-none-any.whl → 0.1.76__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.
pulse/__init__.py CHANGED
@@ -1409,6 +1409,18 @@ from pulse.reactive_extensions import (
1409
1409
  from pulse.reactive_extensions import (
1410
1410
  unwrap as unwrap,
1411
1411
  )
1412
+ from pulse.refs import (
1413
+ RefHandle as RefHandle,
1414
+ )
1415
+ from pulse.refs import (
1416
+ RefNotMounted as RefNotMounted,
1417
+ )
1418
+ from pulse.refs import (
1419
+ RefTimeout as RefTimeout,
1420
+ )
1421
+ from pulse.refs import (
1422
+ ref as ref,
1423
+ )
1412
1424
 
1413
1425
  # JavaScript execution
1414
1426
  from pulse.render_session import JsExecError as JsExecError
pulse/channel.py CHANGED
@@ -81,7 +81,9 @@ class ChannelsManager:
81
81
  self.pending_requests = {}
82
82
 
83
83
  # ------------------------------------------------------------------
84
- def create(self, identifier: str | None = None) -> "Channel":
84
+ def create(
85
+ self, identifier: str | None = None, *, bind_route: bool = True
86
+ ) -> "Channel":
85
87
  ctx = PulseContext.get()
86
88
  render = ctx.render
87
89
  session = ctx.session
@@ -93,7 +95,7 @@ class ChannelsManager:
93
95
  raise ValueError(f"Channel id '{channel_id}' is already in use")
94
96
 
95
97
  route_path: str | None = None
96
- if ctx.route is not None:
98
+ if bind_route and ctx.route is not None:
97
99
  # unique_path() returns absolute path, use as-is for keys
98
100
  route_path = ctx.route.pulse_route.unique_path()
99
101
 
@@ -129,7 +131,8 @@ class ChannelsManager:
129
131
  if not response_to:
130
132
  return
131
133
 
132
- if error := message.get("error") is not None:
134
+ error = message.get("error")
135
+ if error is not None:
133
136
  self.resolve_pending_error(response_to, error)
134
137
  else:
135
138
  self._resolve_pending_success(response_to, message.get("payload"))
pulse/dom/props.py CHANGED
@@ -68,6 +68,7 @@ from pulse.dom.events import (
68
68
  TextAreaDOMEvents,
69
69
  )
70
70
  from pulse.helpers import CSSProperties
71
+ from pulse.refs import RefHandle
71
72
  from pulse.transpiler.nodes import Expr
72
73
 
73
74
  Booleanish = Literal[True, False, "true", "false"]
@@ -82,6 +83,7 @@ class BaseHTMLProps(TypedDict, total=False):
82
83
  defaultValue: str | int | list[str]
83
84
  suppressContentEditableWarning: bool
84
85
  suppressHydrationWarning: bool
86
+ ref: RefHandle[Any]
85
87
 
86
88
  # Standard HTML Attributes
87
89
  accessKey: str
pulse/refs.py ADDED
@@ -0,0 +1,893 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import re
6
+ import uuid
7
+ from collections.abc import Callable
8
+ from typing import Any, Generic, Literal, TypeVar, cast, overload, override
9
+
10
+ from pulse.channel import Channel
11
+ from pulse.context import PulseContext
12
+ from pulse.helpers import Disposable
13
+ from pulse.hooks.core import HookMetadata, HookState, hooks
14
+ from pulse.hooks.state import collect_component_identity
15
+ from pulse.scheduling import create_future, create_task
16
+
17
+ T = TypeVar("T")
18
+ Number = int | float
19
+
20
+ _ATTR_ALIASES: dict[str, str] = {
21
+ "className": "class",
22
+ "htmlFor": "for",
23
+ "tabIndex": "tabindex",
24
+ }
25
+
26
+ _ATTR_NAME_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_:\-\.]*$")
27
+
28
+ _GETTABLE_PROPS: set[str] = {
29
+ "value",
30
+ "checked",
31
+ "disabled",
32
+ "readOnly",
33
+ "selectedIndex",
34
+ "selectionStart",
35
+ "selectionEnd",
36
+ "selectionDirection",
37
+ "scrollTop",
38
+ "scrollLeft",
39
+ "scrollHeight",
40
+ "scrollWidth",
41
+ "clientWidth",
42
+ "clientHeight",
43
+ "offsetWidth",
44
+ "offsetHeight",
45
+ "innerText",
46
+ "textContent",
47
+ "className",
48
+ "id",
49
+ "name",
50
+ "type",
51
+ "tabIndex",
52
+ }
53
+
54
+ _SETTABLE_PROPS: set[str] = {
55
+ "value",
56
+ "checked",
57
+ "disabled",
58
+ "readOnly",
59
+ "selectedIndex",
60
+ "selectionStart",
61
+ "selectionEnd",
62
+ "selectionDirection",
63
+ "scrollTop",
64
+ "scrollLeft",
65
+ "className",
66
+ "id",
67
+ "name",
68
+ "type",
69
+ "tabIndex",
70
+ }
71
+
72
+
73
+ def _normalize_attr_name(name: str) -> str:
74
+ return _ATTR_ALIASES.get(name, name)
75
+
76
+
77
+ def _validate_attr_name(name: str) -> str:
78
+ if not isinstance(name, str):
79
+ raise TypeError("ref attribute name must be a string")
80
+ trimmed = name.strip()
81
+ if not trimmed:
82
+ raise ValueError("ref attribute name must be non-empty")
83
+ normalized = _normalize_attr_name(trimmed)
84
+ if not _ATTR_NAME_PATTERN.match(normalized):
85
+ raise ValueError(f"Invalid attribute name: {normalized}")
86
+ if normalized.lower().startswith("on"):
87
+ raise ValueError("ref attribute name cannot start with 'on'")
88
+ return normalized
89
+
90
+
91
+ def _validate_prop_name(name: str, *, settable: bool) -> str:
92
+ if not isinstance(name, str):
93
+ raise TypeError("ref property name must be a string")
94
+ trimmed = name.strip()
95
+ if not trimmed:
96
+ raise ValueError("ref property name must be non-empty")
97
+ if trimmed not in _GETTABLE_PROPS:
98
+ raise ValueError(f"Unsupported ref property: {trimmed}")
99
+ if settable and trimmed not in _SETTABLE_PROPS:
100
+ raise ValueError(f"Ref property is read-only: {trimmed}")
101
+ return trimmed
102
+
103
+
104
+ class RefNotMounted(RuntimeError):
105
+ """Raised when a ref operation is attempted before mount."""
106
+
107
+
108
+ class RefTimeout(asyncio.TimeoutError):
109
+ """Raised when waiting for a ref mount times out."""
110
+
111
+
112
+ class RefHandle(Disposable, Generic[T]):
113
+ """Server-side handle for a client DOM ref."""
114
+
115
+ __slots__: tuple[str, ...] = (
116
+ "_channel",
117
+ "id",
118
+ "_mounted",
119
+ "_mount_waiters",
120
+ "_mount_handlers",
121
+ "_unmount_handlers",
122
+ "_owns_channel",
123
+ "_remove_mount",
124
+ "_remove_unmount",
125
+ )
126
+
127
+ _channel: Channel
128
+ id: str
129
+ _mounted: bool
130
+ _mount_waiters: list[asyncio.Future[None]]
131
+ _mount_handlers: list[Callable[[], Any]]
132
+ _unmount_handlers: list[Callable[[], Any]]
133
+ _owns_channel: bool
134
+ _remove_mount: Callable[[], None] | None
135
+ _remove_unmount: Callable[[], None] | None
136
+
137
+ def __init__(
138
+ self,
139
+ channel: Channel,
140
+ *,
141
+ ref_id: str | None = None,
142
+ owns_channel: bool = True,
143
+ ) -> None:
144
+ self._channel = channel
145
+ self.id = ref_id or uuid.uuid4().hex
146
+ self._mounted = False
147
+ self._mount_waiters = []
148
+ self._mount_handlers = []
149
+ self._unmount_handlers = []
150
+ self._owns_channel = owns_channel
151
+ self._remove_mount = self._channel.on("ref:mounted", self._on_mounted)
152
+ self._remove_unmount = self._channel.on("ref:unmounted", self._on_unmounted)
153
+
154
+ @property
155
+ def channel_id(self) -> str:
156
+ return self._channel.id
157
+
158
+ @property
159
+ def mounted(self) -> bool:
160
+ return self._mounted
161
+
162
+ def on_mount(self, handler: Callable[[], Any]) -> Callable[[], None]:
163
+ self._mount_handlers.append(handler)
164
+
165
+ def _remove() -> None:
166
+ try:
167
+ self._mount_handlers.remove(handler)
168
+ except ValueError:
169
+ return
170
+
171
+ return _remove
172
+
173
+ def on_unmount(self, handler: Callable[[], Any]) -> Callable[[], None]:
174
+ self._unmount_handlers.append(handler)
175
+
176
+ def _remove() -> None:
177
+ try:
178
+ self._unmount_handlers.remove(handler)
179
+ except ValueError:
180
+ return
181
+
182
+ return _remove
183
+
184
+ async def wait_mounted(self, timeout: float | None = None) -> None:
185
+ if self._mounted:
186
+ return
187
+ fut = create_future()
188
+ self._mount_waiters.append(fut)
189
+ try:
190
+ if timeout is None:
191
+ await fut
192
+ else:
193
+ await asyncio.wait_for(fut, timeout=timeout)
194
+ except asyncio.TimeoutError as exc:
195
+ raise RefTimeout("Timed out waiting for ref to mount") from exc
196
+ finally:
197
+ if fut in self._mount_waiters:
198
+ self._mount_waiters.remove(fut)
199
+
200
+ def focus(self, *, prevent_scroll: bool | None = None) -> None:
201
+ payload = None
202
+ if prevent_scroll is not None:
203
+ if not isinstance(prevent_scroll, bool):
204
+ raise TypeError("focus() prevent_scroll must be a bool")
205
+ payload = {"preventScroll": prevent_scroll}
206
+ self._emit("focus", payload)
207
+
208
+ def blur(self) -> None:
209
+ self._emit("blur")
210
+
211
+ def click(self) -> None:
212
+ self._emit("click")
213
+
214
+ def submit(self) -> None:
215
+ self._emit("submit")
216
+
217
+ def reset(self) -> None:
218
+ self._emit("reset")
219
+
220
+ def scroll_into_view(
221
+ self,
222
+ *,
223
+ behavior: str | None = None,
224
+ block: str | None = None,
225
+ inline: str | None = None,
226
+ ) -> None:
227
+ payload = {
228
+ k: v
229
+ for k, v in {
230
+ "behavior": behavior,
231
+ "block": block,
232
+ "inline": inline,
233
+ }.items()
234
+ if v is not None
235
+ }
236
+ self._emit("scrollIntoView", payload if payload else None)
237
+
238
+ def scroll_to(
239
+ self,
240
+ *,
241
+ top: float | int | None = None,
242
+ left: float | int | None = None,
243
+ behavior: str | None = None,
244
+ ) -> None:
245
+ if top is not None and not isinstance(top, (int, float)):
246
+ raise TypeError("scroll_to() top must be a number")
247
+ if left is not None and not isinstance(left, (int, float)):
248
+ raise TypeError("scroll_to() left must be a number")
249
+ if behavior is not None and not isinstance(behavior, str):
250
+ raise TypeError("scroll_to() behavior must be a string")
251
+ payload = {
252
+ k: v
253
+ for k, v in {
254
+ "top": top,
255
+ "left": left,
256
+ "behavior": behavior,
257
+ }.items()
258
+ if v is not None
259
+ }
260
+ self._emit("scrollTo", payload if payload else None)
261
+
262
+ def scroll_by(
263
+ self,
264
+ *,
265
+ top: float | int | None = None,
266
+ left: float | int | None = None,
267
+ behavior: str | None = None,
268
+ ) -> None:
269
+ if top is not None and not isinstance(top, (int, float)):
270
+ raise TypeError("scroll_by() top must be a number")
271
+ if left is not None and not isinstance(left, (int, float)):
272
+ raise TypeError("scroll_by() left must be a number")
273
+ if behavior is not None and not isinstance(behavior, str):
274
+ raise TypeError("scroll_by() behavior must be a string")
275
+ payload = {
276
+ k: v
277
+ for k, v in {
278
+ "top": top,
279
+ "left": left,
280
+ "behavior": behavior,
281
+ }.items()
282
+ if v is not None
283
+ }
284
+ self._emit("scrollBy", payload if payload else None)
285
+
286
+ async def measure(self, *, timeout: float | None = None) -> dict[str, Any] | None:
287
+ result = await self._request("measure", timeout=timeout)
288
+ if result is None:
289
+ return None
290
+ if isinstance(result, dict):
291
+ return result
292
+ raise TypeError("measure() expected dict result")
293
+
294
+ async def get_value(self, *, timeout: float | None = None) -> Any:
295
+ return await self._request("getValue", timeout=timeout)
296
+
297
+ async def set_value(self, value: Any, *, timeout: float | None = None) -> Any:
298
+ return await self._request("setValue", {"value": value}, timeout=timeout)
299
+
300
+ async def get_text(self, *, timeout: float | None = None) -> str | None:
301
+ result = await self._request("getText", timeout=timeout)
302
+ if result is None:
303
+ return None
304
+ if isinstance(result, str):
305
+ return result
306
+ raise TypeError("get_text() expected string result")
307
+
308
+ async def set_text(self, text: str, *, timeout: float | None = None) -> str | None:
309
+ result = await self._request("setText", {"text": text}, timeout=timeout)
310
+ if result is None:
311
+ return None
312
+ if isinstance(result, str):
313
+ return result
314
+ raise TypeError("set_text() expected string result")
315
+
316
+ def select(self) -> None:
317
+ self._emit("select")
318
+
319
+ def set_selection_range(
320
+ self, start: int, end: int, *, direction: str | None = None
321
+ ) -> None:
322
+ if not isinstance(start, int) or not isinstance(end, int):
323
+ raise TypeError("set_selection_range() requires integer start/end")
324
+ if direction is not None and not isinstance(direction, str):
325
+ raise TypeError("set_selection_range() direction must be a string")
326
+ payload: dict[str, Any] = {"start": start, "end": end}
327
+ if direction is not None:
328
+ payload["direction"] = direction
329
+ self._emit("setSelectionRange", payload)
330
+
331
+ @overload
332
+ async def get_attr(
333
+ self,
334
+ name: Literal[
335
+ "className",
336
+ "class",
337
+ "id",
338
+ "name",
339
+ "type",
340
+ "title",
341
+ "placeholder",
342
+ "role",
343
+ "href",
344
+ "src",
345
+ "alt",
346
+ "htmlFor",
347
+ "for",
348
+ "tabIndex",
349
+ "tabindex",
350
+ "aria-label",
351
+ "aria-hidden",
352
+ "data-test",
353
+ "value",
354
+ ],
355
+ *,
356
+ timeout: float | None = None,
357
+ ) -> str | None: ...
358
+
359
+ @overload
360
+ async def get_attr(
361
+ self, name: str, *, timeout: float | None = None
362
+ ) -> str | None: ...
363
+
364
+ async def get_attr(self, name: str, *, timeout: float | None = None) -> str | None:
365
+ normalized = _validate_attr_name(name)
366
+ result = await self._request("getAttr", {"name": normalized}, timeout=timeout)
367
+ if result is None:
368
+ return None
369
+ if isinstance(result, str):
370
+ return result
371
+ raise TypeError("get_attr() expected string result")
372
+
373
+ @overload
374
+ async def set_attr(
375
+ self,
376
+ name: Literal[
377
+ "className",
378
+ "class",
379
+ "id",
380
+ "name",
381
+ "type",
382
+ "title",
383
+ "placeholder",
384
+ "role",
385
+ "href",
386
+ "src",
387
+ "alt",
388
+ "htmlFor",
389
+ "for",
390
+ "tabIndex",
391
+ "tabindex",
392
+ "aria-label",
393
+ "aria-hidden",
394
+ "data-test",
395
+ "value",
396
+ ],
397
+ value: str | int | float | bool | None,
398
+ *,
399
+ timeout: float | None = None,
400
+ ) -> str | None: ...
401
+
402
+ @overload
403
+ async def set_attr(
404
+ self,
405
+ name: str,
406
+ value: Any,
407
+ *,
408
+ timeout: float | None = None,
409
+ ) -> str | None: ...
410
+
411
+ async def set_attr(
412
+ self, name: str, value: Any, *, timeout: float | None = None
413
+ ) -> str | None:
414
+ normalized = _validate_attr_name(name)
415
+ result = await self._request(
416
+ "setAttr", {"name": normalized, "value": value}, timeout=timeout
417
+ )
418
+ if result is None:
419
+ return None
420
+ if isinstance(result, str):
421
+ return result
422
+ raise TypeError("set_attr() expected string result")
423
+
424
+ async def remove_attr(self, name: str, *, timeout: float | None = None) -> None:
425
+ normalized = _validate_attr_name(name)
426
+ await self._request("removeAttr", {"name": normalized}, timeout=timeout)
427
+
428
+ @overload
429
+ async def get_prop(
430
+ self, name: Literal["value"], *, timeout: float | None = None
431
+ ) -> str: ...
432
+
433
+ @overload
434
+ async def get_prop(
435
+ self, name: Literal["checked"], *, timeout: float | None = None
436
+ ) -> bool: ...
437
+
438
+ @overload
439
+ async def get_prop(
440
+ self, name: Literal["disabled"], *, timeout: float | None = None
441
+ ) -> bool: ...
442
+
443
+ @overload
444
+ async def get_prop(
445
+ self, name: Literal["readOnly"], *, timeout: float | None = None
446
+ ) -> bool: ...
447
+
448
+ @overload
449
+ async def get_prop(
450
+ self, name: Literal["selectedIndex"], *, timeout: float | None = None
451
+ ) -> Number: ...
452
+
453
+ @overload
454
+ async def get_prop(
455
+ self, name: Literal["selectionStart"], *, timeout: float | None = None
456
+ ) -> Number | None: ...
457
+
458
+ @overload
459
+ async def get_prop(
460
+ self, name: Literal["selectionEnd"], *, timeout: float | None = None
461
+ ) -> Number | None: ...
462
+
463
+ @overload
464
+ async def get_prop(
465
+ self, name: Literal["selectionDirection"], *, timeout: float | None = None
466
+ ) -> str | None: ...
467
+
468
+ @overload
469
+ async def get_prop(
470
+ self, name: Literal["scrollTop"], *, timeout: float | None = None
471
+ ) -> Number: ...
472
+
473
+ @overload
474
+ async def get_prop(
475
+ self, name: Literal["scrollLeft"], *, timeout: float | None = None
476
+ ) -> Number: ...
477
+
478
+ @overload
479
+ async def get_prop(
480
+ self, name: Literal["scrollHeight"], *, timeout: float | None = None
481
+ ) -> Number: ...
482
+
483
+ @overload
484
+ async def get_prop(
485
+ self, name: Literal["scrollWidth"], *, timeout: float | None = None
486
+ ) -> Number: ...
487
+
488
+ @overload
489
+ async def get_prop(
490
+ self, name: Literal["clientWidth"], *, timeout: float | None = None
491
+ ) -> Number: ...
492
+
493
+ @overload
494
+ async def get_prop(
495
+ self, name: Literal["clientHeight"], *, timeout: float | None = None
496
+ ) -> Number: ...
497
+
498
+ @overload
499
+ async def get_prop(
500
+ self, name: Literal["offsetWidth"], *, timeout: float | None = None
501
+ ) -> Number: ...
502
+
503
+ @overload
504
+ async def get_prop(
505
+ self, name: Literal["offsetHeight"], *, timeout: float | None = None
506
+ ) -> Number: ...
507
+
508
+ @overload
509
+ async def get_prop(
510
+ self, name: Literal["innerText"], *, timeout: float | None = None
511
+ ) -> str: ...
512
+
513
+ @overload
514
+ async def get_prop(
515
+ self, name: Literal["textContent"], *, timeout: float | None = None
516
+ ) -> str | None: ...
517
+
518
+ @overload
519
+ async def get_prop(
520
+ self, name: Literal["className"], *, timeout: float | None = None
521
+ ) -> str: ...
522
+
523
+ @overload
524
+ async def get_prop(
525
+ self, name: Literal["id"], *, timeout: float | None = None
526
+ ) -> str: ...
527
+
528
+ @overload
529
+ async def get_prop(
530
+ self, name: Literal["name"], *, timeout: float | None = None
531
+ ) -> str: ...
532
+
533
+ @overload
534
+ async def get_prop(
535
+ self, name: Literal["type"], *, timeout: float | None = None
536
+ ) -> str: ...
537
+
538
+ @overload
539
+ async def get_prop(
540
+ self, name: Literal["tabIndex"], *, timeout: float | None = None
541
+ ) -> Number: ...
542
+
543
+ @overload
544
+ async def get_prop(self, name: str, *, timeout: float | None = None) -> Any: ...
545
+
546
+ async def get_prop(self, name: str, *, timeout: float | None = None) -> Any:
547
+ prop = _validate_prop_name(name, settable=False)
548
+ return await self._request("getProp", {"name": prop}, timeout=timeout)
549
+
550
+ @overload
551
+ async def set_prop(
552
+ self, name: Literal["value"], value: str, *, timeout: float | None = None
553
+ ) -> str: ...
554
+
555
+ @overload
556
+ async def set_prop(
557
+ self, name: Literal["checked"], value: bool, *, timeout: float | None = None
558
+ ) -> bool: ...
559
+
560
+ @overload
561
+ async def set_prop(
562
+ self, name: Literal["disabled"], value: bool, *, timeout: float | None = None
563
+ ) -> bool: ...
564
+
565
+ @overload
566
+ async def set_prop(
567
+ self, name: Literal["readOnly"], value: bool, *, timeout: float | None = None
568
+ ) -> bool: ...
569
+
570
+ @overload
571
+ async def set_prop(
572
+ self,
573
+ name: Literal["selectedIndex"],
574
+ value: Number,
575
+ *,
576
+ timeout: float | None = None,
577
+ ) -> Number: ...
578
+
579
+ @overload
580
+ async def set_prop(
581
+ self,
582
+ name: Literal["selectionStart"],
583
+ value: Number | None,
584
+ *,
585
+ timeout: float | None = None,
586
+ ) -> Number | None: ...
587
+
588
+ @overload
589
+ async def set_prop(
590
+ self,
591
+ name: Literal["selectionEnd"],
592
+ value: Number | None,
593
+ *,
594
+ timeout: float | None = None,
595
+ ) -> Number | None: ...
596
+
597
+ @overload
598
+ async def set_prop(
599
+ self,
600
+ name: Literal["selectionDirection"],
601
+ value: str | None,
602
+ *,
603
+ timeout: float | None = None,
604
+ ) -> str | None: ...
605
+
606
+ @overload
607
+ async def set_prop(
608
+ self, name: Literal["scrollTop"], value: Number, *, timeout: float | None = None
609
+ ) -> Number: ...
610
+
611
+ @overload
612
+ async def set_prop(
613
+ self,
614
+ name: Literal["scrollLeft"],
615
+ value: Number,
616
+ *,
617
+ timeout: float | None = None,
618
+ ) -> Number: ...
619
+
620
+ @overload
621
+ async def set_prop(
622
+ self, name: Literal["className"], value: str, *, timeout: float | None = None
623
+ ) -> str: ...
624
+
625
+ @overload
626
+ async def set_prop(
627
+ self, name: Literal["id"], value: str, *, timeout: float | None = None
628
+ ) -> str: ...
629
+
630
+ @overload
631
+ async def set_prop(
632
+ self, name: Literal["name"], value: str, *, timeout: float | None = None
633
+ ) -> str: ...
634
+
635
+ @overload
636
+ async def set_prop(
637
+ self, name: Literal["type"], value: str, *, timeout: float | None = None
638
+ ) -> str: ...
639
+
640
+ @overload
641
+ async def set_prop(
642
+ self, name: Literal["tabIndex"], value: Number, *, timeout: float | None = None
643
+ ) -> Number: ...
644
+
645
+ @overload
646
+ async def set_prop(
647
+ self, name: str, value: Any, *, timeout: float | None = None
648
+ ) -> Any: ...
649
+
650
+ async def set_prop(
651
+ self, name: str, value: Any, *, timeout: float | None = None
652
+ ) -> Any:
653
+ prop = _validate_prop_name(name, settable=True)
654
+ return await self._request(
655
+ "setProp", {"name": prop, "value": value}, timeout=timeout
656
+ )
657
+
658
+ async def set_style(
659
+ self, styles: dict[str, Any], *, timeout: float | None = None
660
+ ) -> None:
661
+ if not isinstance(styles, dict):
662
+ raise TypeError("set_style() requires a dict")
663
+ for key, value in styles.items():
664
+ if not isinstance(key, str) or not key:
665
+ raise ValueError("set_style() keys must be non-empty strings")
666
+ if isinstance(value, bool):
667
+ raise TypeError("set_style() values must be string, number, or None")
668
+ if value is not None and not isinstance(value, (str, int, float)):
669
+ raise TypeError("set_style() values must be string, number, or None")
670
+ await self._request("setStyle", {"styles": styles}, timeout=timeout)
671
+
672
+ def _emit(self, op: str, payload: Any = None) -> None:
673
+ self._ensure_mounted()
674
+ self._channel.emit(
675
+ "ref:call",
676
+ {"refId": self.id, "op": op, "payload": payload},
677
+ )
678
+
679
+ async def _request(
680
+ self,
681
+ op: str,
682
+ payload: Any = None,
683
+ *,
684
+ timeout: float | None = None,
685
+ ) -> Any:
686
+ self._ensure_mounted()
687
+ return await self._channel.request(
688
+ "ref:request",
689
+ {"refId": self.id, "op": op, "payload": payload},
690
+ timeout=timeout,
691
+ )
692
+
693
+ def _ensure_mounted(self) -> None:
694
+ if not self._mounted:
695
+ raise RefNotMounted("Ref is not mounted")
696
+
697
+ def _on_mounted(self, payload: Any) -> None:
698
+ if isinstance(payload, dict):
699
+ ref_id = cast(dict[str, Any], payload).get("refId")
700
+ if ref_id is not None and str(ref_id) != self.id:
701
+ return
702
+ self._mounted = True
703
+ for fut in list(self._mount_waiters):
704
+ if not fut.done():
705
+ fut.set_result(None)
706
+ self._mount_waiters.clear()
707
+ self._run_handlers(self._mount_handlers, label="mount")
708
+
709
+ def _on_unmounted(self, payload: Any) -> None:
710
+ if isinstance(payload, dict):
711
+ ref_id = cast(dict[str, Any], payload).get("refId")
712
+ if ref_id is not None and str(ref_id) != self.id:
713
+ return
714
+ self._mounted = False
715
+ self._run_handlers(self._unmount_handlers, label="unmount")
716
+
717
+ def _run_handlers(self, handlers: list[Callable[[], Any]], *, label: str) -> None:
718
+ for handler in list(handlers):
719
+ try:
720
+ result = handler()
721
+ except Exception:
722
+ # Fail early: propagate on next render via error log if desired
723
+ raise
724
+ if inspect.isawaitable(result):
725
+ task = create_task(result, name=f"ref:{self.id}:{label}")
726
+
727
+ def _on_done(done_task: asyncio.Future[Any]) -> None:
728
+ if done_task.cancelled():
729
+ return
730
+ try:
731
+ done_task.result()
732
+ except asyncio.CancelledError:
733
+ return
734
+ except Exception as exc:
735
+ loop = done_task.get_loop()
736
+ loop.call_exception_handler(
737
+ {
738
+ "message": f"Unhandled exception in ref {label} handler",
739
+ "exception": exc,
740
+ "context": {"ref_id": self.id, "handler": label},
741
+ }
742
+ )
743
+
744
+ task.add_done_callback(_on_done)
745
+
746
+ @override
747
+ def dispose(self) -> None:
748
+ self._mounted = False
749
+ if self._remove_mount is not None:
750
+ self._remove_mount()
751
+ self._remove_mount = None
752
+ if self._remove_unmount is not None:
753
+ self._remove_unmount()
754
+ self._remove_unmount = None
755
+ for fut in list(self._mount_waiters):
756
+ if not fut.done():
757
+ fut.set_exception(RefNotMounted("Ref disposed"))
758
+ self._mount_waiters.clear()
759
+ self._mount_handlers.clear()
760
+ self._unmount_handlers.clear()
761
+ if self._owns_channel:
762
+ self._channel.close()
763
+
764
+ @override
765
+ def __repr__(self) -> str:
766
+ return f"RefHandle(id={self.id}, channel={self.channel_id})"
767
+
768
+
769
+ class RefHookState(HookState):
770
+ __slots__: tuple[str, ...] = (
771
+ "instances",
772
+ "called_keys",
773
+ "_channel",
774
+ )
775
+ instances: dict[tuple[str, Any], RefHandle[Any]]
776
+ called_keys: set[tuple[str, Any]]
777
+ _channel: Channel | None
778
+
779
+ def __init__(self) -> None:
780
+ super().__init__()
781
+ self.instances = {}
782
+ self.called_keys = set()
783
+ self._channel = None
784
+
785
+ def _make_key(self, identity: Any, key: str | None) -> tuple[str, Any]:
786
+ if key is None:
787
+ return ("code", identity)
788
+ return ("key", key)
789
+
790
+ @override
791
+ def on_render_start(self, render_cycle: int) -> None:
792
+ super().on_render_start(render_cycle)
793
+ self.called_keys.clear()
794
+
795
+ def get_or_create(
796
+ self, identity: Any, key: str | None
797
+ ) -> tuple[RefHandle[Any], bool]:
798
+ full_identity = self._make_key(identity, key)
799
+ if full_identity in self.called_keys:
800
+ if key is None:
801
+ raise RuntimeError(
802
+ "`pulse.ref` can only be called once per component render at the same location. "
803
+ + "Use the `key` parameter to disambiguate: ps.ref(key=unique_value)"
804
+ )
805
+ raise RuntimeError(
806
+ f"`pulse.ref` can only be called once per component render with key='{key}'"
807
+ )
808
+ self.called_keys.add(full_identity)
809
+
810
+ existing = self.instances.get(full_identity)
811
+ if existing is not None:
812
+ if existing.__disposed__:
813
+ key_label = f"key='{key}'" if key is not None else "callsite"
814
+ raise RuntimeError(
815
+ "`pulse.ref` found a disposed cached RefHandle for "
816
+ + key_label
817
+ + ". Do not dispose handles returned by `pulse.ref`."
818
+ )
819
+ return existing, False
820
+
821
+ if self._channel is None or self._channel.closed:
822
+ ctx = PulseContext.get()
823
+ if ctx.render is None:
824
+ raise RuntimeError("ref() requires an active render session")
825
+ self._channel = ctx.render.get_ref_channel()
826
+ handle = RefHandle(self._channel, owns_channel=False)
827
+ self.instances[full_identity] = handle
828
+ return handle, True
829
+
830
+ @override
831
+ def dispose(self) -> None:
832
+ for handle in self.instances.values():
833
+ try:
834
+ handle.dispose()
835
+ except Exception:
836
+ pass
837
+ self._channel = None
838
+ self.instances.clear()
839
+
840
+
841
+ ref_hook_state = hooks.create(
842
+ "pulse:core.ref",
843
+ factory=RefHookState,
844
+ metadata=HookMetadata(
845
+ owner="pulse.core",
846
+ description="Internal storage for pulse.ref handles",
847
+ ),
848
+ )
849
+
850
+
851
+ def ref(
852
+ *,
853
+ key: str | None = None,
854
+ on_mount: Callable[[], Any] | None = None,
855
+ on_unmount: Callable[[], Any] | None = None,
856
+ ) -> RefHandle[Any]:
857
+ """Create or retrieve a stable ref handle for a component.
858
+
859
+ Args:
860
+ key: Optional key to disambiguate multiple refs created at the same callsite.
861
+ on_mount: Optional handler called when the ref mounts.
862
+ on_unmount: Optional handler called when the ref unmounts.
863
+ """
864
+ if key is not None and not isinstance(key, str):
865
+ raise TypeError("ref() key must be a string")
866
+ if key == "":
867
+ raise ValueError("ref() requires a non-empty string key")
868
+ if on_mount is not None and not callable(on_mount):
869
+ raise TypeError("ref() on_mount must be callable")
870
+ if on_unmount is not None and not callable(on_unmount):
871
+ raise TypeError("ref() on_unmount must be callable")
872
+
873
+ identity: Any
874
+ if key is None:
875
+ frame = inspect.currentframe()
876
+ assert frame is not None
877
+ caller = frame.f_back
878
+ assert caller is not None
879
+ identity = collect_component_identity(caller)
880
+ else:
881
+ identity = key
882
+
883
+ hook_state = ref_hook_state()
884
+ handle, created = hook_state.get_or_create(identity, key)
885
+ if created:
886
+ if on_mount is not None:
887
+ handle.on_mount(on_mount)
888
+ if on_unmount is not None:
889
+ handle.on_unmount(on_unmount)
890
+ return handle
891
+
892
+
893
+ __all__ = ["RefHandle", "RefNotMounted", "RefTimeout", "ref"]
pulse/render_session.py CHANGED
@@ -6,6 +6,7 @@ from asyncio import iscoroutine
6
6
  from collections.abc import Awaitable, Callable
7
7
  from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
8
8
 
9
+ from pulse.channel import Channel
9
10
  from pulse.context import PulseContext
10
11
  from pulse.hooks.runtime import NotFoundInterrupt, RedirectInterrupt
11
12
  from pulse.messages import (
@@ -257,6 +258,8 @@ class RenderSession:
257
258
  _send_message: Callable[[ServerMessage], Any] | None
258
259
  _pending_api: dict[str, asyncio.Future[dict[str, Any]]]
259
260
  _pending_js_results: dict[str, asyncio.Future[Any]]
261
+ _ref_channel: Channel | None
262
+ _ref_channels_by_route: dict[str, Channel]
260
263
  _global_states: dict[str, State]
261
264
  _global_queue: list[ServerMessage]
262
265
  _tasks: TaskRegistry
@@ -290,6 +293,8 @@ class RenderSession:
290
293
  self.forms = FormRegistry(self)
291
294
  self._pending_api = {}
292
295
  self._pending_js_results = {}
296
+ self._ref_channel = None
297
+ self._ref_channels_by_route = {}
293
298
  self._tasks = TaskRegistry(name=f"render:{id}")
294
299
  self._timers = TimerRegistry(tasks=self._tasks, name=f"render:{id}")
295
300
  self.query_store = QueryStore()
@@ -479,6 +484,7 @@ class RenderSession:
479
484
  return
480
485
  try:
481
486
  self.route_mounts.pop(path, None)
487
+ self._ref_channels_by_route.pop(path, None)
482
488
  mount.dispose()
483
489
  except Exception as e:
484
490
  self.report_error(path, "unmount", e)
@@ -486,6 +492,7 @@ class RenderSession:
486
492
  def detach(self, path: str, *, timeout: float | None = None):
487
493
  """Client no longer wants updates. Queue briefly, then dispose."""
488
494
  path = ensure_absolute_path(path)
495
+ self._ref_channels_by_route.pop(path, None)
489
496
  mount = self.route_mounts.get(path)
490
497
  if not mount:
491
498
  return
@@ -598,6 +605,8 @@ class RenderSession:
598
605
  if not fut.done():
599
606
  fut.cancel()
600
607
  self._pending_js_results.clear()
608
+ self._ref_channel = None
609
+ self._ref_channels_by_route.clear()
601
610
  # Close any timer that may have been scheduled during cleanup (ex: query GC)
602
611
  self._timers.cancel_all()
603
612
  self._global_queue = []
@@ -619,6 +628,24 @@ class RenderSession:
619
628
  self._global_states[key] = inst
620
629
  return inst
621
630
 
631
+ def get_ref_channel(self) -> Channel:
632
+ ctx = PulseContext.get()
633
+ if ctx.route is None:
634
+ if self._ref_channel is not None and not self._ref_channel.closed:
635
+ return self._ref_channel
636
+ self._ref_channel = self.channels.create(bind_route=False)
637
+ return self._ref_channel
638
+
639
+ route_path = ctx.route.pulse_route.unique_path()
640
+ channel = self._ref_channels_by_route.get(route_path)
641
+ if channel is not None and channel.closed:
642
+ self._ref_channels_by_route.pop(route_path, None)
643
+ channel = None
644
+ if channel is None:
645
+ channel = self.channels.create(bind_route=True)
646
+ self._ref_channels_by_route[route_path] = channel
647
+ return channel
648
+
622
649
  def flush(self):
623
650
  with PulseContext.update(render=self):
624
651
  flush_effects()
pulse/renderer.py CHANGED
@@ -9,6 +9,7 @@ from typing import Any, NamedTuple, TypeAlias, cast
9
9
  from pulse.debounce import Debounced
10
10
  from pulse.helpers import values_equal
11
11
  from pulse.hooks.core import HookContext
12
+ from pulse.refs import RefHandle
12
13
  from pulse.transpiler import Import
13
14
  from pulse.transpiler.function import Constant, JsFunction, JsxFunction
14
15
  from pulse.transpiler.nodes import (
@@ -34,7 +35,7 @@ from pulse.transpiler.vdom import (
34
35
  VDOMPropValue,
35
36
  )
36
37
 
37
- PropValue: TypeAlias = Node | Callable[..., Any] | Debounced[Any, Any]
38
+ PropValue: TypeAlias = Node | Callable[..., Any] | Debounced[Any, Any] | RefHandle[Any]
38
39
 
39
40
  FRAGMENT_TAG = ""
40
41
  MOUNT_PREFIX = "$$"
@@ -405,6 +406,25 @@ class Renderer:
405
406
  updated[key] = value.render()
406
407
  continue
407
408
 
409
+ if isinstance(value, RefHandle):
410
+ if key != "ref":
411
+ raise TypeError("RefHandle can only be used as the 'ref' prop")
412
+ eval_keys.add(key)
413
+ if isinstance(old_value, (Element, PulseNode)):
414
+ unmount_element(old_value)
415
+ if normalized is None:
416
+ normalized = current.copy()
417
+ normalized[key] = value
418
+ if not (
419
+ isinstance(old_value, RefHandle) and values_equal(old_value, value)
420
+ ):
421
+ updated[key] = {
422
+ "__pulse_ref__": {
423
+ "channelId": value.channel_id,
424
+ "refId": value.id,
425
+ }
426
+ }
427
+ continue
408
428
  if isinstance(value, Debounced):
409
429
  eval_keys.add(key)
410
430
  if isinstance(old_value, (Element, PulseNode)):
@@ -499,6 +519,8 @@ def prop_requires_eval(value: PropValue) -> bool:
499
519
  return True
500
520
  if isinstance(value, Expr):
501
521
  return True
522
+ if isinstance(value, RefHandle):
523
+ return True
502
524
  if isinstance(value, Debounced):
503
525
  return True
504
526
  return callable(value)
pulse/transpiler/vdom.py CHANGED
@@ -161,7 +161,18 @@ single sentinel string. Debounced callbacks use "$cb:<delay_ms>" in the wire for
161
161
  """
162
162
 
163
163
 
164
- VDOMPropValue: TypeAlias = "JsonValue | VDOMExpr | VDOMElement | CallbackPlaceholder"
164
+ class PulseRefPayload(TypedDict):
165
+ channelId: str
166
+ refId: str
167
+
168
+
169
+ class PulseRefSpec(TypedDict):
170
+ __pulse_ref__: PulseRefPayload
171
+
172
+
173
+ VDOMPropValue: TypeAlias = (
174
+ "JsonValue | VDOMExpr | VDOMElement | CallbackPlaceholder | PulseRefSpec"
175
+ )
165
176
  """Allowed prop value types.
166
177
 
167
178
  Hot path:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pulse-framework
3
- Version: 0.1.75
3
+ Version: 0.1.76
4
4
  Summary: Pulse - Full-stack framework for building real-time React applications in Python
5
5
  Requires-Dist: fastapi>=0.128.0
6
6
  Requires-Dist: uvicorn>=0.24.0
@@ -1,7 +1,7 @@
1
- pulse/__init__.py,sha256=GUPD8THGENBgHsZvixIP8wOiEtdEMtIVpr8N8MpuRL4,32675
1
+ pulse/__init__.py,sha256=jFSqmTbDLp07bGVr8N7Pa6k0h7Ipq2pYn_tsrr2Ztu8,32881
2
2
  pulse/_examples.py,sha256=dFuhD2EVXsbvAeexoG57s4VuN4gWLaTMOEMNYvlPm9A,561
3
3
  pulse/app.py,sha256=Bi94rYG-MoldkGa-_CscLMstjTEV8BHVAgDbvapRGzI,36167
4
- pulse/channel.py,sha256=ePpvD2mDbddt_LMxxxDjNRgOLbVi8Ed6TmJFgkrALB0,15790
4
+ pulse/channel.py,sha256=UkImBCIFr5sWdkpB3dFwwFa-nWyEnl1W3EaLv0BRsMU,15845
5
5
  pulse/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  pulse/cli/cmd.py,sha256=LQK_B6iANOAqcQCM0KMTfRbpqGYRaPDkEBvvaAS3qNI,15985
7
7
  pulse/cli/dependencies.py,sha256=qU-rF7QyP0Rl1Fl0YKQubrGNBzj84BAbH1uUT3ehxik,4283
@@ -33,7 +33,7 @@ pulse/decorators.py,sha256=Lskni9Keqfb-xmUliFQe5x-4AcNqrwdvoh0kuz2fXa0,9958
33
33
  pulse/dom/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
34
  pulse/dom/elements.py,sha256=YHXkVpfMAC4-0o61fK-E0LGTOM3KMCtBfpHHAwLx7dw,23241
35
35
  pulse/dom/events.py,sha256=yHioH8Y-b7raOaZ43JuCxk2lUBryUAcDSc-5VhXtiSI,14699
36
- pulse/dom/props.py,sha256=WrPwOYSoJmn-VWxU2KvJC1j64L4tlT8X2JpabK94gYQ,26721
36
+ pulse/dom/props.py,sha256=6F3dE_bShI2WdAVfFG0DbIQom2GcM0iF8B3FBE5bj14,26775
37
37
  pulse/dom/svg.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
38
38
  pulse/dom/tags.py,sha256=U6mKmwB9JAFM6LTESMJcoIejNfnyxIdQo2-TLM5OaZ0,7585
39
39
  pulse/dom/tags.pyi,sha256=0BC7zTh22roPBuMQawL8hgI6IrfN8xJZuDIoKMd4QKc,14393
@@ -106,8 +106,9 @@ pulse/queries/store.py,sha256=iw05_EFpyfiXv5_FV_x4aHtCo00mk0dDPFD461cajcg,3850
106
106
  pulse/react_component.py,sha256=8RLg4Bi7IcjqbnbEnp4hJpy8t1UsE7mG0UR1Q655LDk,2332
107
107
  pulse/reactive.py,sha256=GSh9wSH3THCBjDTafwWttyx7djeKBWV_KqjaKRYUNsA,31393
108
108
  pulse/reactive_extensions.py,sha256=yQ1PpdAh4kMvll7R15T72FOg8NFdG_HGBsGc63dawYk,33754
109
- pulse/render_session.py,sha256=WKWDOqtIjy9n00HxMiViI-pBHw34QOEhLgZap28BCMg,23431
110
- pulse/renderer.py,sha256=a4gTEFZuhAc1V5uTcFFcsOREDg6ZU9-jf4Ic7qLo2CY,16902
109
+ pulse/refs.py,sha256=-6QlzJwJ_lLgGGvJDl6OTOkoA6799TvBGla54ZTH_jA,22889
110
+ pulse/render_session.py,sha256=bujJ0Ch7wUgCqjlq3Z0e8zlg6xpkMThCD-daUSCw-xc,24406
111
+ pulse/renderer.py,sha256=xFjF9Ttv7M74BpHDHpAp32rTFztwyQDZcEWhOFLI5MU,17553
111
112
  pulse/request.py,sha256=N0oFOLiGxpbgSgxznjvu64lG3YyOcZPKC8JFyKx6X7w,6023
112
113
  pulse/requirements.py,sha256=nMnE25Uu-TUuQd88jW7m2xwus6fD-HvXxQ9UNb7OOGc,1254
113
114
  pulse/routing.py,sha256=oRfVaeIrsbDR9yW9BYwxVWV3HZI7wk21yZX69IVADIU,17279
@@ -139,12 +140,12 @@ pulse/transpiler/nodes.py,sha256=ObdCFIEvtKMVRO8iy1hIN4L-vC4yPqRvhPS6E344-bE,526
139
140
  pulse/transpiler/parse.py,sha256=uz_KDnjmjzFSjGtVKRznWg95P0NHM8CafWgvqrqJcOs,1622
140
141
  pulse/transpiler/py_module.py,sha256=um4BYLrbs01bpgv2LEBHTbhXXh8Bs174c3ygv5tHHOg,4410
141
142
  pulse/transpiler/transpiler.py,sha256=bu33-wGNqHGheT_ZqMnQgEARyPG6xyOvuLuixjxIZnI,42761
142
- pulse/transpiler/vdom.py,sha256=Bf1yw10hQl8BXa6rhr5byRa5ua3qgRsVGNgEtQneA2A,6460
143
+ pulse/transpiler/vdom.py,sha256=5ooW9uoWoBEEKBSds27m6Birj3eOuWZ2Qh2nZ4f_kvo,6609
143
144
  pulse/types/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
144
145
  pulse/types/event_handler.py,sha256=psQCydj-WEtBcFU5JU4mDwvyzkW8V2O0g_VFRU2EOHI,1618
145
146
  pulse/user_session.py,sha256=nsnsMgqq2xGJZLpbHRMHUHcLrElMP8WcA4gjGMrcoBk,10208
146
147
  pulse/version.py,sha256=711vaM1jVIQPgkisGgKZqwmw019qZIsc_QTae75K2pg,1895
147
- pulse_framework-0.1.75.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
148
- pulse_framework-0.1.75.dist-info/entry_points.txt,sha256=i7aohd3QaPu5IcuGKKvsQQEiMYMe5HcF56QEsaLVO64,46
149
- pulse_framework-0.1.75.dist-info/METADATA,sha256=Y2M3HpMari75ZADpqGSxEfyP5YGCnRS2AkZ3tE3lmpA,8299
150
- pulse_framework-0.1.75.dist-info/RECORD,,
148
+ pulse_framework-0.1.76.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
149
+ pulse_framework-0.1.76.dist-info/entry_points.txt,sha256=i7aohd3QaPu5IcuGKKvsQQEiMYMe5HcF56QEsaLVO64,46
150
+ pulse_framework-0.1.76.dist-info/METADATA,sha256=NQtUswIWI_6HPwjmn9htpOBclmshDNxcXdPmhggApNQ,8299
151
+ pulse_framework-0.1.76.dist-info/RECORD,,