fastapi-ws-rpc 0.1.0__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.
@@ -0,0 +1,11 @@
1
+ from fastapi_ws_rpc.rpc_methods import RpcMethodsBase
2
+ from fastapi_ws_rpc.schemas import WebSocketFrameType
3
+ from fastapi_ws_rpc.websocket_rpc_client import WebSocketRpcClient
4
+ from fastapi_ws_rpc.websocket_rpc_endpoint import WebsocketRPCEndpoint
5
+
6
+ __all__ = [
7
+ "RpcMethodsBase",
8
+ "WebSocketRpcClient",
9
+ "WebsocketRPCEndpoint",
10
+ "WebSocketFrameType",
11
+ ]
@@ -0,0 +1,15 @@
1
+ from typing import List
2
+
3
+ from fastapi import WebSocket
4
+
5
+
6
+ class ConnectionManager:
7
+ def __init__(self):
8
+ self.active_connections: List[WebSocket] = []
9
+
10
+ async def connect(self, websocket: WebSocket):
11
+ await websocket.accept()
12
+ self.active_connections.append(websocket)
13
+
14
+ def disconnect(self, websocket: WebSocket):
15
+ self.active_connections.remove(websocket)
@@ -0,0 +1,113 @@
1
+ import logging
2
+ import os
3
+ from enum import Enum
4
+ from logging.config import dictConfig
5
+ from typing import NewType
6
+
7
+ ENV_VAR = "WS_RPC_LOGGING"
8
+
9
+
10
+ class LoggingModes(Enum):
11
+ # don't produce logs
12
+ NO_LOGS = 0
13
+ # Log alongside uvicorn
14
+ UVICORN = 1
15
+ # Simple log calls (no config)
16
+ SIMPLE = 2
17
+ # log via the loguru module
18
+ LOGURU = 3
19
+
20
+
21
+ LoggingMode = NewType("LoggingMode", LoggingModes)
22
+
23
+
24
+ class LoggingConfig:
25
+ def __init__(self) -> None:
26
+ self._mode = None
27
+
28
+ config_template = {
29
+ "version": 1,
30
+ "disable_existing_loggers": True,
31
+ "formatters": {
32
+ "default": {
33
+ "()": "uvicorn.logging.DefaultFormatter",
34
+ "fmt": "%(levelprefix)s %(asctime)s %(message)s",
35
+ "datefmt": "%Y-%m-%d %H:%M:%S",
36
+ },
37
+ },
38
+ "handlers": {
39
+ "default": {
40
+ "formatter": "default",
41
+ "class": "logging.StreamHandler",
42
+ },
43
+ },
44
+ "loggers": {},
45
+ }
46
+
47
+ UVICORN_LOGGERS = {
48
+ "uvicorn.error": {
49
+ "propagate": False,
50
+ "handlers": ["default"],
51
+ },
52
+ "fastapi_ws_rpc": {
53
+ "handlers": ["default"],
54
+ "propagate": False,
55
+ "level": logging.INFO,
56
+ },
57
+ }
58
+
59
+ def get_mode(self):
60
+ # if no one set the mode - set default from ENV or hardcoded default
61
+ if self._mode is None:
62
+ mode = LoggingModes.__members__.get(
63
+ os.environ.get(ENV_VAR, "").upper(), LoggingModes.SIMPLE
64
+ )
65
+ self.set_mode(mode)
66
+ return self._mode
67
+
68
+ def set_mode(self, mode: LoggingMode = LoggingModes.UVICORN, level=logging.INFO):
69
+ """
70
+ Configure logging. this method calls 'logging.config.dictConfig()' to enable
71
+ quick setup of logging. Call this method before starting the app.
72
+ For more advanced cases use 'logging.config' directly (loggers used by this
73
+ library are all nested under "fastapi_ws_rpc" logger name)
74
+
75
+ Args:
76
+ mode (LoggingMode, optional): The mode to set logging to. Defaults to
77
+ LoggingModes.UVICORN.
78
+ """
79
+ self._mode = mode
80
+ logging_config = self.config_template.copy()
81
+ # add logs beside uvicorn
82
+ if mode == LoggingModes.UVICORN:
83
+ logging_config["loggers"] = self.UVICORN_LOGGERS.copy()
84
+ logging_config["loggers"]["fastapi_ws_rpc"]["level"] = level
85
+ dictConfig(logging_config)
86
+ elif mode == LoggingModes.SIMPLE:
87
+ pass
88
+ elif mode == LoggingModes.LOGURU:
89
+ pass
90
+ # no logs
91
+ else:
92
+ logging_config["loggers"] = {}
93
+ dictConfig(logging_config)
94
+
95
+
96
+ # Singelton for logging configuration
97
+ logging_config = LoggingConfig()
98
+
99
+
100
+ def get_logger(name):
101
+ """
102
+ Get a logger object to log with.
103
+ Called by inner modules for logging.
104
+ """
105
+ mode = logging_config.get_mode()
106
+ # logging through loguru
107
+ if mode == LoggingModes.LOGURU:
108
+ from loguru import logger
109
+ else:
110
+ # regular python logging
111
+ logger = logging.getLogger(f"fastapi_ws_rpc.{name}")
112
+
113
+ return logger
@@ -0,0 +1,482 @@
1
+ """
2
+ Definition for an RPC channel protocol on top of a websocket -
3
+ enabling bi-directional request/response interactions
4
+ """
5
+ import asyncio
6
+ from inspect import _empty, signature
7
+ from typing import Any, Dict, List
8
+
9
+ from pydantic import ValidationError
10
+
11
+ from .logger import get_logger
12
+ from .rpc_methods import EXPOSED_BUILT_IN_METHODS, NoResponse, RpcMethodsBase
13
+ from .schemas import RpcMessage, RpcRequest, RpcResponse
14
+ from .utils import gen_uid, pydantic_parse
15
+
16
+ logger = get_logger("RPC_CHANNEL")
17
+
18
+
19
+ class DEFAULT_TIMEOUT:
20
+ pass
21
+
22
+
23
+ class RemoteValueError(ValueError):
24
+ pass
25
+
26
+
27
+ class RpcException(Exception):
28
+ pass
29
+
30
+
31
+ class RpcChannelClosedException(Exception):
32
+ """
33
+ Raised when the channel is closed mid-operation
34
+ """
35
+
36
+ pass
37
+
38
+
39
+ class UnknownMethodException(RpcException):
40
+ pass
41
+
42
+
43
+ class RpcPromise:
44
+ """
45
+ Simple Event and id wrapper/proxy
46
+ Holds the state of a pending request
47
+ """
48
+
49
+ def __init__(self, request: RpcRequest):
50
+ self._request = request
51
+ self._id = request.call_id
52
+ # event used to wait for the completion of the request
53
+ # (upon receiving its matching response)
54
+ self._event = asyncio.Event()
55
+
56
+ @property
57
+ def request(self):
58
+ return self._request
59
+
60
+ @property
61
+ def call_id(self):
62
+ return self._id
63
+
64
+ def set(self):
65
+ """
66
+ Signal completion of request with received response
67
+ """
68
+ self._event.set()
69
+
70
+ def wait(self):
71
+ """
72
+ Wait on the internal event - triggered on response
73
+ """
74
+ return self._event.wait()
75
+
76
+
77
+ class RpcProxy:
78
+ """Provides a proxy to a remote method on the other side of the channel."""
79
+
80
+ def __init__(self, channel, method_name: str) -> None:
81
+ self.channel = channel
82
+ self.method_name = method_name
83
+
84
+ def __call__(self, **kwargs: Any) -> Any:
85
+ """Calls the remote method with the given keyword arguments.
86
+
87
+ Parameters
88
+ ----------
89
+ kwargs : dict
90
+ Keyword arguments to pass to the remote method.
91
+
92
+ Returns
93
+ -------
94
+ Any
95
+ Return value of the remote method.
96
+ """
97
+ logger.debug("Calling RPC method: %s", self.method_name)
98
+ return self.channel.call(self.method_name, args=kwargs)
99
+
100
+
101
+ class RpcCaller:
102
+ """Calls remote methods on the other side of the channel."""
103
+
104
+ def __init__(self, channel: "RpcChannel", methods=None) -> None:
105
+ self._channel = channel
106
+
107
+ def __getattribute__(self, name: str):
108
+ """Returns an :class:`~fastapi_ws_rpc.rpc_channel.RpcProxy` instance
109
+ for exposed RPC methods.
110
+
111
+ Parameters
112
+ ----------
113
+ name : str
114
+ Name of the requested attribute (or RPC method).
115
+
116
+ Returns
117
+ -------
118
+ Any
119
+ Attribute or RPC method.
120
+ """
121
+ is_exposed = not name.startswith("_") or name in EXPOSED_BUILT_IN_METHODS
122
+ if is_exposed:
123
+ logger.debug("%s was detected to be a remote RPC method.", name)
124
+ return RpcProxy(self._channel, name)
125
+ return super().__getattribute__(name)
126
+
127
+
128
+ # Callback signatures
129
+ async def OnConnectCallback(channel):
130
+ pass
131
+
132
+
133
+ async def OnDisconnectCallback(channel):
134
+ pass
135
+
136
+
137
+ async def OnErrorCallback(channel, err: Exception):
138
+ pass
139
+
140
+
141
+ class RpcChannel:
142
+ """
143
+ A wire agnostic json-rpc channel protocol for both server and client.
144
+ Enable each side to send RPC-requests (calling exposed methods on other side)
145
+ and receive rpc-responses with the return value
146
+
147
+ provides a .other property for calling remote methods.
148
+ e.g. answer = channel.other.add(a=1,b=1) will (For example) ask the other
149
+ side to perform 1+1 and will return an RPC-response of 2
150
+ """
151
+
152
+ def __init__(
153
+ self,
154
+ methods: RpcMethodsBase,
155
+ socket,
156
+ channel_id=None,
157
+ default_response_timeout=None,
158
+ sync_channel_id=False,
159
+ **kwargs,
160
+ ):
161
+ """
162
+
163
+ Args:
164
+ methods (RpcMethodsBase): RPC methods to expose to other side
165
+ socket: socket object providing simple send/recv methods
166
+ channel_id (str, optional): uuid for channel. Defaults to None in
167
+ which case a random UUID is generated.
168
+ default_response_timeout(float, optional) default timeout for RPC
169
+ call responses. Defaults to None - i.e. no timeout
170
+ sync_channel_id(bool, optional) should get the other side of the
171
+ channel id, helps to identify connections, cost a bit networking time.
172
+ Defaults to False - i.e. not getting the other side channel id
173
+ """
174
+ logger.debug("Initializing RPC channel...")
175
+ self.methods = methods._copy_()
176
+ # allow methods to access channel (for recursive calls - e.g. call me as
177
+ # a response for me calling you)
178
+ self.methods._set_channel_(self)
179
+ # Pending requests - id-mapped to async-event
180
+ self.requests: Dict[str, asyncio.Event] = {}
181
+ # Received responses
182
+ self.responses = {}
183
+ self.socket = socket
184
+ # timeout
185
+ self.default_response_timeout = default_response_timeout
186
+ # Unique channel id
187
+ self.id = channel_id if channel_id is not None else gen_uid()
188
+ # flag control should we retrieve the channel id of the other side
189
+ self._sync_channel_id = sync_channel_id
190
+ # The channel id of the other side (if sync_channel_id is True)
191
+ self._other_channel_id = None
192
+ # asyncio event to check if we got the channel id of the other side
193
+ self._channel_id_synced = asyncio.Event()
194
+ #
195
+ # convenience caller
196
+ # TODO - pass remote methods object to support validation before call
197
+ self.other = RpcCaller(self)
198
+ # core event callback registers
199
+ self._connect_handlers: List[OnConnectCallback] = []
200
+ self._disconnect_handlers: List[OnDisconnectCallback] = []
201
+ self._error_handlers = []
202
+ # internal event
203
+ self._closed = asyncio.Event()
204
+
205
+ # any other kwarg goes straight to channel context (Accessible to methods)
206
+ self._context = kwargs or {}
207
+ logger.debug("RPC channel initialized.")
208
+
209
+ @property
210
+ def context(self) -> Dict[str, Any]:
211
+ return self._context
212
+
213
+ async def get_other_channel_id(self) -> str:
214
+ """
215
+ Method to get the channel id of the other side of the channel
216
+ The _channel_id_synced verify we have it
217
+ Timeout exception can be raised if the value isn't available
218
+ """
219
+ await asyncio.wait_for(
220
+ self._channel_id_synced.wait(), self.default_response_timeout
221
+ )
222
+ return self._other_channel_id
223
+
224
+ def get_return_type(self, method):
225
+ method_signature = signature(method)
226
+ return (
227
+ method_signature.return_annotation
228
+ if method_signature.return_annotation is not _empty
229
+ else str
230
+ )
231
+
232
+ async def send(self, data):
233
+ """
234
+ For internal use. wrap calls to underlying socket
235
+ """
236
+ await self.socket.send(data)
237
+
238
+ async def receive(self):
239
+ """
240
+ For internal use. wrap calls to underlying socket
241
+ """
242
+ return await self.socket.recv()
243
+
244
+ async def close(self):
245
+ res = await self.socket.close()
246
+ # signal closer
247
+ self._closed.set()
248
+ return res
249
+
250
+ def isClosed(self):
251
+ return self._closed.is_set()
252
+
253
+ async def wait_until_closed(self):
254
+ """
255
+ Waits until the close internal event happens.
256
+ """
257
+ return await self._closed.wait()
258
+
259
+ async def on_message(self, data):
260
+ """Handle an incoming RPC message."""
261
+ logger.debug(f"Processing received message: {data}")
262
+ try:
263
+ message = pydantic_parse(RpcMessage, data)
264
+ if message.request is not None:
265
+ await self.on_request(message.request)
266
+ if message.response is not None:
267
+ await self.on_response(message.response)
268
+ except ValidationError as e:
269
+ logger.error("Failed to parse message", {"message": data, "error": e})
270
+ await self.on_error(e)
271
+ except Exception as e:
272
+ await self.on_error(e)
273
+ raise
274
+
275
+ def register_connect_handler(self, coros: List[OnConnectCallback] = None):
276
+ """
277
+ Register a connection handler callback that will be called (As an async
278
+ task)) with the channel
279
+ Args:
280
+ coros (List[Coroutine]): async callback
281
+ """
282
+ if coros is not None:
283
+ self._connect_handlers.extend(coros)
284
+
285
+ def register_disconnect_handler(self, coros: List[OnDisconnectCallback] = None):
286
+ """
287
+ Register a disconnect handler callback that will be called (As an async
288
+ task)) with the channel id
289
+ Args:
290
+ coros (List[Coroutine]): async callback
291
+ """
292
+ if coros is not None:
293
+ self._disconnect_handlers.extend(coros)
294
+
295
+ def register_error_handler(self, coros: List[OnErrorCallback] = None):
296
+ """
297
+ Register an error handler callback that will be called (As an async
298
+ task)) with the channel and triggered error.
299
+ Args:
300
+ coros (List[Coroutine]): async callback
301
+ """
302
+ if coros is not None:
303
+ self._error_handlers.extend(coros)
304
+
305
+ async def on_handler_event(self, handlers, *args, **kwargs):
306
+ await asyncio.gather(*(callback(*args, **kwargs) for callback in handlers))
307
+
308
+ async def on_connect(self):
309
+ """
310
+ Run get other channel id if sync_channel_id is True
311
+ Run all callbacks from self._connect_handlers
312
+ """
313
+ if self._sync_channel_id:
314
+ logger.debug("Syncing channel ID...")
315
+ self._get_other_channel_id_task = asyncio.create_task(
316
+ self._get_other_channel_id()
317
+ )
318
+ await self.on_handler_event(self._connect_handlers, self)
319
+
320
+ async def _get_other_channel_id(self):
321
+ """
322
+ Perform call to the other side of the channel to get its channel id
323
+ Each side is generating the channel id by itself so there is no way
324
+ to identify a connection without this sync
325
+ """
326
+ if self._other_channel_id is None:
327
+ logger.debug("No cached channel ID found, calling _get_channel_id_()...")
328
+ other_channel_id = await self.other._get_channel_id_()
329
+ self._other_channel_id = (
330
+ other_channel_id.result
331
+ if other_channel_id and other_channel_id.result
332
+ else None
333
+ )
334
+ logger.debug("Got channel ID: %s", self._other_channel_id)
335
+ if self._other_channel_id is None:
336
+ raise RemoteValueError()
337
+ # update asyncio event that we received remote channel id
338
+ self._channel_id_synced.set()
339
+ return self._other_channel_id
340
+ return self._other_channel_id
341
+
342
+ async def on_disconnect(self):
343
+ # disconnect happened - mark the channel as closed
344
+ self._closed.set()
345
+ await self.on_handler_event(self._disconnect_handlers, self)
346
+
347
+ async def on_error(self, error: Exception):
348
+ await self.on_handler_event(self._error_handlers, self, error)
349
+
350
+ async def on_request(self, message: RpcRequest):
351
+ """
352
+ Handle incoming RPC requests - calling relevant exposed method
353
+ Note: methods prefixed with "_" are protected and ignored.
354
+
355
+ Args:
356
+ message (RpcRequest): the RPC request with the method to call
357
+ """
358
+ # TODO add exception support (catch exceptions and pass to other side
359
+ # as response with errors)
360
+ logger.debug(f"Handling RPC request on channel {self.id}: {message}")
361
+ method_name = message.method
362
+ # Ignore "_" prefixed methods (except the built in "_ping_")
363
+ if isinstance(method_name, str) and (
364
+ not method_name.startswith("_") or method_name in EXPOSED_BUILT_IN_METHODS
365
+ ):
366
+ method = getattr(self.methods, method_name)
367
+ if callable(method):
368
+ result = await method(**message.arguments)
369
+ if result is not NoResponse:
370
+ # get indicated type
371
+ result_type = self.get_return_type(method)
372
+ # if no type given - try to convert to string
373
+ if result_type is str and not isinstance(result, str):
374
+ result = str(result)
375
+ response = RpcMessage(
376
+ request=RpcRequest(
377
+ method=method_name,
378
+ arguments=message.arguments,
379
+ call_id=message.call_id,
380
+ ),
381
+ response=RpcResponse[result_type](
382
+ call_id=message.call_id,
383
+ result=result,
384
+ result_type=getattr(
385
+ result_type,
386
+ "__name__",
387
+ getattr(result_type, "_name", "unknown-type"),
388
+ ),
389
+ ),
390
+ )
391
+ await self.send(response)
392
+
393
+ def get_saved_promise(self, call_id):
394
+ return self.requests[call_id]
395
+
396
+ def get_saved_response(self, call_id):
397
+ return self.responses[call_id]
398
+
399
+ def clear_saved_call(self, call_id):
400
+ del self.requests[call_id]
401
+ del self.responses[call_id]
402
+
403
+ async def on_response(self, response: RpcResponse):
404
+ """
405
+ Handle an incoming response to a previous RPC call
406
+
407
+ Args:
408
+ response (RpcResponse): the received response
409
+ """
410
+ logger.debug("Handling RPC response - %s", {"response": response})
411
+ if response.call_id is not None and response.call_id in self.requests:
412
+ self.responses[response.call_id] = response
413
+ promise = self.requests[response.call_id]
414
+ promise.set()
415
+
416
+ async def wait_for_response(self, promise, timeout=DEFAULT_TIMEOUT) -> RpcResponse:
417
+ """
418
+ Wait on a previously made call
419
+ Args:
420
+ promise (RpcPromise): the awaitable-wrapper returned from the RPC
421
+ request call
422
+ timeout (float, None, or DEFAULT_TIMEOUT): the timeout to wait on
423
+ the response, defaults to DEFAULT_TIMEOUT.
424
+ - DEFAULT_TIMEOUT - use the value passed as
425
+ 'default_response_timeout' in channel init
426
+ - None - no timeout
427
+ - a number - seconds to wait before timing out
428
+ Raises:
429
+ asyncio.exceptions.TimeoutError - on timeout
430
+ RpcChannelClosedException - if the channel fails before wait could
431
+ be completed
432
+ """
433
+ if timeout is DEFAULT_TIMEOUT:
434
+ timeout = self.default_response_timeout
435
+ # wait for the promise or until the channel is terminated
436
+ _, pending = await asyncio.wait(
437
+ [
438
+ asyncio.ensure_future(promise.wait()),
439
+ asyncio.ensure_future(self._closed.wait()),
440
+ ],
441
+ timeout=timeout,
442
+ return_when=asyncio.FIRST_COMPLETED,
443
+ )
444
+ # Cancel all pending futures and then detect if close was the first done
445
+ for fut in pending:
446
+ fut.cancel()
447
+ response = self.responses.get(promise.call_id, NoResponse)
448
+ # if the channel was closed before we could finish
449
+ if response is NoResponse:
450
+ raise RpcChannelClosedException(
451
+ f"Channel Closed before RPC response for {promise.call_id} could be received" # noqa: E501
452
+ )
453
+ self.clear_saved_call(promise.call_id)
454
+ return response
455
+
456
+ async def async_call(self, name, args={}, call_id=None) -> RpcPromise:
457
+ """
458
+ Call a method and return the event and the sent message (including the
459
+ chosen call_id)
460
+ use self.wait_for_response on the event and call_id to get the return
461
+ value of the call
462
+ Args:
463
+ name (str): name of the method to call on the other side
464
+ args (dict): keyword args to pass tot he other side
465
+ call_id (string, optional): a UUID to use to track the call () -
466
+ override only with true UUIDs
467
+ """
468
+ call_id = call_id or gen_uid()
469
+ message = RpcMessage(
470
+ request=RpcRequest(method=name, arguments=args, call_id=call_id)
471
+ )
472
+ logger.debug("Sending the following RPC message: %s", message)
473
+ await self.send(message)
474
+ promise = self.requests[message.request.call_id] = RpcPromise(message.request)
475
+ return promise
476
+
477
+ async def call(self, name, args={}, timeout=DEFAULT_TIMEOUT):
478
+ """
479
+ Call a method and wait for a response to be received
480
+ """
481
+ promise = await self.async_call(name, args)
482
+ return await self.wait_for_response(promise, timeout=timeout)