pulse-framework 0.1.54__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/code_analysis.py +38 -0
- pulse/codegen/codegen.py +61 -62
- pulse/codegen/templates/route.py +100 -56
- pulse/component.py +128 -6
- 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 +41 -25
- 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 +190 -44
- 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 +13 -3
- pulse/transpiler/assets.py +66 -0
- pulse/transpiler/dynamic_import.py +131 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/function.py +6 -2
- pulse/transpiler/imports.py +33 -27
- pulse/transpiler/js_module.py +64 -8
- pulse/transpiler/py_module.py +1 -7
- pulse/transpiler/transpiler.py +4 -0
- pulse/user_session.py +119 -18
- {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/METADATA +5 -5
- pulse_framework-0.1.56.dist-info/RECORD +127 -0
- pulse/js/react_dom.py +0 -30
- pulse/transpiler/react_component.py +0 -51
- pulse_framework-0.1.54.dist-info/RECORD +0 -124
- {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/entry_points.txt +0 -0
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:
|
pulse/hooks/core.py
CHANGED
|
@@ -31,6 +31,18 @@ MISSING: Any = object()
|
|
|
31
31
|
|
|
32
32
|
@dataclass(slots=True)
|
|
33
33
|
class HookMetadata:
|
|
34
|
+
"""Metadata for a registered hook.
|
|
35
|
+
|
|
36
|
+
Contains optional descriptive information about a hook, useful for
|
|
37
|
+
debugging and documentation.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
description: Human-readable description of the hook's purpose.
|
|
41
|
+
owner: Module or package that owns this hook (e.g., "pulse.core").
|
|
42
|
+
version: Version string for the hook implementation.
|
|
43
|
+
extra: Additional metadata as a mapping.
|
|
44
|
+
"""
|
|
45
|
+
|
|
34
46
|
description: str | None = None
|
|
35
47
|
owner: str | None = None
|
|
36
48
|
version: str | None = None
|
|
@@ -38,20 +50,58 @@ class HookMetadata:
|
|
|
38
50
|
|
|
39
51
|
|
|
40
52
|
class HookState(Disposable):
|
|
41
|
-
"""Base class returned by hook factories.
|
|
53
|
+
"""Base class for hook state returned by hook factories.
|
|
54
|
+
|
|
55
|
+
Subclass this to create custom hook state that persists across renders.
|
|
56
|
+
Override lifecycle methods to respond to render events.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
render_cycle: The current render cycle number.
|
|
60
|
+
|
|
61
|
+
Example:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
class TimerHookState(ps.hooks.State):
|
|
65
|
+
def __init__(self):
|
|
66
|
+
self.start_time = time.time()
|
|
67
|
+
|
|
68
|
+
def elapsed(self) -> float:
|
|
69
|
+
return time.time() - self.start_time
|
|
70
|
+
|
|
71
|
+
def dispose(self) -> None:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
_timer_hook = ps.hooks.create("my_app:timer", lambda: TimerHookState())
|
|
75
|
+
|
|
76
|
+
def use_timer() -> TimerHookState:
|
|
77
|
+
return _timer_hook()
|
|
78
|
+
```
|
|
79
|
+
"""
|
|
42
80
|
|
|
43
81
|
render_cycle: int = 0
|
|
44
82
|
|
|
45
83
|
def on_render_start(self, render_cycle: int) -> None:
|
|
84
|
+
"""Called before each component render.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
render_cycle: The current render cycle number.
|
|
88
|
+
"""
|
|
46
89
|
self.render_cycle = render_cycle
|
|
47
90
|
|
|
48
91
|
def on_render_end(self, render_cycle: int) -> None:
|
|
49
|
-
"""Called after the component render has completed.
|
|
92
|
+
"""Called after the component render has completed.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
render_cycle: The current render cycle number.
|
|
96
|
+
"""
|
|
50
97
|
...
|
|
51
98
|
|
|
52
99
|
@override
|
|
53
100
|
def dispose(self) -> None:
|
|
54
|
-
"""Called when the hook instance is discarded.
|
|
101
|
+
"""Called when the hook instance is discarded.
|
|
102
|
+
|
|
103
|
+
Override to clean up resources (close connections, cancel tasks, etc.).
|
|
104
|
+
"""
|
|
55
105
|
...
|
|
56
106
|
|
|
57
107
|
|
|
@@ -66,11 +116,33 @@ def _default_factory() -> HookState:
|
|
|
66
116
|
|
|
67
117
|
@dataclass(slots=True)
|
|
68
118
|
class Hook(Generic[T]):
|
|
119
|
+
"""A registered hook definition.
|
|
120
|
+
|
|
121
|
+
Hooks are created via ``ps.hooks.create()`` and can be called during
|
|
122
|
+
component render to access their associated state.
|
|
123
|
+
|
|
124
|
+
Attributes:
|
|
125
|
+
name: Unique name identifying this hook.
|
|
126
|
+
factory: Function that creates new HookState instances.
|
|
127
|
+
metadata: Optional metadata about the hook.
|
|
128
|
+
"""
|
|
129
|
+
|
|
69
130
|
name: str
|
|
70
131
|
factory: HookFactory[T]
|
|
71
132
|
metadata: HookMetadata
|
|
72
133
|
|
|
73
134
|
def __call__(self, key: str | None = None) -> T:
|
|
135
|
+
"""Get or create hook state for the current component.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
key: Optional key for multiple instances of the same hook.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
The hook state instance.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
HookError: If called outside of a render context.
|
|
145
|
+
"""
|
|
74
146
|
ctx = HookContext.require(self.name)
|
|
75
147
|
namespace = ctx.namespace_for(self)
|
|
76
148
|
state = namespace.ensure(ctx, key)
|
|
@@ -79,6 +151,17 @@ class Hook(Generic[T]):
|
|
|
79
151
|
|
|
80
152
|
@dataclass(slots=True)
|
|
81
153
|
class HookInit(Generic[T]):
|
|
154
|
+
"""Initialization context passed to hook factories.
|
|
155
|
+
|
|
156
|
+
When a hook factory accepts a single argument, it receives this object
|
|
157
|
+
containing context about the initialization.
|
|
158
|
+
|
|
159
|
+
Attributes:
|
|
160
|
+
key: Optional key if the hook was called with a specific key.
|
|
161
|
+
render_cycle: The current render cycle number.
|
|
162
|
+
definition: Reference to the Hook definition being initialized.
|
|
163
|
+
"""
|
|
164
|
+
|
|
82
165
|
key: str | None
|
|
83
166
|
render_cycle: int
|
|
84
167
|
definition: Hook[T]
|
|
@@ -178,7 +261,6 @@ class HookContext:
|
|
|
178
261
|
if self._token is not None:
|
|
179
262
|
HOOK_CONTEXT.reset(self._token)
|
|
180
263
|
self._token = None
|
|
181
|
-
|
|
182
264
|
for namespace in self.namespaces.values():
|
|
183
265
|
namespace.on_render_end(self.render_cycle)
|
|
184
266
|
return False
|
|
@@ -265,6 +347,19 @@ HOOK_REGISTRY: HookRegistry = HookRegistry()
|
|
|
265
347
|
|
|
266
348
|
|
|
267
349
|
class HooksAPI:
|
|
350
|
+
"""Low-level API for creating custom hooks.
|
|
351
|
+
|
|
352
|
+
Access via ``ps.hooks``. Provides methods for registering, listing,
|
|
353
|
+
and managing hooks.
|
|
354
|
+
|
|
355
|
+
Attributes:
|
|
356
|
+
State: Alias for HookState base class.
|
|
357
|
+
Metadata: Alias for HookMetadata dataclass.
|
|
358
|
+
AlreadyRegisteredError: Exception for duplicate hook registration.
|
|
359
|
+
NotFoundError: Exception for missing hook lookup.
|
|
360
|
+
RenameCollisionError: Exception for hook rename conflicts.
|
|
361
|
+
"""
|
|
362
|
+
|
|
268
363
|
__slots__ = () # pyright: ignore[reportUnannotatedClassAttribute]
|
|
269
364
|
|
|
270
365
|
State: type[HookState] = HookState
|
|
@@ -281,7 +376,42 @@ class HooksAPI:
|
|
|
281
376
|
factory: HookFactory[T] = _default_factory,
|
|
282
377
|
*,
|
|
283
378
|
metadata: HookMetadata | None = None,
|
|
284
|
-
):
|
|
379
|
+
) -> "Hook[T]":
|
|
380
|
+
"""Register a new hook.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
name: Unique name for the hook (e.g., "my_app:timer").
|
|
384
|
+
factory: Function that creates HookState instances. Can be a
|
|
385
|
+
zero-argument callable or accept a HookInit object.
|
|
386
|
+
metadata: Optional metadata describing the hook.
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
Hook[T]: The registered hook, callable during component render.
|
|
390
|
+
|
|
391
|
+
Raises:
|
|
392
|
+
ValueError: If name is empty.
|
|
393
|
+
HookAlreadyRegisteredError: If a hook with this name already exists.
|
|
394
|
+
HookError: If the registry is locked.
|
|
395
|
+
|
|
396
|
+
Example:
|
|
397
|
+
|
|
398
|
+
```python
|
|
399
|
+
class TimerHookState(ps.hooks.State):
|
|
400
|
+
def __init__(self):
|
|
401
|
+
self.start_time = time.time()
|
|
402
|
+
|
|
403
|
+
def elapsed(self) -> float:
|
|
404
|
+
return time.time() - self.start_time
|
|
405
|
+
|
|
406
|
+
def dispose(self) -> None:
|
|
407
|
+
pass
|
|
408
|
+
|
|
409
|
+
_timer_hook = ps.hooks.create("my_app:timer", lambda: TimerHookState())
|
|
410
|
+
|
|
411
|
+
def use_timer() -> TimerHookState:
|
|
412
|
+
return _timer_hook()
|
|
413
|
+
```
|
|
414
|
+
"""
|
|
285
415
|
return HOOK_REGISTRY.create(name, factory, metadata)
|
|
286
416
|
|
|
287
417
|
def rename(self, current: str, new: str) -> None:
|