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.
Files changed (80) 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/code_analysis.py +38 -0
  6. pulse/codegen/codegen.py +61 -62
  7. pulse/codegen/templates/route.py +100 -56
  8. pulse/component.py +128 -6
  9. pulse/components/for_.py +30 -4
  10. pulse/components/if_.py +28 -5
  11. pulse/components/react_router.py +61 -3
  12. pulse/context.py +39 -5
  13. pulse/cookies.py +108 -4
  14. pulse/decorators.py +193 -24
  15. pulse/env.py +56 -2
  16. pulse/form.py +198 -5
  17. pulse/helpers.py +7 -1
  18. pulse/hooks/core.py +135 -5
  19. pulse/hooks/effects.py +61 -77
  20. pulse/hooks/init.py +60 -1
  21. pulse/hooks/runtime.py +241 -0
  22. pulse/hooks/setup.py +77 -0
  23. pulse/hooks/stable.py +58 -1
  24. pulse/hooks/state.py +107 -20
  25. pulse/js/__init__.py +41 -25
  26. pulse/js/array.py +9 -6
  27. pulse/js/console.py +15 -12
  28. pulse/js/date.py +9 -6
  29. pulse/js/document.py +5 -2
  30. pulse/js/error.py +7 -4
  31. pulse/js/json.py +9 -6
  32. pulse/js/map.py +8 -5
  33. pulse/js/math.py +9 -6
  34. pulse/js/navigator.py +5 -2
  35. pulse/js/number.py +9 -6
  36. pulse/js/obj.py +16 -13
  37. pulse/js/object.py +9 -6
  38. pulse/js/promise.py +19 -13
  39. pulse/js/pulse.py +28 -25
  40. pulse/js/react.py +190 -44
  41. pulse/js/regexp.py +7 -4
  42. pulse/js/set.py +8 -5
  43. pulse/js/string.py +9 -6
  44. pulse/js/weakmap.py +8 -5
  45. pulse/js/weakset.py +8 -5
  46. pulse/js/window.py +6 -3
  47. pulse/messages.py +5 -0
  48. pulse/middleware.py +147 -76
  49. pulse/plugin.py +76 -5
  50. pulse/queries/client.py +186 -39
  51. pulse/queries/common.py +52 -3
  52. pulse/queries/infinite_query.py +154 -2
  53. pulse/queries/mutation.py +127 -7
  54. pulse/queries/query.py +112 -11
  55. pulse/react_component.py +66 -3
  56. pulse/reactive.py +314 -30
  57. pulse/reactive_extensions.py +106 -26
  58. pulse/render_session.py +304 -173
  59. pulse/request.py +46 -11
  60. pulse/routing.py +140 -4
  61. pulse/serializer.py +71 -0
  62. pulse/state.py +177 -9
  63. pulse/test_helpers.py +15 -0
  64. pulse/transpiler/__init__.py +13 -3
  65. pulse/transpiler/assets.py +66 -0
  66. pulse/transpiler/dynamic_import.py +131 -0
  67. pulse/transpiler/emit_context.py +49 -0
  68. pulse/transpiler/function.py +6 -2
  69. pulse/transpiler/imports.py +33 -27
  70. pulse/transpiler/js_module.py +64 -8
  71. pulse/transpiler/py_module.py +1 -7
  72. pulse/transpiler/transpiler.py +4 -0
  73. pulse/user_session.py +119 -18
  74. {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/METADATA +5 -5
  75. pulse_framework-0.1.56.dist-info/RECORD +127 -0
  76. pulse/js/react_dom.py +0 -30
  77. pulse/transpiler/react_component.py +0 -51
  78. pulse_framework-0.1.54.dist-info/RECORD +0 -124
  79. {pulse_framework-0.1.54.dist-info → pulse_framework-0.1.56.dist-info}/WHEEL +0 -0
  80. {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
- # 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:
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: