pulse-framework 0.1.55__py3-none-any.whl → 0.1.56__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 +5 -6
- pulse/app.py +144 -57
- pulse/channel.py +139 -7
- pulse/cli/cmd.py +16 -2
- pulse/codegen/codegen.py +43 -12
- pulse/component.py +104 -0
- pulse/components/for_.py +30 -4
- pulse/components/if_.py +28 -5
- pulse/components/react_router.py +61 -3
- pulse/context.py +39 -5
- pulse/cookies.py +108 -4
- pulse/decorators.py +193 -24
- pulse/env.py +56 -2
- pulse/form.py +198 -5
- pulse/helpers.py +7 -1
- pulse/hooks/core.py +135 -5
- pulse/hooks/effects.py +61 -77
- pulse/hooks/init.py +60 -1
- pulse/hooks/runtime.py +241 -0
- pulse/hooks/setup.py +77 -0
- pulse/hooks/stable.py +58 -1
- pulse/hooks/state.py +107 -20
- pulse/js/__init__.py +40 -24
- pulse/js/array.py +9 -6
- pulse/js/console.py +15 -12
- pulse/js/date.py +9 -6
- pulse/js/document.py +5 -2
- pulse/js/error.py +7 -4
- pulse/js/json.py +9 -6
- pulse/js/map.py +8 -5
- pulse/js/math.py +9 -6
- pulse/js/navigator.py +5 -2
- pulse/js/number.py +9 -6
- pulse/js/obj.py +16 -13
- pulse/js/object.py +9 -6
- pulse/js/promise.py +19 -13
- pulse/js/pulse.py +28 -25
- pulse/js/react.py +94 -55
- pulse/js/regexp.py +7 -4
- pulse/js/set.py +8 -5
- pulse/js/string.py +9 -6
- pulse/js/weakmap.py +8 -5
- pulse/js/weakset.py +8 -5
- pulse/js/window.py +6 -3
- pulse/messages.py +5 -0
- pulse/middleware.py +147 -76
- pulse/plugin.py +76 -5
- pulse/queries/client.py +186 -39
- pulse/queries/common.py +52 -3
- pulse/queries/infinite_query.py +154 -2
- pulse/queries/mutation.py +127 -7
- pulse/queries/query.py +112 -11
- pulse/react_component.py +66 -3
- pulse/reactive.py +314 -30
- pulse/reactive_extensions.py +106 -26
- pulse/render_session.py +304 -173
- pulse/request.py +46 -11
- pulse/routing.py +140 -4
- pulse/serializer.py +71 -0
- pulse/state.py +177 -9
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +0 -3
- pulse/transpiler/py_module.py +1 -7
- pulse/user_session.py +119 -18
- {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.56.dist-info}/METADATA +5 -5
- pulse_framework-0.1.56.dist-info/RECORD +127 -0
- pulse/transpiler/react_component.py +0 -44
- pulse_framework-0.1.55.dist-info/RECORD +0 -127
- {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.56.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.56.dist-info}/entry_points.txt +0 -0
pulse/hooks/runtime.py
CHANGED
|
@@ -18,6 +18,16 @@ from pulse.state import State
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
class RedirectInterrupt(Exception):
|
|
21
|
+
"""Exception raised to interrupt render and trigger a redirect.
|
|
22
|
+
|
|
23
|
+
This exception is thrown by ``ps.redirect()`` to interrupt the current
|
|
24
|
+
render cycle and navigate to a different path.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
path: The destination URL to redirect to.
|
|
28
|
+
replace: If True, replaces the current history entry instead of pushing.
|
|
29
|
+
"""
|
|
30
|
+
|
|
21
31
|
path: str
|
|
22
32
|
replace: bool
|
|
23
33
|
|
|
@@ -28,10 +38,34 @@ class RedirectInterrupt(Exception):
|
|
|
28
38
|
|
|
29
39
|
|
|
30
40
|
class NotFoundInterrupt(Exception):
|
|
41
|
+
"""Exception raised to interrupt render and show 404 page.
|
|
42
|
+
|
|
43
|
+
This exception is thrown by ``ps.not_found()`` to interrupt the current
|
|
44
|
+
render cycle and display the 404 not found page.
|
|
45
|
+
"""
|
|
46
|
+
|
|
31
47
|
pass
|
|
32
48
|
|
|
33
49
|
|
|
34
50
|
def route() -> RouteContext:
|
|
51
|
+
"""Get the current route context.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
RouteContext: Object with access to route parameters, path, and query.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
RuntimeError: If called outside of a component render context.
|
|
58
|
+
|
|
59
|
+
Example:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
def user_page():
|
|
63
|
+
r = ps.route()
|
|
64
|
+
user_id = r.params.get("user_id") # From /users/:user_id
|
|
65
|
+
page = r.query.get("page", "1") # From ?page=2
|
|
66
|
+
return m.Text(f"User {user_id}, Page {page}")
|
|
67
|
+
```
|
|
68
|
+
"""
|
|
35
69
|
ctx = PulseContext.get()
|
|
36
70
|
if not ctx or not ctx.route:
|
|
37
71
|
raise RuntimeError(
|
|
@@ -41,6 +75,24 @@ def route() -> RouteContext:
|
|
|
41
75
|
|
|
42
76
|
|
|
43
77
|
def session() -> ReactiveDict[str, Any]:
|
|
78
|
+
"""Get the current user session data.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
ReactiveDict[str, Any]: Reactive dictionary of session data that persists
|
|
82
|
+
across page navigations.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
RuntimeError: If called outside of a session context.
|
|
86
|
+
|
|
87
|
+
Example:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
def my_component():
|
|
91
|
+
sess = ps.session()
|
|
92
|
+
sess["last_visited"] = datetime.now()
|
|
93
|
+
return m.Text(f"Visits: {sess.get('visit_count', 0)}")
|
|
94
|
+
```
|
|
95
|
+
"""
|
|
44
96
|
ctx = PulseContext.get()
|
|
45
97
|
if not ctx.session:
|
|
46
98
|
raise RuntimeError("Could not resolve user session")
|
|
@@ -48,6 +100,14 @@ def session() -> ReactiveDict[str, Any]:
|
|
|
48
100
|
|
|
49
101
|
|
|
50
102
|
def session_id() -> str:
|
|
103
|
+
"""Get the current session identifier.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
str: Unique identifier for the current user session.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
RuntimeError: If called outside of a session context.
|
|
110
|
+
"""
|
|
51
111
|
ctx = PulseContext.get()
|
|
52
112
|
if not ctx.session:
|
|
53
113
|
raise RuntimeError("Could not resolve user session")
|
|
@@ -55,6 +115,14 @@ def session_id() -> str:
|
|
|
55
115
|
|
|
56
116
|
|
|
57
117
|
def websocket_id() -> str:
|
|
118
|
+
"""Get the current WebSocket connection identifier.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
str: Unique identifier for the current WebSocket connection.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
RuntimeError: If called outside of a WebSocket session context.
|
|
125
|
+
"""
|
|
58
126
|
ctx = PulseContext.get()
|
|
59
127
|
if not ctx.render:
|
|
60
128
|
raise RuntimeError("Could not resolve WebSocket session")
|
|
@@ -69,6 +137,25 @@ async def call_api(
|
|
|
69
137
|
body: Any | None = None,
|
|
70
138
|
credentials: str = "include",
|
|
71
139
|
) -> dict[str, Any]:
|
|
140
|
+
"""Make an API call through the client browser.
|
|
141
|
+
|
|
142
|
+
This function sends a request to the specified path via the client's browser,
|
|
143
|
+
which is useful for calling third-party APIs that require browser cookies
|
|
144
|
+
or credentials.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
path: The URL path to call.
|
|
148
|
+
method: HTTP method (default: "POST").
|
|
149
|
+
headers: Optional HTTP headers to include in the request.
|
|
150
|
+
body: Optional request body (will be JSON serialized).
|
|
151
|
+
credentials: Credential mode for the request (default: "include").
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
dict[str, Any]: The JSON response from the API.
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
RuntimeError: If called outside of a Pulse callback context.
|
|
158
|
+
"""
|
|
72
159
|
ctx = PulseContext.get()
|
|
73
160
|
if ctx.render is None:
|
|
74
161
|
raise RuntimeError("call_api() must be invoked inside a Pulse callback context")
|
|
@@ -90,6 +177,19 @@ async def set_cookie(
|
|
|
90
177
|
samesite: Literal["lax", "strict", "none"] = "lax",
|
|
91
178
|
max_age_seconds: int = 7 * 24 * 3600,
|
|
92
179
|
) -> None:
|
|
180
|
+
"""Set a cookie on the client.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
name: The cookie name.
|
|
184
|
+
value: The cookie value.
|
|
185
|
+
domain: Optional domain for the cookie.
|
|
186
|
+
secure: Whether the cookie should only be sent over HTTPS (default: True).
|
|
187
|
+
samesite: SameSite attribute ("lax", "strict", or "none"; default: "lax").
|
|
188
|
+
max_age_seconds: Cookie lifetime in seconds (default: 7 days).
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
RuntimeError: If called outside of a session context.
|
|
192
|
+
"""
|
|
93
193
|
ctx = PulseContext.get()
|
|
94
194
|
if ctx.session is None:
|
|
95
195
|
raise RuntimeError("Could not resolve the user session")
|
|
@@ -104,6 +204,29 @@ async def set_cookie(
|
|
|
104
204
|
|
|
105
205
|
|
|
106
206
|
def navigate(path: str, *, replace: bool = False, hard: bool = False) -> None:
|
|
207
|
+
"""Navigate to a new URL.
|
|
208
|
+
|
|
209
|
+
Triggers client-side navigation to the specified path. By default, uses
|
|
210
|
+
client-side routing which is faster and preserves application state.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
path: Destination URL to navigate to.
|
|
214
|
+
replace: If True, replaces the current history entry instead of pushing
|
|
215
|
+
a new one (default: False).
|
|
216
|
+
hard: If True, performs a full page reload instead of client-side
|
|
217
|
+
navigation (default: False).
|
|
218
|
+
|
|
219
|
+
Raises:
|
|
220
|
+
RuntimeError: If called outside of a Pulse callback context.
|
|
221
|
+
|
|
222
|
+
Example:
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
async def handle_login():
|
|
226
|
+
await api.login(username, password)
|
|
227
|
+
ps.navigate("/dashboard")
|
|
228
|
+
```
|
|
229
|
+
"""
|
|
107
230
|
ctx = PulseContext.get()
|
|
108
231
|
if ctx.render is None:
|
|
109
232
|
raise RuntimeError("navigate() must be invoked inside a Pulse callback context")
|
|
@@ -113,6 +236,32 @@ def navigate(path: str, *, replace: bool = False, hard: bool = False) -> None:
|
|
|
113
236
|
|
|
114
237
|
|
|
115
238
|
def redirect(path: str, *, replace: bool = False) -> NoReturn:
|
|
239
|
+
"""Redirect during render (throws exception to interrupt render).
|
|
240
|
+
|
|
241
|
+
Unlike ``navigate()``, this function is intended for use during the render
|
|
242
|
+
phase to immediately redirect before the component finishes rendering.
|
|
243
|
+
It raises a ``RedirectInterrupt`` exception that is caught by the framework.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
path: Destination URL to redirect to.
|
|
247
|
+
replace: If True, replaces the current history entry instead of pushing
|
|
248
|
+
a new one (default: False).
|
|
249
|
+
|
|
250
|
+
Raises:
|
|
251
|
+
RuntimeError: If called outside of component render.
|
|
252
|
+
RedirectInterrupt: Always raised to interrupt the render.
|
|
253
|
+
|
|
254
|
+
Example:
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
def protected_page():
|
|
258
|
+
user = get_current_user()
|
|
259
|
+
if not user:
|
|
260
|
+
ps.redirect("/login") # Interrupts render
|
|
261
|
+
|
|
262
|
+
return m.Text(f"Welcome, {user.name}")
|
|
263
|
+
```
|
|
264
|
+
"""
|
|
116
265
|
ctx = HOOK_CONTEXT.get()
|
|
117
266
|
if not ctx:
|
|
118
267
|
raise RuntimeError("redirect() must be invoked during component render")
|
|
@@ -120,6 +269,27 @@ def redirect(path: str, *, replace: bool = False) -> NoReturn:
|
|
|
120
269
|
|
|
121
270
|
|
|
122
271
|
def not_found() -> NoReturn:
|
|
272
|
+
"""Trigger 404 during render (throws exception to interrupt render).
|
|
273
|
+
|
|
274
|
+
Interrupts the current render and displays the 404 not found page.
|
|
275
|
+
Raises a ``NotFoundInterrupt`` exception that is caught by the framework.
|
|
276
|
+
|
|
277
|
+
Raises:
|
|
278
|
+
RuntimeError: If called outside of component render.
|
|
279
|
+
NotFoundInterrupt: Always raised to trigger 404 page.
|
|
280
|
+
|
|
281
|
+
Example:
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
def user_page():
|
|
285
|
+
r = ps.route()
|
|
286
|
+
user = get_user(r.params["id"])
|
|
287
|
+
if not user:
|
|
288
|
+
ps.not_found() # Shows 404 page
|
|
289
|
+
|
|
290
|
+
return m.Text(user.name)
|
|
291
|
+
```
|
|
292
|
+
"""
|
|
123
293
|
ctx = HOOK_CONTEXT.get()
|
|
124
294
|
if not ctx:
|
|
125
295
|
raise RuntimeError("not_found() must be invoked during component render")
|
|
@@ -127,6 +297,15 @@ def not_found() -> NoReturn:
|
|
|
127
297
|
|
|
128
298
|
|
|
129
299
|
def server_address() -> str:
|
|
300
|
+
"""Get the server's public address.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
str: The server's public address (e.g., "https://example.com").
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
RuntimeError: If called outside of a Pulse render/callback context
|
|
307
|
+
or if the server address is not configured.
|
|
308
|
+
"""
|
|
130
309
|
ctx = PulseContext.get()
|
|
131
310
|
if ctx.render is None:
|
|
132
311
|
raise RuntimeError(
|
|
@@ -140,6 +319,15 @@ def server_address() -> str:
|
|
|
140
319
|
|
|
141
320
|
|
|
142
321
|
def client_address() -> str:
|
|
322
|
+
"""Get the client's IP address.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
str: The client's IP address.
|
|
326
|
+
|
|
327
|
+
Raises:
|
|
328
|
+
RuntimeError: If called outside of a Pulse render/callback context
|
|
329
|
+
or if the client address is not available.
|
|
330
|
+
"""
|
|
143
331
|
ctx = PulseContext.get()
|
|
144
332
|
if ctx.render is None:
|
|
145
333
|
raise RuntimeError(
|
|
@@ -157,17 +345,70 @@ S = TypeVar("S", covariant=True, bound=State)
|
|
|
157
345
|
|
|
158
346
|
|
|
159
347
|
class GlobalStateAccessor(Protocol, Generic[P, S]):
|
|
348
|
+
"""Protocol for global state accessor functions.
|
|
349
|
+
|
|
350
|
+
A callable that returns the shared state instance, optionally scoped
|
|
351
|
+
by an instance ID.
|
|
352
|
+
"""
|
|
353
|
+
|
|
160
354
|
def __call__(
|
|
161
355
|
self, id: str | None = None, *args: P.args, **kwargs: P.kwargs
|
|
162
356
|
) -> S: ...
|
|
163
357
|
|
|
164
358
|
|
|
165
359
|
GLOBAL_STATES: dict[str, State] = {}
|
|
360
|
+
"""Global dictionary storing state instances keyed by their qualified names."""
|
|
166
361
|
|
|
167
362
|
|
|
168
363
|
def global_state(
|
|
169
364
|
factory: Callable[P, S] | type[S], key: str | None = None
|
|
170
365
|
) -> GlobalStateAccessor[P, S]:
|
|
366
|
+
"""Create a globally shared state accessor.
|
|
367
|
+
|
|
368
|
+
Creates a decorator or callable that provides access to a shared state
|
|
369
|
+
instance. The state is shared across all components that use the same
|
|
370
|
+
accessor.
|
|
371
|
+
|
|
372
|
+
Can be used as a decorator on a State class or with a factory function.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
factory: State class or factory function that creates the state instance.
|
|
376
|
+
key: Optional custom key for the global state. If not provided, a key
|
|
377
|
+
is derived from the factory's module and qualified name.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
GlobalStateAccessor: A callable that returns the shared state instance.
|
|
381
|
+
Call with ``id=`` parameter for per-entity global state.
|
|
382
|
+
|
|
383
|
+
Example:
|
|
384
|
+
|
|
385
|
+
```python
|
|
386
|
+
@ps.global_state
|
|
387
|
+
class AppSettings(ps.State):
|
|
388
|
+
theme: str = "light"
|
|
389
|
+
language: str = "en"
|
|
390
|
+
|
|
391
|
+
def settings_panel():
|
|
392
|
+
settings = AppSettings() # Same instance across all components
|
|
393
|
+
return m.Select(
|
|
394
|
+
value=settings.theme,
|
|
395
|
+
data=["light", "dark"],
|
|
396
|
+
on_change=lambda v: setattr(settings, "theme", v),
|
|
397
|
+
)
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
With instance ID for per-entity global state:
|
|
401
|
+
|
|
402
|
+
```python
|
|
403
|
+
@ps.global_state
|
|
404
|
+
class UserCache(ps.State):
|
|
405
|
+
data: dict = {}
|
|
406
|
+
|
|
407
|
+
def user_profile(user_id: str):
|
|
408
|
+
cache = UserCache(id=user_id) # Shared per user_id
|
|
409
|
+
return m.Text(cache.data.get("name", "Loading..."))
|
|
410
|
+
```
|
|
411
|
+
"""
|
|
171
412
|
if isinstance(factory, type):
|
|
172
413
|
cls = factory
|
|
173
414
|
|
pulse/hooks/setup.py
CHANGED
|
@@ -11,6 +11,20 @@ T = TypeVar("T")
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class SetupHookState(HookState):
|
|
14
|
+
"""Internal hook state for the setup hook.
|
|
15
|
+
|
|
16
|
+
Manages the initialization, argument tracking, and lifecycle of
|
|
17
|
+
setup-created values.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
value: The value returned by the setup function.
|
|
21
|
+
initialized: Whether setup has been called at least once.
|
|
22
|
+
args: List of signals tracking positional argument values.
|
|
23
|
+
kwargs: Dict of signals tracking keyword argument values.
|
|
24
|
+
effects: List of effects created during setup execution.
|
|
25
|
+
key: Optional key for re-initialization control.
|
|
26
|
+
"""
|
|
27
|
+
|
|
14
28
|
__slots__ = ( # pyright: ignore[reportUnannotatedClassAttribute]
|
|
15
29
|
"value",
|
|
16
30
|
"initialized",
|
|
@@ -141,6 +155,46 @@ _setup_hook = hooks.create(
|
|
|
141
155
|
|
|
142
156
|
|
|
143
157
|
def setup(init_func: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
|
|
158
|
+
"""One-time initialization that persists across renders.
|
|
159
|
+
|
|
160
|
+
Calls the init function on first render and caches the result. On subsequent
|
|
161
|
+
renders, returns the cached value without re-running the function.
|
|
162
|
+
|
|
163
|
+
This is the lower-level alternative to ``ps.init()`` that doesn't require
|
|
164
|
+
AST rewriting and works in all environments.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
init_func: Function to call on first render. Its return value is cached.
|
|
168
|
+
*args: Positional arguments passed to init_func. Changes to these are
|
|
169
|
+
tracked via reactive signals.
|
|
170
|
+
**kwargs: Keyword arguments passed to init_func. Changes to these are
|
|
171
|
+
tracked via reactive signals.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The value returned by init_func (cached on first render).
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
RuntimeError: If called more than once per component render.
|
|
178
|
+
RuntimeError: If the number or names of arguments change between renders.
|
|
179
|
+
|
|
180
|
+
Example:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
@ps.component
|
|
184
|
+
def Counter():
|
|
185
|
+
def init():
|
|
186
|
+
return CounterState(), expensive_calculation()
|
|
187
|
+
|
|
188
|
+
state, value = ps.setup(init)
|
|
189
|
+
|
|
190
|
+
return ps.div(f"Count: {state.count}")
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Notes:
|
|
194
|
+
- ``ps.init()`` is syntactic sugar that transforms into ``ps.setup()`` calls
|
|
195
|
+
- Use ``ps.setup()`` directly when AST rewriting is problematic
|
|
196
|
+
- Arguments must be consistent across renders (same count and names)
|
|
197
|
+
"""
|
|
144
198
|
state = _setup_hook()
|
|
145
199
|
state.ensure_not_called()
|
|
146
200
|
|
|
@@ -166,6 +220,29 @@ def setup(init_func: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
|
|
|
166
220
|
|
|
167
221
|
|
|
168
222
|
def setup_key(key: str) -> None:
|
|
223
|
+
"""Set a key for the next setup call to control re-initialization.
|
|
224
|
+
|
|
225
|
+
When the key changes between renders, the setup function is re-run
|
|
226
|
+
and a new value is created. This is useful for resetting state when
|
|
227
|
+
a prop changes.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
key: String key that, when changed, triggers re-initialization
|
|
231
|
+
of the subsequent setup call.
|
|
232
|
+
|
|
233
|
+
Raises:
|
|
234
|
+
TypeError: If key is not a string.
|
|
235
|
+
RuntimeError: If called after setup() in the same render.
|
|
236
|
+
|
|
237
|
+
Example:
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
def user_profile(user_id: str):
|
|
241
|
+
ps.setup_key(user_id) # Re-run setup when user_id changes
|
|
242
|
+
data = ps.setup(lambda: fetch_user_data(user_id))
|
|
243
|
+
return m.Text(data.name)
|
|
244
|
+
```
|
|
245
|
+
"""
|
|
169
246
|
if not isinstance(key, str):
|
|
170
247
|
raise TypeError("setup_key() requires a string key")
|
|
171
248
|
state = _setup_hook()
|
pulse/hooks/stable.py
CHANGED
|
@@ -8,6 +8,17 @@ TCallable = TypeVar("TCallable", bound=Callable[..., Any])
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class StableEntry:
|
|
11
|
+
"""Container for a stable value and its wrapper function.
|
|
12
|
+
|
|
13
|
+
Holds a value and a wrapper function that always delegates to the
|
|
14
|
+
current value, allowing the wrapper reference to remain stable while
|
|
15
|
+
the underlying value can change.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
value: The current wrapped value.
|
|
19
|
+
wrapper: Stable function that delegates to the current value.
|
|
20
|
+
"""
|
|
21
|
+
|
|
11
22
|
__slots__ = ("value", "wrapper") # pyright: ignore[reportUnannotatedClassAttribute]
|
|
12
23
|
value: Any
|
|
13
24
|
wrapper: Callable[..., Any]
|
|
@@ -25,6 +36,13 @@ class StableEntry:
|
|
|
25
36
|
|
|
26
37
|
|
|
27
38
|
class StableRegistry(HookState):
|
|
39
|
+
"""Internal hook state that stores stable entries by key.
|
|
40
|
+
|
|
41
|
+
Maintains a dictionary of StableEntry objects, allowing stable
|
|
42
|
+
wrappers to persist across renders while their underlying values
|
|
43
|
+
can be updated.
|
|
44
|
+
"""
|
|
45
|
+
|
|
28
46
|
__slots__ = ("entries",) # pyright: ignore[reportUnannotatedClassAttribute]
|
|
29
47
|
|
|
30
48
|
def __init__(self) -> None:
|
|
@@ -58,7 +76,46 @@ def stable(key: str, value: TCallable) -> TCallable: ...
|
|
|
58
76
|
def stable(key: str, value: T) -> Callable[[], T]: ...
|
|
59
77
|
|
|
60
78
|
|
|
61
|
-
def stable(key: str, value: Any = MISSING):
|
|
79
|
+
def stable(key: str, value: Any = MISSING) -> Any:
|
|
80
|
+
"""Return a stable wrapper that always calls the latest value.
|
|
81
|
+
|
|
82
|
+
Creates a wrapper function that maintains a stable reference across renders
|
|
83
|
+
while delegating to the current value. Useful for event handlers and callbacks
|
|
84
|
+
that need to stay referentially stable.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
key: Unique identifier for this stable value within the component.
|
|
88
|
+
value: Optional value or callable to wrap. If provided, updates the
|
|
89
|
+
stored value and returns the wrapper. If omitted, returns the
|
|
90
|
+
existing wrapper for the key.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
A stable wrapper function that delegates to the current value. If the
|
|
94
|
+
value is callable, the wrapper calls it with any provided arguments.
|
|
95
|
+
If not callable, the wrapper returns the value directly.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
ValueError: If key is empty.
|
|
99
|
+
KeyError: If value is not provided and no entry exists for the key.
|
|
100
|
+
|
|
101
|
+
Example:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
def my_component():
|
|
105
|
+
s = ps.state("data", lambda: DataState())
|
|
106
|
+
|
|
107
|
+
# Without stable, this would create a new function each render
|
|
108
|
+
handle_click = ps.stable("click", lambda: s.increment())
|
|
109
|
+
|
|
110
|
+
return m.Button("Click", on_click=handle_click)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Use Cases:
|
|
114
|
+
- Event handlers passed to child components to prevent unnecessary re-renders
|
|
115
|
+
- Callbacks registered with external systems
|
|
116
|
+
- Any function reference that needs to stay stable across renders
|
|
117
|
+
) -> Any:
|
|
118
|
+
"""
|
|
62
119
|
if not key:
|
|
63
120
|
raise ValueError("stable() requires a non-empty string key")
|
|
64
121
|
|
pulse/hooks/state.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import inspect
|
|
1
2
|
from collections.abc import Callable
|
|
2
|
-
from
|
|
3
|
+
from types import CodeType, FrameType
|
|
4
|
+
from typing import Any, TypeVar, override
|
|
3
5
|
|
|
6
|
+
from pulse.component import is_component_code
|
|
4
7
|
from pulse.hooks.core import HookMetadata, HookState, hooks
|
|
5
8
|
from pulse.state import State
|
|
6
9
|
|
|
@@ -8,42 +11,71 @@ S = TypeVar("S", bound=State)
|
|
|
8
11
|
|
|
9
12
|
|
|
10
13
|
class StateHookState(HookState):
|
|
14
|
+
"""Internal hook state for managing State instances across renders.
|
|
15
|
+
|
|
16
|
+
Stores State instances keyed by string identifier and tracks which keys
|
|
17
|
+
have been accessed during the current render cycle.
|
|
18
|
+
"""
|
|
19
|
+
|
|
11
20
|
__slots__ = ("instances", "called_keys") # pyright: ignore[reportUnannotatedClassAttribute]
|
|
12
|
-
instances: dict[str, State]
|
|
13
|
-
called_keys: set[str]
|
|
21
|
+
instances: dict[tuple[str, Any], State]
|
|
22
|
+
called_keys: set[tuple[str, Any]]
|
|
14
23
|
|
|
15
24
|
def __init__(self) -> None:
|
|
16
25
|
super().__init__()
|
|
17
26
|
self.instances = {}
|
|
18
27
|
self.called_keys = set()
|
|
19
28
|
|
|
29
|
+
def _make_key(self, identity: Any, key: str | None) -> tuple[str, Any]:
|
|
30
|
+
if key is None:
|
|
31
|
+
return ("code", identity)
|
|
32
|
+
return ("key", key)
|
|
33
|
+
|
|
20
34
|
@override
|
|
21
35
|
def on_render_start(self, render_cycle: int) -> None:
|
|
22
36
|
super().on_render_start(render_cycle)
|
|
23
37
|
self.called_keys.clear()
|
|
24
38
|
|
|
25
|
-
def get_or_create_state(
|
|
26
|
-
|
|
39
|
+
def get_or_create_state(
|
|
40
|
+
self,
|
|
41
|
+
identity: Any,
|
|
42
|
+
key: str | None,
|
|
43
|
+
arg: State | Callable[[], State],
|
|
44
|
+
) -> State:
|
|
45
|
+
full_identity = self._make_key(identity, key)
|
|
46
|
+
if full_identity in self.called_keys:
|
|
47
|
+
if key is None:
|
|
48
|
+
raise RuntimeError(
|
|
49
|
+
"`pulse.state` can only be called once per component render at the same location. "
|
|
50
|
+
+ "Use the `key` parameter to disambiguate: ps.state(..., key=unique_value)"
|
|
51
|
+
)
|
|
27
52
|
raise RuntimeError(
|
|
28
53
|
f"`pulse.state` can only be called once per component render with key='{key}'"
|
|
29
54
|
)
|
|
30
|
-
self.called_keys.add(
|
|
55
|
+
self.called_keys.add(full_identity)
|
|
31
56
|
|
|
32
|
-
existing = self.instances.get(
|
|
57
|
+
existing = self.instances.get(full_identity)
|
|
33
58
|
if existing is not None:
|
|
34
59
|
# Dispose any State instances passed directly as args that aren't being used
|
|
35
60
|
if isinstance(arg, State) and arg is not existing:
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
61
|
+
arg.dispose()
|
|
62
|
+
if existing.__disposed__:
|
|
63
|
+
key_label = f"key='{key}'" if key is not None else "callsite"
|
|
64
|
+
raise RuntimeError(
|
|
65
|
+
"`pulse.state` found a disposed cached State for "
|
|
66
|
+
+ key_label
|
|
67
|
+
+ ". Do not dispose states returned by `pulse.state`."
|
|
68
|
+
)
|
|
42
69
|
return existing
|
|
43
70
|
|
|
44
71
|
# Create new state
|
|
45
72
|
instance = _instantiate_state(arg)
|
|
46
|
-
|
|
73
|
+
if instance.__disposed__:
|
|
74
|
+
raise RuntimeError(
|
|
75
|
+
"`pulse.state` received a disposed State instance. "
|
|
76
|
+
+ "Do not dispose states passed to `pulse.state`."
|
|
77
|
+
)
|
|
78
|
+
self.instances[full_identity] = instance
|
|
47
79
|
return instance
|
|
48
80
|
|
|
49
81
|
@override
|
|
@@ -71,6 +103,26 @@ def _state_factory():
|
|
|
71
103
|
return StateHookState()
|
|
72
104
|
|
|
73
105
|
|
|
106
|
+
def _frame_offset(frame: FrameType) -> int:
|
|
107
|
+
offset = frame.f_lasti
|
|
108
|
+
if offset < 0:
|
|
109
|
+
offset = frame.f_lineno
|
|
110
|
+
return offset
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def collect_component_identity(
|
|
114
|
+
frame: FrameType,
|
|
115
|
+
) -> tuple[tuple[CodeType, int], ...]:
|
|
116
|
+
identity: list[tuple[CodeType, int]] = []
|
|
117
|
+
cursor: FrameType | None = frame
|
|
118
|
+
while cursor is not None:
|
|
119
|
+
identity.append((cursor.f_code, _frame_offset(cursor)))
|
|
120
|
+
if is_component_code(cursor.f_code):
|
|
121
|
+
return tuple(identity)
|
|
122
|
+
cursor = cursor.f_back
|
|
123
|
+
return tuple(identity[:1])
|
|
124
|
+
|
|
125
|
+
|
|
74
126
|
_state_hook = hooks.create(
|
|
75
127
|
"pulse:core.state",
|
|
76
128
|
_state_factory,
|
|
@@ -81,25 +133,60 @@ _state_hook = hooks.create(
|
|
|
81
133
|
)
|
|
82
134
|
|
|
83
135
|
|
|
84
|
-
def state(
|
|
85
|
-
|
|
136
|
+
def state(
|
|
137
|
+
arg: S | Callable[[], S],
|
|
138
|
+
*,
|
|
139
|
+
key: str | None = None,
|
|
140
|
+
) -> S:
|
|
141
|
+
"""Get or create a state instance associated with a key or callsite.
|
|
86
142
|
|
|
87
143
|
Args:
|
|
88
|
-
key: A unique string key identifying this state within the component.
|
|
89
144
|
arg: A State instance or a callable that returns a State instance.
|
|
145
|
+
key: Optional key to disambiguate multiple calls from the same location.
|
|
90
146
|
|
|
91
147
|
Returns:
|
|
92
|
-
The
|
|
148
|
+
The same State instance on subsequent renders with the same key.
|
|
93
149
|
|
|
94
150
|
Raises:
|
|
95
151
|
ValueError: If key is empty.
|
|
96
152
|
RuntimeError: If called more than once per render with the same key.
|
|
97
153
|
TypeError: If arg is not a State or callable returning a State.
|
|
154
|
+
|
|
155
|
+
Example:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
def counter():
|
|
159
|
+
s = ps.state("counter", lambda: CounterState())
|
|
160
|
+
return m.Button(f"Count: {s.count}", on_click=lambda: s.increment())
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Notes:
|
|
164
|
+
- Key must be non-empty string
|
|
165
|
+
- Can only be called once per render with the same key
|
|
166
|
+
- Factory is only called on first render; subsequent renders return cached instance
|
|
167
|
+
- State is disposed when component unmounts
|
|
98
168
|
"""
|
|
99
|
-
if not key:
|
|
169
|
+
if key is not None and not isinstance(key, str):
|
|
170
|
+
raise TypeError("state() key must be a string")
|
|
171
|
+
|
|
172
|
+
if key == "":
|
|
100
173
|
raise ValueError("state() requires a non-empty string key")
|
|
174
|
+
|
|
175
|
+
resolved_key = key
|
|
176
|
+
resolved_arg = arg
|
|
177
|
+
|
|
178
|
+
identity: Any
|
|
179
|
+
if resolved_key is None:
|
|
180
|
+
frame = inspect.currentframe()
|
|
181
|
+
assert frame is not None
|
|
182
|
+
caller = frame.f_back
|
|
183
|
+
assert caller is not None
|
|
184
|
+
identity = collect_component_identity(caller)
|
|
185
|
+
else:
|
|
186
|
+
identity = resolved_key
|
|
187
|
+
|
|
101
188
|
hook_state = _state_hook()
|
|
102
|
-
return hook_state.get_or_create_state(
|
|
189
|
+
return hook_state.get_or_create_state(identity, resolved_key, resolved_arg) # pyright: ignore[reportReturnType]
|
|
103
190
|
|
|
104
191
|
|
|
105
192
|
__all__ = ["state", "StateHookState"]
|