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/hooks/runtime.py CHANGED
@@ -18,6 +18,16 @@ from pulse.state import State
18
18
 
19
19
 
20
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
+
21
31
  path: str
22
32
  replace: bool
23
33
 
@@ -28,10 +38,34 @@ class RedirectInterrupt(Exception):
28
38
 
29
39
 
30
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
+
31
47
  pass
32
48
 
33
49
 
34
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
+ """
35
69
  ctx = PulseContext.get()
36
70
  if not ctx or not ctx.route:
37
71
  raise RuntimeError(
@@ -41,6 +75,24 @@ def route() -> RouteContext:
41
75
 
42
76
 
43
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
+ """
44
96
  ctx = PulseContext.get()
45
97
  if not ctx.session:
46
98
  raise RuntimeError("Could not resolve user session")
@@ -48,6 +100,14 @@ def session() -> ReactiveDict[str, Any]:
48
100
 
49
101
 
50
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
+ """
51
111
  ctx = PulseContext.get()
52
112
  if not ctx.session:
53
113
  raise RuntimeError("Could not resolve user session")
@@ -55,6 +115,14 @@ def session_id() -> str:
55
115
 
56
116
 
57
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
+ """
58
126
  ctx = PulseContext.get()
59
127
  if not ctx.render:
60
128
  raise RuntimeError("Could not resolve WebSocket session")
@@ -69,6 +137,25 @@ async def call_api(
69
137
  body: Any | None = None,
70
138
  credentials: str = "include",
71
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
+ """
72
159
  ctx = PulseContext.get()
73
160
  if ctx.render is None:
74
161
  raise RuntimeError("call_api() must be invoked inside a Pulse callback context")
@@ -90,6 +177,19 @@ async def set_cookie(
90
177
  samesite: Literal["lax", "strict", "none"] = "lax",
91
178
  max_age_seconds: int = 7 * 24 * 3600,
92
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
+ """
93
193
  ctx = PulseContext.get()
94
194
  if ctx.session is None:
95
195
  raise RuntimeError("Could not resolve the user session")
@@ -104,6 +204,29 @@ async def set_cookie(
104
204
 
105
205
 
106
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
+ """
107
230
  ctx = PulseContext.get()
108
231
  if ctx.render is None:
109
232
  raise RuntimeError("navigate() must be invoked inside a Pulse callback context")
@@ -113,6 +236,32 @@ def navigate(path: str, *, replace: bool = False, hard: bool = False) -> None:
113
236
 
114
237
 
115
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
+ """
116
265
  ctx = HOOK_CONTEXT.get()
117
266
  if not ctx:
118
267
  raise RuntimeError("redirect() must be invoked during component render")
@@ -120,6 +269,27 @@ def redirect(path: str, *, replace: bool = False) -> NoReturn:
120
269
 
121
270
 
122
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
+ """
123
293
  ctx = HOOK_CONTEXT.get()
124
294
  if not ctx:
125
295
  raise RuntimeError("not_found() must be invoked during component render")
@@ -127,6 +297,15 @@ def not_found() -> NoReturn:
127
297
 
128
298
 
129
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
+ """
130
309
  ctx = PulseContext.get()
131
310
  if ctx.render is None:
132
311
  raise RuntimeError(
@@ -140,6 +319,15 @@ def server_address() -> str:
140
319
 
141
320
 
142
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
+ """
143
331
  ctx = PulseContext.get()
144
332
  if ctx.render is None:
145
333
  raise RuntimeError(
@@ -157,17 +345,70 @@ S = TypeVar("S", covariant=True, bound=State)
157
345
 
158
346
 
159
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
+
160
354
  def __call__(
161
355
  self, id: str | None = None, *args: P.args, **kwargs: P.kwargs
162
356
  ) -> S: ...
163
357
 
164
358
 
165
359
  GLOBAL_STATES: dict[str, State] = {}
360
+ """Global dictionary storing state instances keyed by their qualified names."""
166
361
 
167
362
 
168
363
  def global_state(
169
364
  factory: Callable[P, S] | type[S], key: str | None = None
170
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
+ """
171
412
  if isinstance(factory, type):
172
413
  cls = factory
173
414
 
pulse/hooks/setup.py CHANGED
@@ -11,6 +11,20 @@ T = TypeVar("T")
11
11
 
12
12
 
13
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
+
14
28
  __slots__ = ( # pyright: ignore[reportUnannotatedClassAttribute]
15
29
  "value",
16
30
  "initialized",
@@ -141,6 +155,46 @@ _setup_hook = hooks.create(
141
155
 
142
156
 
143
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
+ """
144
198
  state = _setup_hook()
145
199
  state.ensure_not_called()
146
200
 
@@ -166,6 +220,29 @@ def setup(init_func: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
166
220
 
167
221
 
168
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
+ """
169
246
  if not isinstance(key, str):
170
247
  raise TypeError("setup_key() requires a string key")
171
248
  state = _setup_hook()
pulse/hooks/stable.py CHANGED
@@ -8,6 +8,17 @@ TCallable = TypeVar("TCallable", bound=Callable[..., Any])
8
8
 
9
9
 
10
10
  class StableEntry:
11
+ """Container for a stable value and its wrapper function.
12
+
13
+ Holds a value and a wrapper function that always delegates to the
14
+ current value, allowing the wrapper reference to remain stable while
15
+ the underlying value can change.
16
+
17
+ Attributes:
18
+ value: The current wrapped value.
19
+ wrapper: Stable function that delegates to the current value.
20
+ """
21
+
11
22
  __slots__ = ("value", "wrapper") # pyright: ignore[reportUnannotatedClassAttribute]
12
23
  value: Any
13
24
  wrapper: Callable[..., Any]
@@ -25,6 +36,13 @@ class StableEntry:
25
36
 
26
37
 
27
38
  class StableRegistry(HookState):
39
+ """Internal hook state that stores stable entries by key.
40
+
41
+ Maintains a dictionary of StableEntry objects, allowing stable
42
+ wrappers to persist across renders while their underlying values
43
+ can be updated.
44
+ """
45
+
28
46
  __slots__ = ("entries",) # pyright: ignore[reportUnannotatedClassAttribute]
29
47
 
30
48
  def __init__(self) -> None:
@@ -58,7 +76,46 @@ def stable(key: str, value: TCallable) -> TCallable: ...
58
76
  def stable(key: str, value: T) -> Callable[[], T]: ...
59
77
 
60
78
 
61
- def stable(key: str, value: Any = MISSING):
79
+ def stable(key: str, value: Any = MISSING) -> Any:
80
+ """Return a stable wrapper that always calls the latest value.
81
+
82
+ Creates a wrapper function that maintains a stable reference across renders
83
+ while delegating to the current value. Useful for event handlers and callbacks
84
+ that need to stay referentially stable.
85
+
86
+ Args:
87
+ key: Unique identifier for this stable value within the component.
88
+ value: Optional value or callable to wrap. If provided, updates the
89
+ stored value and returns the wrapper. If omitted, returns the
90
+ existing wrapper for the key.
91
+
92
+ Returns:
93
+ A stable wrapper function that delegates to the current value. If the
94
+ value is callable, the wrapper calls it with any provided arguments.
95
+ If not callable, the wrapper returns the value directly.
96
+
97
+ Raises:
98
+ ValueError: If key is empty.
99
+ KeyError: If value is not provided and no entry exists for the key.
100
+
101
+ Example:
102
+
103
+ ```python
104
+ def my_component():
105
+ s = ps.state("data", lambda: DataState())
106
+
107
+ # Without stable, this would create a new function each render
108
+ handle_click = ps.stable("click", lambda: s.increment())
109
+
110
+ return m.Button("Click", on_click=handle_click)
111
+ ```
112
+
113
+ Use Cases:
114
+ - Event handlers passed to child components to prevent unnecessary re-renders
115
+ - Callbacks registered with external systems
116
+ - Any function reference that needs to stay stable across renders
117
+ ) -> Any:
118
+ """
62
119
  if not key:
63
120
  raise ValueError("stable() requires a non-empty string key")
64
121
 
pulse/hooks/state.py CHANGED
@@ -1,6 +1,9 @@
1
+ import inspect
1
2
  from collections.abc import Callable
2
- from typing import TypeVar, override
3
+ from types import CodeType, FrameType
4
+ from typing import Any, TypeVar, override
3
5
 
6
+ from pulse.component import is_component_code
4
7
  from pulse.hooks.core import HookMetadata, HookState, hooks
5
8
  from pulse.state import State
6
9
 
@@ -8,42 +11,71 @@ S = TypeVar("S", bound=State)
8
11
 
9
12
 
10
13
  class StateHookState(HookState):
14
+ """Internal hook state for managing State instances across renders.
15
+
16
+ Stores State instances keyed by string identifier and tracks which keys
17
+ have been accessed during the current render cycle.
18
+ """
19
+
11
20
  __slots__ = ("instances", "called_keys") # pyright: ignore[reportUnannotatedClassAttribute]
12
- instances: dict[str, State]
13
- called_keys: set[str]
21
+ instances: dict[tuple[str, Any], State]
22
+ called_keys: set[tuple[str, Any]]
14
23
 
15
24
  def __init__(self) -> None:
16
25
  super().__init__()
17
26
  self.instances = {}
18
27
  self.called_keys = set()
19
28
 
29
+ def _make_key(self, identity: Any, key: str | None) -> tuple[str, Any]:
30
+ if key is None:
31
+ return ("code", identity)
32
+ return ("key", key)
33
+
20
34
  @override
21
35
  def on_render_start(self, render_cycle: int) -> None:
22
36
  super().on_render_start(render_cycle)
23
37
  self.called_keys.clear()
24
38
 
25
- def get_or_create_state(self, key: str, arg: State | Callable[[], State]) -> State:
26
- if key in self.called_keys:
39
+ def get_or_create_state(
40
+ self,
41
+ identity: Any,
42
+ key: str | None,
43
+ arg: State | Callable[[], State],
44
+ ) -> State:
45
+ full_identity = self._make_key(identity, key)
46
+ if full_identity in self.called_keys:
47
+ if key is None:
48
+ raise RuntimeError(
49
+ "`pulse.state` can only be called once per component render at the same location. "
50
+ + "Use the `key` parameter to disambiguate: ps.state(..., key=unique_value)"
51
+ )
27
52
  raise RuntimeError(
28
53
  f"`pulse.state` can only be called once per component render with key='{key}'"
29
54
  )
30
- self.called_keys.add(key)
55
+ self.called_keys.add(full_identity)
31
56
 
32
- existing = self.instances.get(key)
57
+ existing = self.instances.get(full_identity)
33
58
  if existing is not None:
34
59
  # Dispose any State instances passed directly as args that aren't being used
35
60
  if isinstance(arg, State) and arg is not existing:
36
- try:
37
- if not arg.__disposed__:
38
- arg.dispose()
39
- except RuntimeError:
40
- # Already disposed, ignore
41
- pass
61
+ arg.dispose()
62
+ if existing.__disposed__:
63
+ key_label = f"key='{key}'" if key is not None else "callsite"
64
+ raise RuntimeError(
65
+ "`pulse.state` found a disposed cached State for "
66
+ + key_label
67
+ + ". Do not dispose states returned by `pulse.state`."
68
+ )
42
69
  return existing
43
70
 
44
71
  # Create new state
45
72
  instance = _instantiate_state(arg)
46
- self.instances[key] = instance
73
+ if instance.__disposed__:
74
+ raise RuntimeError(
75
+ "`pulse.state` received a disposed State instance. "
76
+ + "Do not dispose states passed to `pulse.state`."
77
+ )
78
+ self.instances[full_identity] = instance
47
79
  return instance
48
80
 
49
81
  @override
@@ -71,6 +103,26 @@ def _state_factory():
71
103
  return StateHookState()
72
104
 
73
105
 
106
+ def _frame_offset(frame: FrameType) -> int:
107
+ offset = frame.f_lasti
108
+ if offset < 0:
109
+ offset = frame.f_lineno
110
+ return offset
111
+
112
+
113
+ def collect_component_identity(
114
+ frame: FrameType,
115
+ ) -> tuple[tuple[CodeType, int], ...]:
116
+ identity: list[tuple[CodeType, int]] = []
117
+ cursor: FrameType | None = frame
118
+ while cursor is not None:
119
+ identity.append((cursor.f_code, _frame_offset(cursor)))
120
+ if is_component_code(cursor.f_code):
121
+ return tuple(identity)
122
+ cursor = cursor.f_back
123
+ return tuple(identity[:1])
124
+
125
+
74
126
  _state_hook = hooks.create(
75
127
  "pulse:core.state",
76
128
  _state_factory,
@@ -81,25 +133,60 @@ _state_hook = hooks.create(
81
133
  )
82
134
 
83
135
 
84
- def state(key: str, arg: S | Callable[[], S]) -> S:
85
- """Get or create a state instance associated with the given key.
136
+ def state(
137
+ arg: S | Callable[[], S],
138
+ *,
139
+ key: str | None = None,
140
+ ) -> S:
141
+ """Get or create a state instance associated with a key or callsite.
86
142
 
87
143
  Args:
88
- key: A unique string key identifying this state within the component.
89
144
  arg: A State instance or a callable that returns a State instance.
145
+ key: Optional key to disambiguate multiple calls from the same location.
90
146
 
91
147
  Returns:
92
- The state instance (same instance on subsequent renders with the same key).
148
+ The same State instance on subsequent renders with the same key.
93
149
 
94
150
  Raises:
95
151
  ValueError: If key is empty.
96
152
  RuntimeError: If called more than once per render with the same key.
97
153
  TypeError: If arg is not a State or callable returning a State.
154
+
155
+ Example:
156
+
157
+ ```python
158
+ def counter():
159
+ s = ps.state("counter", lambda: CounterState())
160
+ return m.Button(f"Count: {s.count}", on_click=lambda: s.increment())
161
+ ```
162
+
163
+ Notes:
164
+ - Key must be non-empty string
165
+ - Can only be called once per render with the same key
166
+ - Factory is only called on first render; subsequent renders return cached instance
167
+ - State is disposed when component unmounts
98
168
  """
99
- if not key:
169
+ if key is not None and not isinstance(key, str):
170
+ raise TypeError("state() key must be a string")
171
+
172
+ if key == "":
100
173
  raise ValueError("state() requires a non-empty string key")
174
+
175
+ resolved_key = key
176
+ resolved_arg = arg
177
+
178
+ identity: Any
179
+ if resolved_key is None:
180
+ frame = inspect.currentframe()
181
+ assert frame is not None
182
+ caller = frame.f_back
183
+ assert caller is not None
184
+ identity = collect_component_identity(caller)
185
+ else:
186
+ identity = resolved_key
187
+
101
188
  hook_state = _state_hook()
102
- return hook_state.get_or_create_state(key, arg) # pyright: ignore[reportReturnType]
189
+ return hook_state.get_or_create_state(identity, resolved_key, resolved_arg) # pyright: ignore[reportReturnType]
103
190
 
104
191
 
105
192
  __all__ = ["state", "StateHookState"]