pulse-framework 0.1.43__py3-none-any.whl → 0.1.46__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 +1 -1
- pulse/app.py +1 -1
- pulse/queries/client.py +7 -7
- pulse/queries/effect.py +16 -0
- pulse/queries/infinite_query.py +138 -29
- pulse/queries/mutation.py +1 -15
- pulse/queries/protocol.py +136 -0
- pulse/queries/query.py +610 -174
- pulse/queries/store.py +11 -14
- pulse/reactive.py +19 -1
- pulse/render_session.py +41 -25
- pulse/renderer.py +0 -43
- pulse/types/event_handler.py +3 -2
- pulse/user_session.py +7 -3
- pulse/vdom.py +100 -7
- {pulse_framework-0.1.43.dist-info → pulse_framework-0.1.46.dist-info}/METADATA +1 -1
- {pulse_framework-0.1.43.dist-info → pulse_framework-0.1.46.dist-info}/RECORD +19 -18
- {pulse_framework-0.1.43.dist-info → pulse_framework-0.1.46.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.43.dist-info → pulse_framework-0.1.46.dist-info}/entry_points.txt +0 -0
pulse/queries/store.py
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import datetime as dt
|
|
2
|
-
from collections.abc import
|
|
2
|
+
from collections.abc import Callable
|
|
3
3
|
from typing import Any, TypeVar, cast
|
|
4
4
|
|
|
5
|
+
from pulse.helpers import MISSING
|
|
5
6
|
from pulse.queries.common import QueryKey
|
|
6
7
|
from pulse.queries.infinite_query import InfiniteQuery, Page
|
|
7
|
-
from pulse.queries.query import RETRY_DELAY_DEFAULT,
|
|
8
|
+
from pulse.queries.query import RETRY_DELAY_DEFAULT, KeyedQuery
|
|
8
9
|
|
|
9
10
|
T = TypeVar("T")
|
|
10
11
|
|
|
@@ -15,26 +16,25 @@ class QueryStore:
|
|
|
15
16
|
"""
|
|
16
17
|
|
|
17
18
|
def __init__(self):
|
|
18
|
-
self._entries: dict[QueryKey,
|
|
19
|
+
self._entries: dict[QueryKey, KeyedQuery[Any] | InfiniteQuery[Any, Any]] = {}
|
|
19
20
|
|
|
20
21
|
def items(self):
|
|
21
22
|
"""Iterate over all (key, query) pairs in the store."""
|
|
22
23
|
return self._entries.items()
|
|
23
24
|
|
|
24
|
-
def get_any(self, key: QueryKey)
|
|
25
|
+
def get_any(self, key: QueryKey):
|
|
25
26
|
"""Get any query (regular or infinite) by key, or None if not found."""
|
|
26
27
|
return self._entries.get(key)
|
|
27
28
|
|
|
28
29
|
def ensure(
|
|
29
30
|
self,
|
|
30
31
|
key: QueryKey,
|
|
31
|
-
|
|
32
|
-
initial_data: T | None = None,
|
|
32
|
+
initial_data: T | None = MISSING,
|
|
33
33
|
initial_data_updated_at: float | dt.datetime | None = None,
|
|
34
34
|
gc_time: float = 300.0,
|
|
35
35
|
retries: int = 3,
|
|
36
36
|
retry_delay: float = RETRY_DELAY_DEFAULT,
|
|
37
|
-
) ->
|
|
37
|
+
) -> KeyedQuery[T]:
|
|
38
38
|
# Return existing entry if present
|
|
39
39
|
existing = self._entries.get(key)
|
|
40
40
|
if existing:
|
|
@@ -42,15 +42,14 @@ class QueryStore:
|
|
|
42
42
|
raise TypeError(
|
|
43
43
|
"Query key is already used for an infinite query; cannot reuse for regular query"
|
|
44
44
|
)
|
|
45
|
-
return cast(
|
|
45
|
+
return cast(KeyedQuery[T], existing)
|
|
46
46
|
|
|
47
|
-
def _on_dispose(e:
|
|
47
|
+
def _on_dispose(e: KeyedQuery[Any]) -> None:
|
|
48
48
|
if e.key in self._entries and self._entries[e.key] is e:
|
|
49
49
|
del self._entries[e.key]
|
|
50
50
|
|
|
51
|
-
entry =
|
|
51
|
+
entry = KeyedQuery(
|
|
52
52
|
key,
|
|
53
|
-
fetch_fn,
|
|
54
53
|
initial_data=initial_data,
|
|
55
54
|
initial_data_updated_at=initial_data_updated_at,
|
|
56
55
|
gc_time=gc_time,
|
|
@@ -61,7 +60,7 @@ class QueryStore:
|
|
|
61
60
|
self._entries[key] = entry
|
|
62
61
|
return entry
|
|
63
62
|
|
|
64
|
-
def get(self, key: QueryKey) ->
|
|
63
|
+
def get(self, key: QueryKey) -> KeyedQuery[Any] | None:
|
|
65
64
|
"""
|
|
66
65
|
Get an existing regular query by key, or None if not found.
|
|
67
66
|
"""
|
|
@@ -82,7 +81,6 @@ class QueryStore:
|
|
|
82
81
|
def ensure_infinite(
|
|
83
82
|
self,
|
|
84
83
|
key: QueryKey,
|
|
85
|
-
query_fn: Callable[[Any], Awaitable[Any]],
|
|
86
84
|
*,
|
|
87
85
|
initial_page_param: Any,
|
|
88
86
|
get_next_page_param: Callable[[list[Page[Any, Any]]], Any | None],
|
|
@@ -108,7 +106,6 @@ class QueryStore:
|
|
|
108
106
|
|
|
109
107
|
entry = InfiniteQuery(
|
|
110
108
|
key,
|
|
111
|
-
query_fn,
|
|
112
109
|
initial_page_param=initial_page_param,
|
|
113
110
|
get_next_page_param=get_next_page_param,
|
|
114
111
|
get_previous_page_param=get_previous_page_param,
|
pulse/reactive.py
CHANGED
|
@@ -191,7 +191,8 @@ class Computed(Generic[T_co]):
|
|
|
191
191
|
if len(scope.effects) > 0:
|
|
192
192
|
raise RuntimeError(
|
|
193
193
|
"An effect was created within a computed variable's function. "
|
|
194
|
-
+ "This
|
|
194
|
+
+ "This is most likely unintended. If you need to create an effect here, "
|
|
195
|
+
+ "wrap the effect creation with Untrack()."
|
|
195
196
|
)
|
|
196
197
|
finally:
|
|
197
198
|
self.on_stack = False
|
|
@@ -274,6 +275,7 @@ class Effect(Disposable):
|
|
|
274
275
|
_interval_handle: asyncio.TimerHandle | None
|
|
275
276
|
explicit_deps: bool
|
|
276
277
|
batch: "Batch | None"
|
|
278
|
+
paused: bool
|
|
277
279
|
|
|
278
280
|
def __init__(
|
|
279
281
|
self,
|
|
@@ -301,6 +303,7 @@ class Effect(Disposable):
|
|
|
301
303
|
self._lazy = lazy
|
|
302
304
|
self._interval = interval
|
|
303
305
|
self._interval_handle = None
|
|
306
|
+
self.paused = False
|
|
304
307
|
|
|
305
308
|
if immediate and lazy:
|
|
306
309
|
raise ValueError("An effect cannot be boht immediate and lazy")
|
|
@@ -358,7 +361,20 @@ class Effect(Disposable):
|
|
|
358
361
|
self._interval_handle.cancel()
|
|
359
362
|
self._interval_handle = None
|
|
360
363
|
|
|
364
|
+
def pause(self):
|
|
365
|
+
"""Pause the effect - it won't run when dependencies change."""
|
|
366
|
+
self.paused = True
|
|
367
|
+
self.cancel(cancel_interval=True)
|
|
368
|
+
|
|
369
|
+
def resume(self):
|
|
370
|
+
"""Resume a paused effect and schedule it to run."""
|
|
371
|
+
if self.paused:
|
|
372
|
+
self.paused = False
|
|
373
|
+
self.schedule()
|
|
374
|
+
|
|
361
375
|
def schedule(self):
|
|
376
|
+
if self.paused:
|
|
377
|
+
return
|
|
362
378
|
# Immediate effects run right away when scheduled and do not enter a batch
|
|
363
379
|
if self.immediate:
|
|
364
380
|
self.run()
|
|
@@ -383,6 +399,8 @@ class Effect(Disposable):
|
|
|
383
399
|
self._cancel_interval()
|
|
384
400
|
|
|
385
401
|
def push_change(self):
|
|
402
|
+
if self.paused:
|
|
403
|
+
return
|
|
386
404
|
# Short-circuit if already scheduled in a batch.
|
|
387
405
|
# This avoids redundant schedule() calls and O(n) list checks
|
|
388
406
|
# when the same effect is reached through multiple dependency paths.
|
pulse/render_session.py
CHANGED
|
@@ -43,17 +43,21 @@ class RouteMount:
|
|
|
43
43
|
render: "RenderSession"
|
|
44
44
|
route: RouteContext
|
|
45
45
|
tree: RenderTree
|
|
46
|
+
effect: Effect | None
|
|
47
|
+
_pulse_ctx: PulseContext | None
|
|
48
|
+
element: Element
|
|
49
|
+
rendered: bool
|
|
46
50
|
|
|
47
51
|
def __init__(
|
|
48
52
|
self, render: "RenderSession", route: Route | Layout, route_info: RouteInfo
|
|
49
53
|
) -> None:
|
|
50
54
|
self.render = render
|
|
51
55
|
self.route = RouteContext(route_info, route)
|
|
52
|
-
self.effect
|
|
53
|
-
self._pulse_ctx
|
|
54
|
-
self.element
|
|
56
|
+
self.effect = None
|
|
57
|
+
self._pulse_ctx = None
|
|
58
|
+
self.element = route.render()
|
|
55
59
|
self.tree = RenderTree(self.element)
|
|
56
|
-
self.rendered
|
|
60
|
+
self.rendered = False
|
|
57
61
|
|
|
58
62
|
|
|
59
63
|
class RenderSession:
|
|
@@ -62,6 +66,13 @@ class RenderSession:
|
|
|
62
66
|
channels: "ChannelsManager"
|
|
63
67
|
forms: "FormRegistry"
|
|
64
68
|
query_store: QueryStore
|
|
69
|
+
route_mounts: dict[str, RouteMount]
|
|
70
|
+
_server_address: str | None
|
|
71
|
+
_client_address: str | None
|
|
72
|
+
_send_message: Callable[[ServerMessage], Any] | None
|
|
73
|
+
_pending_api: dict[str, asyncio.Future[dict[str, Any]]]
|
|
74
|
+
_global_states: dict[str, State]
|
|
75
|
+
connected: bool
|
|
65
76
|
|
|
66
77
|
def __init__(
|
|
67
78
|
self,
|
|
@@ -76,20 +87,18 @@ class RenderSession:
|
|
|
76
87
|
|
|
77
88
|
self.id = id
|
|
78
89
|
self.routes = routes
|
|
79
|
-
self.route_mounts
|
|
90
|
+
self.route_mounts = {}
|
|
80
91
|
# Base server address for building absolute API URLs (e.g., http://localhost:8000)
|
|
81
|
-
self._server_address
|
|
92
|
+
self._server_address = server_address
|
|
82
93
|
# Best-effort client address, captured at prerender or socket connect time
|
|
83
|
-
self._client_address
|
|
84
|
-
self._send_message
|
|
85
|
-
|
|
86
|
-
self._message_buffer: list[ServerMessage] = []
|
|
87
|
-
self._pending_api: dict[str, asyncio.Future[dict[str, Any]]] = {}
|
|
94
|
+
self._client_address = client_address
|
|
95
|
+
self._send_message = None
|
|
96
|
+
self._pending_api = {}
|
|
88
97
|
# Registry of per-session global singletons (created via ps.global_state without id)
|
|
89
|
-
self._global_states
|
|
98
|
+
self._global_states = {}
|
|
90
99
|
self.query_store = QueryStore()
|
|
91
100
|
# Connection state
|
|
92
|
-
self.connected
|
|
101
|
+
self.connected = False
|
|
93
102
|
self.channels = ChannelsManager(self)
|
|
94
103
|
self.forms = FormRegistry(self)
|
|
95
104
|
|
|
@@ -117,19 +126,22 @@ class RenderSession:
|
|
|
117
126
|
def connect(self, send_message: Callable[[ServerMessage], Any]):
|
|
118
127
|
self._send_message = send_message
|
|
119
128
|
self.connected = True
|
|
120
|
-
#
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
129
|
+
# Don't flush buffer or resume effects here - mount() handles reconnection
|
|
130
|
+
# by resetting mount.rendered and resuming effects to send fresh vdom_init
|
|
131
|
+
|
|
132
|
+
def disconnect(self):
|
|
133
|
+
"""Called when client disconnects - pause render effects."""
|
|
134
|
+
self._send_message = None
|
|
135
|
+
self.connected = False
|
|
136
|
+
for mount in self.route_mounts.values():
|
|
137
|
+
if mount.effect:
|
|
138
|
+
mount.effect.pause()
|
|
125
139
|
|
|
126
140
|
def send(self, message: ServerMessage):
|
|
127
141
|
# If a sender is available (connected or during prerender capture), send immediately.
|
|
128
|
-
# Otherwise,
|
|
142
|
+
# Otherwise, drop the message - we'll send full VDOM state on reconnection.
|
|
129
143
|
if self._send_message:
|
|
130
144
|
self._send_message(message)
|
|
131
|
-
else:
|
|
132
|
-
self._message_buffer.append(message)
|
|
133
145
|
|
|
134
146
|
def report_error(
|
|
135
147
|
self,
|
|
@@ -174,8 +186,6 @@ class RenderSession:
|
|
|
174
186
|
self.channels.dispose_channel(channel, reason="render.close")
|
|
175
187
|
# The effect will be garbage collected, and with it the dependencies
|
|
176
188
|
self._send_message = None
|
|
177
|
-
# Discard any buffered messages on close
|
|
178
|
-
self._message_buffer.clear()
|
|
179
189
|
self.connected = False
|
|
180
190
|
|
|
181
191
|
def execute_callback(self, path: str, key: str, args: list[Any] | tuple[Any, ...]):
|
|
@@ -392,8 +402,14 @@ class RenderSession:
|
|
|
392
402
|
|
|
393
403
|
def mount(self, path: str, route_info: RouteInfo):
|
|
394
404
|
if path in self.route_mounts:
|
|
395
|
-
#
|
|
396
|
-
#
|
|
405
|
+
# Route already mounted - this is a reconnection case.
|
|
406
|
+
# Reset rendered flag so effect sends vdom_init, update route info,
|
|
407
|
+
# and resume the paused effect.
|
|
408
|
+
mount = self.route_mounts[path]
|
|
409
|
+
mount.rendered = False
|
|
410
|
+
mount.route.update(route_info)
|
|
411
|
+
if mount.effect and mount.effect.paused:
|
|
412
|
+
mount.effect.resume()
|
|
397
413
|
return
|
|
398
414
|
|
|
399
415
|
mount = self.create_route_mount(path, route_info)
|
pulse/renderer.py
CHANGED
|
@@ -527,21 +527,6 @@ class Renderer:
|
|
|
527
527
|
unmount_element(node)
|
|
528
528
|
|
|
529
529
|
|
|
530
|
-
def extract_key(element: Element) -> str | None:
|
|
531
|
-
if isinstance(element, ComponentNode):
|
|
532
|
-
return element.key
|
|
533
|
-
if isinstance(element, Node):
|
|
534
|
-
return element.key
|
|
535
|
-
return None
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
def child_key(element: Element, index: int) -> str:
|
|
539
|
-
key = extract_key(element)
|
|
540
|
-
if key is not None:
|
|
541
|
-
return key
|
|
542
|
-
return f"__idx__{index}"
|
|
543
|
-
|
|
544
|
-
|
|
545
530
|
def normalize_children(children: Sequence[Element] | None) -> list[Element]:
|
|
546
531
|
if not children:
|
|
547
532
|
return []
|
|
@@ -573,34 +558,6 @@ def same_node(left: Element, right: Element) -> bool:
|
|
|
573
558
|
return False
|
|
574
559
|
|
|
575
560
|
|
|
576
|
-
def lis(seq: list[int]) -> list[int]:
|
|
577
|
-
if not seq:
|
|
578
|
-
return []
|
|
579
|
-
tails: list[int] = []
|
|
580
|
-
prev: list[int] = [-1] * len(seq)
|
|
581
|
-
for i, v in enumerate(seq):
|
|
582
|
-
lo, hi = 0, len(tails)
|
|
583
|
-
while lo < hi:
|
|
584
|
-
mid = (lo + hi) // 2
|
|
585
|
-
if seq[tails[mid]] < v:
|
|
586
|
-
lo = mid + 1
|
|
587
|
-
else:
|
|
588
|
-
hi = mid
|
|
589
|
-
if lo > 0:
|
|
590
|
-
prev[i] = tails[lo - 1]
|
|
591
|
-
if lo == len(tails):
|
|
592
|
-
tails.append(i)
|
|
593
|
-
else:
|
|
594
|
-
tails[lo] = i
|
|
595
|
-
lis_indices: list[int] = []
|
|
596
|
-
k = tails[-1] if tails else -1
|
|
597
|
-
while k != -1:
|
|
598
|
-
lis_indices.append(k)
|
|
599
|
-
k = prev[k]
|
|
600
|
-
lis_indices.reverse()
|
|
601
|
-
return lis_indices
|
|
602
|
-
|
|
603
|
-
|
|
604
561
|
def _css_ref_token(ref: CssReference) -> str:
|
|
605
562
|
return f"{ref.module.id}:{ref.name}"
|
|
606
563
|
|
pulse/types/event_handler.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
from collections.abc import
|
|
1
|
+
from collections.abc import Callable
|
|
2
2
|
from typing import (
|
|
3
|
+
Any,
|
|
3
4
|
TypeVar,
|
|
4
5
|
)
|
|
5
6
|
|
|
6
|
-
EventHandlerResult =
|
|
7
|
+
EventHandlerResult = Any
|
|
7
8
|
|
|
8
9
|
T1 = TypeVar("T1", contravariant=True)
|
|
9
10
|
T2 = TypeVar("T2", contravariant=True)
|
pulse/user_session.py
CHANGED
|
@@ -14,7 +14,7 @@ from pulse.cookies import SetCookie
|
|
|
14
14
|
from pulse.env import env
|
|
15
15
|
from pulse.helpers import Disposable
|
|
16
16
|
from pulse.reactive import AsyncEffect, Effect
|
|
17
|
-
from pulse.reactive_extensions import ReactiveDict, reactive
|
|
17
|
+
from pulse.reactive_extensions import ReactiveDict, reactive, unwrap
|
|
18
18
|
|
|
19
19
|
if TYPE_CHECKING:
|
|
20
20
|
from pulse.app import App
|
|
@@ -52,11 +52,15 @@ class UserSession(Disposable):
|
|
|
52
52
|
|
|
53
53
|
async def _save_server_session(self):
|
|
54
54
|
assert isinstance(self.app.session_store, SessionStore)
|
|
55
|
-
|
|
55
|
+
# unwrap subscribes the effect to all signals in the session ReactiveDict
|
|
56
|
+
data = unwrap(self.data)
|
|
57
|
+
await self.app.session_store.save(self.sid, data)
|
|
56
58
|
|
|
57
59
|
def refresh_session_cookie(self, app: "App"):
|
|
58
60
|
assert isinstance(app.session_store, CookieSessionStore)
|
|
59
|
-
|
|
61
|
+
# unwrap subscribes the effect to all signals in the session ReactiveDict
|
|
62
|
+
data = unwrap(self.data)
|
|
63
|
+
signed_cookie = app.session_store.encode(self.sid, data)
|
|
60
64
|
self.set_cookie(
|
|
61
65
|
name=app.cookie.name,
|
|
62
66
|
value=signed_cookie,
|
pulse/vdom.py
CHANGED
|
@@ -6,6 +6,7 @@ the TypeScript UINode format exactly, eliminating the need for translation.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import functools
|
|
9
|
+
import math
|
|
9
10
|
import warnings
|
|
10
11
|
from collections.abc import Callable, Iterable, Sequence
|
|
11
12
|
from inspect import Parameter, signature
|
|
@@ -22,9 +23,60 @@ from typing import (
|
|
|
22
23
|
override,
|
|
23
24
|
)
|
|
24
25
|
|
|
26
|
+
from pulse.env import env
|
|
25
27
|
from pulse.hooks.core import HookContext
|
|
26
28
|
from pulse.hooks.init import rewrite_init_blocks
|
|
27
29
|
|
|
30
|
+
# ============================================================================
|
|
31
|
+
# Validation helpers (dev mode only)
|
|
32
|
+
# ============================================================================
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _check_json_safe_float(value: float, context: str) -> None:
|
|
36
|
+
"""Raise ValueError if a float is NaN or Infinity."""
|
|
37
|
+
if math.isnan(value):
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"Cannot use nan in {context}. "
|
|
40
|
+
+ "NaN and Infinity are not supported in Pulse because they cannot be serialized to JSON. "
|
|
41
|
+
+ "Replace with None or a sentinel value before passing to components."
|
|
42
|
+
)
|
|
43
|
+
if math.isinf(value):
|
|
44
|
+
kind = "inf" if value > 0 else "-inf"
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"Cannot use {kind} in {context}. "
|
|
47
|
+
+ "NaN and Infinity are not supported in Pulse because they cannot be serialized to JSON. "
|
|
48
|
+
+ "Replace with None or a sentinel value before passing to components."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _validate_value(value: Any, context: str) -> None:
|
|
53
|
+
"""Recursively validate a value for JSON-unsafe floats (NaN, Infinity)."""
|
|
54
|
+
if isinstance(value, float):
|
|
55
|
+
_check_json_safe_float(value, context)
|
|
56
|
+
elif isinstance(value, dict):
|
|
57
|
+
for v in value.values():
|
|
58
|
+
_validate_value(v, context)
|
|
59
|
+
elif isinstance(value, (list, tuple)):
|
|
60
|
+
for item in value:
|
|
61
|
+
_validate_value(item, context)
|
|
62
|
+
# Skip other types - they'll be handled by the serializer
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _validate_props(props: dict[str, Any] | None, parent_name: str) -> None:
|
|
66
|
+
"""Validate all props for JSON-unsafe values."""
|
|
67
|
+
if not props:
|
|
68
|
+
return
|
|
69
|
+
for key, value in props.items():
|
|
70
|
+
_validate_value(value, f"{parent_name} prop '{key}'")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _validate_children(children: "Sequence[Element]", parent_name: str) -> None:
|
|
74
|
+
"""Validate primitive children for JSON-unsafe values."""
|
|
75
|
+
for child in children:
|
|
76
|
+
if isinstance(child, float):
|
|
77
|
+
_check_json_safe_float(child, f"{parent_name} children")
|
|
78
|
+
|
|
79
|
+
|
|
28
80
|
# ============================================================================
|
|
29
81
|
# Core VDOM
|
|
30
82
|
# ============================================================================
|
|
@@ -75,6 +127,12 @@ class Node:
|
|
|
75
127
|
raise ValueError("key must be a string or None")
|
|
76
128
|
if not self.allow_children and children:
|
|
77
129
|
raise ValueError(f"{self.tag} cannot have children")
|
|
130
|
+
# Dev-only validation for JSON-unsafe values
|
|
131
|
+
if env.pulse_env == "dev":
|
|
132
|
+
parent_name = f"<{self.tag}>"
|
|
133
|
+
_validate_props(self.props, parent_name)
|
|
134
|
+
if self.children:
|
|
135
|
+
_validate_children(self.children, parent_name)
|
|
78
136
|
|
|
79
137
|
# --- Pretty printing helpers -------------------------------------------------
|
|
80
138
|
@override
|
|
@@ -199,6 +257,15 @@ class Component(Generic[P]):
|
|
|
199
257
|
if key is not None and not isinstance(key, str):
|
|
200
258
|
raise ValueError("key must be a string or None")
|
|
201
259
|
|
|
260
|
+
# Flatten children if component takes children (has *children parameter)
|
|
261
|
+
if self._takes_children and args:
|
|
262
|
+
flattened = _flatten_children(
|
|
263
|
+
args, # pyright: ignore[reportArgumentType]
|
|
264
|
+
parent_name=f"<{self.name}>",
|
|
265
|
+
warn_stacklevel=4,
|
|
266
|
+
)
|
|
267
|
+
args = tuple(flattened) # pyright: ignore[reportAssignmentType]
|
|
268
|
+
|
|
202
269
|
return ComponentNode(
|
|
203
270
|
fn=self.fn,
|
|
204
271
|
key=key,
|
|
@@ -244,6 +311,17 @@ class ComponentNode:
|
|
|
244
311
|
# Used for rendering
|
|
245
312
|
self.contents: Element | None = None
|
|
246
313
|
self.hooks = HookContext()
|
|
314
|
+
# Dev-only validation for JSON-unsafe values
|
|
315
|
+
if env.pulse_env == "dev":
|
|
316
|
+
parent_name = f"<{self.name}>"
|
|
317
|
+
# Validate kwargs (props)
|
|
318
|
+
_validate_props(self.kwargs, parent_name)
|
|
319
|
+
# Validate args (children passed positionally)
|
|
320
|
+
for arg in self.args:
|
|
321
|
+
if isinstance(arg, float):
|
|
322
|
+
_check_json_safe_float(arg, f"{parent_name} children")
|
|
323
|
+
elif isinstance(arg, (dict, list, tuple)):
|
|
324
|
+
_validate_value(arg, f"{parent_name} children")
|
|
247
325
|
|
|
248
326
|
def __getitem__(self, children_arg: "Child | tuple[Child, ...]"):
|
|
249
327
|
if not self.takes_children:
|
|
@@ -259,7 +337,7 @@ class ComponentNode:
|
|
|
259
337
|
children_arg = (children_arg,)
|
|
260
338
|
# Flatten children for ComponentNode as well
|
|
261
339
|
flattened_children = _flatten_children(
|
|
262
|
-
children_arg, parent_name=f"<{self.name}>"
|
|
340
|
+
children_arg, parent_name=f"<{self.name}>", warn_stacklevel=4
|
|
263
341
|
)
|
|
264
342
|
result = ComponentNode(
|
|
265
343
|
fn=self.fn,
|
|
@@ -315,28 +393,43 @@ Props = dict[str, Any]
|
|
|
315
393
|
# ----------------------------------------------------------------------------
|
|
316
394
|
|
|
317
395
|
|
|
318
|
-
def _flatten_children(
|
|
319
|
-
|
|
396
|
+
def _flatten_children(
|
|
397
|
+
children: Children, *, parent_name: str, warn_stacklevel: int = 5
|
|
398
|
+
) -> Sequence[Element]:
|
|
399
|
+
"""Flatten children and emit warnings for unkeyed iterables (dev mode only).
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
children: The children sequence to flatten.
|
|
403
|
+
parent_name: Name of the parent element for error messages.
|
|
404
|
+
warn_stacklevel: Stack level for warnings. Adjust based on call site:
|
|
405
|
+
- 5 for Node.__init__ via tag factory (user -> tag factory -> Node.__init__ -> _flatten_children -> visit -> warn)
|
|
406
|
+
- 4 for ComponentNode.__getitem__ or Component.__call__ (user -> method -> _flatten_children -> visit -> warn)
|
|
407
|
+
"""
|
|
320
408
|
flat: list[Element] = []
|
|
321
409
|
return_tuple = isinstance(children, tuple)
|
|
410
|
+
is_dev = env.pulse_env == "dev"
|
|
322
411
|
|
|
323
412
|
def visit(item: Child) -> None:
|
|
324
413
|
if isinstance(item, Iterable) and not isinstance(item, str):
|
|
325
414
|
# If any Node/ComponentNode yielded by this iterable lacks a key,
|
|
326
|
-
# emit a single warning for this iterable.
|
|
415
|
+
# emit a single warning for this iterable (dev mode only).
|
|
327
416
|
missing_key = False
|
|
328
417
|
for sub in item:
|
|
329
|
-
if
|
|
418
|
+
if (
|
|
419
|
+
is_dev
|
|
420
|
+
and isinstance(sub, (Node, ComponentNode))
|
|
421
|
+
and sub.key is None
|
|
422
|
+
):
|
|
330
423
|
missing_key = True
|
|
331
424
|
visit(sub)
|
|
332
425
|
if missing_key:
|
|
333
|
-
# Warn once per iterable without keys on its elements
|
|
426
|
+
# Warn once per iterable without keys on its elements.
|
|
334
427
|
warnings.warn(
|
|
335
428
|
(
|
|
336
429
|
f"[Pulse] Iterable children of {parent_name} contain elements without 'key'. "
|
|
337
430
|
"Add a stable 'key' to each element inside iterables to improve reconciliation."
|
|
338
431
|
),
|
|
339
|
-
stacklevel=
|
|
432
|
+
stacklevel=warn_stacklevel,
|
|
340
433
|
)
|
|
341
434
|
else:
|
|
342
435
|
# Not an iterable child: must be a Element or primitive
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
pulse/__init__.py,sha256=
|
|
2
|
-
pulse/app.py,sha256
|
|
1
|
+
pulse/__init__.py,sha256=s5J8i92q5Sy4h_FTPhNhirZdx-R573vtKypJiUkBwWk,32724
|
|
2
|
+
pulse/app.py,sha256=-0BWA8YDlm0Hm6mPbsQKPCC_3NrhbHizoAd0WnoG0Rg,32077
|
|
3
3
|
pulse/channel.py,sha256=d9eLxgyB0P9UBVkPkXV7MHkC4LWED1Cq3GKsEu_SYy4,13056
|
|
4
4
|
pulse/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
5
|
pulse/cli/cmd.py,sha256=UBT7OoqWRU-idLOKkA9TDN8m8ugi1gwRMiUJTUmkVfU,14853
|
|
@@ -52,28 +52,29 @@ pulse/plugin.py,sha256=RfGl6Vtr7VRHb8bp4Ob4dOX9dVzvc4Riu7HWnStMPpk,580
|
|
|
52
52
|
pulse/proxy.py,sha256=zh4v5lmYNg5IBE_xdHHmGPwbMQNSXb2npeLXvw_O1Oc,6591
|
|
53
53
|
pulse/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
54
54
|
pulse/queries/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
55
|
-
pulse/queries/client.py,sha256=
|
|
55
|
+
pulse/queries/client.py,sha256=GGckE0P3YCBO4Mj-08AO_I9eXVC4sIDSNw_xTLrBFuE,15224
|
|
56
56
|
pulse/queries/common.py,sha256=Cr_NV0dWz5DQ7Qg771jvUms1o2-EnTYqjZJe4tVeoVk,1160
|
|
57
|
-
pulse/queries/effect.py,sha256=
|
|
58
|
-
pulse/queries/infinite_query.py,sha256=
|
|
59
|
-
pulse/queries/mutation.py,sha256=
|
|
60
|
-
pulse/queries/
|
|
61
|
-
pulse/queries/
|
|
57
|
+
pulse/queries/effect.py,sha256=7KvV_yK7OHTWhfQbZFGzg_pRhyI2mn25pKIF9AmSmcU,1471
|
|
58
|
+
pulse/queries/infinite_query.py,sha256=oUHWjP2OliB7h8VDJooGocefHm4m9TDy4WaJesSrsdI,40457
|
|
59
|
+
pulse/queries/mutation.py,sha256=px1fprFL-RxNfbRSoRtdsOLkEbjSsMrJxGHKBIPYQTM,4959
|
|
60
|
+
pulse/queries/protocol.py,sha256=R8n238Ex9DbYIAVKB83a8FAPtnCiPNhWar-F01K2fTo,3345
|
|
61
|
+
pulse/queries/query.py,sha256=G8eXCaT5wuvVcstlqWU8VBxuuUUS7K1R5Y-VtDpMIG0,35065
|
|
62
|
+
pulse/queries/store.py,sha256=Ct7a-h1-Cq07zEfe9vw-LM85Fm7jIJx7CLAIlsiznlU,3444
|
|
62
63
|
pulse/react_component.py,sha256=hPibKBEkVdpBKNSpMQ6bZ-7GnJQcNQwcw2SvfY1chHA,26026
|
|
63
|
-
pulse/reactive.py,sha256=
|
|
64
|
+
pulse/reactive.py,sha256=v8a9IttkabeWwYrrHAx33zqzW9WC4WlS4iXbIh2KQkU,24374
|
|
64
65
|
pulse/reactive_extensions.py,sha256=WAx4hlB1ByZLFVpgtRnaWXAQ3N2QQplf647oQXDL5vg,31901
|
|
65
|
-
pulse/render_session.py,sha256=
|
|
66
|
-
pulse/renderer.py,sha256=
|
|
66
|
+
pulse/render_session.py,sha256=G76r9hKHzAJT2x_BWNmy-gNHyDT2NwUbkxQRrDBcKS4,14928
|
|
67
|
+
pulse/renderer.py,sha256=FnfCl7c0n-YAdWfFyrLY1EceBDL2hP85UQv8PLQL20I,15891
|
|
67
68
|
pulse/request.py,sha256=sPsSRWi5KvReSPBLIs_kzqomn1wlRk1BTLZ5s0chQr4,4979
|
|
68
69
|
pulse/routing.py,sha256=RlrGHyK4F28_zUHMYNeKp4pNbvqrt4GY4t5xNdhzunI,13926
|
|
69
70
|
pulse/serializer.py,sha256=8RAITNoSNm5-U38elHpWmkBpcM_rxZFMCluJSfldfk4,5420
|
|
70
71
|
pulse/state.py,sha256=ikQbK4R8PieV96qd4uWREUvs0jXo9sCapawY7i6oCYo,10776
|
|
71
72
|
pulse/types/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
72
|
-
pulse/types/event_handler.py,sha256=
|
|
73
|
-
pulse/user_session.py,sha256=
|
|
74
|
-
pulse/vdom.py,sha256=
|
|
73
|
+
pulse/types/event_handler.py,sha256=psQCydj-WEtBcFU5JU4mDwvyzkW8V2O0g_VFRU2EOHI,1618
|
|
74
|
+
pulse/user_session.py,sha256=FITxLSEl3JU-jod6UWuUYC6EpnPG2rbaLCnIOdkQPtg,7803
|
|
75
|
+
pulse/vdom.py,sha256=_quboxfVcUdpn5tiGrL1NESD7730620M4qA7HR9mC20,15950
|
|
75
76
|
pulse/version.py,sha256=711vaM1jVIQPgkisGgKZqwmw019qZIsc_QTae75K2pg,1895
|
|
76
|
-
pulse_framework-0.1.
|
|
77
|
-
pulse_framework-0.1.
|
|
78
|
-
pulse_framework-0.1.
|
|
79
|
-
pulse_framework-0.1.
|
|
77
|
+
pulse_framework-0.1.46.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
78
|
+
pulse_framework-0.1.46.dist-info/entry_points.txt,sha256=i7aohd3QaPu5IcuGKKvsQQEiMYMe5HcF56QEsaLVO64,46
|
|
79
|
+
pulse_framework-0.1.46.dist-info/METADATA,sha256=s22qR5YwwOz1eZ3PPQAbW7hSRvZ-lDuliRKjmcp098k,580
|
|
80
|
+
pulse_framework-0.1.46.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|