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/hooks/effects.py
CHANGED
|
@@ -1,104 +1,88 @@
|
|
|
1
1
|
from collections.abc import Callable
|
|
2
|
-
from typing import
|
|
2
|
+
from typing import Any, override
|
|
3
3
|
|
|
4
4
|
from pulse.hooks.core import HookMetadata, HookState, hooks
|
|
5
|
-
from pulse.reactive import
|
|
5
|
+
from pulse.reactive import AsyncEffect, Effect
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
class
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
class InlineEffectHookState(HookState):
|
|
9
|
+
"""Stores inline effects keyed by function identity or explicit key."""
|
|
10
|
+
|
|
11
|
+
__slots__ = ("effects", "_seen_this_render") # pyright: ignore[reportUnannotatedClassAttribute]
|
|
12
12
|
|
|
13
13
|
def __init__(self) -> None:
|
|
14
14
|
super().__init__()
|
|
15
|
-
self.
|
|
16
|
-
self.
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
self.effects: dict[tuple[str, Any], Effect | AsyncEffect] = {}
|
|
16
|
+
self._seen_this_render: set[tuple[str, Any]] = set()
|
|
17
|
+
|
|
18
|
+
def _make_key(self, identity: Any, key: str | None) -> tuple[str, Any]:
|
|
19
|
+
if key is None:
|
|
20
|
+
return ("code", identity)
|
|
21
|
+
return ("key", key)
|
|
19
22
|
|
|
20
23
|
@override
|
|
21
24
|
def on_render_start(self, render_cycle: int) -> None:
|
|
22
25
|
super().on_render_start(render_cycle)
|
|
23
|
-
self.
|
|
24
|
-
|
|
25
|
-
def replace(self, effects: list[Effect], key: str | None) -> None:
|
|
26
|
-
self.dispose_effects()
|
|
27
|
-
self.effects = tuple(effects)
|
|
28
|
-
self.key = key
|
|
29
|
-
self.initialized = True
|
|
30
|
-
|
|
31
|
-
def dispose_effects(self) -> None:
|
|
32
|
-
for effect in self.effects:
|
|
33
|
-
effect.dispose()
|
|
34
|
-
self.effects = ()
|
|
35
|
-
self.initialized = False
|
|
36
|
-
self.key = None
|
|
26
|
+
self._seen_this_render.clear()
|
|
37
27
|
|
|
38
28
|
@override
|
|
39
|
-
def
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
29
|
+
def on_render_end(self, render_cycle: int) -> None:
|
|
30
|
+
super().on_render_end(render_cycle)
|
|
31
|
+
# Dispose effects that weren't seen this render (e.g., inside conditionals that became false)
|
|
32
|
+
for key in list(self.effects.keys()):
|
|
33
|
+
if key not in self._seen_this_render:
|
|
34
|
+
self.effects[key].dispose()
|
|
35
|
+
del self.effects[key]
|
|
36
|
+
|
|
37
|
+
def get_or_create(
|
|
38
|
+
self,
|
|
39
|
+
identity: Any,
|
|
40
|
+
key: str | None,
|
|
41
|
+
factory: Callable[[], Effect | AsyncEffect],
|
|
42
|
+
) -> Effect | AsyncEffect:
|
|
43
|
+
"""Return cached effect or create a new one."""
|
|
44
|
+
# Effects with explicit keys fully bypass identity matching.
|
|
45
|
+
full_identity = self._make_key(identity, key)
|
|
46
|
+
|
|
47
|
+
if full_identity in self._seen_this_render:
|
|
48
|
+
if key is None:
|
|
49
|
+
raise RuntimeError(
|
|
50
|
+
"@ps.effect decorator called multiple times at the same location during a single render. "
|
|
51
|
+
+ "This usually happens when using @ps.effect inside a loop. "
|
|
52
|
+
+ "Use the `key` parameter to disambiguate: @ps.effect(key=unique_value)"
|
|
53
|
+
)
|
|
44
54
|
raise RuntimeError(
|
|
45
|
-
"
|
|
55
|
+
f"@ps.effect decorator called multiple times with the same key='{key}' during a single render."
|
|
46
56
|
)
|
|
57
|
+
self._seen_this_render.add(full_identity)
|
|
47
58
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def _build_effects(
|
|
53
|
-
fns: tuple[EffectFn, ...],
|
|
54
|
-
on_error: Callable[[Exception], None] | None,
|
|
55
|
-
) -> list[Effect]:
|
|
56
|
-
effects: list[Effect] = []
|
|
57
|
-
with Untrack():
|
|
58
|
-
for fn in fns:
|
|
59
|
-
if not callable(fn):
|
|
60
|
-
raise ValueError(
|
|
61
|
-
"Only pass functions or callable objects to `ps.effects`"
|
|
62
|
-
)
|
|
63
|
-
effects.append(
|
|
64
|
-
Effect(fn, name=getattr(fn, "__name__", "effect"), on_error=on_error)
|
|
65
|
-
)
|
|
66
|
-
return effects
|
|
59
|
+
existing = self.effects.get(full_identity)
|
|
60
|
+
if existing is not None:
|
|
61
|
+
return existing
|
|
67
62
|
|
|
63
|
+
effect = factory()
|
|
64
|
+
self.effects[full_identity] = effect
|
|
65
|
+
return effect
|
|
68
66
|
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
@override
|
|
68
|
+
def dispose(self) -> None:
|
|
69
|
+
for eff in self.effects.values():
|
|
70
|
+
eff.dispose()
|
|
71
|
+
self.effects.clear()
|
|
72
|
+
self._seen_this_render.clear()
|
|
71
73
|
|
|
72
74
|
|
|
73
|
-
|
|
74
|
-
"pulse:core.
|
|
75
|
-
|
|
75
|
+
inline_effect_hook = hooks.create(
|
|
76
|
+
"pulse:core.inline_effects",
|
|
77
|
+
lambda: InlineEffectHookState(),
|
|
76
78
|
metadata=HookMetadata(
|
|
77
79
|
owner="pulse.core",
|
|
78
|
-
description="
|
|
80
|
+
description="Storage for inline @ps.effect decorators in components",
|
|
79
81
|
),
|
|
80
82
|
)
|
|
81
83
|
|
|
82
84
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
) -> None:
|
|
88
|
-
state = cast(EffectsHookState, _effects_hook())
|
|
89
|
-
state.ensure_not_called()
|
|
90
|
-
|
|
91
|
-
if not state.initialized:
|
|
92
|
-
state.replace(_build_effects(fns, on_error), key)
|
|
93
|
-
state.mark_called()
|
|
94
|
-
return
|
|
95
|
-
|
|
96
|
-
if key is not None and key != state.key:
|
|
97
|
-
state.replace(_build_effects(fns, on_error), key)
|
|
98
|
-
state.mark_called()
|
|
99
|
-
return
|
|
100
|
-
|
|
101
|
-
state.mark_called()
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
__all__ = ["effects", "EffectsHookState"]
|
|
85
|
+
__all__ = [
|
|
86
|
+
"InlineEffectHookState",
|
|
87
|
+
"inline_effect_hook",
|
|
88
|
+
]
|
pulse/hooks/init.py
CHANGED
|
@@ -41,7 +41,33 @@ def previous_frame() -> types.FrameType:
|
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
class InitContext:
|
|
44
|
-
"""Context
|
|
44
|
+
"""Context manager for one-time initialization in components.
|
|
45
|
+
|
|
46
|
+
Variables assigned inside the block persist across re-renders. On first render,
|
|
47
|
+
the code inside runs normally and variables are captured. On subsequent renders,
|
|
48
|
+
the block is skipped and variables are restored from storage.
|
|
49
|
+
|
|
50
|
+
This class is returned by ``ps.init()`` and should be used as a context manager.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
callsite: Tuple of (code object, line number) identifying the call site.
|
|
54
|
+
frame: The stack frame where init was called.
|
|
55
|
+
first_render: True if this is the first render cycle.
|
|
56
|
+
pre_keys: Set of variable names that existed before entering the block.
|
|
57
|
+
saved: Dictionary of captured variable values.
|
|
58
|
+
|
|
59
|
+
Example:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
def my_component():
|
|
63
|
+
with ps.init():
|
|
64
|
+
counter = 0
|
|
65
|
+
api = ApiClient()
|
|
66
|
+
data = fetch_initial_data()
|
|
67
|
+
# counter, api, data retain their values across renders
|
|
68
|
+
return m.Text(f"Counter: {counter}")
|
|
69
|
+
```
|
|
70
|
+
"""
|
|
45
71
|
|
|
46
72
|
callsite: tuple[Any, int] | None
|
|
47
73
|
frame: types.FrameType | None
|
|
@@ -113,6 +139,39 @@ class InitContext:
|
|
|
113
139
|
|
|
114
140
|
|
|
115
141
|
def init() -> InitContext:
|
|
142
|
+
"""Context manager for one-time initialization in components.
|
|
143
|
+
|
|
144
|
+
Variables assigned inside the block persist across re-renders. Uses AST
|
|
145
|
+
rewriting to transform the code at decoration time.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
InitContext: Context manager that captures and restores variables.
|
|
149
|
+
|
|
150
|
+
Example:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
def my_component():
|
|
154
|
+
with ps.init():
|
|
155
|
+
counter = 0
|
|
156
|
+
api = ApiClient()
|
|
157
|
+
data = fetch_initial_data()
|
|
158
|
+
# counter, api, data retain their values across renders
|
|
159
|
+
return m.Text(f"Counter: {counter}")
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Rules:
|
|
163
|
+
- Can only be used once per component
|
|
164
|
+
- Must be at the top level of the component function (not inside
|
|
165
|
+
conditionals, loops, or nested functions)
|
|
166
|
+
- Cannot contain control flow (if, for, while, try, with, match)
|
|
167
|
+
- Cannot use ``as`` binding (``with ps.init() as ctx:`` not allowed)
|
|
168
|
+
- Variables are restored from first render on subsequent renders
|
|
169
|
+
|
|
170
|
+
Notes:
|
|
171
|
+
If you encounter issues with ``ps.init()`` (e.g., source code not
|
|
172
|
+
available in some deployment environments), use ``ps.setup()`` instead.
|
|
173
|
+
It provides the same functionality without AST rewriting.
|
|
174
|
+
"""
|
|
116
175
|
return InitContext()
|
|
117
176
|
|
|
118
177
|
|
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()
|