xuri-rpc-websocket 1.0.0__tar.gz

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.
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: xuri-rpc-websocket
3
+ Version: 1.0.0
4
+ Summary: WebSocket sender for xuri-rpc
5
+ Author: prometheus0017
6
+ License: MIT
7
+ Keywords: rpc,remote,callback,websocket
8
+ Requires-Python: >=3.9
9
+ Requires-Dist: xuri-rpc>=1.0.0
10
+ Requires-Dist: websockets>=12.0
11
+ Requires-Dist: cbor2>=5.4
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "xuri-rpc-websocket"
7
+ version = "1.0.0"
8
+ description = "WebSocket sender for xuri-rpc"
9
+ license = {text = "MIT"}
10
+ authors = [
11
+ {name = "prometheus0017"},
12
+ ]
13
+ keywords = ["rpc", "remote", "callback", "websocket"]
14
+ requires-python = ">=3.9"
15
+ dependencies = [
16
+ "xuri-rpc>=1.0.0",
17
+ "websockets>=12.0",
18
+ "cbor2>=5.4",
19
+ ]
20
+
21
+ [tool.setuptools.packages.find]
22
+ include = ["xuri_rpc_websocket*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,15 @@
1
+ """
2
+ xuri-rpc-websocket — WebSocket sender for xuri-rpc.
3
+ """
4
+
5
+ from .websocket_sender import (
6
+ WebSocketBinarySender,
7
+ createServer,
8
+ createMain,
9
+ )
10
+
11
+ __all__ = [
12
+ 'WebSocketBinarySender',
13
+ 'createServer',
14
+ 'createMain',
15
+ ]
@@ -0,0 +1,6 @@
1
+ def q(b):
2
+ a=b
3
+ return lambda : a
4
+ v=q(10)
5
+ k=q(20)
6
+ print(v())
@@ -0,0 +1,332 @@
1
+ """
2
+ WebSocket sender and connection helpers using CBOR binary format.
3
+ Requires: pip install websockets cbor2
4
+ """
5
+ import asyncio
6
+ import logging
7
+ import uuid
8
+ from typing import Any, Awaitable, Callable, Optional, Tuple, Dict, Union
9
+
10
+ import cbor2
11
+ from xuri_rpc.rpc import debugFlag
12
+ import websockets
13
+ from websockets.exceptions import ConnectionClosed
14
+ from websockets.legacy.protocol import WebSocketCommonProtocol
15
+ from websockets.legacy.server import WebSocketServerProtocol, WebSocketServer
16
+ from websockets.legacy.client import WebSocketClientProtocol
17
+
18
+ from xuri_rpc import Client, MessageReceiver, RpcMessage, ISender
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Sender
25
+ # ---------------------------------------------------------------------------
26
+
27
+ class WebSocketBinarySender(ISender):
28
+ """Sends RPC messages as CBOR-encoded binary over a WebSocket connection.
29
+
30
+ *session_id* may be a plain string **or** a zero-argument callable that
31
+ returns the current session id (useful on the client side where the id is
32
+ learned lazily from the server's responses).
33
+
34
+ Reconnection
35
+ ------------
36
+ The sender transparently reconnects when the connection is lost.
37
+ Reconnection is triggered from two places:
38
+
39
+ * :meth:`send` — catches ``ConnectionClosed`` and reconnects before retry.
40
+ * :func:`listen` — catches ``ConnectionClosed`` and reconnects, then
41
+ continues the receive loop.
42
+
43
+ Both paths go through :meth:`ensure_connected` which uses an internal lock
44
+ so that only one reconnection happens at a time.
45
+ """
46
+
47
+ def __init__(self, ws: Optional[WebSocketCommonProtocol], session_id: Optional[Union[str, Callable[[], Optional[str]]]] = None) -> None:
48
+ self.ws: Optional[WebSocketCommonProtocol] = ws
49
+ self._session_id: Optional[Union[str, Callable[[], Optional[str]]]] = session_id
50
+ # sessionId → sender mapping for server-side multi-connection routing
51
+ self.session_sender_map: Dict[str, 'WebSocketBinarySender'] = {}
52
+
53
+ # reconnection config (set by createMain)
54
+ self._uri: Optional[str] = None
55
+ self._max_retries: int = 0
56
+ self._retry_delay: float = 1.0
57
+ self._retry_backoff: float = 2.0
58
+ self._on_reconnect: Optional[Callable[[],Awaitable[Any]]] = None
59
+ self._reconnect_lock: Optional[asyncio.Lock] = None
60
+ self._client: Optional[Client] = None
61
+
62
+ # -- session id ---------------------------------------------------------
63
+
64
+ @property
65
+ def session_id(self) -> Optional[str]:
66
+ return self._session_id
67
+
68
+ @session_id.setter
69
+ def session_id(self, value: Optional[str])->None:
70
+ self._session_id = value
71
+
72
+ # -- reconnection -------------------------------------------------------
73
+
74
+ def _configure_reconnect(
75
+ self,
76
+ uri: str,
77
+ client: Client,
78
+ *,
79
+ max_retries: int = 0,
80
+ retry_delay: float = 1.0,
81
+ retry_backoff: float = 2.0,
82
+ on_reconnect: Optional[Callable[[Client, Optional[Any]], Any]] = None,
83
+ ) -> None:
84
+ """Store reconnection parameters (called by :func:`createMain`)."""
85
+ self._uri = uri
86
+ self._client = client
87
+ self._max_retries = max_retries
88
+ self._retry_delay = retry_delay
89
+ self._retry_backoff = retry_backoff
90
+ self._on_reconnect = on_reconnect
91
+ self._reconnect_lock = asyncio.Lock()
92
+
93
+ async def ensure_connected(self) -> None:
94
+ """Ensure the underlying WebSocket is connected; reconnect if needed.
95
+
96
+ Safe to call from both :meth:`send` and the listen loop concurrently —
97
+ only one actual reconnection will take place; the other callers wait
98
+ and return once the connection is restored.
99
+ """
100
+ if self.ws is not None and self.ws.close_code is None:
101
+ return
102
+
103
+ if self._reconnect_lock is None:
104
+ if(debugFlag):
105
+ logger.warning("ws disconnected and no reconnect configured")
106
+ return
107
+
108
+ async with self._reconnect_lock:
109
+ # double-check after acquiring the lock
110
+ if self.ws is not None and self.ws.close_code is None:
111
+ return
112
+
113
+ delay = self._retry_delay
114
+ attempts = 0
115
+
116
+ while True:
117
+ attempts += 1
118
+ if 0 < self._max_retries < attempts:
119
+ logger.warning(
120
+ "Gave up reconnecting to %s after %d attempts",
121
+ self._uri, attempts - 1,
122
+ )
123
+ raise ConnectionError(
124
+ f"Failed to reconnect after {attempts - 1} attempts"
125
+ )
126
+
127
+ logger.info(
128
+ "Reconnecting to %s in %.1fs (attempt %d)",
129
+ self._uri, delay, attempts,
130
+ )
131
+ await asyncio.sleep(delay)
132
+
133
+ try:
134
+ self.ws = await websockets.connect(self._uri)
135
+ local_addr = self.ws.local_address
136
+ remote_addr = self.ws.remote_address
137
+ print(f"[WebSocket] Reconnected: local port {local_addr[1]}, remote port {remote_addr[1]}")
138
+
139
+ if self._on_reconnect is not None:
140
+ try:
141
+ await self._on_reconnect(self._client, None)
142
+ except Exception:
143
+ logger.exception("on_reconnect callback raised")
144
+ return
145
+ except (OSError, websockets.WebSocketException) as exc:
146
+ print(f"[WebSocket] Reconnect to {self._uri} failed (attempt {attempts}): {exc}")
147
+ logger.warning(
148
+ "Reconnect to %s failed (attempt %d): %s",
149
+ self._uri, attempts, exc,
150
+ )
151
+ delay = min(delay * self._retry_backoff, 60.0)
152
+
153
+ # -- send ---------------------------------------------------------------
154
+
155
+ async def send(self, message: RpcMessage) -> None:
156
+ sid = self.session_id
157
+ if sid and 'sessionId' not in message.get('meta', {}):
158
+ message.setdefault('meta', {})['sessionId'] = sid
159
+ broken=False
160
+
161
+ try:
162
+ await self.ensure_connected()
163
+ await self.ws.send(cbor2.dumps(message))
164
+ except ConnectionClosed:
165
+ local_addr = self.ws.local_address
166
+ remote_addr = self.ws.remote_address
167
+ print(f"[WebSocket] broken Reconnected: local port {local_addr[1]}, remote port {remote_addr[1]}")
168
+ broken=True
169
+ if broken:
170
+ raise Exception('failed to send message,ws broken')
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Server side
175
+ # ---------------------------------------------------------------------------
176
+
177
+ async def createServer(
178
+ hostId: str,
179
+ host: str = "localhost",
180
+ port: int = 8765,
181
+ path: str = "/",
182
+ ) -> Tuple[Callable[[Any], Any], Any]:
183
+ """Create a WebSocket-based RPC server.
184
+
185
+ Returns ``(serve, ws_server)`` where ``serve`` is an async function that
186
+ registers the main object and blocks until the server is closed.
187
+ ``ws_server`` is the underlying WebSocket server for lifecycle management.
188
+
189
+ Usage::
190
+
191
+ serve, ws_server = await createServer('myServer', 'localhost', 8765)
192
+ await serve(MyService()) # blocks until ws_server is closed
193
+ """
194
+ serverReceiver: MessageReceiver = MessageReceiver(hostId)
195
+
196
+ async def _onWsConnected(ws: WebSocketServerProtocol, path: Optional[str] = None) -> None:
197
+ """Handle a single WebSocket client connection."""
198
+ local_addr = ws.local_address
199
+ remote_addr = ws.remote_address
200
+ print(f'[WebSocket] Server connection handled: local port {local_addr[1]}, remote port {remote_addr[1]}, ws_id={id(ws)}')
201
+
202
+ session_id: str = str(uuid.uuid4())
203
+ conn_sender: WebSocketBinarySender = WebSocketBinarySender(ws, session_id)
204
+ # Register sender in session map at connection time
205
+ conn_client: Client = Client(hostId)
206
+ conn_client.getSessionData()[session_id]=conn_sender
207
+ conn_client.setSender(lambda: conn_client.getSessionData()[session_id])
208
+ connReceiver: MessageReceiver = MessageReceiver(hostId)
209
+
210
+ try:
211
+ async for raw in ws:
212
+ data = cbor2.loads(raw)
213
+ await connReceiver.onReceiveMessage(data, conn_client)
214
+ except ConnectionClosed as e:
215
+ import traceback
216
+ traceback.print_exc()
217
+ pass
218
+
219
+ ws_server: WebSocketServer = await websockets.serve(_onWsConnected, host, port)
220
+
221
+ async def serve(mainObject: Any) -> Tuple[MessageReceiver, Callable[[Any], Any]]:
222
+ serverReceiver.setMain(mainObject)
223
+ await ws_server.wait_closed()
224
+ return (serverReceiver, serve)
225
+
226
+ return (serve, ws_server)
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # Client side (with auto-reconnect)
231
+ # ---------------------------------------------------------------------------
232
+
233
+ async def createMain(
234
+ hostId: str,
235
+ host: str = "localhost",
236
+ port: int = 8765,
237
+ path: str = "/",
238
+ *,
239
+ max_retries: int = 0,
240
+ retry_delay: float = 1.0,
241
+ retry_backoff: float = 2.0,
242
+ on_reconnect: Optional[Callable[[Client, Optional[Any]], Any]] = None,
243
+ ) -> Tuple[Client, Any]:
244
+ """Connect to a WebSocket-based RPC server and return the main proxy.
245
+
246
+ Returns ``(client, main_proxy)``.
247
+
248
+ If the connection drops later, reconnection is handled transparently:
249
+
250
+ * When :meth:`send <WebSocketBinarySender.send>` detects a closed
251
+ connection, it reconnects and retries.
252
+ * When the background listen loop detects a closed connection, it
253
+ reconnects and continues receiving.
254
+
255
+ Reconnection parameters
256
+ -----------------------
257
+ max_retries : int
258
+ Maximum consecutive reconnection attempts. ``0`` (default) means
259
+ **reconnect forever**.
260
+ retry_delay : float
261
+ Initial delay in seconds between reconnection attempts.
262
+ retry_backoff : float
263
+ Multiplier applied to *retry_delay* after each failed attempt
264
+ (capped at 60 s).
265
+ on_reconnect : callable, optional
266
+ ``fn(client, main)`` or ``async def fn(client, main)`` called after
267
+ every successful *re*connection. Useful for re-subscribing or
268
+ re-registering state.
269
+
270
+ Usage::
271
+
272
+ client, main = await createMain('myClient', 'localhost', 8765)
273
+ result = await main.hello('world')
274
+ """
275
+ uri = f"ws://{host}:{port}{path}"
276
+
277
+ client: Client = Client(hostId)
278
+ messageReceiver: MessageReceiver = MessageReceiver(hostId)
279
+ sender: WebSocketBinarySender = WebSocketBinarySender(None) # ws set below
280
+ client.setSender(lambda: sender)
281
+
282
+ # Configure reconnection parameters on the sender
283
+ sender._configure_reconnect(
284
+ uri, client,
285
+ max_retries=max_retries,
286
+ retry_delay=retry_delay,
287
+ retry_backoff=retry_backoff,
288
+ on_reconnect=on_reconnect,
289
+ )
290
+
291
+ # Initial connection
292
+ try:
293
+ ws: WebSocketClientProtocol = await websockets.connect(uri)
294
+ except (OSError, websockets.WebSocketException) as exc:
295
+ print(f"[WebSocket] Initial connection to {uri} failed: {exc}")
296
+ raise
297
+ sender.ws = ws
298
+ local_addr = ws.local_address
299
+ remote_addr = ws.remote_address
300
+ print(f"[WebSocket] Connected: local port {local_addr[1]}, remote port {remote_addr[1]}")
301
+
302
+ # Start background listen — reconnect is triggered from inside
303
+ asyncio.ensure_future(_listen(sender, messageReceiver, client))
304
+
305
+ main: Any = await client.getMain()
306
+ return (client, main)
307
+
308
+
309
+ async def _listen(
310
+ sender: WebSocketBinarySender,
311
+ messageReceiver: MessageReceiver,
312
+ client: Client,
313
+ ) -> None:
314
+ """Read messages from the sender's ws; reconnect and continue on drop."""
315
+ while True:
316
+ try:
317
+ async for raw in sender.ws:
318
+ data = cbor2.loads(raw)
319
+ # Extract sessionId from meta and update sender
320
+ meta = data.get('meta') or {}
321
+ if meta.get('sessionId'):
322
+ sender.session_id = meta['sessionId']
323
+ await messageReceiver.onReceiveMessage(data, client)
324
+ except ConnectionClosed:
325
+ pass
326
+
327
+ # Connection lost — reconnect via sender (shared with send path)
328
+ try:
329
+ await sender.ensure_connected()
330
+ except ConnectionError:
331
+ logger.warning("Reconnect gave up, listen loop exiting")
332
+ return
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: xuri-rpc-websocket
3
+ Version: 1.0.0
4
+ Summary: WebSocket sender for xuri-rpc
5
+ Author: prometheus0017
6
+ License: MIT
7
+ Keywords: rpc,remote,callback,websocket
8
+ Requires-Python: >=3.9
9
+ Requires-Dist: xuri-rpc>=1.0.0
10
+ Requires-Dist: websockets>=12.0
11
+ Requires-Dist: cbor2>=5.4
@@ -0,0 +1,9 @@
1
+ pyproject.toml
2
+ xuri_rpc_websocket/__init__.py
3
+ xuri_rpc_websocket/t.py
4
+ xuri_rpc_websocket/websocket_sender.py
5
+ xuri_rpc_websocket.egg-info/PKG-INFO
6
+ xuri_rpc_websocket.egg-info/SOURCES.txt
7
+ xuri_rpc_websocket.egg-info/dependency_links.txt
8
+ xuri_rpc_websocket.egg-info/requires.txt
9
+ xuri_rpc_websocket.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ xuri-rpc>=1.0.0
2
+ websockets>=12.0
3
+ cbor2>=5.4
@@ -0,0 +1 @@
1
+ xuri_rpc_websocket