pulse-framework 0.1.39__py3-none-any.whl → 0.1.41__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 +14 -4
- pulse/app.py +176 -126
- pulse/channel.py +7 -7
- pulse/cli/cmd.py +81 -45
- pulse/cli/models.py +2 -0
- pulse/cli/processes.py +67 -22
- pulse/cli/uvicorn_log_config.py +1 -1
- pulse/codegen/codegen.py +14 -1
- pulse/codegen/templates/layout.py +10 -2
- pulse/decorators.py +132 -40
- pulse/form.py +9 -9
- pulse/helpers.py +75 -11
- pulse/hooks/core.py +4 -3
- pulse/hooks/states.py +91 -54
- pulse/messages.py +1 -1
- pulse/middleware.py +170 -119
- pulse/plugin.py +0 -3
- pulse/proxy.py +168 -147
- pulse/queries/__init__.py +0 -0
- pulse/queries/common.py +24 -0
- pulse/queries/mutation.py +142 -0
- pulse/queries/query.py +270 -0
- pulse/queries/query_observer.py +365 -0
- pulse/queries/store.py +60 -0
- pulse/reactive.py +146 -50
- pulse/render_session.py +5 -2
- pulse/routing.py +68 -10
- pulse/state.py +8 -7
- pulse/types/event_handler.py +2 -3
- pulse/user_session.py +3 -2
- {pulse_framework-0.1.39.dist-info → pulse_framework-0.1.41.dist-info}/METADATA +1 -1
- {pulse_framework-0.1.39.dist-info → pulse_framework-0.1.41.dist-info}/RECORD +34 -29
- pulse/query.py +0 -408
- {pulse_framework-0.1.39.dist-info → pulse_framework-0.1.41.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.39.dist-info → pulse_framework-0.1.41.dist-info}/entry_points.txt +0 -0
pulse/proxy.py
CHANGED
|
@@ -1,46 +1,38 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Proxy
|
|
2
|
+
Proxy handler for forwarding requests to React Router server in single-server mode.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import logging
|
|
6
|
-
from
|
|
7
|
-
from typing import Callable, cast
|
|
7
|
+
from typing import cast
|
|
8
8
|
|
|
9
9
|
import httpx
|
|
10
|
-
|
|
11
|
-
from
|
|
10
|
+
import websockets
|
|
11
|
+
from fastapi.responses import StreamingResponse
|
|
12
|
+
from starlette.background import BackgroundTask
|
|
13
|
+
from starlette.requests import Request
|
|
14
|
+
from starlette.responses import PlainTextResponse, Response
|
|
15
|
+
from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
16
|
+
from websockets.typing import Subprotocol
|
|
12
17
|
|
|
13
18
|
logger = logging.getLogger(__name__)
|
|
14
19
|
|
|
15
20
|
|
|
16
|
-
class
|
|
21
|
+
class ReactProxy:
|
|
17
22
|
"""
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
In single-server mode, Python FastAPI handles /_pulse/* routes and
|
|
21
|
-
proxies everything else to the React Router server running on an internal port.
|
|
23
|
+
Handles proxying HTTP requests and WebSocket connections to React Router server.
|
|
22
24
|
"""
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
app: ASGIApp,
|
|
27
|
-
get_react_server_address: Callable[[], str | None],
|
|
28
|
-
api_prefix: str = "/_pulse",
|
|
29
|
-
):
|
|
30
|
-
"""
|
|
31
|
-
Initialize proxy ASGI app.
|
|
26
|
+
react_server_address: str
|
|
27
|
+
_client: httpx.AsyncClient | None
|
|
32
28
|
|
|
29
|
+
def __init__(self, react_server_address: str):
|
|
30
|
+
"""
|
|
33
31
|
Args:
|
|
34
|
-
|
|
35
|
-
get_react_server_address: Callable that returns the React Router server full URL (or None if not started)
|
|
36
|
-
api_prefix: Prefix for API routes that should NOT be proxied (default: "/_pulse")
|
|
32
|
+
react_server_address: React Router server full URL (required in single-server mode)
|
|
37
33
|
"""
|
|
38
|
-
self.
|
|
39
|
-
self.
|
|
40
|
-
get_react_server_address
|
|
41
|
-
)
|
|
42
|
-
self.api_prefix: str = api_prefix
|
|
43
|
-
self._client: httpx.AsyncClient | None = None
|
|
34
|
+
self.react_server_address = react_server_address
|
|
35
|
+
self._client = None
|
|
44
36
|
|
|
45
37
|
@property
|
|
46
38
|
def client(self) -> httpx.AsyncClient:
|
|
@@ -52,141 +44,170 @@ class PulseProxy:
|
|
|
52
44
|
)
|
|
53
45
|
return self._client
|
|
54
46
|
|
|
55
|
-
|
|
47
|
+
def _is_websocket_upgrade(self, request: Request) -> bool:
|
|
48
|
+
"""Check if request is a WebSocket upgrade."""
|
|
49
|
+
upgrade = request.headers.get("upgrade", "").lower()
|
|
50
|
+
connection = request.headers.get("connection", "").lower()
|
|
51
|
+
return upgrade == "websocket" and "upgrade" in connection
|
|
52
|
+
|
|
53
|
+
def _http_to_ws_url(self, http_url: str) -> str:
|
|
54
|
+
"""Convert HTTP URL to WebSocket URL."""
|
|
55
|
+
if http_url.startswith("https://"):
|
|
56
|
+
return http_url.replace("https://", "wss://", 1)
|
|
57
|
+
elif http_url.startswith("http://"):
|
|
58
|
+
return http_url.replace("http://", "ws://", 1)
|
|
59
|
+
return http_url
|
|
60
|
+
|
|
61
|
+
async def proxy_websocket(self, websocket: WebSocket) -> None:
|
|
56
62
|
"""
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
Routes starting with api_prefix or WebSocket connections go to FastAPI.
|
|
60
|
-
Everything else is proxied to React Router.
|
|
61
|
-
"""
|
|
62
|
-
if scope["type"] != "http":
|
|
63
|
-
# Pass through non-HTTP requests (WebSocket, lifespan, etc.)
|
|
64
|
-
await self.app(scope, receive, send)
|
|
65
|
-
return
|
|
66
|
-
|
|
67
|
-
path = scope["path"]
|
|
68
|
-
|
|
69
|
-
# Check if path starts with API prefix or is a WebSocket upgrade
|
|
70
|
-
if path.startswith(self.api_prefix):
|
|
71
|
-
# This is an API route, pass through to FastAPI
|
|
72
|
-
await self.app(scope, receive, send)
|
|
73
|
-
return
|
|
74
|
-
|
|
75
|
-
# Check if this is a WebSocket upgrade request (even if not prefixed)
|
|
76
|
-
headers = Headers(scope=scope)
|
|
77
|
-
if headers.get("upgrade", "").lower() == "websocket":
|
|
78
|
-
# WebSocket request, pass through to FastAPI
|
|
79
|
-
await self.app(scope, receive, send)
|
|
80
|
-
return
|
|
81
|
-
|
|
82
|
-
# Proxy to React Router server
|
|
83
|
-
await self._proxy_request(scope, receive, send)
|
|
84
|
-
|
|
85
|
-
async def _proxy_request(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
63
|
+
Proxy WebSocket connection to React Router server.
|
|
64
|
+
Only allowed in dev mode and on root path "/".
|
|
86
65
|
"""
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
66
|
+
|
|
67
|
+
# Build target WebSocket URL
|
|
68
|
+
ws_url = self._http_to_ws_url(self.react_server_address)
|
|
69
|
+
target_url = ws_url.rstrip("/") + websocket.url.path
|
|
70
|
+
if websocket.url.query:
|
|
71
|
+
target_url += "?" + websocket.url.query
|
|
72
|
+
|
|
73
|
+
# Extract subprotocols from client request
|
|
74
|
+
subprotocol_header = websocket.headers.get("sec-websocket-protocol")
|
|
75
|
+
subprotocols: list[Subprotocol] | None = None
|
|
76
|
+
if subprotocol_header:
|
|
77
|
+
# Parse comma-separated list of subprotocols
|
|
78
|
+
# Subprotocol is a NewType (just a type annotation), so cast strings to it
|
|
79
|
+
subprotocols = cast(
|
|
80
|
+
list[Subprotocol], [p.strip() for p in subprotocol_header.split(",")]
|
|
99
81
|
)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
82
|
+
|
|
83
|
+
# Extract headers for WebSocket connection (excluding WebSocket-specific headers)
|
|
84
|
+
headers = {
|
|
85
|
+
k: v
|
|
86
|
+
for k, v in websocket.headers.items()
|
|
87
|
+
if k.lower()
|
|
88
|
+
not in (
|
|
89
|
+
"host",
|
|
90
|
+
"upgrade",
|
|
91
|
+
"connection",
|
|
92
|
+
"sec-websocket-key",
|
|
93
|
+
"sec-websocket-version",
|
|
94
|
+
"sec-websocket-protocol",
|
|
105
95
|
)
|
|
106
|
-
|
|
96
|
+
}
|
|
107
97
|
|
|
108
|
-
#
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
# Ensure react_server_address doesn't end with /
|
|
112
|
-
base_url = react_server_address.rstrip("/")
|
|
113
|
-
target_path = f"{base_url}{path}"
|
|
114
|
-
if query_string:
|
|
115
|
-
target_path += f"?{query_string}"
|
|
116
|
-
|
|
117
|
-
# Extract headers
|
|
118
|
-
headers: dict[str, str] = {}
|
|
119
|
-
for name, value in cast(Iterable[tuple[bytes, bytes]], scope["headers"]):
|
|
120
|
-
name = name.decode("latin1")
|
|
121
|
-
value = value.decode("latin1")
|
|
122
|
-
|
|
123
|
-
# Skip host header (will be set by httpx)
|
|
124
|
-
if name.lower() == "host":
|
|
125
|
-
continue
|
|
126
|
-
|
|
127
|
-
# Collect headers (handle multiple values)
|
|
128
|
-
existing = headers.get(name)
|
|
129
|
-
if existing:
|
|
130
|
-
headers[name] = f"{existing},{value}"
|
|
131
|
-
else:
|
|
132
|
-
headers[name] = value
|
|
133
|
-
|
|
134
|
-
# Read request body
|
|
135
|
-
body_parts: list[bytes] = []
|
|
136
|
-
while True:
|
|
137
|
-
message = await receive()
|
|
138
|
-
if message["type"] == "http.request":
|
|
139
|
-
body_parts.append(message.get("body", b""))
|
|
140
|
-
if not message.get("more_body", False):
|
|
141
|
-
break
|
|
142
|
-
body = b"".join(body_parts)
|
|
98
|
+
# Accept the client WebSocket connection first
|
|
99
|
+
# We'll accept without subprotocol initially, then update if target accepts one
|
|
100
|
+
await websocket.accept()
|
|
143
101
|
|
|
102
|
+
# Connect to target WebSocket server
|
|
144
103
|
try:
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
104
|
+
async with websockets.connect(
|
|
105
|
+
target_url,
|
|
106
|
+
additional_headers=headers,
|
|
107
|
+
subprotocols=subprotocols,
|
|
108
|
+
ping_interval=None, # Let the target server handle ping/pong
|
|
109
|
+
) as target_ws:
|
|
110
|
+
# Forward messages bidirectionally
|
|
111
|
+
async def forward_client_to_target():
|
|
112
|
+
try:
|
|
113
|
+
async for message in websocket.iter_text():
|
|
114
|
+
await target_ws.send(message)
|
|
115
|
+
except (WebSocketDisconnect, websockets.ConnectionClosed):
|
|
116
|
+
# Client disconnected, close target connection
|
|
117
|
+
logger.debug("Client disconnected, closing target connection")
|
|
118
|
+
try:
|
|
119
|
+
await target_ws.close()
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
except Exception as e:
|
|
123
|
+
logger.error(f"Error forwarding client message: {e}")
|
|
124
|
+
raise
|
|
125
|
+
|
|
126
|
+
async def forward_target_to_client():
|
|
127
|
+
try:
|
|
128
|
+
async for message in target_ws:
|
|
129
|
+
if isinstance(message, str):
|
|
130
|
+
await websocket.send_text(message)
|
|
131
|
+
else:
|
|
132
|
+
await websocket.send_bytes(message)
|
|
133
|
+
except (WebSocketDisconnect, websockets.ConnectionClosed) as e:
|
|
134
|
+
# Client or target disconnected, stop forwarding
|
|
135
|
+
logger.debug(
|
|
136
|
+
"Connection closed, stopping forward_target_to_client"
|
|
137
|
+
)
|
|
138
|
+
# If target disconnected, close client connection
|
|
139
|
+
if isinstance(e, websockets.ConnectionClosed):
|
|
140
|
+
try:
|
|
141
|
+
await websocket.close()
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.error(f"Error forwarding target message: {e}")
|
|
146
|
+
raise
|
|
147
|
+
|
|
148
|
+
# Run both forwarding tasks concurrently
|
|
149
|
+
# If one side closes, the other will detect it and stop gracefully
|
|
150
|
+
await asyncio.gather(
|
|
151
|
+
forward_client_to_target(),
|
|
152
|
+
forward_target_to_client(),
|
|
153
|
+
return_exceptions=True,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
except (websockets.WebSocketException, websockets.ConnectionClosedError) as e:
|
|
157
|
+
logger.error(f"WebSocket proxy connection failed: {e}")
|
|
158
|
+
await websocket.close(
|
|
159
|
+
code=1014, # Bad Gateway
|
|
160
|
+
reason="Bad Gateway: Could not connect to React Router server",
|
|
152
161
|
)
|
|
162
|
+
except Exception as e:
|
|
163
|
+
logger.error(f"WebSocket proxy error: {e}")
|
|
164
|
+
await websocket.close(
|
|
165
|
+
code=1011, # Internal Server Error
|
|
166
|
+
reason="Bad Gateway: Proxy error",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
async def __call__(self, request: Request) -> Response:
|
|
170
|
+
"""
|
|
171
|
+
Forward HTTP request to React Router server and stream response back.
|
|
172
|
+
"""
|
|
173
|
+
# Build target URL
|
|
174
|
+
url = self.react_server_address.rstrip("/") + request.url.path
|
|
175
|
+
if request.url.query:
|
|
176
|
+
url += "?" + request.url.query
|
|
153
177
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
178
|
+
# Extract headers, skip host header (will be set by httpx)
|
|
179
|
+
headers = {k: v for k, v in request.headers.items() if k.lower() != "host"}
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
# Build request
|
|
183
|
+
req = self.client.build_request(
|
|
184
|
+
method=request.method,
|
|
185
|
+
url=url,
|
|
186
|
+
headers=headers,
|
|
187
|
+
content=request.stream(),
|
|
164
188
|
)
|
|
165
189
|
|
|
166
|
-
#
|
|
167
|
-
await send(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
190
|
+
# Send request with streaming
|
|
191
|
+
r = await self.client.send(req, stream=True)
|
|
192
|
+
|
|
193
|
+
# Filter out headers that shouldn't be present in streaming responses
|
|
194
|
+
response_headers = {
|
|
195
|
+
k: v
|
|
196
|
+
for k, v in r.headers.items()
|
|
197
|
+
# if k.lower() not in ("content-length", "transfer-encoding")
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return StreamingResponse(
|
|
201
|
+
r.aiter_raw(),
|
|
202
|
+
background=BackgroundTask(r.aclose),
|
|
203
|
+
status_code=r.status_code,
|
|
204
|
+
headers=response_headers,
|
|
172
205
|
)
|
|
173
206
|
|
|
174
207
|
except httpx.RequestError as e:
|
|
175
208
|
logger.error(f"Proxy request failed: {e}")
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
await send(
|
|
179
|
-
{
|
|
180
|
-
"type": "http.response.start",
|
|
181
|
-
"status": 502,
|
|
182
|
-
"headers": [(b"content-type", b"text/plain")],
|
|
183
|
-
}
|
|
184
|
-
)
|
|
185
|
-
await send(
|
|
186
|
-
{
|
|
187
|
-
"type": "http.response.body",
|
|
188
|
-
"body": b"Bad Gateway: Could not reach React Router server",
|
|
189
|
-
}
|
|
209
|
+
return PlainTextResponse(
|
|
210
|
+
"Bad Gateway: Could not reach React Router server", status_code=502
|
|
190
211
|
)
|
|
191
212
|
|
|
192
213
|
async def close(self):
|
|
File without changes
|
pulse/queries/common.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import (
|
|
3
|
+
Any,
|
|
4
|
+
Concatenate,
|
|
5
|
+
ParamSpec,
|
|
6
|
+
TypeVar,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
from pulse.state import State
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
TState = TypeVar("TState", bound="State")
|
|
13
|
+
P = ParamSpec("P")
|
|
14
|
+
R = TypeVar("R")
|
|
15
|
+
|
|
16
|
+
OnSuccessFn = Callable[[TState], Any] | Callable[[TState, T], Any]
|
|
17
|
+
OnErrorFn = Callable[[TState], Any] | Callable[[TState, Exception], Any]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def bind_state(
|
|
21
|
+
state: TState, fn: Callable[Concatenate[TState, P], R]
|
|
22
|
+
) -> Callable[P, R]:
|
|
23
|
+
"Type-safe helper to bind a method to a state"
|
|
24
|
+
return fn.__get__(state, state.__class__)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
from typing import (
|
|
3
|
+
Any,
|
|
4
|
+
Concatenate,
|
|
5
|
+
Generic,
|
|
6
|
+
ParamSpec,
|
|
7
|
+
TypeVar,
|
|
8
|
+
override,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from pulse.helpers import call_flexible, maybe_await
|
|
12
|
+
from pulse.queries.common import OnErrorFn, OnSuccessFn, bind_state
|
|
13
|
+
from pulse.reactive import Signal
|
|
14
|
+
from pulse.state import InitializableProperty, State
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
TState = TypeVar("TState", bound=State)
|
|
18
|
+
R = TypeVar("R")
|
|
19
|
+
P = ParamSpec("P")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MutationResult(Generic[T, P]):
|
|
23
|
+
"""
|
|
24
|
+
Result object for mutations that provides reactive access to mutation state
|
|
25
|
+
and is callable to execute the mutation.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
_data: Signal[T | None]
|
|
29
|
+
_is_running: Signal[bool]
|
|
30
|
+
_error: Signal[Exception | None]
|
|
31
|
+
_fn: Callable[P, Awaitable[T]]
|
|
32
|
+
_on_success: Callable[[T], Any] | None
|
|
33
|
+
_on_error: Callable[[Exception], Any] | None
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
fn: Callable[P, Awaitable[T]],
|
|
38
|
+
on_success: Callable[[T], Any] | None = None,
|
|
39
|
+
on_error: Callable[[Exception], Any] | None = None,
|
|
40
|
+
):
|
|
41
|
+
self._data = Signal(None, name="mutation.data")
|
|
42
|
+
self._is_running = Signal(False, name="mutation.is_running")
|
|
43
|
+
self._error = Signal(None, name="mutation.error")
|
|
44
|
+
self._fn = fn
|
|
45
|
+
self._on_success = on_success
|
|
46
|
+
self._on_error = on_error
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def data(self) -> T | None:
|
|
50
|
+
return self._data()
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def is_running(self) -> bool:
|
|
54
|
+
return self._is_running()
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def error(self) -> Exception | None:
|
|
58
|
+
return self._error()
|
|
59
|
+
|
|
60
|
+
async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T:
|
|
61
|
+
self._is_running.write(True)
|
|
62
|
+
self._error.write(None)
|
|
63
|
+
try:
|
|
64
|
+
mutation_result = await self._fn(*args, **kwargs)
|
|
65
|
+
self._data.write(mutation_result)
|
|
66
|
+
if self._on_success:
|
|
67
|
+
await maybe_await(call_flexible(self._on_success, mutation_result))
|
|
68
|
+
return mutation_result
|
|
69
|
+
except Exception as e:
|
|
70
|
+
self._error.write(e)
|
|
71
|
+
if self._on_error:
|
|
72
|
+
await maybe_await(call_flexible(self._on_error, e))
|
|
73
|
+
raise e
|
|
74
|
+
finally:
|
|
75
|
+
self._is_running.write(False)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class MutationProperty(Generic[T, TState, P], InitializableProperty):
|
|
79
|
+
_on_success_fn: Callable[[TState, T], Any] | None
|
|
80
|
+
_on_error_fn: Callable[[TState, Exception], Any] | None
|
|
81
|
+
name: str
|
|
82
|
+
fn: Callable[Concatenate[TState, P], Awaitable[T]]
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
name: str,
|
|
87
|
+
fn: Callable[Concatenate[TState, P], Awaitable[T]],
|
|
88
|
+
on_success: OnSuccessFn[TState, T] | None = None,
|
|
89
|
+
on_error: OnErrorFn[TState] | None = None,
|
|
90
|
+
):
|
|
91
|
+
self.name = name
|
|
92
|
+
self.fn = fn
|
|
93
|
+
self._on_success_fn = on_success # pyright: ignore[reportAttributeAccessIssue]
|
|
94
|
+
self._on_error_fn = on_error # pyright: ignore[reportAttributeAccessIssue]
|
|
95
|
+
|
|
96
|
+
# Decorator to attach an on-success handler (sync or async)
|
|
97
|
+
def on_success(self, fn: OnSuccessFn[TState, T]):
|
|
98
|
+
if self._on_success_fn is not None:
|
|
99
|
+
raise RuntimeError(
|
|
100
|
+
f"Duplicate on_success() decorator for mutation '{self.name}'. Only one is allowed."
|
|
101
|
+
)
|
|
102
|
+
self._on_success_fn = fn # pyright: ignore[reportAttributeAccessIssue]
|
|
103
|
+
return fn
|
|
104
|
+
|
|
105
|
+
# Decorator to attach an on-error handler (sync or async)
|
|
106
|
+
def on_error(self, fn: OnErrorFn[TState]):
|
|
107
|
+
if self._on_error_fn is not None:
|
|
108
|
+
raise RuntimeError(
|
|
109
|
+
f"Duplicate on_error() decorator for mutation '{self.name}'. Only one is allowed."
|
|
110
|
+
)
|
|
111
|
+
self._on_error_fn = fn # pyright: ignore[reportAttributeAccessIssue]
|
|
112
|
+
return fn
|
|
113
|
+
|
|
114
|
+
def __get__(self, obj: Any, objtype: Any = None) -> MutationResult[T, P]:
|
|
115
|
+
if obj is None:
|
|
116
|
+
return self # pyright: ignore[reportReturnType]
|
|
117
|
+
|
|
118
|
+
# Cache the result on the instance
|
|
119
|
+
cache_key = f"__mutation_{self.name}"
|
|
120
|
+
if not hasattr(obj, cache_key):
|
|
121
|
+
# Bind methods to state
|
|
122
|
+
bound_fn = bind_state(obj, self.fn)
|
|
123
|
+
bound_on_success = (
|
|
124
|
+
bind_state(obj, self._on_success_fn) if self._on_success_fn else None
|
|
125
|
+
)
|
|
126
|
+
bound_on_error = (
|
|
127
|
+
bind_state(obj, self._on_error_fn) if self._on_error_fn else None
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
result = MutationResult[T, P](
|
|
131
|
+
fn=bound_fn,
|
|
132
|
+
on_success=bound_on_success,
|
|
133
|
+
on_error=bound_on_error,
|
|
134
|
+
)
|
|
135
|
+
setattr(obj, cache_key, result)
|
|
136
|
+
|
|
137
|
+
return getattr(obj, cache_key)
|
|
138
|
+
|
|
139
|
+
@override
|
|
140
|
+
def initialize(self, state: State, name: str) -> MutationResult[T, P]:
|
|
141
|
+
# For compatibility with InitializableProperty, but mutations don't need special initialization
|
|
142
|
+
return self.__get__(state, state.__class__)
|