pulse-framework 0.1.62__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 (126) hide show
  1. pulse/__init__.py +1493 -0
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +1086 -0
  4. pulse/channel.py +607 -0
  5. pulse/cli/__init__.py +0 -0
  6. pulse/cli/cmd.py +575 -0
  7. pulse/cli/dependencies.py +181 -0
  8. pulse/cli/folder_lock.py +134 -0
  9. pulse/cli/helpers.py +271 -0
  10. pulse/cli/logging.py +102 -0
  11. pulse/cli/models.py +35 -0
  12. pulse/cli/packages.py +262 -0
  13. pulse/cli/processes.py +292 -0
  14. pulse/cli/secrets.py +39 -0
  15. pulse/cli/uvicorn_log_config.py +87 -0
  16. pulse/code_analysis.py +38 -0
  17. pulse/codegen/__init__.py +0 -0
  18. pulse/codegen/codegen.py +359 -0
  19. pulse/codegen/templates/__init__.py +0 -0
  20. pulse/codegen/templates/layout.py +106 -0
  21. pulse/codegen/templates/route.py +345 -0
  22. pulse/codegen/templates/routes_ts.py +42 -0
  23. pulse/codegen/utils.py +20 -0
  24. pulse/component.py +237 -0
  25. pulse/components/__init__.py +0 -0
  26. pulse/components/for_.py +83 -0
  27. pulse/components/if_.py +86 -0
  28. pulse/components/react_router.py +94 -0
  29. pulse/context.py +108 -0
  30. pulse/cookies.py +322 -0
  31. pulse/decorators.py +344 -0
  32. pulse/dom/__init__.py +0 -0
  33. pulse/dom/elements.py +1024 -0
  34. pulse/dom/events.py +445 -0
  35. pulse/dom/props.py +1250 -0
  36. pulse/dom/svg.py +0 -0
  37. pulse/dom/tags.py +328 -0
  38. pulse/dom/tags.pyi +480 -0
  39. pulse/env.py +178 -0
  40. pulse/form.py +538 -0
  41. pulse/helpers.py +541 -0
  42. pulse/hooks/__init__.py +0 -0
  43. pulse/hooks/core.py +452 -0
  44. pulse/hooks/effects.py +88 -0
  45. pulse/hooks/init.py +668 -0
  46. pulse/hooks/runtime.py +464 -0
  47. pulse/hooks/setup.py +254 -0
  48. pulse/hooks/stable.py +138 -0
  49. pulse/hooks/state.py +192 -0
  50. pulse/js/__init__.py +125 -0
  51. pulse/js/__init__.pyi +115 -0
  52. pulse/js/_types.py +299 -0
  53. pulse/js/array.py +339 -0
  54. pulse/js/console.py +50 -0
  55. pulse/js/date.py +119 -0
  56. pulse/js/document.py +145 -0
  57. pulse/js/error.py +140 -0
  58. pulse/js/json.py +66 -0
  59. pulse/js/map.py +97 -0
  60. pulse/js/math.py +69 -0
  61. pulse/js/navigator.py +79 -0
  62. pulse/js/number.py +57 -0
  63. pulse/js/obj.py +81 -0
  64. pulse/js/object.py +172 -0
  65. pulse/js/promise.py +172 -0
  66. pulse/js/pulse.py +115 -0
  67. pulse/js/react.py +495 -0
  68. pulse/js/regexp.py +57 -0
  69. pulse/js/set.py +124 -0
  70. pulse/js/string.py +38 -0
  71. pulse/js/weakmap.py +53 -0
  72. pulse/js/weakset.py +48 -0
  73. pulse/js/window.py +205 -0
  74. pulse/messages.py +202 -0
  75. pulse/middleware.py +471 -0
  76. pulse/plugin.py +96 -0
  77. pulse/proxy.py +242 -0
  78. pulse/py.typed +0 -0
  79. pulse/queries/__init__.py +0 -0
  80. pulse/queries/client.py +609 -0
  81. pulse/queries/common.py +101 -0
  82. pulse/queries/effect.py +55 -0
  83. pulse/queries/infinite_query.py +1418 -0
  84. pulse/queries/mutation.py +295 -0
  85. pulse/queries/protocol.py +136 -0
  86. pulse/queries/query.py +1314 -0
  87. pulse/queries/store.py +120 -0
  88. pulse/react_component.py +88 -0
  89. pulse/reactive.py +1208 -0
  90. pulse/reactive_extensions.py +1172 -0
  91. pulse/render_session.py +768 -0
  92. pulse/renderer.py +584 -0
  93. pulse/request.py +205 -0
  94. pulse/routing.py +598 -0
  95. pulse/serializer.py +279 -0
  96. pulse/state.py +556 -0
  97. pulse/test_helpers.py +15 -0
  98. pulse/transpiler/__init__.py +111 -0
  99. pulse/transpiler/assets.py +81 -0
  100. pulse/transpiler/builtins.py +1029 -0
  101. pulse/transpiler/dynamic_import.py +130 -0
  102. pulse/transpiler/emit_context.py +49 -0
  103. pulse/transpiler/errors.py +96 -0
  104. pulse/transpiler/function.py +611 -0
  105. pulse/transpiler/id.py +18 -0
  106. pulse/transpiler/imports.py +341 -0
  107. pulse/transpiler/js_module.py +336 -0
  108. pulse/transpiler/modules/__init__.py +33 -0
  109. pulse/transpiler/modules/asyncio.py +57 -0
  110. pulse/transpiler/modules/json.py +24 -0
  111. pulse/transpiler/modules/math.py +265 -0
  112. pulse/transpiler/modules/pulse/__init__.py +5 -0
  113. pulse/transpiler/modules/pulse/tags.py +250 -0
  114. pulse/transpiler/modules/typing.py +63 -0
  115. pulse/transpiler/nodes.py +1987 -0
  116. pulse/transpiler/py_module.py +135 -0
  117. pulse/transpiler/transpiler.py +1100 -0
  118. pulse/transpiler/vdom.py +256 -0
  119. pulse/types/__init__.py +0 -0
  120. pulse/types/event_handler.py +50 -0
  121. pulse/user_session.py +386 -0
  122. pulse/version.py +69 -0
  123. pulse_framework-0.1.62.dist-info/METADATA +198 -0
  124. pulse_framework-0.1.62.dist-info/RECORD +126 -0
  125. pulse_framework-0.1.62.dist-info/WHEEL +4 -0
  126. pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
pulse/hooks/runtime.py ADDED
@@ -0,0 +1,464 @@
1
+ from collections.abc import Callable, Mapping
2
+ from typing import (
3
+ Any,
4
+ Generic,
5
+ Literal,
6
+ NoReturn,
7
+ ParamSpec,
8
+ Protocol,
9
+ TypeVar,
10
+ cast,
11
+ )
12
+
13
+ from pulse.context import PulseContext
14
+ from pulse.hooks.core import HOOK_CONTEXT
15
+ from pulse.reactive_extensions import ReactiveDict
16
+ from pulse.routing import RouteContext
17
+ from pulse.state import State
18
+
19
+
20
+ class RedirectInterrupt(Exception):
21
+ """Exception raised to interrupt render and trigger a redirect.
22
+
23
+ This exception is thrown by ``ps.redirect()`` to interrupt the current
24
+ render cycle and navigate to a different path.
25
+
26
+ Attributes:
27
+ path: The destination URL to redirect to.
28
+ replace: If True, replaces the current history entry instead of pushing.
29
+ """
30
+
31
+ path: str
32
+ replace: bool
33
+
34
+ def __init__(self, path: str, *, replace: bool = False):
35
+ super().__init__(path)
36
+ self.path = path
37
+ self.replace = replace
38
+
39
+
40
+ class NotFoundInterrupt(Exception):
41
+ """Exception raised to interrupt render and show 404 page.
42
+
43
+ This exception is thrown by ``ps.not_found()`` to interrupt the current
44
+ render cycle and display the 404 not found page.
45
+ """
46
+
47
+ pass
48
+
49
+
50
+ def route() -> RouteContext:
51
+ """Get the current route context.
52
+
53
+ Returns:
54
+ RouteContext: Object with access to route parameters, path, and query.
55
+
56
+ Raises:
57
+ RuntimeError: If called outside of a component render context.
58
+
59
+ Example:
60
+
61
+ ```python
62
+ def user_page():
63
+ r = ps.route()
64
+ user_id = r.params.get("user_id") # From /users/:user_id
65
+ page = r.query.get("page", "1") # From ?page=2
66
+ return m.Text(f"User {user_id}, Page {page}")
67
+ ```
68
+ """
69
+ ctx = PulseContext.get()
70
+ if not ctx or not ctx.route:
71
+ raise RuntimeError(
72
+ "`pulse.route` can only be called within a component during rendering."
73
+ )
74
+ return ctx.route
75
+
76
+
77
+ def session() -> ReactiveDict[str, Any]:
78
+ """Get the current user session data.
79
+
80
+ Returns:
81
+ ReactiveDict[str, Any]: Reactive dictionary of session data that persists
82
+ across page navigations.
83
+
84
+ Raises:
85
+ RuntimeError: If called outside of a session context.
86
+
87
+ Example:
88
+
89
+ ```python
90
+ def my_component():
91
+ sess = ps.session()
92
+ sess["last_visited"] = datetime.now()
93
+ return m.Text(f"Visits: {sess.get('visit_count', 0)}")
94
+ ```
95
+ """
96
+ ctx = PulseContext.get()
97
+ if not ctx.session:
98
+ raise RuntimeError("Could not resolve user session")
99
+ return ctx.session.data
100
+
101
+
102
+ def session_id() -> str:
103
+ """Get the current session identifier.
104
+
105
+ Returns:
106
+ str: Unique identifier for the current user session.
107
+
108
+ Raises:
109
+ RuntimeError: If called outside of a session context.
110
+ """
111
+ ctx = PulseContext.get()
112
+ if not ctx.session:
113
+ raise RuntimeError("Could not resolve user session")
114
+ return ctx.session.sid
115
+
116
+
117
+ def websocket_id() -> str:
118
+ """Get the current WebSocket connection identifier.
119
+
120
+ Returns:
121
+ str: Unique identifier for the current WebSocket connection.
122
+
123
+ Raises:
124
+ RuntimeError: If called outside of a WebSocket session context.
125
+ """
126
+ ctx = PulseContext.get()
127
+ if not ctx.render:
128
+ raise RuntimeError("Could not resolve WebSocket session")
129
+ return ctx.render.id
130
+
131
+
132
+ async def call_api(
133
+ path: str,
134
+ *,
135
+ method: str = "POST",
136
+ headers: Mapping[str, str] | None = None,
137
+ body: Any | None = None,
138
+ credentials: str = "include",
139
+ ) -> dict[str, Any]:
140
+ """Make an API call through the client browser.
141
+
142
+ This function sends a request to the specified path via the client's browser,
143
+ which is useful for calling third-party APIs that require browser cookies
144
+ or credentials.
145
+
146
+ Args:
147
+ path: The URL path to call.
148
+ method: HTTP method (default: "POST").
149
+ headers: Optional HTTP headers to include in the request.
150
+ body: Optional request body (will be JSON serialized).
151
+ credentials: Credential mode for the request (default: "include").
152
+
153
+ Returns:
154
+ dict[str, Any]: The JSON response from the API.
155
+
156
+ Raises:
157
+ RuntimeError: If called outside of a Pulse callback context.
158
+ """
159
+ ctx = PulseContext.get()
160
+ if ctx.render is None:
161
+ raise RuntimeError("call_api() must be invoked inside a Pulse callback context")
162
+
163
+ return await ctx.render.call_api(
164
+ path,
165
+ method=method,
166
+ headers=dict(headers or {}),
167
+ body=body,
168
+ credentials=credentials,
169
+ )
170
+
171
+
172
+ async def set_cookie(
173
+ name: str,
174
+ value: str,
175
+ domain: str | None = None,
176
+ secure: bool = True,
177
+ samesite: Literal["lax", "strict", "none"] = "lax",
178
+ max_age_seconds: int = 7 * 24 * 3600,
179
+ ) -> None:
180
+ """Set a cookie on the client.
181
+
182
+ Args:
183
+ name: The cookie name.
184
+ value: The cookie value.
185
+ domain: Optional domain for the cookie.
186
+ secure: Whether the cookie should only be sent over HTTPS (default: True).
187
+ samesite: SameSite attribute ("lax", "strict", or "none"; default: "lax").
188
+ max_age_seconds: Cookie lifetime in seconds (default: 7 days).
189
+
190
+ Raises:
191
+ RuntimeError: If called outside of a session context.
192
+ """
193
+ ctx = PulseContext.get()
194
+ if ctx.session is None:
195
+ raise RuntimeError("Could not resolve the user session")
196
+ ctx.session.set_cookie(
197
+ name=name,
198
+ value=value,
199
+ domain=domain,
200
+ secure=secure,
201
+ samesite=samesite,
202
+ max_age_seconds=max_age_seconds,
203
+ )
204
+
205
+
206
+ def navigate(path: str, *, replace: bool = False, hard: bool = False) -> None:
207
+ """Navigate to a new URL.
208
+
209
+ Triggers client-side navigation to the specified path. By default, uses
210
+ client-side routing which is faster and preserves application state.
211
+
212
+ Args:
213
+ path: Destination URL to navigate to.
214
+ replace: If True, replaces the current history entry instead of pushing
215
+ a new one (default: False).
216
+ hard: If True, performs a full page reload instead of client-side
217
+ navigation (default: False).
218
+
219
+ Raises:
220
+ RuntimeError: If called outside of a Pulse callback context.
221
+
222
+ Example:
223
+
224
+ ```python
225
+ async def handle_login():
226
+ await api.login(username, password)
227
+ ps.navigate("/dashboard")
228
+ ```
229
+ """
230
+ ctx = PulseContext.get()
231
+ if ctx.render is None:
232
+ raise RuntimeError("navigate() must be invoked inside a Pulse callback context")
233
+ ctx.render.send(
234
+ {"type": "navigate_to", "path": path, "replace": replace, "hard": hard}
235
+ )
236
+
237
+
238
+ def redirect(path: str, *, replace: bool = False) -> NoReturn:
239
+ """Redirect during render (throws exception to interrupt render).
240
+
241
+ Unlike ``navigate()``, this function is intended for use during the render
242
+ phase to immediately redirect before the component finishes rendering.
243
+ It raises a ``RedirectInterrupt`` exception that is caught by the framework.
244
+
245
+ Args:
246
+ path: Destination URL to redirect to.
247
+ replace: If True, replaces the current history entry instead of pushing
248
+ a new one (default: False).
249
+
250
+ Raises:
251
+ RuntimeError: If called outside of component render.
252
+ RedirectInterrupt: Always raised to interrupt the render.
253
+
254
+ Example:
255
+
256
+ ```python
257
+ def protected_page():
258
+ user = get_current_user()
259
+ if not user:
260
+ ps.redirect("/login") # Interrupts render
261
+
262
+ return m.Text(f"Welcome, {user.name}")
263
+ ```
264
+ """
265
+ ctx = HOOK_CONTEXT.get()
266
+ if not ctx:
267
+ raise RuntimeError("redirect() must be invoked during component render")
268
+ raise RedirectInterrupt(path, replace=replace)
269
+
270
+
271
+ def not_found() -> NoReturn:
272
+ """Trigger 404 during render (throws exception to interrupt render).
273
+
274
+ Interrupts the current render and displays the 404 not found page.
275
+ Raises a ``NotFoundInterrupt`` exception that is caught by the framework.
276
+
277
+ Raises:
278
+ RuntimeError: If called outside of component render.
279
+ NotFoundInterrupt: Always raised to trigger 404 page.
280
+
281
+ Example:
282
+
283
+ ```python
284
+ def user_page():
285
+ r = ps.route()
286
+ user = get_user(r.params["id"])
287
+ if not user:
288
+ ps.not_found() # Shows 404 page
289
+
290
+ return m.Text(user.name)
291
+ ```
292
+ """
293
+ ctx = HOOK_CONTEXT.get()
294
+ if not ctx:
295
+ raise RuntimeError("not_found() must be invoked during component render")
296
+ raise NotFoundInterrupt()
297
+
298
+
299
+ def server_address() -> str:
300
+ """Get the server's public address.
301
+
302
+ Returns:
303
+ str: The server's public address (e.g., "https://example.com").
304
+
305
+ Raises:
306
+ RuntimeError: If called outside of a Pulse render/callback context
307
+ or if the server address is not configured.
308
+ """
309
+ ctx = PulseContext.get()
310
+ if ctx.render is None:
311
+ raise RuntimeError(
312
+ "server_address() must be called inside a Pulse render/callback context"
313
+ )
314
+ if not ctx.render.server_address:
315
+ raise RuntimeError(
316
+ "Server address unavailable. Ensure App.run_codegen/asgi_factory configured server_address."
317
+ )
318
+ return ctx.render.server_address
319
+
320
+
321
+ def client_address() -> str:
322
+ """Get the client's IP address.
323
+
324
+ Returns:
325
+ str: The client's IP address.
326
+
327
+ Raises:
328
+ RuntimeError: If called outside of a Pulse render/callback context
329
+ or if the client address is not available.
330
+ """
331
+ ctx = PulseContext.get()
332
+ if ctx.render is None:
333
+ raise RuntimeError(
334
+ "client_address() must be called inside a Pulse render/callback context"
335
+ )
336
+ if not ctx.render.client_address:
337
+ raise RuntimeError(
338
+ "Client address unavailable. It is set during prerender or socket connect."
339
+ )
340
+ return ctx.render.client_address
341
+
342
+
343
+ P = ParamSpec("P")
344
+ S = TypeVar("S", covariant=True, bound=State)
345
+
346
+
347
+ class GlobalStateAccessor(Protocol, Generic[P, S]):
348
+ """Protocol for global state accessor functions.
349
+
350
+ A callable that returns the shared state instance, optionally scoped
351
+ by an instance ID.
352
+ """
353
+
354
+ def __call__(
355
+ self, id: str | None = None, *args: P.args, **kwargs: P.kwargs
356
+ ) -> S: ...
357
+
358
+
359
+ GLOBAL_STATES: dict[str, State] = {}
360
+ """Global dictionary storing state instances keyed by their qualified names."""
361
+
362
+
363
+ def global_state(
364
+ factory: Callable[P, S] | type[S], key: str | None = None
365
+ ) -> GlobalStateAccessor[P, S]:
366
+ """Create a globally shared state accessor.
367
+
368
+ Creates a decorator or callable that provides access to a shared state
369
+ instance. The state is shared across all components that use the same
370
+ accessor.
371
+
372
+ Can be used as a decorator on a State class or with a factory function.
373
+
374
+ Args:
375
+ factory: State class or factory function that creates the state instance.
376
+ key: Optional custom key for the global state. If not provided, a key
377
+ is derived from the factory's module and qualified name.
378
+
379
+ Returns:
380
+ GlobalStateAccessor: A callable that returns the shared state instance.
381
+ Call with ``id=`` parameter for per-entity global state.
382
+
383
+ Example:
384
+
385
+ ```python
386
+ @ps.global_state
387
+ class AppSettings(ps.State):
388
+ theme: str = "light"
389
+ language: str = "en"
390
+
391
+ def settings_panel():
392
+ settings = AppSettings() # Same instance across all components
393
+ return m.Select(
394
+ value=settings.theme,
395
+ data=["light", "dark"],
396
+ on_change=lambda v: setattr(settings, "theme", v),
397
+ )
398
+ ```
399
+
400
+ With instance ID for per-entity global state:
401
+
402
+ ```python
403
+ @ps.global_state
404
+ class UserCache(ps.State):
405
+ data: dict = {}
406
+
407
+ def user_profile(user_id: str):
408
+ cache = UserCache(id=user_id) # Shared per user_id
409
+ return m.Text(cache.data.get("name", "Loading..."))
410
+ ```
411
+ """
412
+ if isinstance(factory, type):
413
+ cls = factory
414
+
415
+ def _mk(*args: P.args, **kwargs: P.kwargs) -> S:
416
+ return cast(S, cls(*args, **kwargs))
417
+
418
+ default_key = f"{cls.__module__}:{cls.__qualname__}"
419
+ mk = _mk
420
+ else:
421
+ default_key = f"{factory.__module__}:{factory.__qualname__}"
422
+ mk = factory
423
+
424
+ base_key = key or default_key
425
+
426
+ def accessor(id: str | None = None, *args: P.args, **kwargs: P.kwargs) -> S:
427
+ if id is not None:
428
+ shared_key = f"{base_key}|{id}"
429
+ inst = cast(S | None, GLOBAL_STATES.get(shared_key))
430
+ if inst is None:
431
+ inst = mk(*args, **kwargs)
432
+ GLOBAL_STATES[shared_key] = inst
433
+ return inst
434
+
435
+ ctx = PulseContext.get()
436
+ if ctx.render is None:
437
+ raise RuntimeError(
438
+ "ps.global_state must be called inside a Pulse render/callback context"
439
+ )
440
+ return cast(
441
+ S, ctx.render.get_global_state(base_key, lambda: mk(*args, **kwargs))
442
+ )
443
+
444
+ return accessor
445
+
446
+
447
+ __all__ = [
448
+ "RedirectInterrupt",
449
+ "NotFoundInterrupt",
450
+ "route",
451
+ "session",
452
+ "session_id",
453
+ "websocket_id",
454
+ "call_api",
455
+ "set_cookie",
456
+ "navigate",
457
+ "redirect",
458
+ "not_found",
459
+ "server_address",
460
+ "client_address",
461
+ "global_state",
462
+ "GLOBAL_STATES",
463
+ "GlobalStateAccessor",
464
+ ]
pulse/hooks/setup.py ADDED
@@ -0,0 +1,254 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any, ParamSpec, TypeVar, cast, override
5
+
6
+ from pulse.hooks.core import HookMetadata, HookState, hooks
7
+ from pulse.reactive import Effect, Scope, Signal
8
+
9
+ P = ParamSpec("P")
10
+ T = TypeVar("T")
11
+
12
+
13
+ class SetupHookState(HookState):
14
+ """Internal hook state for the setup hook.
15
+
16
+ Manages the initialization, argument tracking, and lifecycle of
17
+ setup-created values.
18
+
19
+ Attributes:
20
+ value: The value returned by the setup function.
21
+ initialized: Whether setup has been called at least once.
22
+ args: List of signals tracking positional argument values.
23
+ kwargs: Dict of signals tracking keyword argument values.
24
+ effects: List of effects created during setup execution.
25
+ key: Optional key for re-initialization control.
26
+ """
27
+
28
+ __slots__ = ( # pyright: ignore[reportUnannotatedClassAttribute]
29
+ "value",
30
+ "initialized",
31
+ "args",
32
+ "kwargs",
33
+ "effects",
34
+ "key",
35
+ "_called",
36
+ "_pending_key",
37
+ )
38
+ initialized: bool
39
+ _called: bool
40
+
41
+ def __init__(self) -> None:
42
+ super().__init__()
43
+ self.value: Any = None
44
+ self.initialized = False
45
+ self.args: list[Signal[Any]] = []
46
+ self.kwargs: dict[str, Signal[Any]] = {}
47
+ self.effects: list[Effect] = []
48
+ self.key: str | None = None
49
+ self._called = False
50
+ self._pending_key: str | None = None
51
+
52
+ @override
53
+ def on_render_start(self, render_cycle: int) -> None:
54
+ super().on_render_start(render_cycle)
55
+ self._called = False
56
+ self._pending_key = None
57
+
58
+ def initialize(
59
+ self,
60
+ init_func: Callable[..., Any],
61
+ args: tuple[Any, ...],
62
+ kwargs: dict[str, Any],
63
+ key: str | None,
64
+ ) -> Any:
65
+ self.dispose_effects()
66
+ with Scope() as scope:
67
+ self.value = init_func(*args, **kwargs)
68
+ self.effects = list(scope.effects)
69
+ self.args = [Signal(arg) for arg in args]
70
+ self.kwargs = {name: Signal(value) for name, value in kwargs.items()}
71
+ self.initialized = True
72
+ self.key = key
73
+ return self.value
74
+
75
+ def ensure_signature(
76
+ self,
77
+ args: tuple[Any, ...],
78
+ kwargs: dict[str, Any],
79
+ ) -> None:
80
+ if len(args) != len(self.args):
81
+ raise RuntimeError(
82
+ "Number of positional arguments passed to `pulse.setup` changed. "
83
+ + "Make sure you always call `pulse.setup` with the same number of positional "
84
+ + "arguments and the same keyword arguments."
85
+ )
86
+ if kwargs.keys() != self.kwargs.keys():
87
+ new_keys = kwargs.keys() - self.kwargs.keys()
88
+ missing_keys = self.kwargs.keys() - kwargs.keys()
89
+ raise RuntimeError(
90
+ "Keyword arguments passed to `pulse.setup` changed. "
91
+ + f"New arguments: {list(new_keys)}. Missing arguments: {list(missing_keys)}. "
92
+ + "Make sure you always call `pulse.setup` with the same number of positional "
93
+ + "arguments and the same keyword arguments."
94
+ )
95
+
96
+ def update_args(
97
+ self,
98
+ args: tuple[Any, ...],
99
+ kwargs: dict[str, Any],
100
+ ) -> None:
101
+ for idx, value in enumerate(args):
102
+ self.args[idx].write(value)
103
+ for name, value in kwargs.items():
104
+ self.kwargs[name].write(value)
105
+
106
+ def dispose_effects(self) -> None:
107
+ for effect in self.effects:
108
+ effect.dispose()
109
+ self.effects = []
110
+
111
+ @override
112
+ def dispose(self) -> None:
113
+ self.dispose_effects()
114
+ self.args = []
115
+ self.kwargs = {}
116
+ self.value = None
117
+ self.initialized = False
118
+ self.key = None
119
+ self._pending_key = None
120
+
121
+ def ensure_not_called(self) -> None:
122
+ if self._called:
123
+ raise RuntimeError(
124
+ "`pulse.setup` can only be called once per component render"
125
+ )
126
+
127
+ def mark_called(self) -> None:
128
+ self._called = True
129
+
130
+ @property
131
+ def called_this_render(self) -> bool:
132
+ return self._called
133
+
134
+ def set_pending_key(self, key: str) -> None:
135
+ self._pending_key = key
136
+
137
+ def consume_pending_key(self) -> str | None:
138
+ key = self._pending_key
139
+ self._pending_key = None
140
+ return key
141
+
142
+
143
+ def _setup_factory():
144
+ return SetupHookState()
145
+
146
+
147
+ _setup_hook = hooks.create(
148
+ "pulse:core.setup",
149
+ _setup_factory,
150
+ metadata=HookMetadata(
151
+ owner="pulse.core",
152
+ description="Internal storage for pulse.setup hook",
153
+ ),
154
+ )
155
+
156
+
157
+ def setup(init_func: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
158
+ """One-time initialization that persists across renders.
159
+
160
+ Calls the init function on first render and caches the result. On subsequent
161
+ renders, returns the cached value without re-running the function.
162
+
163
+ This is the lower-level alternative to ``ps.init()`` that doesn't require
164
+ AST rewriting and works in all environments.
165
+
166
+ Args:
167
+ init_func: Function to call on first render. Its return value is cached.
168
+ *args: Positional arguments passed to init_func. Changes to these are
169
+ tracked via reactive signals.
170
+ **kwargs: Keyword arguments passed to init_func. Changes to these are
171
+ tracked via reactive signals.
172
+
173
+ Returns:
174
+ The value returned by init_func (cached on first render).
175
+
176
+ Raises:
177
+ RuntimeError: If called more than once per component render.
178
+ RuntimeError: If the number or names of arguments change between renders.
179
+
180
+ Example:
181
+
182
+ ```python
183
+ @ps.component
184
+ def Counter():
185
+ def init():
186
+ return CounterState(), expensive_calculation()
187
+
188
+ state, value = ps.setup(init)
189
+
190
+ return ps.div(f"Count: {state.count}")
191
+ ```
192
+
193
+ Notes:
194
+ - ``ps.init()`` is syntactic sugar that transforms into ``ps.setup()`` calls
195
+ - Use ``ps.setup()`` directly when AST rewriting is problematic
196
+ - Arguments must be consistent across renders (same count and names)
197
+ """
198
+ state = _setup_hook()
199
+ state.ensure_not_called()
200
+
201
+ key = state.consume_pending_key()
202
+ args_tuple = tuple(args)
203
+ kwargs_dict = dict(kwargs)
204
+
205
+ if state.initialized:
206
+ if key is not None and key != state.key:
207
+ state.initialize(init_func, args_tuple, kwargs_dict, key)
208
+ state.mark_called()
209
+ return cast(T, state.value)
210
+ state.ensure_signature(args_tuple, kwargs_dict)
211
+ state.update_args(args_tuple, kwargs_dict)
212
+ if key is not None:
213
+ state.key = key
214
+ state.mark_called()
215
+ return cast(T, state.value)
216
+
217
+ state.initialize(init_func, args_tuple, kwargs_dict, key)
218
+ state.mark_called()
219
+ return cast(T, state.value)
220
+
221
+
222
+ def setup_key(key: str) -> None:
223
+ """Set a key for the next setup call to control re-initialization.
224
+
225
+ When the key changes between renders, the setup function is re-run
226
+ and a new value is created. This is useful for resetting state when
227
+ a prop changes.
228
+
229
+ Args:
230
+ key: String key that, when changed, triggers re-initialization
231
+ of the subsequent setup call.
232
+
233
+ Raises:
234
+ TypeError: If key is not a string.
235
+ RuntimeError: If called after setup() in the same render.
236
+
237
+ Example:
238
+
239
+ ```python
240
+ def user_profile(user_id: str):
241
+ ps.setup_key(user_id) # Re-run setup when user_id changes
242
+ data = ps.setup(lambda: fetch_user_data(user_id))
243
+ return m.Text(data.name)
244
+ ```
245
+ """
246
+ if not isinstance(key, str):
247
+ raise TypeError("setup_key() requires a string key")
248
+ state = _setup_hook()
249
+ if state.called_this_render:
250
+ raise RuntimeError("setup_key() must be called before setup() in a render")
251
+ state.set_pending_key(key)
252
+
253
+
254
+ __all__ = ["setup", "setup_key", "SetupHookState"]