pyloid 0.26.3__py3-none-any.whl → 0.26.5__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/__init__.py +4 -2
- pyloid/autostart.py +131 -68
- pyloid/base_ipc/base.py +395 -0
- pyloid/{js_api → base_ipc}/event_api.py +1 -1
- pyloid/browser_window.py +3771 -3006
- pyloid/custom/titlebar.py +153 -90
- pyloid/filewatcher.py +191 -161
- pyloid/ipc.py +142 -0
- pyloid/monitor.py +1117 -920
- pyloid/pyloid.py +3396 -2671
- pyloid/rpc.py +734 -527
- pyloid/serve.py +306 -214
- pyloid/store.py +253 -175
- pyloid/thread_pool.py +643 -496
- pyloid/timer.py +424 -305
- pyloid/tray.py +61 -45
- pyloid/url_interceptor.py +37 -20
- pyloid/utils.py +243 -193
- {pyloid-0.26.3.dist-info → pyloid-0.26.5.dist-info}/METADATA +1 -1
- pyloid-0.26.5.dist-info/RECORD +23 -0
- pyloid/api.py +0 -104
- pyloid/js_api/base.py +0 -259
- pyloid-0.26.3.dist-info/RECORD +0 -23
- /pyloid/{js_api → base_ipc}/window_api.py +0 -0
- {pyloid-0.26.3.dist-info → pyloid-0.26.5.dist-info}/LICENSE +0 -0
- {pyloid-0.26.3.dist-info → pyloid-0.26.5.dist-info}/WHEEL +0 -0
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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
from
|
|
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
|
|
26
|
+
from typing import (
|
|
27
|
+
TYPE_CHECKING,
|
|
28
|
+
)
|
|
13
29
|
|
|
14
30
|
if TYPE_CHECKING:
|
|
15
|
-
|
|
16
|
-
|
|
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(
|
|
40
|
+
log = logging.getLogger('pyloid.rpc')
|
|
21
41
|
|
|
22
42
|
|
|
23
43
|
class RPCContext:
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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()
|