python-jsonrpc-lib 0.3.1__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.
jsonrpc/method.py ADDED
@@ -0,0 +1,561 @@
1
+ """Method and MethodGroup classes for JSON-RPC methods."""
2
+
3
+ import asyncio
4
+ import inspect
5
+ from dataclasses import is_dataclass
6
+ from typing import (
7
+ TYPE_CHECKING,
8
+ Any,
9
+ Union,
10
+ get_type_hints,
11
+ )
12
+
13
+ from .validation import (
14
+ _unwrap_optional,
15
+ validate_params,
16
+ validate_result_type,
17
+ )
18
+
19
+ if TYPE_CHECKING:
20
+ from .jsonrpc import JSONRPC
21
+
22
+
23
+ class Method:
24
+ """Base class for RPC methods.
25
+
26
+ Subclasses must:
27
+ - Implement `execute(self, params: ParamsType) -> ResultType` with type hints
28
+ - ParamsType must be a dataclass (or None for no params)
29
+ - ResultType can be any type
30
+
31
+ Type hints are REQUIRED and will be automatically extracted to set
32
+ params_type and result_type attributes.
33
+
34
+ Name is specified during registration, not in the class.
35
+
36
+ Attributes:
37
+ params_type: Auto-extracted from execute() params hint
38
+ result_type: Auto-extracted from execute() return hint
39
+ rpc: Reference to JSONRPC instance (injected on registration)
40
+ """
41
+
42
+ params_type: type = type(None)
43
+ result_type: type = type(None)
44
+ rpc: 'JSONRPC'
45
+ accepts_context: bool = False
46
+ context_type: type | None = None
47
+ _is_async_method: bool = False
48
+
49
+ def __init_subclass__(cls, **kwargs: Any) -> None:
50
+ """Extract params_type and result_type from execute() signature."""
51
+ super().__init_subclass__(**kwargs)
52
+
53
+ try:
54
+ hints = get_type_hints(cls.execute)
55
+
56
+ sig = inspect.signature(cls.execute)
57
+ params_list = list(sig.parameters.values())
58
+
59
+ if len(params_list) < 2:
60
+ raise TypeError(f"{cls.__name__}.execute() must have 'params' parameter")
61
+
62
+ params_param = params_list[1] # Skip 'self'
63
+
64
+ if params_param.name != 'params':
65
+ raise TypeError(
66
+ f"{cls.__name__}.execute() must have 'params' as second parameter, got '{params_param.name}'"
67
+ )
68
+
69
+ if 'params' not in hints:
70
+ raise TypeError(f"{cls.__name__}.execute() must have type hint for 'params' parameter")
71
+
72
+ params_type = hints['params']
73
+
74
+ params_type = _unwrap_optional(params_type)
75
+
76
+ if params_type is not type(None) and not is_dataclass(params_type):
77
+ raise TypeError(f'{cls.__name__}.execute() params type must be a dataclass or None, got {params_type}')
78
+
79
+ cls.params_type = params_type
80
+
81
+ if 'context' in sig.parameters:
82
+ if 'context' not in hints:
83
+ raise TypeError(f"{cls.__name__}.execute() has 'context' parameter but no type hint")
84
+
85
+ cls.accepts_context = True
86
+ cls.context_type = hints['context']
87
+
88
+ context_param = params_list[2]
89
+ if context_param.name != 'context':
90
+ raise TypeError(
91
+ f"{cls.__name__}.execute() third parameter must be 'context', got '{context_param.name}'"
92
+ )
93
+ else:
94
+ cls.accepts_context = False
95
+ cls.context_type = None
96
+
97
+ if 'return' not in hints:
98
+ raise TypeError(f'{cls.__name__}.execute() must have return type annotation')
99
+
100
+ cls.result_type = hints['return']
101
+ cls._is_async_method = asyncio.iscoroutinefunction(cls.execute)
102
+
103
+ except TypeError:
104
+ raise
105
+ except Exception as e:
106
+ # Wrap other exceptions
107
+ raise TypeError(f'Failed to infer types for {cls.__name__}: {e}') from e
108
+
109
+ def execute(self, params: Any, context: Any = None) -> Any:
110
+ """Execute the method with validated params and optional context.
111
+
112
+ Args:
113
+ params: Validated dataclass instance, or None if params_type is None
114
+ context: Optional context object (only passed if accepts_context=True)
115
+
116
+ Returns:
117
+ Method result (any JSON-serializable value)
118
+ """
119
+ raise NotImplementedError(f'Method {self.__class__.__name__} must implement execute()')
120
+
121
+ def _is_async(self) -> bool:
122
+ """Check if execute() is an async method."""
123
+ return self._is_async_method
124
+
125
+
126
+ class MethodGroup:
127
+ """Hierarchical container for methods with support for nesting and middleware.
128
+
129
+ MethodGroup acts as a tree structure supporting:
130
+ - Nested subgroups (e.g., sudo -> user -> management)
131
+ - Method registration with arbitrary names
132
+ - Middleware via execute_method() override
133
+ - Built-in routing and dispatch (replaces Dispatcher)
134
+
135
+ Name is set during registration, not in constructor.
136
+
137
+ Attributes:
138
+ name: Group name (read-only property, None for root group)
139
+ rpc: Reference to JSONRPC instance (injected on registration)
140
+
141
+ Examples:
142
+ # Basic usage
143
+ math = MethodGroup()
144
+ math.register('add', AddMethod())
145
+
146
+ # Nested groups
147
+ sudo = MethodGroup()
148
+ user = MethodGroup()
149
+ user.register('adduser', AddUserMethod())
150
+ sudo.register('user', user)
151
+
152
+ # Custom names
153
+ math.register('add_2', AddXMethod(2))
154
+ math.register('add_5', AddXMethod(5))
155
+
156
+ # Middleware
157
+ class SudoGroup(MethodGroup):
158
+ def execute_method(self, method, params):
159
+ if not self._check_auth():
160
+ raise PermissionError("Requires sudo")
161
+ return super().execute_method(method, params)
162
+ """
163
+
164
+ rpc: 'JSONRPC'
165
+ context_type: type | None = None # Extracted from execute_method() signature
166
+ accepts_context_param: bool = True # Whether execute_method accepts context parameter
167
+
168
+ def __init_subclass__(cls, **kwargs: Any) -> None:
169
+ """Extract context_type and accepts_context_param from execute_method() signature."""
170
+ super().__init_subclass__(**kwargs)
171
+
172
+ # Check if execute_method is overridden
173
+ if cls.execute_method is MethodGroup.execute_method:
174
+ # Not overridden, base implementation accepts context
175
+ cls.context_type = None
176
+ cls.accepts_context_param = True
177
+ return
178
+
179
+ try:
180
+ sig = inspect.signature(cls.execute_method)
181
+
182
+ if 'context' in sig.parameters:
183
+ # MethodGroup has context parameter - can pass context to methods
184
+ cls.accepts_context_param = True
185
+ hints = get_type_hints(cls.execute_method)
186
+
187
+ if 'context' not in hints:
188
+ # context parameter without type hint → no validation (context_type = None)
189
+ # This allows: def execute_method(self, method, params, context=None)
190
+ cls.context_type = None
191
+ else:
192
+ # context parameter with type hint → validate type
193
+ # This allows: def execute_method(self, method, params, context: AdminContext)
194
+ cls.context_type = hints['context']
195
+ else:
196
+ # execute_method overridden but no context parameter
197
+ # Cannot pass context to methods!
198
+ cls.accepts_context_param = False
199
+ cls.context_type = None
200
+
201
+ except Exception as e:
202
+ raise TypeError(f'Failed to infer context_type for {cls.__name__}: {e}') from e
203
+
204
+ def __init__(self) -> None:
205
+ """Initialize method group (name set during registration)."""
206
+ self._name: str | None = None # Internal, set by parent
207
+ self._methods: dict[str, Method] = {}
208
+ self._subgroups: dict[str, MethodGroup] = {}
209
+
210
+ @property
211
+ def name(self) -> str | None:
212
+ """Get group name (read-only)."""
213
+ return self._name
214
+
215
+ def register(self, name: str, target: Union[Method, 'MethodGroup']) -> None:
216
+ """Register method or subgroup instance.
217
+
218
+ Unified registration API supporting both methods and subgroups.
219
+
220
+ Args:
221
+ name: Registration name (must not contain '.', cannot be empty or None)
222
+ target: Method instance or MethodGroup instance
223
+
224
+ Raises:
225
+ ValueError: If name is None, empty, contains '.', or already registered
226
+ TypeError: If target is a class or invalid type
227
+
228
+ Examples:
229
+ group.register('add', AddMethod())
230
+ group.register('user', user_subgroup)
231
+ """
232
+ # Validate name
233
+ if name is None:
234
+ raise ValueError('Name cannot be None in MethodGroup.register()')
235
+ if not name or name == '':
236
+ raise ValueError('Name cannot be empty string. Use None only in JSONRPC.register()')
237
+ if '.' in name:
238
+ raise ValueError(f"Name cannot contain '.': '{name}'")
239
+ if name in self._methods:
240
+ raise ValueError(f"Method '{name}' already registered")
241
+ if name in self._subgroups:
242
+ raise ValueError(f"Subgroup '{name}' already registered")
243
+
244
+ if isinstance(target, type):
245
+ raise TypeError(
246
+ f"Cannot register class '{target.__name__}'. "
247
+ f"Must register instance: register('{name}', {target.__name__}())"
248
+ )
249
+
250
+ # Validate context_type compatibility
251
+ if isinstance(target, Method):
252
+ # Fail-fast: check if method is already registered elsewhere
253
+ if hasattr(target, 'rpc'):
254
+ raise ValueError(
255
+ f"Method instance '{target.__class__.__name__}' is already registered. "
256
+ f'Create a new instance for each registration.'
257
+ )
258
+
259
+ # Check if group can pass context to method
260
+ if target.accepts_context and not self.accepts_context_param:
261
+ raise TypeError(
262
+ f'Cannot register {target.__class__.__name__}: '
263
+ f'method requires context but group execute_method() does not accept context parameter. '
264
+ f'Add context parameter to execute_method: '
265
+ f'def execute_method(self, method, params, context=None)'
266
+ )
267
+
268
+ # Check context type hierarchy
269
+ if target.accepts_context and target.context_type is not None and self.context_type is not None:
270
+ if not issubclass(target.context_type, self.context_type):
271
+ raise TypeError(
272
+ f'Cannot register {target.__class__.__name__}: '
273
+ f'method context_type {target.context_type.__name__} must be '
274
+ f'subclass of group context_type {self.context_type.__name__}'
275
+ )
276
+
277
+ elif isinstance(target, MethodGroup):
278
+ if target.context_type is not None and self.context_type is not None:
279
+ if not issubclass(target.context_type, self.context_type):
280
+ raise TypeError(
281
+ f'Cannot register {target.__class__.__name__}: '
282
+ f'group context_type {target.context_type.__name__} must be '
283
+ f'subclass of parent context_type {self.context_type.__name__}'
284
+ )
285
+
286
+ # Register based on type
287
+ if isinstance(target, MethodGroup):
288
+ # Subgroup
289
+ target._name = name
290
+ if hasattr(self, 'rpc') and self.rpc is not None:
291
+ target._inject_rpc(self.rpc)
292
+ self._subgroups[name] = target
293
+
294
+ elif isinstance(target, Method):
295
+ # Method instance
296
+ if hasattr(self, 'rpc') and self.rpc is not None:
297
+ target.rpc = self.rpc
298
+ self._methods[name] = target
299
+
300
+ else:
301
+ raise TypeError(f'Expected Method or MethodGroup instance, got {type(target).__name__}')
302
+
303
+ def unregister(self, name: str) -> None:
304
+ """Unregister a method or subgroup by name.
305
+
306
+ Args:
307
+ name: Method or subgroup name (not a path)
308
+
309
+ Raises:
310
+ KeyError: If name not found in either methods or subgroups
311
+ """
312
+ if name in self._methods:
313
+ del self._methods[name]
314
+ elif name in self._subgroups:
315
+ del self._subgroups[name]
316
+ else:
317
+ raise KeyError(f"'{name}' not found in group '{self._name}'")
318
+
319
+ def resolve_path(self, path: str) -> tuple['MethodGroup', Method]:
320
+ """Resolve method path to (group, method) tuple.
321
+
322
+ This is the core routing logic that replaces Dispatcher._resolve_method().
323
+
324
+ Args:
325
+ path: Dot-separated path (e.g., "add", "user.add", "sudo.user.addgroup")
326
+
327
+ Returns:
328
+ Tuple of (final_group, method)
329
+
330
+ Raises:
331
+ KeyError: If path not found (method or subgroup missing)
332
+
333
+ Examples:
334
+ "add" → (self, self._methods["add"])
335
+ "user.add" → (user_group, user_group._methods["add"])
336
+ "sudo.user.add" → (user_group, user_group._methods["add"])
337
+ """
338
+ from .errors import MethodNotFoundError
339
+
340
+ parts = path.split('.')
341
+
342
+ # Single name - look in this group
343
+ if len(parts) == 1:
344
+ method = self._methods.get(path)
345
+ if method:
346
+ return (self, method)
347
+ raise MethodNotFoundError(f"Method '{path}' not found in group '{self._name}'")
348
+
349
+ # Multi-part path - navigate subgroups
350
+ first, *rest = parts
351
+
352
+ if first in self._subgroups:
353
+ return self._subgroups[first].resolve_path('.'.join(rest))
354
+
355
+ raise MethodNotFoundError(f"Path '{path}' not found: no subgroup '{first}' in group '{self._name}'")
356
+
357
+ def dispatch(
358
+ self,
359
+ path: str,
360
+ params: list[Any] | dict[str, Any] | None,
361
+ id: str | int | None,
362
+ validate_result: bool = False,
363
+ context: Any = None,
364
+ ) -> Any:
365
+ """Dispatch method call synchronously (with middleware support).
366
+
367
+ This method replaces Dispatcher.dispatch(). It can be overridden
368
+ by subclasses to implement middleware logic.
369
+
370
+ Args:
371
+ path: Method path (e.g., "add", "user.add")
372
+ params: Method parameters
373
+ id: Request ID
374
+ validate_result: Whether to validate result type
375
+ context: Optional context object
376
+
377
+ Returns:
378
+ Method result
379
+
380
+ Raises:
381
+ MethodNotFoundError: If method not found
382
+ InvalidParamsError: If params validation fails
383
+ RuntimeError: If method is async (use dispatch_async instead)
384
+ """
385
+ group, method = self.resolve_path(path)
386
+
387
+ validated_params = validate_params(params, method.params_type)
388
+
389
+ if method._is_async():
390
+ raise RuntimeError(f"Method '{path}' is async, use dispatch_async() instead")
391
+
392
+ # Execute via middleware hook with context
393
+ result = group.execute_method(method, validated_params, context=context)
394
+
395
+ # Validate result if requested
396
+ if validate_result and method.result_type is not None:
397
+ validate_result_type(result, method.result_type)
398
+
399
+ return result
400
+
401
+ async def dispatch_async(
402
+ self,
403
+ path: str,
404
+ params: list[Any] | dict[str, Any] | None,
405
+ id: str | int | None,
406
+ validate_result: bool = False,
407
+ context: Any = None,
408
+ ) -> Any:
409
+ """Dispatch method call asynchronously (with middleware support).
410
+
411
+ Override this method to implement async middleware.
412
+
413
+ Args:
414
+ path: Method path
415
+ params: Method parameters
416
+ id: Request ID
417
+ validate_result: Whether to validate result
418
+ context: Optional context object
419
+
420
+ Returns:
421
+ Method result
422
+ """
423
+ group, method = self.resolve_path(path)
424
+
425
+ validated_params = validate_params(params, method.params_type)
426
+
427
+ if method._is_async():
428
+ result = await group.execute_method_async(method, validated_params, context=context)
429
+ else:
430
+ result = group.execute_method(method, validated_params, context=context)
431
+
432
+ # Validate result if requested
433
+ if validate_result and method.result_type is not None:
434
+ validate_result_type(result, method.result_type)
435
+
436
+ return result
437
+
438
+ def execute_method(self, method: Method, params: Any, context: Any = None) -> Any:
439
+ """Execute method synchronously (override for middleware).
440
+
441
+ Override this in subclasses to add pre/post processing:
442
+
443
+ Examples:
444
+ class SudoGroup(MethodGroup):
445
+ def execute_method(self, method, params, context):
446
+ if not self._check_sudo_rights():
447
+ raise PermissionError("Sudo required")
448
+ return super().execute_method(method, params, context)
449
+
450
+ Args:
451
+ method: Method instance to execute
452
+ params: Validated params
453
+ context: Optional context object
454
+
455
+ Returns:
456
+ Method result
457
+ """
458
+ # Runtime validation (ONLY here!)
459
+ if method.accepts_context and context is not None and method.context_type is not None:
460
+ if not isinstance(context, method.context_type):
461
+ raise TypeError(
462
+ f'Expected context of type {method.context_type.__name__}, got {type(context).__name__}'
463
+ )
464
+
465
+ # Conditional context passing
466
+ if method.accepts_context:
467
+ return method.execute(params, context)
468
+ else:
469
+ return method.execute(params)
470
+
471
+ async def execute_method_async(self, method: Method, params: Any, context: Any = None) -> Any:
472
+ """Execute method asynchronously (override for middleware).
473
+
474
+ Override this in subclasses for async middleware.
475
+
476
+ Args:
477
+ method: Method instance to execute
478
+ params: Validated params
479
+ context: Optional context object
480
+
481
+ Returns:
482
+ Method result
483
+ """
484
+ # Runtime validation (ONLY here!)
485
+ if method.accepts_context and context is not None and method.context_type is not None:
486
+ if not isinstance(context, method.context_type):
487
+ raise TypeError(
488
+ f'Expected context of type {method.context_type.__name__}, got {type(context).__name__}'
489
+ )
490
+
491
+ # Conditional context passing
492
+ if method.accepts_context:
493
+ return await method.execute(params, context)
494
+ else:
495
+ return await method.execute(params)
496
+
497
+ def list_methods(self, recursive: bool = False) -> list[str]:
498
+ """List method names in this group.
499
+
500
+ Args:
501
+ recursive: If True, include subgroup methods with paths
502
+
503
+ Returns:
504
+ List of method names (without group prefix)
505
+
506
+ Examples:
507
+ recursive=False: ["add", "subtract"]
508
+ recursive=True: ["add", "subtract", "user.add", "user.delete"]
509
+ """
510
+ result = list(self._methods.keys())
511
+
512
+ if recursive:
513
+ for subgroup_name, subgroup in self._subgroups.items():
514
+ for method_name in subgroup.list_methods(recursive=True):
515
+ result.append(f'{subgroup_name}.{method_name}')
516
+
517
+ return result
518
+
519
+ def get_method(self, name: str) -> Method | None:
520
+ """Get method by name (without path, local only).
521
+
522
+ Args:
523
+ name: Method name (not a path)
524
+
525
+ Returns:
526
+ Method instance or None
527
+ """
528
+ return self._methods.get(name)
529
+
530
+ def get_subgroup(self, name: str) -> 'MethodGroup | None':
531
+ """Get subgroup by name.
532
+
533
+ Args:
534
+ name: Subgroup name
535
+
536
+ Returns:
537
+ MethodGroup instance or None
538
+ """
539
+ return self._subgroups.get(name)
540
+
541
+ def get_all_groups(self) -> dict[str, 'MethodGroup']:
542
+ """Get all subgroups (shallow, not recursive).
543
+
544
+ Returns:
545
+ Dict of {name: group}
546
+ """
547
+ return dict(self._subgroups)
548
+
549
+ def _inject_rpc(self, rpc: 'JSONRPC') -> None:
550
+ """Inject RPC reference into group and all children (recursive).
551
+
552
+ Args:
553
+ rpc: JSONRPC instance
554
+ """
555
+ self.rpc = rpc
556
+
557
+ for method in self._methods.values():
558
+ method.rpc = rpc
559
+
560
+ for subgroup in self._subgroups.values():
561
+ subgroup._inject_rpc(rpc)