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.
Files changed (70) hide show
  1. pulse/__init__.py +5 -6
  2. pulse/app.py +144 -57
  3. pulse/channel.py +139 -7
  4. pulse/cli/cmd.py +16 -2
  5. pulse/codegen/codegen.py +43 -12
  6. pulse/component.py +104 -0
  7. pulse/components/for_.py +30 -4
  8. pulse/components/if_.py +28 -5
  9. pulse/components/react_router.py +61 -3
  10. pulse/context.py +39 -5
  11. pulse/cookies.py +108 -4
  12. pulse/decorators.py +193 -24
  13. pulse/env.py +56 -2
  14. pulse/form.py +198 -5
  15. pulse/helpers.py +7 -1
  16. pulse/hooks/core.py +135 -5
  17. pulse/hooks/effects.py +61 -77
  18. pulse/hooks/init.py +60 -1
  19. pulse/hooks/runtime.py +241 -0
  20. pulse/hooks/setup.py +77 -0
  21. pulse/hooks/stable.py +58 -1
  22. pulse/hooks/state.py +107 -20
  23. pulse/js/__init__.py +40 -24
  24. pulse/js/array.py +9 -6
  25. pulse/js/console.py +15 -12
  26. pulse/js/date.py +9 -6
  27. pulse/js/document.py +5 -2
  28. pulse/js/error.py +7 -4
  29. pulse/js/json.py +9 -6
  30. pulse/js/map.py +8 -5
  31. pulse/js/math.py +9 -6
  32. pulse/js/navigator.py +5 -2
  33. pulse/js/number.py +9 -6
  34. pulse/js/obj.py +16 -13
  35. pulse/js/object.py +9 -6
  36. pulse/js/promise.py +19 -13
  37. pulse/js/pulse.py +28 -25
  38. pulse/js/react.py +94 -55
  39. pulse/js/regexp.py +7 -4
  40. pulse/js/set.py +8 -5
  41. pulse/js/string.py +9 -6
  42. pulse/js/weakmap.py +8 -5
  43. pulse/js/weakset.py +8 -5
  44. pulse/js/window.py +6 -3
  45. pulse/messages.py +5 -0
  46. pulse/middleware.py +147 -76
  47. pulse/plugin.py +76 -5
  48. pulse/queries/client.py +186 -39
  49. pulse/queries/common.py +52 -3
  50. pulse/queries/infinite_query.py +154 -2
  51. pulse/queries/mutation.py +127 -7
  52. pulse/queries/query.py +112 -11
  53. pulse/react_component.py +66 -3
  54. pulse/reactive.py +314 -30
  55. pulse/reactive_extensions.py +106 -26
  56. pulse/render_session.py +304 -173
  57. pulse/request.py +46 -11
  58. pulse/routing.py +140 -4
  59. pulse/serializer.py +71 -0
  60. pulse/state.py +177 -9
  61. pulse/test_helpers.py +15 -0
  62. pulse/transpiler/__init__.py +0 -3
  63. pulse/transpiler/py_module.py +1 -7
  64. pulse/user_session.py +119 -18
  65. {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.56.dist-info}/METADATA +5 -5
  66. pulse_framework-0.1.56.dist-info/RECORD +127 -0
  67. pulse/transpiler/react_component.py +0 -44
  68. pulse_framework-0.1.55.dist-info/RECORD +0 -127
  69. {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.56.dist-info}/WHEEL +0 -0
  70. {pulse_framework-0.1.55.dist-info → pulse_framework-0.1.56.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(fn: Callable[..., Any] | None = None, *, name: str | None = None):
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
- # The type checker is not happy if I don't specify the `/` here.
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 intermediate + async
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
- if len(params) > 0:
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 or a single 'self' argument"
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
- # This is a standalone effect function. Choose subclass based on async-ness
154
- if inspect.iscoroutinefunction(func):
155
- return AsyncEffect(
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
- return Effect(
164
- func, # type: ignore[arg-type]
165
- name=name or func.__name__,
166
- immediate=immediate,
167
- lazy=lazy,
168
- on_error=on_error,
169
- deps=deps,
170
- interval=interval,
171
- )
172
-
173
- if fn:
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
- # Commonly used helpesr
123
- def mode():
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
- loop = asyncio.get_running_loop()
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: