pyloid 0.26.2__py3-none-any.whl → 0.26.4__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.
pyloid/rpc.py CHANGED
@@ -2,547 +2,754 @@ import asyncio
2
2
  import json
3
3
  import logging
4
4
  import inspect
5
- from functools import wraps
6
- from typing import Any, Callable, Coroutine, Dict, List, Optional, Union
7
- from .utils import get_free_port
8
- from aiohttp import web
5
+ from functools import (
6
+ wraps,
7
+ )
8
+ from typing import (
9
+ Any,
10
+ Callable,
11
+ Coroutine,
12
+ Dict,
13
+ List,
14
+ Optional,
15
+ Union,
16
+ )
17
+ from .utils import (
18
+ get_free_port,
19
+ )
20
+ from aiohttp import (
21
+ web,
22
+ )
9
23
  import threading
10
24
  import time
11
25
  import aiohttp_cors
12
- from typing import TYPE_CHECKING
26
+ from typing import (
27
+ TYPE_CHECKING,
28
+ )
13
29
 
14
30
  if TYPE_CHECKING:
15
- from .pyloid import Pyloid
16
- from .browser_window import BrowserWindow
31
+ from .pyloid import (
32
+ Pyloid,
33
+ )
34
+ from .browser_window import (
35
+ BrowserWindow,
36
+ )
17
37
 
18
38
  # Configure logging
19
39
  logging.basicConfig(level=logging.INFO)
20
- log = logging.getLogger("pyloid.rpc")
40
+ log = logging.getLogger('pyloid.rpc')
21
41
 
22
42
 
23
43
  class RPCContext:
24
- """
25
- Class that provides context information when calling RPC methods.
26
-
27
- Attributes
28
- ----------
29
- pyloid : Pyloid
30
- Pyloid application instance.
31
- window : BrowserWindow
32
- Current browser window instance.
33
- """
34
-
35
- def __init__(self, pyloid: "Pyloid", window: "BrowserWindow"):
36
- self.pyloid: "Pyloid" = pyloid
37
- self.window: "BrowserWindow" = window
44
+ """
45
+ Class that provides context information when calling RPC methods.
46
+
47
+ Attributes
48
+ ----------
49
+ pyloid : Pyloid
50
+ Pyloid application instance.
51
+ window : BrowserWindow
52
+ Current browser window instance.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ pyloid: 'Pyloid',
58
+ window: 'BrowserWindow',
59
+ ):
60
+ self.pyloid: 'Pyloid' = pyloid
61
+ self.window: 'BrowserWindow' = window
38
62
 
39
63
 
40
64
  class RPCError(Exception):
41
- """
42
- Custom exception for RPC-related errors.
43
-
44
- Follows the JSON-RPC 2.0 error object structure.
45
-
46
- Attributes
47
- ----------
48
- message : str
49
- A human-readable description of the error.
50
- code : int, optional
51
- A number indicating the error type that occurred. Standard JSON-RPC
52
- codes are used where applicable, with application-specific codes
53
- also possible. Defaults to -32000 (Server error).
54
- data : Any, optional
55
- Additional information about the error, by default None.
56
- """
57
-
58
- def __init__(self, message: str, code: int = -32000, data: Any = None):
59
- """
60
- Initialize the RPCError.
61
-
62
- Parameters
63
- ----------
64
- message : str
65
- The error message.
66
- code : int, optional
67
- The error code. Defaults to -32000.
68
- data : Any, optional
69
- Additional data associated with the error. Defaults to None.
70
- """
71
- self.message = message
72
- self.code = code
73
- self.data = data
74
- super().__init__(self.message)
75
-
76
- def to_dict(self) -> Dict[str, Any]:
77
- """
78
- Convert the error details into a dictionary suitable for JSON-RPC responses.
79
-
80
- Returns
81
- -------
82
- Dict[str, Any]
83
- A dictionary representing the JSON-RPC error object.
84
- """
85
- error_obj = {"code": self.code, "message": self.message}
86
- if self.data is not None:
87
- error_obj["data"] = self.data
88
- return error_obj
65
+ """
66
+ Custom exception for RPC-related errors.
67
+
68
+ Follows the JSON-RPC 2.0 error object structure.
69
+
70
+ Attributes
71
+ ----------
72
+ message : str
73
+ A human-readable description of the error.
74
+ code : int, optional
75
+ A number indicating the error type that occurred. Standard JSON-RPC
76
+ codes are used where applicable, with application-specific codes
77
+ also possible. Defaults to -32000 (Server error).
78
+ data : Any, optional
79
+ Additional information about the error, by default None.
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ message: str,
85
+ code: int = -32000,
86
+ data: Any = None,
87
+ ):
88
+ """
89
+ Initialize the RPCError.
90
+
91
+ Parameters
92
+ ----------
93
+ message : str
94
+ The error message.
95
+ code : int, optional
96
+ The error code. Defaults to -32000.
97
+ data : Any, optional
98
+ Additional data associated with the error. Defaults to None.
99
+ """
100
+ self.message = message
101
+ self.code = code
102
+ self.data = data
103
+ super().__init__(self.message)
104
+
105
+ def to_dict(
106
+ self,
107
+ ) -> Dict[
108
+ str,
109
+ Any,
110
+ ]:
111
+ """
112
+ Convert the error details into a dictionary suitable for JSON-RPC responses.
113
+
114
+ Returns
115
+ -------
116
+ Dict[str, Any]
117
+ A dictionary representing the JSON-RPC error object.
118
+ """
119
+ error_obj = {
120
+ 'code': self.code,
121
+ 'message': self.message,
122
+ }
123
+ if self.data is not None:
124
+ error_obj['data'] = self.data
125
+ return error_obj
89
126
 
90
127
 
91
128
  class PyloidRPC:
92
- """
93
- A simple JSON-RPC server wrapper based on aiohttp.
94
-
95
- Allows registering asynchronous functions as RPC methods using the `@rpc`
96
- decorator and handles JSON-RPC 2.0 request parsing, validation,
97
- method dispatching, and response formatting.
98
-
99
- Attributes
100
- ----------
101
- _host : str
102
- The hostname or IP address to bind the server to.
103
- _port : int
104
- The port number to listen on.
105
- _rpc_path : str
106
- The URL path for handling RPC requests.
107
- _functions : Dict[str, Callable[..., Coroutine[Any, Any, Any]]]
108
- A dictionary mapping registered RPC method names to their
109
- corresponding asynchronous functions.
110
- _app : web.Application
111
- The underlying aiohttp web application instance.
112
- """
113
-
114
- def __init__(self, client_max_size: int = 1024 * 1024 * 10):
115
- """
116
- Initialize the PyloidRPC server instance.
117
-
118
- Parameters
119
- ----------
120
- client_max_size : int, optional
121
- The maximum size of client requests (bytes). Default is 10MB.
122
-
123
- Examples
124
- --------
125
- ```python
126
- from pyloid.rpc import PyloidRPC
127
-
128
- rpc = PyloidRPC()
129
-
130
- @rpc.method()
131
- async def add(a: int, b: int) -> int:
132
- return a + b
133
- ```
134
- """
135
- self._host = "127.0.0.1"
136
- self._port = get_free_port()
137
- self._rpc_path = "/rpc"
138
-
139
- self.url = f"http://{self._host}:{self._port}{self._rpc_path}"
140
-
141
- self._functions: Dict[str, Callable[..., Coroutine[Any, Any, Any]]] = {}
142
- self._app = web.Application(client_max_size=client_max_size)
143
-
144
- self.pyloid: Optional["Pyloid"] = None
145
- # self.window: Optional["BrowserWindow"] = None
146
-
147
- # CORS 설정 추가
148
- cors = aiohttp_cors.setup(
149
- self._app,
150
- defaults={
151
- "*": aiohttp_cors.ResourceOptions(
152
- allow_credentials=True,
153
- expose_headers="*",
154
- allow_headers="*",
155
- allow_methods=["POST"],
156
- )
157
- },
158
- )
159
-
160
- # CORS 적용된 라우트 추가
161
- resource = cors.add(self._app.router.add_resource(self._rpc_path))
162
- cors.add(resource.add_route("POST", self._handle_rpc))
163
-
164
- log.info(f"RPC server initialized.")
165
- self._runner: Optional[web.AppRunner] = None
166
- self._site: Optional[web.TCPSite] = None
167
-
168
- def method(self, name: Optional[str] = None) -> Callable:
169
- """
170
- Use a decorator to register an async function as an RPC method.
171
-
172
- If there is a 'ctx' parameter, an RPCContext object is automatically injected.
173
- This object allows access to the pyloid application and current window.
174
-
175
- Parameters
176
- ----------
177
- name : Optional[str], optional
178
- Name to register the RPC method. If None, the function name is used. Default is None.
179
-
180
- Returns
181
- -------
182
- Callable
183
- The decorator function.
184
-
185
- Raises
186
- ------
187
- TypeError
188
- If the decorated function is not an async function (`coroutinefunction`).
189
- ValueError
190
- If an RPC function with the specified name is already registered.
191
-
192
- Examples
193
- --------
194
- ```python
195
- from pyloid.rpc import PyloidRPC, RPCContext
196
-
197
- rpc = PyloidRPC()
198
-
199
- @rpc.method()
200
- async def add(ctx: RPCContext, a: int, b: int) -> int:
201
- # Access the application and window through ctx.pyloid and ctx.window
202
- if ctx.window:
203
- print(f"Window title: {ctx.window.title}")
204
- return a + b
205
- ```
206
- """
207
-
208
- def decorator(func: Callable[..., Coroutine[Any, Any, Any]]):
209
- rpc_name = name or func.__name__
210
- if not asyncio.iscoroutinefunction(func):
211
- raise TypeError(f"RPC function '{rpc_name}' must be an async function.")
212
- if rpc_name in self._functions:
213
- raise ValueError(
214
- f"RPC function name '{rpc_name}' is already registered."
215
- )
216
-
217
- # Analyze function signature
218
- sig = inspect.signature(func)
219
- has_ctx_param = "ctx" in sig.parameters
220
-
221
- # Store the original function
222
- self._functions[rpc_name] = func
223
- # log.info(f"RPC function registered: {rpc_name}")
224
-
225
- @wraps(func)
226
- async def wrapper(*args, _pyloid_window_id=None, **kwargs):
227
- if has_ctx_param and "ctx" not in kwargs:
228
- ctx = RPCContext(
229
- pyloid=self.pyloid,
230
- window=self.pyloid.get_window_by_id(_pyloid_window_id),
231
- )
232
- kwargs["ctx"] = ctx
233
- return await func(*args, **kwargs)
234
-
235
- return wrapper
236
-
237
- return decorator
238
-
239
- def _validate_jsonrpc_request(self, data: Any) -> Optional[Dict[str, Any]]:
240
- """
241
- Validate the structure of a potential JSON-RPC request object.
242
-
243
- Checks for required fields ('jsonrpc', 'method') and validates the
244
- types of fields like 'params' and 'id' according to the JSON-RPC 2.0 spec.
245
-
246
- Parameters
247
- ----------
248
- data : Any
249
- The parsed JSON data from the request body.
250
-
251
- Returns
252
- -------
253
- Optional[Dict[str, Any]]
254
- None if the request is valid according to the basic structure,
255
- otherwise a dictionary representing the JSON-RPC error object
256
- to be returned to the client.
257
- """
258
- # Attempt to extract the ID if possible, even for invalid requests
259
- request_id = data.get("id") if isinstance(data, dict) else None
260
-
261
- if not isinstance(data, dict):
262
- return {
263
- "code": -32600,
264
- "message": "Invalid Request: Request must be a JSON object.",
265
- }
266
- if data.get("jsonrpc") != "2.0":
267
- return {
268
- "code": -32600,
269
- "message": "Invalid Request: 'jsonrpc' version must be '2.0'.",
270
- }
271
- if "method" not in data or not isinstance(data["method"], str):
272
- return {
273
- "code": -32600,
274
- "message": "Invalid Request: 'method' must be a string.",
275
- }
276
- if "params" in data and not isinstance(data["params"], (list, dict)):
277
- # JSON-RPC 2.0: "params" must be array or object if present
278
- return {
279
- "code": -32602,
280
- "message": "Invalid params: 'params' must be an array or object.",
281
- }
282
- # JSON-RPC 2.0: "id" is optional, but if present, must be string, number, or null.
283
- # This validation is simplified here. A more robust check could be added.
284
- # if "id" in data and not isinstance(data.get("id"), (str, int, float, type(None))):
285
- # return {"code": -32600, "message": "Invalid Request: 'id', if present, must be a string, number, or null."}
286
- return None # Request structure is valid
287
-
288
- async def _handle_rpc(self, request: web.Request) -> web.Response:
289
- """
290
- Handles incoming JSON-RPC requests.
291
-
292
- Parses the request, validates it, dispatches to the appropriate
293
- registered RPC method, executes the method, and returns the
294
- JSON-RPC response or error object.
295
-
296
- Parameters
297
- ----------
298
- request : web.Request
299
- The incoming aiohttp request object.
300
-
301
- Returns
302
- -------
303
- web.Response
304
- An aiohttp JSON response object containing the JSON-RPC response or error.
305
- """
306
- request_id: Optional[Union[str, int, None]] = None
307
- data: Any = None # Define data outside try block for broader scope if needed
308
-
309
- try:
310
- # 1. Check Content-Type
311
- if request.content_type != "application/json":
312
- # Cannot determine ID if content type is wrong, respond with null ID
313
- error_resp = {
314
- "jsonrpc": "2.0",
315
- "error": {
316
- "code": -32700,
317
- "message": "Parse error: Content-Type must be application/json.",
318
- },
319
- "id": None,
320
- }
321
- return web.json_response(
322
- error_resp, status=415
323
- ) # Unsupported Media Type
324
-
325
- # 2. Parse JSON Body
326
- try:
327
- raw_data = await request.read()
328
- data = json.loads(raw_data)
329
- # Extract ID early for inclusion in potential error responses
330
- if isinstance(data, dict):
331
- request_id = data.get("id") # Can be str, int, null, or absent
332
- except json.JSONDecodeError:
333
- # Invalid JSON, ID might be unknown, respond with null ID
334
- error_resp = {
335
- "jsonrpc": "2.0",
336
- "error": {
337
- "code": -32700,
338
- "message": "Parse error: Invalid JSON format.",
339
- },
340
- "id": None,
341
- }
342
- return web.json_response(error_resp, status=400) # Bad Request
343
-
344
- # 3. Validate JSON-RPC Structure
345
- validation_error = self._validate_jsonrpc_request(data)
346
- if validation_error:
347
- # Use extracted ID if available, otherwise it remains None
348
- error_resp = {
349
- "jsonrpc": "2.0",
350
- "error": validation_error,
351
- "id": request_id,
352
- }
353
- return web.json_response(error_resp, status=400) # Bad Request
354
-
355
- # Assuming validation passed, data is a dict with 'method'
356
- method_name: str = data["method"]
357
- # Use empty list/dict if 'params' is omitted, as per spec flexibility
358
- params: Union[List, Dict] = data.get("params", [])
359
-
360
- # 4. Find and Call Method
361
- func = self._functions.get(method_name)
362
- if func is None:
363
- error_resp = {
364
- "jsonrpc": "2.0",
365
- "error": {"code": -32601, "message": "Method not found."},
366
- "id": request_id,
367
- }
368
- return web.json_response(error_resp, status=404) # Not Found
369
-
370
- try:
371
- log.debug(f"Executing RPC method: {method_name}(params={params})")
372
-
373
- # 함수의 서명 분석하여 ctx 매개변수 유무 확인
374
- sig = inspect.signature(func)
375
- has_ctx_param = "ctx" in sig.parameters
376
-
377
- # ctx 매개변수가 있으면 컨텍스트 객체 생성
378
- if has_ctx_param and isinstance(params, dict) and "ctx" not in params:
379
- ctx = RPCContext(
380
- pyloid=self.pyloid,
381
- window=self.pyloid.get_window_by_id(request_id),
382
- )
383
- # 딕셔너리 형태로 params 사용할
384
- params = params.copy() # 원본 params 복사
385
- params["ctx"] = ctx
386
-
387
- # Call the function with positional or keyword arguments
388
- if isinstance(params, list):
389
- # 리스트 형태로 params 사용할 때 처리 필요
390
- if has_ctx_param:
391
- ctx = RPCContext(
392
- pyloid=self.pyloid,
393
- window=self.pyloid.get_window_by_id(request_id),
394
- )
395
- result = await func(ctx, *params, request_id=request_id)
396
- else:
397
- result = await func(*params, request_id=request_id)
398
- else: # isinstance(params, dict)
399
- internal_window_id = request_id
400
- params = params.copy()
401
- params["_pyloid_window_id"] = internal_window_id
402
-
403
- # 함수 시그니처에 맞는 인자만 추려서 전달
404
- sig = inspect.signature(func)
405
- allowed_params = set(sig.parameters.keys())
406
- filtered_params = {
407
- k: v for k, v in params.items() if k in allowed_params
408
- }
409
- result = await func(**filtered_params)
410
-
411
- # 5. Format Success Response (only for non-notification requests)
412
- if (
413
- request_id is not None
414
- ): # Notifications (id=null or absent) don't get responses
415
- response_data = {
416
- "jsonrpc": "2.0",
417
- "result": result,
418
- "id": request_id,
419
- }
420
- return web.json_response(response_data)
421
- else:
422
- # No response for notifications, return 204 No Content might be appropriate
423
- # or just an empty response. aiohttp handles this implicitly if nothing is returned.
424
- # For clarity/standard compliance, maybe return 204?
425
- return web.Response(status=204)
426
-
427
- except RPCError as e:
428
- # Application-specific error during method execution
429
- log.warning(
430
- f"RPC execution error in method '{method_name}': {e}",
431
- exc_info=False,
432
- )
433
- if request_id is not None:
434
- error_resp = {
435
- "jsonrpc": "2.0",
436
- "error": e.to_dict(),
437
- "id": request_id,
438
- }
439
- # Use 500 or a more specific 4xx/5xx if applicable based on error code?
440
- # Sticking to 500 for server-side execution errors.
441
- return web.json_response(error_resp, status=500)
442
- else:
443
- return web.Response(
444
- status=204
445
- ) # No response for notification errors
446
- except Exception as e:
447
- # Unexpected error during method execution
448
- log.exception(
449
- f"Unexpected error during execution of RPC method '{method_name}':"
450
- ) # Log full traceback
451
- if request_id is not None:
452
- # Minimize internal details exposed to the client
453
- error_resp = {
454
- "jsonrpc": "2.0",
455
- "error": {
456
- "code": -32000,
457
- "message": f"Server error: {type(e).__name__}",
458
- },
459
- "id": request_id,
460
- }
461
- return web.json_response(
462
- error_resp, status=500
463
- ) # Internal Server Error
464
- else:
465
- return web.Response(
466
- status=204
467
- ) # No response for notification errors
468
-
469
- except Exception as e:
470
- # Catch-all for fatal errors during request handling itself (before/after method call)
471
- log.exception("Fatal error in RPC handler:")
472
- # ID might be uncertain at this stage, include if available
473
- error_resp = {
474
- "jsonrpc": "2.0",
475
- "error": {"code": -32603, "message": "Internal error"},
476
- "id": request_id,
477
- }
478
- return web.json_response(error_resp, status=500)
479
-
480
- async def start_async(self, **kwargs):
481
- """Starts the server asynchronously without blocking."""
482
- self._runner = web.AppRunner(self._app, access_log=None, **kwargs)
483
- await self._runner.setup()
484
- self._site = web.TCPSite(self._runner, self._host, self._port)
485
- await self._site.start()
486
- log.info(f"RPC server started asynchronously on {self.url}")
487
- # 서버가 백그라운드에서 실행되도록 여기서 블로킹하지 않습니다.
488
- # 이 코루틴은 서버 시작 후 즉시 반환됩니다.
489
-
490
- async def stop_async(self):
491
- """Stops the server asynchronously."""
492
- if self._runner:
493
- await self._runner.cleanup()
494
- log.info("RPC server stopped.")
495
- self._site = None
496
- self._runner = None
497
-
498
- def start(self, **kwargs):
499
- """
500
- Start the aiohttp web server to listen for RPC requests (blocking).
501
-
502
- This method wraps `aiohttp.web.run_app` and blocks until the server stops.
503
- Prefer `start_async` for non-blocking operation within an asyncio event loop.
504
-
505
- Parameters
506
- ----------
507
- **kwargs
508
- Additional keyword arguments to pass directly to `aiohttp.web.run_app`.
509
- For example, `ssl_context` for HTTPS. By default, suppresses the
510
- default `aiohttp` startup message using `print=None`.
511
- """
512
- log.info(f"Starting RPC server")
513
- # Default to print=None to avoid duplicate startup messages, can be overridden via kwargs
514
- run_app_kwargs = {"print": None, "access_log": None}
515
- run_app_kwargs.update(kwargs)
516
- try:
517
- web.run_app(self._app, host=self._host, port=self._port, **run_app_kwargs)
518
- except Exception as e:
519
- log.exception(f"Failed to start or run the server: {e}")
520
- raise
521
-
522
- def run(self):
523
- """
524
- Runs start_async in a separate thread.
525
-
526
- This method is useful when you want to start the aiohttp server in the background
527
- without blocking the main thread. It creates a new thread, sets up a new asyncio event loop
528
- in that thread, and starts the asynchronous server. The thread is marked as daemon so that
529
- it will not prevent the program from exiting if only daemon threads remain.
530
- """
531
- import asyncio
532
-
533
- def _run_asyncio():
534
- # Create a new event loop for this thread.
535
- loop = asyncio.new_event_loop()
536
- # Set the newly created event loop as the current event loop for this thread.
537
- asyncio.set_event_loop(loop)
538
- # Start the asynchronous server; this coroutine will set up the server.
539
- loop.run_until_complete(self.start_async())
540
- # Keep the event loop running forever to handle incoming requests.
541
- loop.run_forever()
542
-
543
- # Create a new thread to run the event loop and server in the background.
544
- # The thread is set as a daemon so it will not block program exit.
545
- server_thread = threading.Thread(target=_run_asyncio, daemon=True)
546
- # Start the background server thread.
547
- server_thread.start()
548
-
129
+ """
130
+ A simple JSON-RPC server wrapper based on aiohttp.
131
+
132
+ Allows registering asynchronous functions as RPC methods using the `@rpc`
133
+ decorator and handles JSON-RPC 2.0 request parsing, validation,
134
+ method dispatching, and response formatting.
135
+
136
+ Attributes
137
+ ----------
138
+ _host : str
139
+ The hostname or IP address to bind the server to.
140
+ _port : int
141
+ The port number to listen on.
142
+ _rpc_path : str
143
+ The URL path for handling RPC requests.
144
+ _functions : Dict[str, Callable[..., Coroutine[Any, Any, Any]]]
145
+ A dictionary mapping registered RPC method names to their
146
+ corresponding asynchronous functions.
147
+ _app : web.Application
148
+ The underlying aiohttp web application instance.
149
+ """
150
+
151
+ def __init__(
152
+ self,
153
+ client_max_size: int = 1024 * 1024 * 10,
154
+ ):
155
+ """
156
+ Initialize the PyloidRPC server instance.
157
+
158
+ Parameters
159
+ ----------
160
+ client_max_size : int, optional
161
+ The maximum size of client requests (bytes). Default is 10MB.
162
+
163
+ Examples
164
+ --------
165
+ ```python
166
+ from pyloid.rpc import (
167
+ PyloidRPC,
168
+ )
169
+
170
+ rpc = PyloidRPC()
171
+
172
+
173
+ @rpc.method()
174
+ async def add(
175
+ a: int,
176
+ b: int,
177
+ ) -> int:
178
+ return a + b
179
+ ```
180
+ """
181
+ self._host = '127.0.0.1'
182
+ self._port = get_free_port()
183
+ self._rpc_path = '/rpc'
184
+
185
+ self.url = f'http://{self._host}:{self._port}{self._rpc_path}'
186
+
187
+ self._functions: Dict[
188
+ str,
189
+ Callable[
190
+ ...,
191
+ Coroutine[
192
+ Any,
193
+ Any,
194
+ Any,
195
+ ],
196
+ ],
197
+ ] = {}
198
+ self._app = web.Application(client_max_size=client_max_size)
199
+
200
+ self.pyloid: Optional['Pyloid'] = None
201
+ # self.window: Optional["BrowserWindow"] = None
202
+
203
+ # CORS 설정 추가
204
+ cors = aiohttp_cors.setup(
205
+ self._app,
206
+ defaults={
207
+ '*': aiohttp_cors.ResourceOptions(
208
+ allow_credentials=True,
209
+ expose_headers='*',
210
+ allow_headers='*',
211
+ allow_methods=['POST'],
212
+ )
213
+ },
214
+ )
215
+
216
+ # CORS 적용된 라우트 추가
217
+ resource = cors.add(self._app.router.add_resource(self._rpc_path))
218
+ cors.add(
219
+ resource.add_route(
220
+ 'POST',
221
+ self._handle_rpc,
222
+ )
223
+ )
224
+
225
+ log.info(f'RPC server initialized.')
226
+ self._runner: Optional[web.AppRunner] = None
227
+ self._site: Optional[web.TCPSite] = None
228
+
229
+ def method(
230
+ self,
231
+ name: Optional[str] = None,
232
+ ) -> Callable:
233
+ """
234
+ Use a decorator to register an async function as an RPC method.
235
+
236
+ If there is a 'ctx' parameter, an RPCContext object is automatically injected.
237
+ This object allows access to the pyloid application and current window.
238
+
239
+ Parameters
240
+ ----------
241
+ name : Optional[str], optional
242
+ Name to register the RPC method. If None, the function name is used. Default is None.
243
+
244
+ Returns
245
+ -------
246
+ Callable
247
+ The decorator function.
248
+
249
+ Raises
250
+ ------
251
+ TypeError
252
+ If the decorated function is not an async function (`coroutinefunction`).
253
+ ValueError
254
+ If an RPC function with the specified name is already registered.
255
+
256
+ Examples
257
+ --------
258
+ ```python
259
+ from pyloid.rpc import (
260
+ PyloidRPC,
261
+ RPCContext,
262
+ )
263
+
264
+ rpc = PyloidRPC()
265
+
266
+
267
+ @rpc.method()
268
+ async def add(
269
+ ctx: RPCContext,
270
+ a: int,
271
+ b: int,
272
+ ) -> int:
273
+ # Access the application and window through ctx.pyloid and ctx.window
274
+ if ctx.window:
275
+ print(f'Window title: {ctx.window.title}')
276
+ return a + b
277
+ ```
278
+ """
279
+
280
+ def decorator(
281
+ func: Callable[
282
+ ...,
283
+ Coroutine[
284
+ Any,
285
+ Any,
286
+ Any,
287
+ ],
288
+ ],
289
+ ):
290
+ rpc_name = name or func.__name__
291
+ if not asyncio.iscoroutinefunction(func):
292
+ raise TypeError(f"RPC function '{rpc_name}' must be an async function.")
293
+ if rpc_name in self._functions:
294
+ raise ValueError(f"RPC function name '{rpc_name}' is already registered.")
295
+
296
+ # Analyze function signature
297
+ sig = inspect.signature(func)
298
+ has_ctx_param = 'ctx' in sig.parameters
299
+
300
+ # Store the original function
301
+ self._functions[rpc_name] = func
302
+ # log.info(f"RPC function registered: {rpc_name}")
303
+
304
+ @wraps(func)
305
+ async def wrapper(
306
+ *args,
307
+ _pyloid_window_id=None,
308
+ **kwargs,
309
+ ):
310
+ if has_ctx_param and 'ctx' not in kwargs:
311
+ ctx = RPCContext(
312
+ pyloid=self.pyloid,
313
+ window=self.pyloid.get_window_by_id(_pyloid_window_id),
314
+ )
315
+ kwargs['ctx'] = ctx
316
+ return await func(
317
+ *args,
318
+ **kwargs,
319
+ )
320
+
321
+ return wrapper
322
+
323
+ return decorator
324
+
325
+ def _validate_jsonrpc_request(
326
+ self,
327
+ data: Any,
328
+ ) -> Optional[
329
+ Dict[
330
+ str,
331
+ Any,
332
+ ]
333
+ ]:
334
+ """
335
+ Validate the structure of a potential JSON-RPC request object.
336
+
337
+ Checks for required fields ('jsonrpc', 'method') and validates the
338
+ types of fields like 'params' and 'id' according to the JSON-RPC 2.0 spec.
339
+
340
+ Parameters
341
+ ----------
342
+ data : Any
343
+ The parsed JSON data from the request body.
344
+
345
+ Returns
346
+ -------
347
+ Optional[Dict[str, Any]]
348
+ None if the request is valid according to the basic structure,
349
+ otherwise a dictionary representing the JSON-RPC error object
350
+ to be returned to the client.
351
+ """
352
+ # Attempt to extract the ID if possible, even for invalid requests
353
+ request_id = (
354
+ data.get('id')
355
+ if isinstance(
356
+ data,
357
+ dict,
358
+ )
359
+ else None
360
+ )
361
+
362
+ if not isinstance(
363
+ data,
364
+ dict,
365
+ ):
366
+ return {
367
+ 'code': -32600,
368
+ 'message': 'Invalid Request: Request must be a JSON object.',
369
+ }
370
+ if data.get('jsonrpc') != '2.0':
371
+ return {
372
+ 'code': -32600,
373
+ 'message': "Invalid Request: 'jsonrpc' version must be '2.0'.",
374
+ }
375
+ if 'method' not in data or not isinstance(
376
+ data['method'],
377
+ str,
378
+ ):
379
+ return {
380
+ 'code': -32600,
381
+ 'message': "Invalid Request: 'method' must be a string.",
382
+ }
383
+ if 'params' in data and not isinstance(
384
+ data['params'],
385
+ (
386
+ list,
387
+ dict,
388
+ ),
389
+ ):
390
+ # JSON-RPC 2.0: "params" must be array or object if present
391
+ return {
392
+ 'code': -32602,
393
+ 'message': "Invalid params: 'params' must be an array or object.",
394
+ }
395
+ # JSON-RPC 2.0: "id" is optional, but if present, must be string, number, or null.
396
+ # This validation is simplified here. A more robust check could be added.
397
+ # if "id" in data and not isinstance(data.get("id"), (str, int, float, type(None))):
398
+ # return {"code": -32600, "message": "Invalid Request: 'id', if present, must be a string, number, or null."}
399
+ return None # Request structure is valid
400
+
401
+ async def _handle_rpc(
402
+ self,
403
+ request: web.Request,
404
+ ) -> web.Response:
405
+ """
406
+ Handles incoming JSON-RPC requests.
407
+
408
+ Parses the request, validates it, dispatches to the appropriate
409
+ registered RPC method, executes the method, and returns the
410
+ JSON-RPC response or error object.
411
+
412
+ Parameters
413
+ ----------
414
+ request : web.Request
415
+ The incoming aiohttp request object.
416
+
417
+ Returns
418
+ -------
419
+ web.Response
420
+ An aiohttp JSON response object containing the JSON-RPC response or error.
421
+ """
422
+ request_id: Optional[
423
+ Union[
424
+ str,
425
+ int,
426
+ None,
427
+ ]
428
+ ] = None
429
+ data: Any = None # Define data outside try block for broader scope if needed
430
+
431
+ try:
432
+ # 1. Check Content-Type
433
+ if request.content_type != 'application/json':
434
+ # Cannot determine ID if content type is wrong, respond with null ID
435
+ error_resp = {
436
+ 'jsonrpc': '2.0',
437
+ 'error': {
438
+ 'code': -32700,
439
+ 'message': 'Parse error: Content-Type must be application/json.',
440
+ },
441
+ 'id': None,
442
+ }
443
+ return web.json_response(
444
+ error_resp,
445
+ status=415,
446
+ ) # Unsupported Media Type
447
+
448
+ # 2. Parse JSON Body
449
+ try:
450
+ raw_data = await request.read()
451
+ data = json.loads(raw_data)
452
+ # Extract ID early for inclusion in potential error responses
453
+ if isinstance(
454
+ data,
455
+ dict,
456
+ ):
457
+ request_id = data.get('id') # Can be str, int, null, or absent
458
+ except json.JSONDecodeError:
459
+ # Invalid JSON, ID might be unknown, respond with null ID
460
+ error_resp = {
461
+ 'jsonrpc': '2.0',
462
+ 'error': {
463
+ 'code': -32700,
464
+ 'message': 'Parse error: Invalid JSON format.',
465
+ },
466
+ 'id': None,
467
+ }
468
+ return web.json_response(
469
+ error_resp,
470
+ status=400,
471
+ ) # Bad Request
472
+
473
+ # 3. Validate JSON-RPC Structure
474
+ validation_error = self._validate_jsonrpc_request(data)
475
+ if validation_error:
476
+ # Use extracted ID if available, otherwise it remains None
477
+ error_resp = {
478
+ 'jsonrpc': '2.0',
479
+ 'error': validation_error,
480
+ 'id': request_id,
481
+ }
482
+ return web.json_response(
483
+ error_resp,
484
+ status=400,
485
+ ) # Bad Request
486
+
487
+ # Assuming validation passed, data is a dict with 'method'
488
+ method_name: str = data['method']
489
+ # Use empty list/dict if 'params' is omitted, as per spec flexibility
490
+ params: Union[
491
+ List,
492
+ Dict,
493
+ ] = data.get(
494
+ 'params',
495
+ [],
496
+ )
497
+
498
+ # 4. Find and Call Method
499
+ func = self._functions.get(method_name)
500
+ if func is None:
501
+ error_resp = {
502
+ 'jsonrpc': '2.0',
503
+ 'error': {
504
+ 'code': -32601,
505
+ 'message': 'Method not found.',
506
+ },
507
+ 'id': request_id,
508
+ }
509
+ return web.json_response(
510
+ error_resp,
511
+ status=404,
512
+ ) # Not Found
513
+
514
+ try:
515
+ log.debug(f'Executing RPC method: {method_name}(params={params})')
516
+
517
+ # Validate window_id for all RPC requests (security enhancement)
518
+ window = self.pyloid.get_window_by_id(request_id)
519
+ if not window:
520
+ error_resp = {
521
+ 'jsonrpc': '2.0',
522
+ 'error': {
523
+ 'code': -32600,
524
+ 'message': 'Invalid window ID.',
525
+ },
526
+ 'id': request_id,
527
+ }
528
+ return web.json_response(
529
+ error_resp,
530
+ status=400,
531
+ ) # Bad Request
532
+
533
+ # Analyze function signature to check for ctx parameter
534
+ sig = inspect.signature(func)
535
+ has_ctx_param = 'ctx' in sig.parameters
536
+
537
+ # Create context object if ctx parameter exists
538
+ if (
539
+ has_ctx_param
540
+ and isinstance(
541
+ params,
542
+ dict,
543
+ )
544
+ and 'ctx' not in params
545
+ ):
546
+ ctx = RPCContext(
547
+ pyloid=self.pyloid,
548
+ window=window,
549
+ )
550
+ # Handle dictionary-like params when using keyword arguments
551
+ params = params.copy() # 원본 params 복사
552
+ params['ctx'] = ctx
553
+
554
+ # Call the function with positional or keyword arguments
555
+ if isinstance(
556
+ params,
557
+ list,
558
+ ):
559
+ # Handle list-like params when using positional arguments
560
+ if has_ctx_param:
561
+ ctx = RPCContext(
562
+ pyloid=self.pyloid,
563
+ window=window,
564
+ )
565
+ result = await func(
566
+ ctx,
567
+ *params,
568
+ request_id=request_id,
569
+ )
570
+ else:
571
+ result = await func(
572
+ *params,
573
+ request_id=request_id,
574
+ )
575
+ else: # isinstance(params, dict)
576
+ internal_window_id = request_id
577
+ params = params.copy()
578
+ params['_pyloid_window_id'] = internal_window_id
579
+
580
+ # Filter parameters to only include allowed parameters
581
+ sig = inspect.signature(func)
582
+ allowed_params = set(sig.parameters.keys())
583
+ filtered_params = {k: v for k, v in params.items() if k in allowed_params}
584
+ result = await func(**filtered_params)
585
+
586
+ # 5. Format Success Response (only for non-notification requests)
587
+ if request_id is not None: # Notifications (id=null or absent) don't get responses
588
+ response_data = {
589
+ 'jsonrpc': '2.0',
590
+ 'result': result,
591
+ 'id': request_id,
592
+ }
593
+ return web.json_response(response_data)
594
+ else:
595
+ # No response for notifications, return 204 No Content might be appropriate
596
+ # or just an empty response. aiohttp handles this implicitly if nothing is returned.
597
+ # For clarity/standard compliance, maybe return 204?
598
+ return web.Response(status=204)
599
+
600
+ except RPCError as e:
601
+ # Application-specific error during method execution
602
+ log.warning(
603
+ f"RPC execution error in method '{method_name}': {e}",
604
+ exc_info=False,
605
+ )
606
+ if request_id is not None:
607
+ error_resp = {
608
+ 'jsonrpc': '2.0',
609
+ 'error': e.to_dict(),
610
+ 'id': request_id,
611
+ }
612
+ # Use 500 or a more specific 4xx/5xx if applicable based on error code?
613
+ # Sticking to 500 for server-side execution errors.
614
+ return web.json_response(
615
+ error_resp,
616
+ status=500,
617
+ )
618
+ else:
619
+ return web.Response(status=204) # No response for notification errors
620
+ except Exception as e:
621
+ # Unexpected error during method execution
622
+ log.exception(
623
+ f"Unexpected error during execution of RPC method '{method_name}':"
624
+ ) # Log full traceback
625
+ if request_id is not None:
626
+ # Minimize internal details exposed to the client
627
+ error_resp = {
628
+ 'jsonrpc': '2.0',
629
+ 'error': {
630
+ 'code': -32000,
631
+ 'message': f'Server error: {type(e).__name__}',
632
+ },
633
+ 'id': request_id,
634
+ }
635
+ return web.json_response(
636
+ error_resp,
637
+ status=500,
638
+ ) # Internal Server Error
639
+ else:
640
+ return web.Response(status=204) # No response for notification errors
641
+
642
+ except Exception as e:
643
+ # Catch-all for fatal errors during request handling itself (before/after method call)
644
+ log.exception('Fatal error in RPC handler:')
645
+ # ID might be uncertain at this stage, include if available
646
+ error_resp = {
647
+ 'jsonrpc': '2.0',
648
+ 'error': {
649
+ 'code': -32603,
650
+ 'message': 'Internal error',
651
+ },
652
+ 'id': request_id,
653
+ }
654
+ return web.json_response(
655
+ error_resp,
656
+ status=500,
657
+ )
658
+
659
+ async def start_async(
660
+ self,
661
+ **kwargs,
662
+ ):
663
+ """Starts the server asynchronously without blocking."""
664
+ self._runner = web.AppRunner(
665
+ self._app,
666
+ access_log=None,
667
+ **kwargs,
668
+ )
669
+ await self._runner.setup()
670
+ self._site = web.TCPSite(
671
+ self._runner,
672
+ self._host,
673
+ self._port,
674
+ )
675
+ await self._site.start()
676
+ log.info(f'RPC server started asynchronously on {self.url}')
677
+ # 서버가 백그라운드에서 실행되도록 여기서 블로킹하지 않습니다.
678
+ # 이 코루틴은 서버 시작 후 즉시 반환됩니다.
679
+
680
+ async def stop_async(
681
+ self,
682
+ ):
683
+ """Stops the server asynchronously."""
684
+ if self._runner:
685
+ await self._runner.cleanup()
686
+ log.info('RPC server stopped.')
687
+ self._site = None
688
+ self._runner = None
689
+
690
+ def start(
691
+ self,
692
+ **kwargs,
693
+ ):
694
+ """
695
+ Start the aiohttp web server to listen for RPC requests (blocking).
696
+
697
+ This method wraps `aiohttp.web.run_app` and blocks until the server stops.
698
+ Prefer `start_async` for non-blocking operation within an asyncio event loop.
699
+
700
+ Parameters
701
+ ----------
702
+ **kwargs
703
+ Additional keyword arguments to pass directly to `aiohttp.web.run_app`.
704
+ For example, `ssl_context` for HTTPS. By default, suppresses the
705
+ default `aiohttp` startup message using `print=None`.
706
+ """
707
+ log.info(f'Starting RPC server')
708
+ # Default to print=None to avoid duplicate startup messages, can be overridden via kwargs
709
+ run_app_kwargs = {
710
+ 'print': None,
711
+ 'access_log': None,
712
+ }
713
+ run_app_kwargs.update(kwargs)
714
+ try:
715
+ web.run_app(
716
+ self._app,
717
+ host=self._host,
718
+ port=self._port,
719
+ **run_app_kwargs,
720
+ )
721
+ except Exception as e:
722
+ log.exception(f'Failed to start or run the server: {e}')
723
+ raise
724
+
725
+ def run(
726
+ self,
727
+ ):
728
+ """
729
+ Runs start_async in a separate thread.
730
+
731
+ This method is useful when you want to start the aiohttp server in the background
732
+ without blocking the main thread. It creates a new thread, sets up a new asyncio event loop
733
+ in that thread, and starts the asynchronous server. The thread is marked as daemon so that
734
+ it will not prevent the program from exiting if only daemon threads remain.
735
+ """
736
+ import asyncio
737
+
738
+ def _run_asyncio():
739
+ # Create a new event loop for this thread.
740
+ loop = asyncio.new_event_loop()
741
+ # Set the newly created event loop as the current event loop for this thread.
742
+ asyncio.set_event_loop(loop)
743
+ # Start the asynchronous server; this coroutine will set up the server.
744
+ loop.run_until_complete(self.start_async())
745
+ # Keep the event loop running forever to handle incoming requests.
746
+ loop.run_forever()
747
+
748
+ # Create a new thread to run the event loop and server in the background.
749
+ # The thread is set as a daemon so it will not block program exit.
750
+ server_thread = threading.Thread(
751
+ target=_run_asyncio,
752
+ daemon=True,
753
+ )
754
+ # Start the background server thread.
755
+ server_thread.start()