pulse-framework 0.1.55__py3-none-any.whl → 0.1.57__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.57.dist-info}/METADATA +5 -5
- pulse_framework-0.1.57.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.57.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.57.dist-info}/entry_points.txt +0 -0
pulse/decorators.py
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import inspect
|
|
4
4
|
from collections.abc import Awaitable, Callable
|
|
5
|
-
from typing import Any, ParamSpec, Protocol, TypeVar, overload
|
|
5
|
+
from typing import Any, ParamSpec, Protocol, TypeVar, cast, overload
|
|
6
6
|
|
|
7
|
+
from pulse.hooks.core import HOOK_CONTEXT
|
|
8
|
+
from pulse.hooks.effects import inline_effect_hook
|
|
9
|
+
from pulse.hooks.state import collect_component_identity
|
|
7
10
|
from pulse.reactive import (
|
|
8
11
|
AsyncEffect,
|
|
9
12
|
AsyncEffectFn,
|
|
@@ -20,10 +23,6 @@ TState = TypeVar("TState", bound=State)
|
|
|
20
23
|
P = ParamSpec("P")
|
|
21
24
|
|
|
22
25
|
|
|
23
|
-
# -> @ps.computed The chalenge is:
|
|
24
|
-
# - We want to turn regular functions with no arguments into a Computed object
|
|
25
|
-
# - We want to turn state methods into a ComputedProperty (which wraps a
|
|
26
|
-
# Computed, but gives it access to the State object).
|
|
27
26
|
@overload
|
|
28
27
|
def computed(fn: Callable[[], T], *, name: str | None = None) -> Computed[T]: ...
|
|
29
28
|
@overload
|
|
@@ -36,7 +35,61 @@ def computed(
|
|
|
36
35
|
) -> Callable[[Callable[[], T]], Computed[T]]: ...
|
|
37
36
|
|
|
38
37
|
|
|
39
|
-
def computed(
|
|
38
|
+
def computed(
|
|
39
|
+
fn: Callable[..., Any] | None = None,
|
|
40
|
+
*,
|
|
41
|
+
name: str | None = None,
|
|
42
|
+
) -> (
|
|
43
|
+
Computed[T]
|
|
44
|
+
| ComputedProperty[T]
|
|
45
|
+
| Callable[[Callable[..., Any]], Computed[T] | ComputedProperty[T]]
|
|
46
|
+
):
|
|
47
|
+
"""
|
|
48
|
+
Decorator for computed (derived) properties.
|
|
49
|
+
|
|
50
|
+
Creates a cached, reactive value that automatically recalculates when its
|
|
51
|
+
dependencies change. The computed tracks which Signals/Computeds are read
|
|
52
|
+
during execution and subscribes to them.
|
|
53
|
+
|
|
54
|
+
Can be used in two ways:
|
|
55
|
+
1. On a State method (with single `self` argument) - creates a ComputedProperty
|
|
56
|
+
2. As a standalone function (with no arguments) - creates a Computed
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
fn: The function to compute the value. Must take no arguments (standalone) or only `self` (State method).
|
|
60
|
+
name: Optional debug name for the computed. Defaults to the function name.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Computed wrapper or decorator depending on usage.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
TypeError: If the function takes arguments other than `self`.
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
On a State method:
|
|
70
|
+
|
|
71
|
+
class MyState(ps.State):
|
|
72
|
+
count: int = 0
|
|
73
|
+
|
|
74
|
+
@ps.computed
|
|
75
|
+
def doubled(self):
|
|
76
|
+
return self.count * 2
|
|
77
|
+
|
|
78
|
+
As a standalone computed:
|
|
79
|
+
|
|
80
|
+
signal = Signal(5)
|
|
81
|
+
|
|
82
|
+
@ps.computed
|
|
83
|
+
def doubled():
|
|
84
|
+
return signal() * 2
|
|
85
|
+
|
|
86
|
+
With explicit name:
|
|
87
|
+
|
|
88
|
+
@ps.computed(name="my_computed")
|
|
89
|
+
def doubled(self):
|
|
90
|
+
return self.count * 2
|
|
91
|
+
"""
|
|
92
|
+
|
|
40
93
|
# The type checker is not happy if I don't specify the `/` here.
|
|
41
94
|
def decorator(fn: Callable[..., Any], /):
|
|
42
95
|
sig = inspect.signature(fn)
|
|
@@ -81,7 +134,9 @@ def effect(
|
|
|
81
134
|
lazy: bool = False,
|
|
82
135
|
on_error: Callable[[Exception], None] | None = None,
|
|
83
136
|
deps: list[Signal[Any] | Computed[Any]] | None = None,
|
|
137
|
+
update_deps: bool | None = None,
|
|
84
138
|
interval: float | None = None,
|
|
139
|
+
key: str | None = None,
|
|
85
140
|
) -> Effect: ...
|
|
86
141
|
|
|
87
142
|
|
|
@@ -94,7 +149,9 @@ def effect(
|
|
|
94
149
|
lazy: bool = False,
|
|
95
150
|
on_error: Callable[[Exception], None] | None = None,
|
|
96
151
|
deps: list[Signal[Any] | Computed[Any]] | None = None,
|
|
152
|
+
update_deps: bool | None = None,
|
|
97
153
|
interval: float | None = None,
|
|
154
|
+
key: str | None = None,
|
|
98
155
|
) -> AsyncEffect: ...
|
|
99
156
|
# In practice this overload returns a StateEffect, but it gets converted into an
|
|
100
157
|
# Effect at state instantiation.
|
|
@@ -111,7 +168,9 @@ def effect(
|
|
|
111
168
|
lazy: bool = False,
|
|
112
169
|
on_error: Callable[[Exception], None] | None = None,
|
|
113
170
|
deps: list[Signal[Any] | Computed[Any]] | None = None,
|
|
171
|
+
update_deps: bool | None = None,
|
|
114
172
|
interval: float | None = None,
|
|
173
|
+
key: str | None = None,
|
|
115
174
|
) -> EffectBuilder: ...
|
|
116
175
|
|
|
117
176
|
|
|
@@ -123,17 +182,86 @@ def effect(
|
|
|
123
182
|
lazy: bool = False,
|
|
124
183
|
on_error: Callable[[Exception], None] | None = None,
|
|
125
184
|
deps: list[Signal[Any] | Computed[Any]] | None = None,
|
|
185
|
+
update_deps: bool | None = None,
|
|
126
186
|
interval: float | None = None,
|
|
187
|
+
key: str | None = None,
|
|
127
188
|
):
|
|
128
|
-
|
|
189
|
+
"""
|
|
190
|
+
Decorator for side effects that run when dependencies change.
|
|
191
|
+
|
|
192
|
+
Creates an effect that automatically re-runs when any of its tracked
|
|
193
|
+
dependencies change. Dependencies are automatically tracked by observing
|
|
194
|
+
which Signals/Computeds are read during execution.
|
|
195
|
+
|
|
196
|
+
Can be used in two ways:
|
|
197
|
+
1. On a State method (with single `self` argument) - creates a StateEffect
|
|
198
|
+
2. As a standalone function (with no arguments) - creates an Effect
|
|
199
|
+
|
|
200
|
+
Supports both sync and async functions. Async effects cannot use `immediate=True`.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
fn: The effect function. Must take no arguments (standalone) or only
|
|
204
|
+
`self` (State method). Can return a cleanup function.
|
|
205
|
+
name: Optional debug name. Defaults to "ClassName.method_name" or function name.
|
|
206
|
+
immediate: If True, run synchronously when scheduled instead of batching.
|
|
207
|
+
Only valid for sync effects.
|
|
208
|
+
lazy: If True, don't run on creation; wait for first dependency change.
|
|
209
|
+
on_error: Callback invoked if the effect throws an exception.
|
|
210
|
+
deps: Explicit list of dependencies. If provided, auto-tracking is disabled
|
|
211
|
+
and the effect only re-runs when these specific dependencies change.
|
|
212
|
+
interval: Re-run interval in seconds. Creates a polling effect that runs
|
|
213
|
+
periodically regardless of dependency changes.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Effect, AsyncEffect, or StateEffect depending on usage.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
TypeError: If the function takes arguments other than `self`.
|
|
220
|
+
ValueError: If `immediate=True` is used with an async function.
|
|
221
|
+
|
|
222
|
+
Example:
|
|
223
|
+
State method effect:
|
|
224
|
+
|
|
225
|
+
class MyState(ps.State):
|
|
226
|
+
count: int = 0
|
|
227
|
+
|
|
228
|
+
@ps.effect
|
|
229
|
+
def log_changes(self):
|
|
230
|
+
print(f"Count is {self.count}")
|
|
231
|
+
|
|
232
|
+
Async effect:
|
|
233
|
+
|
|
234
|
+
class MyState(ps.State):
|
|
235
|
+
query: str = ""
|
|
236
|
+
|
|
237
|
+
@ps.effect
|
|
238
|
+
async def fetch_data(self):
|
|
239
|
+
data = await api.fetch(self.query)
|
|
240
|
+
self.data = data
|
|
241
|
+
|
|
242
|
+
Effect with cleanup:
|
|
243
|
+
|
|
244
|
+
@ps.effect
|
|
245
|
+
def subscribe(self):
|
|
246
|
+
unsub = event_bus.subscribe(self.handle)
|
|
247
|
+
return unsub # Called before next run or on dispose
|
|
248
|
+
|
|
249
|
+
Polling effect:
|
|
250
|
+
|
|
251
|
+
@ps.effect(interval=5.0)
|
|
252
|
+
async def poll_status(self):
|
|
253
|
+
self.status = await api.get_status()
|
|
254
|
+
"""
|
|
255
|
+
|
|
129
256
|
def decorator(func: Callable[..., Any], /):
|
|
130
257
|
sig = inspect.signature(func)
|
|
131
258
|
params = list(sig.parameters.values())
|
|
132
259
|
|
|
133
|
-
# Disallow
|
|
260
|
+
# Disallow immediate + async
|
|
134
261
|
if immediate and inspect.iscoroutinefunction(func):
|
|
135
262
|
raise ValueError("Async effects cannot have immediate=True")
|
|
136
263
|
|
|
264
|
+
# State method - unchanged behavior
|
|
137
265
|
if len(params) == 1 and params[0].name == "self":
|
|
138
266
|
return StateEffect(
|
|
139
267
|
func,
|
|
@@ -142,34 +270,75 @@ def effect(
|
|
|
142
270
|
lazy=lazy,
|
|
143
271
|
on_error=on_error,
|
|
144
272
|
deps=deps,
|
|
273
|
+
update_deps=update_deps,
|
|
145
274
|
interval=interval,
|
|
146
275
|
)
|
|
147
276
|
|
|
148
|
-
|
|
277
|
+
# Allow params with defaults (used for variable binding in loops)
|
|
278
|
+
# Reject only if there are required params (no default)
|
|
279
|
+
required_params = [p for p in params if p.default is inspect.Parameter.empty]
|
|
280
|
+
if required_params:
|
|
149
281
|
raise TypeError(
|
|
150
|
-
f"@effect: Function '{func.__name__}' must take no arguments
|
|
282
|
+
f"@effect: Function '{func.__name__}' must take no arguments, a single 'self' argument, "
|
|
283
|
+
+ "or only arguments with defaults (for variable binding)"
|
|
151
284
|
)
|
|
152
285
|
|
|
153
|
-
#
|
|
154
|
-
|
|
155
|
-
|
|
286
|
+
# Check if we're in a hook context (component render)
|
|
287
|
+
ctx = HOOK_CONTEXT.get()
|
|
288
|
+
|
|
289
|
+
def create_effect() -> Effect | AsyncEffect:
|
|
290
|
+
if inspect.iscoroutinefunction(func):
|
|
291
|
+
return AsyncEffect(
|
|
292
|
+
func, # type: ignore[arg-type]
|
|
293
|
+
name=name or func.__name__,
|
|
294
|
+
lazy=lazy,
|
|
295
|
+
on_error=on_error,
|
|
296
|
+
deps=deps,
|
|
297
|
+
update_deps=update_deps,
|
|
298
|
+
interval=interval,
|
|
299
|
+
)
|
|
300
|
+
return Effect(
|
|
156
301
|
func, # type: ignore[arg-type]
|
|
157
302
|
name=name or func.__name__,
|
|
303
|
+
immediate=immediate,
|
|
158
304
|
lazy=lazy,
|
|
159
305
|
on_error=on_error,
|
|
160
306
|
deps=deps,
|
|
307
|
+
update_deps=update_deps,
|
|
161
308
|
interval=interval,
|
|
162
309
|
)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
)
|
|
172
|
-
|
|
173
|
-
|
|
310
|
+
|
|
311
|
+
if ctx is None:
|
|
312
|
+
# Not in component - create standalone effect (current behavior)
|
|
313
|
+
return create_effect()
|
|
314
|
+
|
|
315
|
+
# In component render - use inline caching
|
|
316
|
+
|
|
317
|
+
# Get the frame where the decorator was applied.
|
|
318
|
+
# When called as `@ps.effect` (no parens), the call stack is:
|
|
319
|
+
# decorator -> effect -> component
|
|
320
|
+
# When called as `@ps.effect(...)` (with parens), the stack is:
|
|
321
|
+
# decorator -> component
|
|
322
|
+
# We detect which case by checking if the immediate caller is effect().
|
|
323
|
+
frame = inspect.currentframe()
|
|
324
|
+
assert frame is not None
|
|
325
|
+
caller = frame.f_back
|
|
326
|
+
assert caller is not None
|
|
327
|
+
# If the immediate caller is the effect function itself, go back one more
|
|
328
|
+
if (
|
|
329
|
+
caller.f_code.co_name == "effect"
|
|
330
|
+
and "decorators" in caller.f_code.co_filename
|
|
331
|
+
):
|
|
332
|
+
caller = caller.f_back
|
|
333
|
+
assert caller is not None
|
|
334
|
+
if key is None:
|
|
335
|
+
identity = collect_component_identity(caller)
|
|
336
|
+
else:
|
|
337
|
+
identity = key
|
|
338
|
+
|
|
339
|
+
state = inline_effect_hook()
|
|
340
|
+
return state.get_or_create(cast(Any, identity), key, create_effect)
|
|
341
|
+
|
|
342
|
+
if fn is not None:
|
|
174
343
|
return decorator(fn)
|
|
175
344
|
return decorator
|
pulse/env.py
CHANGED
|
@@ -18,6 +18,13 @@ from typing import Literal
|
|
|
18
18
|
|
|
19
19
|
# Types
|
|
20
20
|
PulseEnv = Literal["dev", "ci", "prod"]
|
|
21
|
+
"""Environment type for the Pulse application.
|
|
22
|
+
|
|
23
|
+
Values:
|
|
24
|
+
"dev": Development environment with hot reload and debugging.
|
|
25
|
+
"ci": Continuous integration environment for testing.
|
|
26
|
+
"prod": Production environment with optimizations enabled.
|
|
27
|
+
"""
|
|
21
28
|
|
|
22
29
|
# Keys
|
|
23
30
|
ENV_PULSE_ENV = "PULSE_ENV"
|
|
@@ -31,6 +38,28 @@ ENV_PULSE_DISABLE_CODEGEN = "PULSE_DISABLE_CODEGEN"
|
|
|
31
38
|
|
|
32
39
|
|
|
33
40
|
class EnvVars:
|
|
41
|
+
"""Singleton accessor for Pulse environment variables.
|
|
42
|
+
|
|
43
|
+
Provides typed getters and setters for all Pulse-related environment
|
|
44
|
+
variables. Access via the `env` singleton instance.
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
```python
|
|
48
|
+
from pulse.env import env
|
|
49
|
+
|
|
50
|
+
env.pulse_env = "prod"
|
|
51
|
+
if env.pulse_env == "dev":
|
|
52
|
+
print(f"Running on {env.pulse_host}:{env.pulse_port}")
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
pulse_env: Current environment ("dev", "ci", "prod").
|
|
57
|
+
pulse_host: Server hostname. Defaults to "localhost".
|
|
58
|
+
pulse_port: Server port number. Defaults to 8000.
|
|
59
|
+
pulse_secret: Secret key for JWT session signing.
|
|
60
|
+
codegen_disabled: If True, skip code generation.
|
|
61
|
+
"""
|
|
62
|
+
|
|
34
63
|
def _get(self, key: str) -> str | None:
|
|
35
64
|
return os.environ.get(key)
|
|
36
65
|
|
|
@@ -117,8 +146,33 @@ class EnvVars:
|
|
|
117
146
|
|
|
118
147
|
# Singleton
|
|
119
148
|
env = EnvVars()
|
|
149
|
+
"""Singleton instance for accessing Pulse environment variables.
|
|
150
|
+
|
|
151
|
+
Example:
|
|
152
|
+
```python
|
|
153
|
+
from pulse.env import env
|
|
154
|
+
|
|
155
|
+
env.pulse_env = "prod"
|
|
156
|
+
print(env.pulse_host) # "localhost"
|
|
157
|
+
print(env.pulse_port) # 8000
|
|
158
|
+
```
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def mode() -> PulseEnv:
|
|
163
|
+
"""Returns the current pulse_env value.
|
|
164
|
+
|
|
165
|
+
Shorthand for `env.pulse_env`.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
The current environment: "dev", "ci", or "prod".
|
|
120
169
|
|
|
170
|
+
Example:
|
|
171
|
+
```python
|
|
172
|
+
from pulse.env import mode
|
|
121
173
|
|
|
122
|
-
|
|
123
|
-
|
|
174
|
+
if mode() == "dev":
|
|
175
|
+
enable_debug_toolbar()
|
|
176
|
+
```
|
|
177
|
+
"""
|
|
124
178
|
return env.pulse_env
|
pulse/form.py
CHANGED
|
@@ -42,8 +42,16 @@ __all__ = [
|
|
|
42
42
|
"FormStorage",
|
|
43
43
|
"internal_forms_hook",
|
|
44
44
|
]
|
|
45
|
+
|
|
45
46
|
FormValue = str | UploadFile
|
|
47
|
+
"""Individual form field value: ``str | UploadFile``."""
|
|
48
|
+
|
|
46
49
|
FormData = dict[str, FormValue | list[FormValue]]
|
|
50
|
+
"""Parsed form submission data.
|
|
51
|
+
|
|
52
|
+
Values are either single or multiple (for repeated field names).
|
|
53
|
+
Type alias for ``dict[str, FormValue | list[FormValue]]``.
|
|
54
|
+
"""
|
|
47
55
|
|
|
48
56
|
|
|
49
57
|
@react_component(Import("PulseForm", "pulse-ui-client"))
|
|
@@ -56,6 +64,16 @@ def client_form_component(
|
|
|
56
64
|
|
|
57
65
|
@dataclass
|
|
58
66
|
class FormRegistration:
|
|
67
|
+
"""Internal registration info for a form.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
id: Unique form identifier.
|
|
71
|
+
render_id: Associated render session ID.
|
|
72
|
+
route_path: Route path this form is bound to.
|
|
73
|
+
session_id: Associated user session ID.
|
|
74
|
+
on_submit: Async callback for form submission.
|
|
75
|
+
"""
|
|
76
|
+
|
|
59
77
|
id: str
|
|
60
78
|
render_id: str
|
|
61
79
|
route_path: str
|
|
@@ -64,6 +82,12 @@ class FormRegistration:
|
|
|
64
82
|
|
|
65
83
|
|
|
66
84
|
class FormRegistry(Disposable):
|
|
85
|
+
"""Internal class managing form registrations.
|
|
86
|
+
|
|
87
|
+
Not typically used directly. Forms are registered automatically via
|
|
88
|
+
``ps.Form`` or ``ManualForm``.
|
|
89
|
+
"""
|
|
90
|
+
|
|
67
91
|
def __init__(self, render: "RenderSession") -> None:
|
|
68
92
|
self._render: "RenderSession" = render
|
|
69
93
|
self._handlers: dict[str, FormRegistration] = {}
|
|
@@ -75,6 +99,17 @@ class FormRegistry(Disposable):
|
|
|
75
99
|
session_id: str,
|
|
76
100
|
on_submit: Callable[[FormData], Awaitable[None]],
|
|
77
101
|
) -> FormRegistration:
|
|
102
|
+
"""Register a form handler.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
render_id: Render session ID.
|
|
106
|
+
route_id: Route path.
|
|
107
|
+
session_id: User session ID.
|
|
108
|
+
on_submit: Async callback for form submission.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
FormRegistration with generated form ID.
|
|
112
|
+
"""
|
|
78
113
|
registration = FormRegistration(
|
|
79
114
|
uuid.uuid4().hex,
|
|
80
115
|
render_id=render_id,
|
|
@@ -86,10 +121,16 @@ class FormRegistry(Disposable):
|
|
|
86
121
|
return registration
|
|
87
122
|
|
|
88
123
|
def unregister(self, form_id: str) -> None:
|
|
124
|
+
"""Unregister a form handler.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
form_id: The form ID to unregister.
|
|
128
|
+
"""
|
|
89
129
|
self._handlers.pop(form_id, None)
|
|
90
130
|
|
|
91
131
|
@override
|
|
92
132
|
def dispose(self) -> None:
|
|
133
|
+
"""Clean up all registered forms."""
|
|
93
134
|
self._handlers.clear()
|
|
94
135
|
|
|
95
136
|
async def handle_submit(
|
|
@@ -98,6 +139,20 @@ class FormRegistry(Disposable):
|
|
|
98
139
|
request: Request,
|
|
99
140
|
session: "UserSession",
|
|
100
141
|
) -> Response:
|
|
142
|
+
"""Handle incoming form submission.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
form_id: The form ID being submitted.
|
|
146
|
+
request: The HTTP request.
|
|
147
|
+
session: The user session.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
HTTP response (204 on success).
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
HTTPException: If form not found (404), session mismatch (403),
|
|
154
|
+
or route unmounted (410).
|
|
155
|
+
"""
|
|
101
156
|
registration = self._handlers.get(form_id)
|
|
102
157
|
if registration is None:
|
|
103
158
|
raise HTTPException(status_code=404, detail="Unknown form submission")
|
|
@@ -139,6 +194,16 @@ class FormRegistry(Disposable):
|
|
|
139
194
|
|
|
140
195
|
|
|
141
196
|
def normalize_form_data(raw: StarletteFormData) -> FormData:
|
|
197
|
+
"""Convert Starlette FormData to normalized FormData dict.
|
|
198
|
+
|
|
199
|
+
Handles multiple values for the same key and filters out empty file uploads.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
raw: Starlette FormData from request.form().
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Normalized FormData dictionary.
|
|
206
|
+
"""
|
|
142
207
|
normalized: FormData = {}
|
|
143
208
|
for key, value in raw.multi_items():
|
|
144
209
|
item: FormValue
|
|
@@ -163,6 +228,11 @@ def normalize_form_data(raw: StarletteFormData) -> FormData:
|
|
|
163
228
|
|
|
164
229
|
|
|
165
230
|
class PulseFormProps(HTMLFormProps, total=False):
|
|
231
|
+
"""Form props that exclude action, method, encType, and onSubmit.
|
|
232
|
+
|
|
233
|
+
These props are auto-generated by Pulse for form handling.
|
|
234
|
+
"""
|
|
235
|
+
|
|
166
236
|
action: Never # pyright: ignore[reportIncompatibleVariableOverride]
|
|
167
237
|
method: Never # pyright: ignore[reportIncompatibleVariableOverride]
|
|
168
238
|
encType: Never # pyright: ignore[reportIncompatibleVariableOverride]
|
|
@@ -174,7 +244,48 @@ def Form(
|
|
|
174
244
|
key: str,
|
|
175
245
|
onSubmit: EventHandler1[FormData] | None = None,
|
|
176
246
|
**props: Unpack[PulseFormProps], # pyright: ignore[reportGeneralTypeIssues]
|
|
177
|
-
):
|
|
247
|
+
) -> Node:
|
|
248
|
+
"""Server-registered HTML form component.
|
|
249
|
+
|
|
250
|
+
Automatically wires up form submission to a Python handler. Uses
|
|
251
|
+
``multipart/form-data`` encoding to support file uploads.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
*children: Form content (inputs, buttons, etc.).
|
|
255
|
+
key: Unique form identifier (required, non-empty string).
|
|
256
|
+
onSubmit: Submit handler receiving parsed FormData.
|
|
257
|
+
**props: Standard HTML form props (except action, method, encType, onSubmit).
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Form node.
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
ValueError: If key is empty or onSubmit is not callable.
|
|
264
|
+
RuntimeError: If called outside a component render.
|
|
265
|
+
|
|
266
|
+
Example:
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
async def handle_submit(data: ps.FormData):
|
|
270
|
+
name = data.get("name") # str
|
|
271
|
+
file = data.get("avatar") # UploadFile
|
|
272
|
+
await save_user(name, file)
|
|
273
|
+
|
|
274
|
+
def my_form():
|
|
275
|
+
return ps.Form(
|
|
276
|
+
m.TextInput(name="name", label="Name"),
|
|
277
|
+
m.FileInput(name="avatar", label="Avatar"),
|
|
278
|
+
m.Button("Submit", type="submit"),
|
|
279
|
+
key="user-form",
|
|
280
|
+
onSubmit=handle_submit,
|
|
281
|
+
)
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Note:
|
|
285
|
+
- ``key`` must be unique within the render.
|
|
286
|
+
- Cannot override ``action``, ``method``, ``encType``, or ``onSubmit`` via props.
|
|
287
|
+
- Handler receives parsed form data as a ``FormData`` dict.
|
|
288
|
+
"""
|
|
178
289
|
if not isinstance(key, str) or not key:
|
|
179
290
|
raise ValueError("ps.Form requires a non-empty string key")
|
|
180
291
|
if not callable(onSubmit):
|
|
@@ -197,6 +308,15 @@ def Form(
|
|
|
197
308
|
|
|
198
309
|
|
|
199
310
|
class GeneratedFormProps(TypedDict):
|
|
311
|
+
"""Form props generated by ``ManualForm.props()``.
|
|
312
|
+
|
|
313
|
+
Attributes:
|
|
314
|
+
action: Form submission URL.
|
|
315
|
+
method: HTTP method ("POST").
|
|
316
|
+
encType: Encoding type ("multipart/form-data").
|
|
317
|
+
onSubmit: Submission trigger callback.
|
|
318
|
+
"""
|
|
319
|
+
|
|
200
320
|
action: str
|
|
201
321
|
method: str
|
|
202
322
|
encType: str
|
|
@@ -204,11 +324,51 @@ class GeneratedFormProps(TypedDict):
|
|
|
204
324
|
|
|
205
325
|
|
|
206
326
|
class ManualForm(Disposable):
|
|
327
|
+
"""Low-level form handler for custom form implementations.
|
|
328
|
+
|
|
329
|
+
Use when you need more control over form rendering than ``ps.Form`` provides.
|
|
330
|
+
|
|
331
|
+
Attributes:
|
|
332
|
+
is_submitting: Whether the form is currently submitting.
|
|
333
|
+
registration: Form registration info (raises if disposed).
|
|
334
|
+
|
|
335
|
+
Example:
|
|
336
|
+
|
|
337
|
+
```python
|
|
338
|
+
def custom_form():
|
|
339
|
+
manual = ManualForm(on_submit=handle_data)
|
|
340
|
+
|
|
341
|
+
# Option 1: Render directly
|
|
342
|
+
return manual(
|
|
343
|
+
m.TextInput(name="field"),
|
|
344
|
+
m.Button("Submit", type="submit"),
|
|
345
|
+
key="my-form",
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
# Option 2: Use props manually
|
|
349
|
+
form_props = manual.props()
|
|
350
|
+
return m.form(
|
|
351
|
+
m.TextInput(name="field"),
|
|
352
|
+
m.Button("Submit", type="submit"),
|
|
353
|
+
**form_props,
|
|
354
|
+
)
|
|
355
|
+
```
|
|
356
|
+
"""
|
|
357
|
+
|
|
207
358
|
_submit_signal: Signal[bool]
|
|
208
359
|
_render: "RenderSession"
|
|
209
360
|
_registration: FormRegistration | None
|
|
210
361
|
|
|
211
362
|
def __init__(self, on_submit: EventHandler1[FormData] | None = None) -> None:
|
|
363
|
+
"""Initialize a manual form handler.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
on_submit: Optional submit handler receiving parsed FormData.
|
|
367
|
+
|
|
368
|
+
Raises:
|
|
369
|
+
RuntimeError: If called outside a render pass, route context,
|
|
370
|
+
or user session.
|
|
371
|
+
"""
|
|
212
372
|
ctx = PulseContext.get()
|
|
213
373
|
render = ctx.render
|
|
214
374
|
route = ctx.route
|
|
@@ -238,19 +398,30 @@ class ManualForm(Disposable):
|
|
|
238
398
|
return on_submit_handler
|
|
239
399
|
|
|
240
400
|
@property
|
|
241
|
-
def is_submitting(self):
|
|
401
|
+
def is_submitting(self) -> bool:
|
|
402
|
+
"""Whether the form is currently submitting."""
|
|
242
403
|
return self._submit_signal.read()
|
|
243
404
|
|
|
244
405
|
@property
|
|
245
|
-
def registration(self):
|
|
406
|
+
def registration(self) -> FormRegistration:
|
|
407
|
+
"""Form registration info.
|
|
408
|
+
|
|
409
|
+
Raises:
|
|
410
|
+
ValueError: If the form has been disposed.
|
|
411
|
+
"""
|
|
246
412
|
if self._registration is None:
|
|
247
413
|
raise ValueError("This form has been disposed")
|
|
248
414
|
return self._registration
|
|
249
415
|
|
|
250
|
-
def _start_submit(self):
|
|
416
|
+
def _start_submit(self) -> None:
|
|
251
417
|
self._submit_signal.write(True)
|
|
252
418
|
|
|
253
419
|
def props(self) -> GeneratedFormProps:
|
|
420
|
+
"""Get form props for manual binding to a form element.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
Dict with action, method, encType, and onSubmit props.
|
|
424
|
+
"""
|
|
254
425
|
return {
|
|
255
426
|
"action": f"{server_address()}/pulse/forms/{self._render.id}/{self.registration.id}",
|
|
256
427
|
"method": "POST",
|
|
@@ -263,22 +434,44 @@ class ManualForm(Disposable):
|
|
|
263
434
|
*children: Node,
|
|
264
435
|
key: str | None = None,
|
|
265
436
|
**props: Unpack[PulseFormProps],
|
|
266
|
-
):
|
|
437
|
+
) -> Node:
|
|
438
|
+
"""Render as a form element with children.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
*children: Form content.
|
|
442
|
+
key: Optional element key.
|
|
443
|
+
**props: Additional form props.
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
Form node with auto-generated submission props.
|
|
447
|
+
"""
|
|
267
448
|
props.update(self.props()) # pyright: ignore[reportCallIssue, reportArgumentType]
|
|
268
449
|
return client_form_component(*children, key=key, **props)
|
|
269
450
|
|
|
270
451
|
@override
|
|
271
452
|
def dispose(self) -> None:
|
|
453
|
+
"""Unregister the form and clean up."""
|
|
272
454
|
if self._registration is None:
|
|
273
455
|
return
|
|
274
456
|
self._render.forms.unregister(self._registration.id)
|
|
275
457
|
self._registration = None
|
|
276
458
|
|
|
277
459
|
def update(self, on_submit: EventHandler1[FormData] | None) -> None:
|
|
460
|
+
"""Update the submit handler.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
on_submit: New submit handler.
|
|
464
|
+
"""
|
|
278
465
|
self.registration.on_submit = self.wrap_on_submit(on_submit)
|
|
279
466
|
|
|
280
467
|
|
|
281
468
|
class FormStorage(HookState):
|
|
469
|
+
"""Internal hook state for managing form lifecycle within renders.
|
|
470
|
+
|
|
471
|
+
Not typically used directly. Manages form persistence and cleanup across
|
|
472
|
+
render cycles.
|
|
473
|
+
"""
|
|
474
|
+
|
|
282
475
|
__slots__ = ("forms", "prev_forms", "render_mark") # pyright: ignore[reportUnannotatedClassAttribute]
|
|
283
476
|
render_mark: int
|
|
284
477
|
|
pulse/helpers.py
CHANGED
|
@@ -252,7 +252,13 @@ def later(
|
|
|
252
252
|
|
|
253
253
|
from pulse.reactive import Untrack
|
|
254
254
|
|
|
255
|
-
|
|
255
|
+
try:
|
|
256
|
+
loop = asyncio.get_running_loop()
|
|
257
|
+
except RuntimeError:
|
|
258
|
+
try:
|
|
259
|
+
loop = asyncio.get_event_loop()
|
|
260
|
+
except RuntimeError as exc:
|
|
261
|
+
raise RuntimeError("later() requires an event loop") from exc
|
|
256
262
|
|
|
257
263
|
def _run():
|
|
258
264
|
try:
|