pulse-framework 0.1.62__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 (126) hide show
  1. pulse/__init__.py +1493 -0
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +1086 -0
  4. pulse/channel.py +607 -0
  5. pulse/cli/__init__.py +0 -0
  6. pulse/cli/cmd.py +575 -0
  7. pulse/cli/dependencies.py +181 -0
  8. pulse/cli/folder_lock.py +134 -0
  9. pulse/cli/helpers.py +271 -0
  10. pulse/cli/logging.py +102 -0
  11. pulse/cli/models.py +35 -0
  12. pulse/cli/packages.py +262 -0
  13. pulse/cli/processes.py +292 -0
  14. pulse/cli/secrets.py +39 -0
  15. pulse/cli/uvicorn_log_config.py +87 -0
  16. pulse/code_analysis.py +38 -0
  17. pulse/codegen/__init__.py +0 -0
  18. pulse/codegen/codegen.py +359 -0
  19. pulse/codegen/templates/__init__.py +0 -0
  20. pulse/codegen/templates/layout.py +106 -0
  21. pulse/codegen/templates/route.py +345 -0
  22. pulse/codegen/templates/routes_ts.py +42 -0
  23. pulse/codegen/utils.py +20 -0
  24. pulse/component.py +237 -0
  25. pulse/components/__init__.py +0 -0
  26. pulse/components/for_.py +83 -0
  27. pulse/components/if_.py +86 -0
  28. pulse/components/react_router.py +94 -0
  29. pulse/context.py +108 -0
  30. pulse/cookies.py +322 -0
  31. pulse/decorators.py +344 -0
  32. pulse/dom/__init__.py +0 -0
  33. pulse/dom/elements.py +1024 -0
  34. pulse/dom/events.py +445 -0
  35. pulse/dom/props.py +1250 -0
  36. pulse/dom/svg.py +0 -0
  37. pulse/dom/tags.py +328 -0
  38. pulse/dom/tags.pyi +480 -0
  39. pulse/env.py +178 -0
  40. pulse/form.py +538 -0
  41. pulse/helpers.py +541 -0
  42. pulse/hooks/__init__.py +0 -0
  43. pulse/hooks/core.py +452 -0
  44. pulse/hooks/effects.py +88 -0
  45. pulse/hooks/init.py +668 -0
  46. pulse/hooks/runtime.py +464 -0
  47. pulse/hooks/setup.py +254 -0
  48. pulse/hooks/stable.py +138 -0
  49. pulse/hooks/state.py +192 -0
  50. pulse/js/__init__.py +125 -0
  51. pulse/js/__init__.pyi +115 -0
  52. pulse/js/_types.py +299 -0
  53. pulse/js/array.py +339 -0
  54. pulse/js/console.py +50 -0
  55. pulse/js/date.py +119 -0
  56. pulse/js/document.py +145 -0
  57. pulse/js/error.py +140 -0
  58. pulse/js/json.py +66 -0
  59. pulse/js/map.py +97 -0
  60. pulse/js/math.py +69 -0
  61. pulse/js/navigator.py +79 -0
  62. pulse/js/number.py +57 -0
  63. pulse/js/obj.py +81 -0
  64. pulse/js/object.py +172 -0
  65. pulse/js/promise.py +172 -0
  66. pulse/js/pulse.py +115 -0
  67. pulse/js/react.py +495 -0
  68. pulse/js/regexp.py +57 -0
  69. pulse/js/set.py +124 -0
  70. pulse/js/string.py +38 -0
  71. pulse/js/weakmap.py +53 -0
  72. pulse/js/weakset.py +48 -0
  73. pulse/js/window.py +205 -0
  74. pulse/messages.py +202 -0
  75. pulse/middleware.py +471 -0
  76. pulse/plugin.py +96 -0
  77. pulse/proxy.py +242 -0
  78. pulse/py.typed +0 -0
  79. pulse/queries/__init__.py +0 -0
  80. pulse/queries/client.py +609 -0
  81. pulse/queries/common.py +101 -0
  82. pulse/queries/effect.py +55 -0
  83. pulse/queries/infinite_query.py +1418 -0
  84. pulse/queries/mutation.py +295 -0
  85. pulse/queries/protocol.py +136 -0
  86. pulse/queries/query.py +1314 -0
  87. pulse/queries/store.py +120 -0
  88. pulse/react_component.py +88 -0
  89. pulse/reactive.py +1208 -0
  90. pulse/reactive_extensions.py +1172 -0
  91. pulse/render_session.py +768 -0
  92. pulse/renderer.py +584 -0
  93. pulse/request.py +205 -0
  94. pulse/routing.py +598 -0
  95. pulse/serializer.py +279 -0
  96. pulse/state.py +556 -0
  97. pulse/test_helpers.py +15 -0
  98. pulse/transpiler/__init__.py +111 -0
  99. pulse/transpiler/assets.py +81 -0
  100. pulse/transpiler/builtins.py +1029 -0
  101. pulse/transpiler/dynamic_import.py +130 -0
  102. pulse/transpiler/emit_context.py +49 -0
  103. pulse/transpiler/errors.py +96 -0
  104. pulse/transpiler/function.py +611 -0
  105. pulse/transpiler/id.py +18 -0
  106. pulse/transpiler/imports.py +341 -0
  107. pulse/transpiler/js_module.py +336 -0
  108. pulse/transpiler/modules/__init__.py +33 -0
  109. pulse/transpiler/modules/asyncio.py +57 -0
  110. pulse/transpiler/modules/json.py +24 -0
  111. pulse/transpiler/modules/math.py +265 -0
  112. pulse/transpiler/modules/pulse/__init__.py +5 -0
  113. pulse/transpiler/modules/pulse/tags.py +250 -0
  114. pulse/transpiler/modules/typing.py +63 -0
  115. pulse/transpiler/nodes.py +1987 -0
  116. pulse/transpiler/py_module.py +135 -0
  117. pulse/transpiler/transpiler.py +1100 -0
  118. pulse/transpiler/vdom.py +256 -0
  119. pulse/types/__init__.py +0 -0
  120. pulse/types/event_handler.py +50 -0
  121. pulse/user_session.py +386 -0
  122. pulse/version.py +69 -0
  123. pulse_framework-0.1.62.dist-info/METADATA +198 -0
  124. pulse_framework-0.1.62.dist-info/RECORD +126 -0
  125. pulse_framework-0.1.62.dist-info/WHEEL +4 -0
  126. pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
pulse/renderer.py ADDED
@@ -0,0 +1,584 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from collections.abc import Callable, Iterable
5
+ from dataclasses import dataclass
6
+ from types import NoneType
7
+ from typing import Any, NamedTuple, TypeAlias, cast
8
+
9
+ from pulse.helpers import values_equal
10
+ from pulse.hooks.core import HookContext
11
+ from pulse.transpiler import Import
12
+ from pulse.transpiler.function import Constant, JsFunction, JsxFunction
13
+ from pulse.transpiler.nodes import (
14
+ Child,
15
+ Children,
16
+ Element,
17
+ Expr,
18
+ Literal,
19
+ Node,
20
+ PulseNode,
21
+ Value,
22
+ )
23
+ from pulse.transpiler.vdom import (
24
+ VDOM,
25
+ ReconciliationOperation,
26
+ RegistryRef,
27
+ ReplaceOperation,
28
+ UpdatePropsDelta,
29
+ UpdatePropsOperation,
30
+ VDOMElement,
31
+ VDOMNode,
32
+ VDOMOperation,
33
+ VDOMPropValue,
34
+ )
35
+
36
+ PropValue: TypeAlias = Node | Callable[..., Any]
37
+
38
+ FRAGMENT_TAG = ""
39
+ MOUNT_PREFIX = "$$"
40
+ CALLBACK_PLACEHOLDER = "$cb"
41
+
42
+
43
+ class Callback(NamedTuple):
44
+ fn: Callable[..., Any]
45
+ n_args: int
46
+
47
+
48
+ Callbacks = dict[str, Callback]
49
+
50
+
51
+ @dataclass(slots=True)
52
+ class DiffPropsResult:
53
+ normalized: dict[str, PropValue]
54
+ delta_set: dict[str, VDOMPropValue]
55
+ delta_remove: set[str]
56
+ render_prop_reconciles: list["RenderPropTask"]
57
+ eval_keys: set[str]
58
+ eval_changed: bool
59
+
60
+
61
+ class RenderPropTask(NamedTuple):
62
+ key: str
63
+ previous: Element | PulseNode
64
+ current: Element | PulseNode
65
+ path: str
66
+
67
+
68
+ class RenderTree:
69
+ element: Node
70
+ callbacks: Callbacks
71
+ rendered: bool
72
+
73
+ def __init__(self, element: Node) -> None:
74
+ self.element = element
75
+ self.callbacks = {}
76
+ self.rendered = False
77
+
78
+ def render(self) -> VDOM:
79
+ """First render. Returns VDOM."""
80
+ renderer = Renderer()
81
+ vdom, self.element = renderer.render_tree(self.element)
82
+ self.callbacks = renderer.callbacks
83
+ self.rendered = True
84
+ return vdom
85
+
86
+ def rerender(self, new_element: Node | None = None) -> list[VDOMOperation]:
87
+ """Re-render and return update operations.
88
+
89
+ If new_element is provided, reconciles against it (for testing).
90
+ Otherwise, reconciles against the current element (production use).
91
+ """
92
+ if not self.rendered:
93
+ raise RuntimeError("render() must be called before rerender()")
94
+ target = new_element if new_element is not None else self.element
95
+ renderer = Renderer()
96
+ self.element = renderer.reconcile_tree(self.element, target, path="")
97
+ self.callbacks = renderer.callbacks
98
+ return renderer.operations
99
+
100
+ def unmount(self) -> None:
101
+ if self.rendered:
102
+ unmount_element(self.element)
103
+ self.rendered = False
104
+ self.callbacks.clear()
105
+
106
+
107
+ class Renderer:
108
+ def __init__(self) -> None:
109
+ self.callbacks: Callbacks = {}
110
+ self.operations: list[VDOMOperation] = []
111
+
112
+ # ------------------------------------------------------------------
113
+ # Rendering helpers
114
+ # ------------------------------------------------------------------
115
+
116
+ def render_tree(self, node: Node, path: str = "") -> tuple[Any, Node]:
117
+ if isinstance(node, PulseNode):
118
+ return self.render_component(node, path)
119
+ if isinstance(node, Element):
120
+ return self.render_node(node, path)
121
+ if isinstance(node, Value):
122
+ return node.value, node.value
123
+ if isinstance(node, Expr):
124
+ return node.render(), node
125
+ # Pass through any other value - serializer will validate
126
+ return node, node
127
+
128
+ def render_component(
129
+ self, component: PulseNode, path: str
130
+ ) -> tuple[VDOM, PulseNode]:
131
+ if component.hooks is None:
132
+ component.hooks = HookContext()
133
+ with component.hooks:
134
+ rendered = component.fn(*component.args, **component.kwargs)
135
+ vdom, normalized_child = self.render_tree(rendered, path)
136
+ component.contents = normalized_child
137
+ return vdom, component
138
+
139
+ def render_node(self, element: Element, path: str) -> tuple[VDOMNode, Element]:
140
+ tag = self.render_tag(element.tag)
141
+ vdom_node: VDOMElement = {"tag": tag}
142
+ if (key_val := key_value(element)) is not None:
143
+ vdom_node["key"] = key_val
144
+
145
+ props = element.props_dict()
146
+ props_result = self.diff_props({}, props, path, prev_eval=set())
147
+ if props_result.delta_set:
148
+ vdom_node["props"] = props_result.delta_set
149
+ if props_result.eval_keys:
150
+ vdom_node["eval"] = sorted(props_result.eval_keys)
151
+
152
+ for task in props_result.render_prop_reconciles:
153
+ normalized_value = self.reconcile_tree(
154
+ task.previous, task.current, task.path
155
+ )
156
+ props_result.normalized[task.key] = normalized_value
157
+
158
+ element.props = props_result.normalized or None
159
+
160
+ children_vdom: list[VDOM] = []
161
+ normalized_children: list[Node] = []
162
+ for idx, child in enumerate(normalize_children(element.children)):
163
+ child_path = join_path(path, idx)
164
+ child_vdom, normalized_child = self.render_tree(child, child_path)
165
+ children_vdom.append(child_vdom)
166
+ normalized_children.append(normalized_child)
167
+
168
+ if children_vdom:
169
+ vdom_node["children"] = children_vdom
170
+ element.children = normalized_children
171
+
172
+ return vdom_node, element
173
+
174
+ # ------------------------------------------------------------------
175
+ # Reconciliation
176
+ # ------------------------------------------------------------------
177
+
178
+ def reconcile_tree(
179
+ self,
180
+ previous: Node,
181
+ current: Node,
182
+ path: str = "",
183
+ ) -> Node:
184
+ if isinstance(current, Value):
185
+ current = current.value
186
+ if isinstance(previous, Value):
187
+ previous = previous.value
188
+ if not same_node(previous, current):
189
+ unmount_element(previous)
190
+ new_vdom, normalized = self.render_tree(current, path)
191
+ self.operations.append(
192
+ ReplaceOperation(type="replace", path=path, data=new_vdom)
193
+ )
194
+ return normalized
195
+
196
+ if isinstance(previous, PulseNode) and isinstance(current, PulseNode):
197
+ return self.reconcile_component(previous, current, path)
198
+
199
+ if isinstance(previous, Element) and isinstance(current, Element):
200
+ return self.reconcile_element(previous, current, path)
201
+
202
+ return current
203
+
204
+ def reconcile_component(
205
+ self,
206
+ previous: PulseNode,
207
+ current: PulseNode,
208
+ path: str,
209
+ ) -> PulseNode:
210
+ current.hooks = previous.hooks
211
+ current.contents = previous.contents
212
+
213
+ if current.hooks is None:
214
+ current.hooks = HookContext()
215
+
216
+ with current.hooks:
217
+ rendered = current.fn(*current.args, **current.kwargs)
218
+
219
+ if current.contents is None:
220
+ new_vdom, normalized = self.render_tree(rendered, path)
221
+ current.contents = normalized
222
+ self.operations.append(
223
+ ReplaceOperation(type="replace", path=path, data=new_vdom)
224
+ )
225
+ else:
226
+ current.contents = self.reconcile_tree(current.contents, rendered, path)
227
+
228
+ return current
229
+
230
+ def reconcile_element(
231
+ self,
232
+ previous: Element,
233
+ current: Element,
234
+ path: str,
235
+ ) -> Element:
236
+ prev_props = previous.props_dict()
237
+ new_props = current.props_dict()
238
+ prev_eval = eval_keys_for_props(prev_props)
239
+ props_result = self.diff_props(prev_props, new_props, path, prev_eval)
240
+
241
+ if (
242
+ props_result.delta_set
243
+ or props_result.delta_remove
244
+ or props_result.eval_changed
245
+ ):
246
+ delta: UpdatePropsDelta = {}
247
+ if props_result.delta_set:
248
+ delta["set"] = props_result.delta_set
249
+ if props_result.delta_remove:
250
+ delta["remove"] = sorted(props_result.delta_remove)
251
+ if props_result.eval_changed:
252
+ delta["eval"] = sorted(props_result.eval_keys)
253
+ self.operations.append(
254
+ UpdatePropsOperation(type="update_props", path=path, data=delta)
255
+ )
256
+
257
+ for task in props_result.render_prop_reconciles:
258
+ normalized_value = self.reconcile_tree(
259
+ task.previous, task.current, task.path
260
+ )
261
+ props_result.normalized[task.key] = normalized_value
262
+
263
+ prev_children = normalize_children(previous.children)
264
+ next_children = normalize_children(current.children)
265
+ normalized_children = self.reconcile_children(
266
+ prev_children, next_children, path
267
+ )
268
+
269
+ current.props = props_result.normalized or None
270
+ current.children = normalized_children
271
+ return current
272
+
273
+ def reconcile_children(
274
+ self,
275
+ c1: list[Node],
276
+ c2: list[Node],
277
+ path: str,
278
+ ) -> list[Node]:
279
+ if not c1 and not c2:
280
+ return []
281
+
282
+ N1 = len(c1)
283
+ N2 = len(c2)
284
+ norm: list[Node | None] = [None] * N2
285
+ N = min(N1, N2)
286
+ i = 0
287
+ while i < N:
288
+ x1 = c1[i]
289
+ x2 = c2[i]
290
+ if not same_node(x1, x2):
291
+ break
292
+ norm[i] = self.reconcile_tree(x1, x2, join_path(path, i))
293
+ i += 1
294
+
295
+ if i == N1 == N2:
296
+ return norm
297
+
298
+ op = ReconciliationOperation(
299
+ type="reconciliation", path=path, N=len(c2), new=([], []), reuse=([], [])
300
+ )
301
+ self.operations.append(op)
302
+
303
+ keys_to_old_idx: dict[str, int] = {}
304
+ for j1 in range(i, N1):
305
+ key = key_value(c1[j1])
306
+ if key is not None:
307
+ keys_to_old_idx[key] = j1
308
+
309
+ reused = [False] * (N1 - i)
310
+ for j2 in range(i, N2):
311
+ x2 = c2[j2]
312
+ k = key_value(x2)
313
+ if k is not None:
314
+ j1 = keys_to_old_idx.get(k)
315
+ if j1 is not None:
316
+ x1 = c1[j1]
317
+ if same_node(x1, x2):
318
+ norm[j2] = self.reconcile_tree(x1, x2, join_path(path, j2))
319
+ reused[j1 - i] = True
320
+ if j1 != j2:
321
+ op["reuse"][0].append(j2)
322
+ op["reuse"][1].append(j1)
323
+ continue
324
+ if k is None and j2 < N1:
325
+ x1 = c1[j2]
326
+ if same_node(x1, x2):
327
+ reused[j2 - i] = True
328
+ norm[j2] = self.reconcile_tree(x1, x2, join_path(path, j2))
329
+ continue
330
+
331
+ vdom, el = self.render_tree(x2, join_path(path, j2))
332
+ op["new"][0].append(j2)
333
+ op["new"][1].append(vdom)
334
+ norm[j2] = el
335
+
336
+ for j1 in range(i, N1):
337
+ if not reused[j1 - i]:
338
+ self.unmount_subtree(c1[j1])
339
+
340
+ return norm
341
+
342
+ # ------------------------------------------------------------------
343
+ # Prop diffing
344
+ # ------------------------------------------------------------------
345
+
346
+ def diff_props(
347
+ self,
348
+ previous: dict[str, PropValue],
349
+ current: dict[str, PropValue],
350
+ path: str,
351
+ prev_eval: set[str],
352
+ ) -> DiffPropsResult:
353
+ updated: dict[str, VDOMPropValue] = {}
354
+ normalized: dict[str, PropValue] | None = None
355
+ render_prop_tasks: list[RenderPropTask] = []
356
+ eval_keys: set[str] = set()
357
+ removed_keys = set(previous.keys()) - set(current.keys())
358
+
359
+ for key, value in current.items():
360
+ old_value = previous.get(key)
361
+ prop_path = join_path(path, key)
362
+
363
+ if isinstance(value, (Element, PulseNode)):
364
+ eval_keys.add(key)
365
+ if isinstance(old_value, (Element, PulseNode)):
366
+ if normalized is None:
367
+ normalized = current.copy()
368
+ normalized[key] = old_value
369
+ render_prop_tasks.append(
370
+ RenderPropTask(
371
+ key=key,
372
+ previous=old_value,
373
+ current=value,
374
+ path=prop_path,
375
+ )
376
+ )
377
+ else:
378
+ vdom_value, normalized_value = self.render_tree(value, prop_path)
379
+ if normalized is None:
380
+ normalized = current.copy()
381
+ normalized[key] = normalized_value
382
+ updated[key] = cast(VDOMPropValue, vdom_value)
383
+ continue
384
+
385
+ if isinstance(value, Value):
386
+ unwrapped = value.value
387
+ if normalized is None:
388
+ normalized = current.copy()
389
+ normalized[key] = unwrapped
390
+ if isinstance(old_value, (Element, PulseNode)):
391
+ unmount_element(old_value)
392
+ if key not in previous or not values_equal(unwrapped, old_value):
393
+ updated[key] = cast(VDOMPropValue, unwrapped)
394
+ continue
395
+
396
+ if isinstance(value, Expr):
397
+ eval_keys.add(key)
398
+ if isinstance(old_value, (Element, PulseNode)):
399
+ unmount_element(old_value)
400
+ if normalized is None:
401
+ normalized = current.copy()
402
+ normalized[key] = value
403
+ if not (isinstance(old_value, Expr) and values_equal(old_value, value)):
404
+ updated[key] = value.render()
405
+ continue
406
+
407
+ if callable(value):
408
+ eval_keys.add(key)
409
+ if isinstance(old_value, (Element, PulseNode)):
410
+ unmount_element(old_value)
411
+ if normalized is None:
412
+ normalized = current.copy()
413
+ normalized[key] = value
414
+ register_callback(self.callbacks, prop_path, value)
415
+ if not callable(old_value):
416
+ updated[key] = CALLBACK_PLACEHOLDER
417
+ continue
418
+
419
+ if isinstance(old_value, (Element, PulseNode)):
420
+ unmount_element(old_value)
421
+ # No normalization needed - value passes through unchanged
422
+ if key not in previous or not values_equal(value, old_value):
423
+ updated[key] = cast(VDOMPropValue, value)
424
+
425
+ for key in removed_keys:
426
+ old_value = previous.get(key)
427
+ if isinstance(old_value, (Element, PulseNode)):
428
+ unmount_element(old_value)
429
+
430
+ normalized_props = normalized if normalized is not None else current.copy()
431
+ eval_changed = eval_keys != prev_eval
432
+ return DiffPropsResult(
433
+ normalized=normalized_props,
434
+ delta_set=updated,
435
+ delta_remove=removed_keys,
436
+ render_prop_reconciles=render_prop_tasks,
437
+ eval_keys=eval_keys,
438
+ eval_changed=eval_changed,
439
+ )
440
+
441
+ # ------------------------------------------------------------------
442
+ # Expression + tag rendering
443
+ # ------------------------------------------------------------------
444
+
445
+ def render_tag(self, tag: str | Expr):
446
+ if isinstance(tag, str):
447
+ return tag
448
+
449
+ return self.register_component_expr(tag)
450
+
451
+ def register_component_expr(self, expr: Expr):
452
+ ref = registry_ref(expr)
453
+ if ref is not None:
454
+ return f"{MOUNT_PREFIX}{ref['key']}"
455
+ tag = expr.render()
456
+ if isinstance(tag, (int, float, bool, NoneType)):
457
+ raise TypeError(f"Invalid element tag: {tag}")
458
+ return tag
459
+
460
+ # ------------------------------------------------------------------
461
+ # Unmount helper
462
+ # ------------------------------------------------------------------
463
+
464
+ def unmount_subtree(self, node: Node) -> None:
465
+ unmount_element(node)
466
+
467
+
468
+ # ----------------------------------------------------------------------
469
+ # Helpers
470
+ # ----------------------------------------------------------------------
471
+
472
+
473
+ def registry_ref(expr: Expr) -> RegistryRef | None:
474
+ if isinstance(expr, (Import, JsFunction, Constant, JsxFunction)):
475
+ return {"t": "ref", "key": expr.id}
476
+ return None
477
+
478
+
479
+ def prop_requires_eval(value: PropValue) -> bool:
480
+ if isinstance(value, Value):
481
+ return False
482
+ if isinstance(value, (Element, PulseNode)):
483
+ return True
484
+ if isinstance(value, Expr):
485
+ return True
486
+ return callable(value)
487
+
488
+
489
+ def eval_keys_for_props(props: dict[str, PropValue]) -> set[str]:
490
+ eval_keys: set[str] = set()
491
+ for key, value in props.items():
492
+ if prop_requires_eval(value):
493
+ eval_keys.add(key)
494
+ return eval_keys
495
+
496
+
497
+ def normalize_children(children: Children | None) -> list[Node]:
498
+ if not children:
499
+ return []
500
+
501
+ out: list[Node] = []
502
+ seen_keys: set[str] = set()
503
+
504
+ def register_key(item: Node) -> None:
505
+ key: str | None = None
506
+ if isinstance(item, PulseNode):
507
+ key = item.key
508
+ elif isinstance(item, Element):
509
+ key = key_value(item)
510
+ if key is None:
511
+ return
512
+ if key in seen_keys:
513
+ raise ValueError(f"Duplicate key '{key}'")
514
+ seen_keys.add(key)
515
+
516
+ def visit(item: Child) -> None:
517
+ if isinstance(item, dict):
518
+ raise TypeError("Dict is not a valid child; wrap in Value for props")
519
+ if isinstance(item, Iterable) and not isinstance(item, (str, bytes)):
520
+ for sub in item:
521
+ visit(sub)
522
+ else:
523
+ node = cast(Node, item)
524
+ register_key(node)
525
+ out.append(node)
526
+
527
+ for child in children:
528
+ visit(child)
529
+
530
+ return out
531
+
532
+
533
+ def register_callback(
534
+ callbacks: Callbacks,
535
+ path: str,
536
+ fn: Callable[..., Any],
537
+ ) -> None:
538
+ n_args = len(inspect.signature(fn).parameters)
539
+ callbacks[path] = Callback(fn=fn, n_args=n_args)
540
+
541
+
542
+ def join_path(prefix: str, path: str | int) -> str:
543
+ if prefix:
544
+ return f"{prefix}.{path}"
545
+ return str(path)
546
+
547
+
548
+ def same_node(left: Node, right: Node) -> bool:
549
+ if values_equal(left, right):
550
+ return True
551
+ if isinstance(left, Element) and isinstance(right, Element):
552
+ return values_equal(left.tag, right.tag) and key_value(left) == key_value(right)
553
+ if isinstance(left, PulseNode) and isinstance(right, PulseNode):
554
+ return left.fn == right.fn and key_value(left) == key_value(right)
555
+ return False
556
+
557
+
558
+ def key_value(node: Node | Node) -> str | None:
559
+ key = getattr(node, "key", None)
560
+ if isinstance(key, Literal):
561
+ if not isinstance(key.value, str):
562
+ raise TypeError("Element key must be a string")
563
+ return key.value
564
+ return cast(str | None, key)
565
+
566
+
567
+ def unmount_element(element: Node) -> None:
568
+ if isinstance(element, PulseNode):
569
+ if element.contents is not None:
570
+ unmount_element(element.contents)
571
+ element.contents = None
572
+ if element.hooks is not None:
573
+ element.hooks.unmount()
574
+ return
575
+
576
+ if isinstance(element, Element):
577
+ props = element.props_dict()
578
+ for value in props.values():
579
+ if isinstance(value, (Element, PulseNode)):
580
+ unmount_element(value)
581
+ for child in normalize_children(element.children):
582
+ unmount_element(child)
583
+ element.children = []
584
+ return