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/components/if_.py
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
"""Conditional rendering component.
|
|
2
|
+
|
|
3
|
+
Provides a declarative way to conditionally render elements based on a condition.
|
|
4
|
+
"""
|
|
5
|
+
|
|
1
6
|
from collections.abc import Iterable
|
|
2
7
|
from typing import Any, TypeVar
|
|
3
8
|
|
|
@@ -37,15 +42,33 @@ def If(
|
|
|
37
42
|
then: T1,
|
|
38
43
|
else_: T2 = None,
|
|
39
44
|
) -> T1 | T2:
|
|
40
|
-
"""Conditional rendering helper
|
|
45
|
+
"""Conditional rendering helper.
|
|
46
|
+
|
|
47
|
+
Returns `then` if the condition is truthy, otherwise returns `else_`.
|
|
48
|
+
Automatically unwraps reactive values (Signal, Computed) before evaluation.
|
|
41
49
|
|
|
42
50
|
Args:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
51
|
+
condition: A boolean or reactive value to evaluate. Supports `Signal[bool]`
|
|
52
|
+
and `Computed[bool]` which are automatically unwrapped.
|
|
53
|
+
then: Element to render when condition is truthy.
|
|
54
|
+
else_: Element to render when condition is falsy. Defaults to None.
|
|
46
55
|
|
|
47
56
|
Returns:
|
|
48
|
-
|
|
57
|
+
The `then` value if condition is truthy, otherwise `else_`.
|
|
58
|
+
|
|
59
|
+
Example:
|
|
60
|
+
Basic conditional::
|
|
61
|
+
|
|
62
|
+
ps.If(
|
|
63
|
+
user.is_admin,
|
|
64
|
+
then=AdminPanel(),
|
|
65
|
+
else_=ps.p("Access denied"),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
With reactive condition::
|
|
69
|
+
|
|
70
|
+
is_visible = ps.Signal(True)
|
|
71
|
+
ps.If(is_visible, then=ps.div("Content"))
|
|
49
72
|
"""
|
|
50
73
|
# Unwrap reactive condition if needed and coerce to bool explicitly with guards
|
|
51
74
|
if isinstance(condition, (Signal, Computed)):
|
pulse/components/react_router.py
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
|
+
"""React Router components for client-side navigation.
|
|
2
|
+
|
|
3
|
+
Provides Pulse bindings for react-router's Link and Outlet components.
|
|
4
|
+
"""
|
|
5
|
+
|
|
1
6
|
from typing import Literal, TypedDict, Unpack
|
|
2
7
|
|
|
3
8
|
from pulse.dom.props import HTMLAnchorProps
|
|
9
|
+
from pulse.react_component import react_component
|
|
4
10
|
from pulse.transpiler import Import
|
|
5
11
|
from pulse.transpiler.nodes import Node
|
|
6
|
-
from pulse.transpiler.react_component import react_component
|
|
7
12
|
|
|
8
13
|
|
|
9
14
|
class LinkPath(TypedDict):
|
|
15
|
+
"""TypedDict for Link's `to` prop when using an object instead of string."""
|
|
16
|
+
|
|
10
17
|
pathname: str
|
|
11
18
|
search: str
|
|
12
19
|
hash: str
|
|
@@ -27,12 +34,63 @@ def Link(
|
|
|
27
34
|
state: dict[str, object] | None = None,
|
|
28
35
|
viewTransition: bool | None = None,
|
|
29
36
|
**props: Unpack[HTMLAnchorProps],
|
|
30
|
-
):
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Client-side navigation link using react-router.
|
|
39
|
+
|
|
40
|
+
Renders an anchor tag that performs client-side navigation without a full
|
|
41
|
+
page reload. Supports prefetching and various navigation behaviors.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
*children: Content to render inside the link.
|
|
45
|
+
key: React reconciliation key.
|
|
46
|
+
to: The target URL path (e.g., "/dashboard", "/users/123").
|
|
47
|
+
discover: Route discovery behavior. "render" discovers on render,
|
|
48
|
+
"none" disables discovery.
|
|
49
|
+
prefetch: Prefetch strategy. "intent" (default) prefetches on hover/focus,
|
|
50
|
+
"render" prefetches immediately, "viewport" when visible, "none" disables.
|
|
51
|
+
preventScrollReset: If True, prevents scroll position reset on navigation.
|
|
52
|
+
relative: Path resolution mode. "route" resolves relative to route hierarchy,
|
|
53
|
+
"path" resolves relative to URL path.
|
|
54
|
+
reloadDocument: If True, performs a full page navigation instead of SPA.
|
|
55
|
+
replace: If True, replaces current history entry instead of pushing.
|
|
56
|
+
state: Arbitrary state to pass to the destination location.
|
|
57
|
+
viewTransition: If True, enables View Transitions API for the navigation.
|
|
58
|
+
**props: Additional HTML anchor attributes (className, onClick, etc.).
|
|
59
|
+
|
|
60
|
+
Example:
|
|
61
|
+
Basic navigation::
|
|
62
|
+
|
|
63
|
+
ps.Link(to="/dashboard")["Go to Dashboard"]
|
|
64
|
+
|
|
65
|
+
With prefetching disabled::
|
|
66
|
+
|
|
67
|
+
ps.Link(to="/settings", prefetch="none")["Settings"]
|
|
68
|
+
"""
|
|
69
|
+
...
|
|
31
70
|
|
|
32
71
|
|
|
33
72
|
# @react_component(Import("Outlet", "react-router", version="^7"))
|
|
34
73
|
@react_component(Import("Outlet", "react-router"))
|
|
35
|
-
def Outlet(key: str | None = None):
|
|
74
|
+
def Outlet(key: str | None = None) -> None:
|
|
75
|
+
"""Renders the matched child route's element.
|
|
76
|
+
|
|
77
|
+
Outlet is used in parent route components to render their child routes.
|
|
78
|
+
It acts as a placeholder where nested route content will be displayed.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
key: React reconciliation key.
|
|
82
|
+
|
|
83
|
+
Example:
|
|
84
|
+
Layout with outlet for child routes::
|
|
85
|
+
|
|
86
|
+
@ps.component
|
|
87
|
+
def Layout():
|
|
88
|
+
return ps.div(
|
|
89
|
+
ps.nav("Navigation"),
|
|
90
|
+
ps.Outlet(), # Child route renders here
|
|
91
|
+
)
|
|
92
|
+
"""
|
|
93
|
+
...
|
|
36
94
|
|
|
37
95
|
|
|
38
96
|
__all__ = ["Link", "Outlet"]
|
pulse/context.py
CHANGED
|
@@ -16,9 +16,23 @@ if TYPE_CHECKING:
|
|
|
16
16
|
class PulseContext:
|
|
17
17
|
"""Composite context accessible to hooks and internals.
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
Manages per-request state via context variables. Provides access to the
|
|
20
|
+
application instance, user session, render session, and route context.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
app: Application instance.
|
|
24
|
+
session: Per-user session (UserSession or None).
|
|
25
|
+
render: Per-connection render session (RenderSession or None).
|
|
26
|
+
route: Active route context (RouteContext or None).
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
```python
|
|
30
|
+
ctx = PulseContext(app=app, session=session)
|
|
31
|
+
with ctx:
|
|
32
|
+
# Context is active here
|
|
33
|
+
current = PulseContext.get()
|
|
34
|
+
# Previous context restored
|
|
35
|
+
```
|
|
22
36
|
"""
|
|
23
37
|
|
|
24
38
|
app: "App"
|
|
@@ -28,7 +42,15 @@ class PulseContext:
|
|
|
28
42
|
_token: "Token[PulseContext | None] | None" = None
|
|
29
43
|
|
|
30
44
|
@classmethod
|
|
31
|
-
def get(cls):
|
|
45
|
+
def get(cls) -> "PulseContext":
|
|
46
|
+
"""Get the current context.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Current PulseContext instance.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
RuntimeError: If no context is active.
|
|
53
|
+
"""
|
|
32
54
|
ctx = PULSE_CONTEXT.get()
|
|
33
55
|
if ctx is None:
|
|
34
56
|
raise RuntimeError("Internal error: PULSE_CONTEXT is not set")
|
|
@@ -40,7 +62,19 @@ class PulseContext:
|
|
|
40
62
|
session: "UserSession | None" = None,
|
|
41
63
|
render: "RenderSession | None" = None,
|
|
42
64
|
route: "RouteContext | None" = None,
|
|
43
|
-
):
|
|
65
|
+
) -> "PulseContext":
|
|
66
|
+
"""Create a new context with updated values.
|
|
67
|
+
|
|
68
|
+
Inherits unspecified values from the current context.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
session: New session (optional, inherits if not provided).
|
|
72
|
+
render: New render session (optional, inherits if not provided).
|
|
73
|
+
route: New route context (optional, inherits if not provided).
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
New PulseContext instance with updated values.
|
|
77
|
+
"""
|
|
44
78
|
ctx = cls.get()
|
|
45
79
|
return PulseContext(
|
|
46
80
|
app=ctx.app,
|
pulse/cookies.py
CHANGED
|
@@ -14,6 +14,26 @@ if TYPE_CHECKING:
|
|
|
14
14
|
|
|
15
15
|
@dataclass
|
|
16
16
|
class Cookie:
|
|
17
|
+
"""Configuration for HTTP cookies used in session management.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
name: Cookie name.
|
|
21
|
+
domain: Cookie domain. Set automatically in subdomain mode.
|
|
22
|
+
secure: HTTPS-only flag. Auto-resolved from server address if None.
|
|
23
|
+
samesite: SameSite attribute ("lax", "strict", or "none").
|
|
24
|
+
max_age_seconds: Cookie lifetime in seconds (default 7 days).
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
```python
|
|
28
|
+
cookie = Cookie(
|
|
29
|
+
name="session",
|
|
30
|
+
secure=True,
|
|
31
|
+
samesite="strict",
|
|
32
|
+
max_age_seconds=3600,
|
|
33
|
+
)
|
|
34
|
+
```
|
|
35
|
+
"""
|
|
36
|
+
|
|
17
37
|
name: str
|
|
18
38
|
_: KW_ONLY
|
|
19
39
|
domain: str | None = None
|
|
@@ -22,18 +42,45 @@ class Cookie:
|
|
|
22
42
|
max_age_seconds: int = 7 * 24 * 3600
|
|
23
43
|
|
|
24
44
|
def get_from_fastapi(self, request: Request) -> str | None:
|
|
25
|
-
"""Extract
|
|
45
|
+
"""Extract cookie value from a FastAPI Request.
|
|
46
|
+
|
|
47
|
+
Reads the Cookie header and parses it to find this cookie's value.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
request: FastAPI/Starlette Request object.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Cookie value if found, None otherwise.
|
|
54
|
+
"""
|
|
26
55
|
header = request.headers.get("cookie")
|
|
27
56
|
cookies = parse_cookie_header(header)
|
|
28
57
|
return cookies.get(self.name)
|
|
29
58
|
|
|
30
59
|
def get_from_socketio(self, environ: dict[str, Any]) -> str | None:
|
|
31
|
-
"""Extract
|
|
60
|
+
"""Extract cookie value from a Socket.IO environ mapping.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
environ: Socket.IO environ dictionary.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Cookie value if found, None otherwise.
|
|
67
|
+
"""
|
|
32
68
|
raw = environ.get("HTTP_COOKIE") or environ.get("COOKIE")
|
|
33
69
|
cookies = parse_cookie_header(raw)
|
|
34
70
|
return cookies.get(self.name)
|
|
35
71
|
|
|
36
|
-
async def set_through_api(self, value: str):
|
|
72
|
+
async def set_through_api(self, value: str) -> None:
|
|
73
|
+
"""Set the cookie on the client via WebSocket.
|
|
74
|
+
|
|
75
|
+
Must be called during a callback context.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
value: Cookie value to set.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
RuntimeError: If Cookie.secure is not resolved (ensure App.setup()
|
|
82
|
+
ran first).
|
|
83
|
+
"""
|
|
37
84
|
if self.secure is None:
|
|
38
85
|
raise RuntimeError(
|
|
39
86
|
"Cookie.secure is not resolved. Ensure App.setup() ran or set Cookie(secure=True/False)."
|
|
@@ -48,7 +95,17 @@ class Cookie:
|
|
|
48
95
|
)
|
|
49
96
|
|
|
50
97
|
def set_on_fastapi(self, response: Response, value: str) -> None:
|
|
51
|
-
"""Set the
|
|
98
|
+
"""Set the cookie on a FastAPI Response object.
|
|
99
|
+
|
|
100
|
+
Configured with httponly=True and path="/".
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
response: FastAPI Response object.
|
|
104
|
+
value: Cookie value to set.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
RuntimeError: If Cookie.secure is not resolved.
|
|
108
|
+
"""
|
|
52
109
|
if self.secure is None:
|
|
53
110
|
raise RuntimeError(
|
|
54
111
|
"Cookie.secure is not resolved. Ensure App.setup() ran or set Cookie(secure=True/False)."
|
|
@@ -67,10 +124,31 @@ class Cookie:
|
|
|
67
124
|
|
|
68
125
|
@dataclass
|
|
69
126
|
class SetCookie(Cookie):
|
|
127
|
+
"""Extended Cookie dataclass that includes the cookie value.
|
|
128
|
+
|
|
129
|
+
Used for setting cookies with a specific value. Inherits all configuration
|
|
130
|
+
from Cookie.
|
|
131
|
+
|
|
132
|
+
Attributes:
|
|
133
|
+
value: The cookie value to set.
|
|
134
|
+
"""
|
|
135
|
+
|
|
70
136
|
value: str
|
|
71
137
|
|
|
72
138
|
@classmethod
|
|
73
139
|
def from_cookie(cls, cookie: Cookie, value: str) -> "SetCookie":
|
|
140
|
+
"""Create a SetCookie from an existing Cookie configuration.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
cookie: Cookie configuration to copy settings from.
|
|
144
|
+
value: Cookie value to set.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
SetCookie instance with the same configuration and specified value.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
RuntimeError: If cookie.secure is not resolved.
|
|
151
|
+
"""
|
|
74
152
|
if cookie.secure is None:
|
|
75
153
|
raise RuntimeError(
|
|
76
154
|
"Cookie.secure is not resolved. Ensure App.setup() ran or set Cookie(secure=True/False)."
|
|
@@ -111,6 +189,18 @@ def session_cookie(
|
|
|
111
189
|
|
|
112
190
|
|
|
113
191
|
class CORSOptions(TypedDict, total=False):
|
|
192
|
+
"""TypedDict for CORS middleware configuration.
|
|
193
|
+
|
|
194
|
+
Attributes:
|
|
195
|
+
allow_origins: List of allowed origins. Use ['*'] for all. Default: ().
|
|
196
|
+
allow_methods: List of allowed HTTP methods. Default: ('GET',).
|
|
197
|
+
allow_headers: List of allowed HTTP headers. Default: ().
|
|
198
|
+
allow_credentials: Whether to allow credentials. Default: False.
|
|
199
|
+
allow_origin_regex: Regex pattern for allowed origins. Default: None.
|
|
200
|
+
expose_headers: List of headers to expose to browser. Default: ().
|
|
201
|
+
max_age: Browser CORS cache duration in seconds. Default: 600.
|
|
202
|
+
"""
|
|
203
|
+
|
|
114
204
|
allow_origins: Sequence[str]
|
|
115
205
|
"List of allowed origins. Use ['*'] to allow all origins. Default: ()"
|
|
116
206
|
|
|
@@ -207,6 +297,20 @@ def cors_options(mode: "PulseMode", server_address: str) -> CORSOptions:
|
|
|
207
297
|
|
|
208
298
|
|
|
209
299
|
def parse_cookie_header(header: str | None) -> dict[str, str]:
|
|
300
|
+
"""Parse a raw Cookie header string into a dictionary.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
header: Raw Cookie header string (e.g., "session=abc123; theme=dark").
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
Dictionary of cookie name-value pairs.
|
|
307
|
+
|
|
308
|
+
Example:
|
|
309
|
+
```python
|
|
310
|
+
cookies = parse_cookie_header("session=abc123; theme=dark")
|
|
311
|
+
# {"session": "abc123", "theme": "dark"}
|
|
312
|
+
```
|
|
313
|
+
"""
|
|
210
314
|
cookies: dict[str, str] = {}
|
|
211
315
|
if not header:
|
|
212
316
|
return cookies
|
pulse/decorators.py
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import inspect
|
|
4
4
|
from collections.abc import Awaitable, Callable
|
|
5
|
-
from typing import Any, ParamSpec, Protocol, TypeVar, overload
|
|
5
|
+
from typing import Any, ParamSpec, Protocol, TypeVar, cast, overload
|
|
6
6
|
|
|
7
|
+
from pulse.hooks.core import HOOK_CONTEXT
|
|
8
|
+
from pulse.hooks.effects import inline_effect_hook
|
|
9
|
+
from pulse.hooks.state import collect_component_identity
|
|
7
10
|
from pulse.reactive import (
|
|
8
11
|
AsyncEffect,
|
|
9
12
|
AsyncEffectFn,
|
|
@@ -20,10 +23,6 @@ TState = TypeVar("TState", bound=State)
|
|
|
20
23
|
P = ParamSpec("P")
|
|
21
24
|
|
|
22
25
|
|
|
23
|
-
# -> @ps.computed The chalenge is:
|
|
24
|
-
# - We want to turn regular functions with no arguments into a Computed object
|
|
25
|
-
# - We want to turn state methods into a ComputedProperty (which wraps a
|
|
26
|
-
# Computed, but gives it access to the State object).
|
|
27
26
|
@overload
|
|
28
27
|
def computed(fn: Callable[[], T], *, name: str | None = None) -> Computed[T]: ...
|
|
29
28
|
@overload
|
|
@@ -36,7 +35,61 @@ def computed(
|
|
|
36
35
|
) -> Callable[[Callable[[], T]], Computed[T]]: ...
|
|
37
36
|
|
|
38
37
|
|
|
39
|
-
def computed(
|
|
38
|
+
def computed(
|
|
39
|
+
fn: Callable[..., Any] | None = None,
|
|
40
|
+
*,
|
|
41
|
+
name: str | None = None,
|
|
42
|
+
) -> (
|
|
43
|
+
Computed[T]
|
|
44
|
+
| ComputedProperty[T]
|
|
45
|
+
| Callable[[Callable[..., Any]], Computed[T] | ComputedProperty[T]]
|
|
46
|
+
):
|
|
47
|
+
"""
|
|
48
|
+
Decorator for computed (derived) properties.
|
|
49
|
+
|
|
50
|
+
Creates a cached, reactive value that automatically recalculates when its
|
|
51
|
+
dependencies change. The computed tracks which Signals/Computeds are read
|
|
52
|
+
during execution and subscribes to them.
|
|
53
|
+
|
|
54
|
+
Can be used in two ways:
|
|
55
|
+
1. On a State method (with single `self` argument) - creates a ComputedProperty
|
|
56
|
+
2. As a standalone function (with no arguments) - creates a Computed
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
fn: The function to compute the value. Must take no arguments (standalone) or only `self` (State method).
|
|
60
|
+
name: Optional debug name for the computed. Defaults to the function name.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Computed wrapper or decorator depending on usage.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
TypeError: If the function takes arguments other than `self`.
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
On a State method:
|
|
70
|
+
|
|
71
|
+
class MyState(ps.State):
|
|
72
|
+
count: int = 0
|
|
73
|
+
|
|
74
|
+
@ps.computed
|
|
75
|
+
def doubled(self):
|
|
76
|
+
return self.count * 2
|
|
77
|
+
|
|
78
|
+
As a standalone computed:
|
|
79
|
+
|
|
80
|
+
signal = Signal(5)
|
|
81
|
+
|
|
82
|
+
@ps.computed
|
|
83
|
+
def doubled():
|
|
84
|
+
return signal() * 2
|
|
85
|
+
|
|
86
|
+
With explicit name:
|
|
87
|
+
|
|
88
|
+
@ps.computed(name="my_computed")
|
|
89
|
+
def doubled(self):
|
|
90
|
+
return self.count * 2
|
|
91
|
+
"""
|
|
92
|
+
|
|
40
93
|
# The type checker is not happy if I don't specify the `/` here.
|
|
41
94
|
def decorator(fn: Callable[..., Any], /):
|
|
42
95
|
sig = inspect.signature(fn)
|
|
@@ -81,7 +134,9 @@ def effect(
|
|
|
81
134
|
lazy: bool = False,
|
|
82
135
|
on_error: Callable[[Exception], None] | None = None,
|
|
83
136
|
deps: list[Signal[Any] | Computed[Any]] | None = None,
|
|
137
|
+
update_deps: bool | None = None,
|
|
84
138
|
interval: float | None = None,
|
|
139
|
+
key: str | None = None,
|
|
85
140
|
) -> Effect: ...
|
|
86
141
|
|
|
87
142
|
|
|
@@ -94,7 +149,9 @@ def effect(
|
|
|
94
149
|
lazy: bool = False,
|
|
95
150
|
on_error: Callable[[Exception], None] | None = None,
|
|
96
151
|
deps: list[Signal[Any] | Computed[Any]] | None = None,
|
|
152
|
+
update_deps: bool | None = None,
|
|
97
153
|
interval: float | None = None,
|
|
154
|
+
key: str | None = None,
|
|
98
155
|
) -> AsyncEffect: ...
|
|
99
156
|
# In practice this overload returns a StateEffect, but it gets converted into an
|
|
100
157
|
# Effect at state instantiation.
|
|
@@ -111,7 +168,9 @@ def effect(
|
|
|
111
168
|
lazy: bool = False,
|
|
112
169
|
on_error: Callable[[Exception], None] | None = None,
|
|
113
170
|
deps: list[Signal[Any] | Computed[Any]] | None = None,
|
|
171
|
+
update_deps: bool | None = None,
|
|
114
172
|
interval: float | None = None,
|
|
173
|
+
key: str | None = None,
|
|
115
174
|
) -> EffectBuilder: ...
|
|
116
175
|
|
|
117
176
|
|
|
@@ -123,17 +182,86 @@ def effect(
|
|
|
123
182
|
lazy: bool = False,
|
|
124
183
|
on_error: Callable[[Exception], None] | None = None,
|
|
125
184
|
deps: list[Signal[Any] | Computed[Any]] | None = None,
|
|
185
|
+
update_deps: bool | None = None,
|
|
126
186
|
interval: float | None = None,
|
|
187
|
+
key: str | None = None,
|
|
127
188
|
):
|
|
128
|
-
|
|
189
|
+
"""
|
|
190
|
+
Decorator for side effects that run when dependencies change.
|
|
191
|
+
|
|
192
|
+
Creates an effect that automatically re-runs when any of its tracked
|
|
193
|
+
dependencies change. Dependencies are automatically tracked by observing
|
|
194
|
+
which Signals/Computeds are read during execution.
|
|
195
|
+
|
|
196
|
+
Can be used in two ways:
|
|
197
|
+
1. On a State method (with single `self` argument) - creates a StateEffect
|
|
198
|
+
2. As a standalone function (with no arguments) - creates an Effect
|
|
199
|
+
|
|
200
|
+
Supports both sync and async functions. Async effects cannot use `immediate=True`.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
fn: The effect function. Must take no arguments (standalone) or only
|
|
204
|
+
`self` (State method). Can return a cleanup function.
|
|
205
|
+
name: Optional debug name. Defaults to "ClassName.method_name" or function name.
|
|
206
|
+
immediate: If True, run synchronously when scheduled instead of batching.
|
|
207
|
+
Only valid for sync effects.
|
|
208
|
+
lazy: If True, don't run on creation; wait for first dependency change.
|
|
209
|
+
on_error: Callback invoked if the effect throws an exception.
|
|
210
|
+
deps: Explicit list of dependencies. If provided, auto-tracking is disabled
|
|
211
|
+
and the effect only re-runs when these specific dependencies change.
|
|
212
|
+
interval: Re-run interval in seconds. Creates a polling effect that runs
|
|
213
|
+
periodically regardless of dependency changes.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Effect, AsyncEffect, or StateEffect depending on usage.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
TypeError: If the function takes arguments other than `self`.
|
|
220
|
+
ValueError: If `immediate=True` is used with an async function.
|
|
221
|
+
|
|
222
|
+
Example:
|
|
223
|
+
State method effect:
|
|
224
|
+
|
|
225
|
+
class MyState(ps.State):
|
|
226
|
+
count: int = 0
|
|
227
|
+
|
|
228
|
+
@ps.effect
|
|
229
|
+
def log_changes(self):
|
|
230
|
+
print(f"Count is {self.count}")
|
|
231
|
+
|
|
232
|
+
Async effect:
|
|
233
|
+
|
|
234
|
+
class MyState(ps.State):
|
|
235
|
+
query: str = ""
|
|
236
|
+
|
|
237
|
+
@ps.effect
|
|
238
|
+
async def fetch_data(self):
|
|
239
|
+
data = await api.fetch(self.query)
|
|
240
|
+
self.data = data
|
|
241
|
+
|
|
242
|
+
Effect with cleanup:
|
|
243
|
+
|
|
244
|
+
@ps.effect
|
|
245
|
+
def subscribe(self):
|
|
246
|
+
unsub = event_bus.subscribe(self.handle)
|
|
247
|
+
return unsub # Called before next run or on dispose
|
|
248
|
+
|
|
249
|
+
Polling effect:
|
|
250
|
+
|
|
251
|
+
@ps.effect(interval=5.0)
|
|
252
|
+
async def poll_status(self):
|
|
253
|
+
self.status = await api.get_status()
|
|
254
|
+
"""
|
|
255
|
+
|
|
129
256
|
def decorator(func: Callable[..., Any], /):
|
|
130
257
|
sig = inspect.signature(func)
|
|
131
258
|
params = list(sig.parameters.values())
|
|
132
259
|
|
|
133
|
-
# Disallow
|
|
260
|
+
# Disallow immediate + async
|
|
134
261
|
if immediate and inspect.iscoroutinefunction(func):
|
|
135
262
|
raise ValueError("Async effects cannot have immediate=True")
|
|
136
263
|
|
|
264
|
+
# State method - unchanged behavior
|
|
137
265
|
if len(params) == 1 and params[0].name == "self":
|
|
138
266
|
return StateEffect(
|
|
139
267
|
func,
|
|
@@ -142,34 +270,75 @@ def effect(
|
|
|
142
270
|
lazy=lazy,
|
|
143
271
|
on_error=on_error,
|
|
144
272
|
deps=deps,
|
|
273
|
+
update_deps=update_deps,
|
|
145
274
|
interval=interval,
|
|
146
275
|
)
|
|
147
276
|
|
|
148
|
-
|
|
277
|
+
# Allow params with defaults (used for variable binding in loops)
|
|
278
|
+
# Reject only if there are required params (no default)
|
|
279
|
+
required_params = [p for p in params if p.default is inspect.Parameter.empty]
|
|
280
|
+
if required_params:
|
|
149
281
|
raise TypeError(
|
|
150
|
-
f"@effect: Function '{func.__name__}' must take no arguments
|
|
282
|
+
f"@effect: Function '{func.__name__}' must take no arguments, a single 'self' argument, "
|
|
283
|
+
+ "or only arguments with defaults (for variable binding)"
|
|
151
284
|
)
|
|
152
285
|
|
|
153
|
-
#
|
|
154
|
-
|
|
155
|
-
|
|
286
|
+
# Check if we're in a hook context (component render)
|
|
287
|
+
ctx = HOOK_CONTEXT.get()
|
|
288
|
+
|
|
289
|
+
def create_effect() -> Effect | AsyncEffect:
|
|
290
|
+
if inspect.iscoroutinefunction(func):
|
|
291
|
+
return AsyncEffect(
|
|
292
|
+
func, # type: ignore[arg-type]
|
|
293
|
+
name=name or func.__name__,
|
|
294
|
+
lazy=lazy,
|
|
295
|
+
on_error=on_error,
|
|
296
|
+
deps=deps,
|
|
297
|
+
update_deps=update_deps,
|
|
298
|
+
interval=interval,
|
|
299
|
+
)
|
|
300
|
+
return Effect(
|
|
156
301
|
func, # type: ignore[arg-type]
|
|
157
302
|
name=name or func.__name__,
|
|
303
|
+
immediate=immediate,
|
|
158
304
|
lazy=lazy,
|
|
159
305
|
on_error=on_error,
|
|
160
306
|
deps=deps,
|
|
307
|
+
update_deps=update_deps,
|
|
161
308
|
interval=interval,
|
|
162
309
|
)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
)
|
|
172
|
-
|
|
173
|
-
|
|
310
|
+
|
|
311
|
+
if ctx is None:
|
|
312
|
+
# Not in component - create standalone effect (current behavior)
|
|
313
|
+
return create_effect()
|
|
314
|
+
|
|
315
|
+
# In component render - use inline caching
|
|
316
|
+
|
|
317
|
+
# Get the frame where the decorator was applied.
|
|
318
|
+
# When called as `@ps.effect` (no parens), the call stack is:
|
|
319
|
+
# decorator -> effect -> component
|
|
320
|
+
# When called as `@ps.effect(...)` (with parens), the stack is:
|
|
321
|
+
# decorator -> component
|
|
322
|
+
# We detect which case by checking if the immediate caller is effect().
|
|
323
|
+
frame = inspect.currentframe()
|
|
324
|
+
assert frame is not None
|
|
325
|
+
caller = frame.f_back
|
|
326
|
+
assert caller is not None
|
|
327
|
+
# If the immediate caller is the effect function itself, go back one more
|
|
328
|
+
if (
|
|
329
|
+
caller.f_code.co_name == "effect"
|
|
330
|
+
and "decorators" in caller.f_code.co_filename
|
|
331
|
+
):
|
|
332
|
+
caller = caller.f_back
|
|
333
|
+
assert caller is not None
|
|
334
|
+
if key is None:
|
|
335
|
+
identity = collect_component_identity(caller)
|
|
336
|
+
else:
|
|
337
|
+
identity = key
|
|
338
|
+
|
|
339
|
+
state = inline_effect_hook()
|
|
340
|
+
return state.get_or_create(cast(Any, identity), key, create_effect)
|
|
341
|
+
|
|
342
|
+
if fn is not None:
|
|
174
343
|
return decorator(fn)
|
|
175
344
|
return decorator
|