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/jsonrpc.py ADDED
@@ -0,0 +1,881 @@
1
+ """Main JSONRPC class for handling JSON-RPC protocol."""
2
+
3
+ import asyncio
4
+ import inspect
5
+ import json
6
+ import logging
7
+ import os
8
+ from collections.abc import Callable
9
+ from dataclasses import field, fields, make_dataclass
10
+ from typing import Any, cast, get_type_hints
11
+
12
+ from .errors import (
13
+ InternalError,
14
+ InvalidRequestError,
15
+ JSONRPCError,
16
+ MethodNotFoundError,
17
+ ParseError,
18
+ )
19
+ from .method import Method, MethodGroup
20
+ from .request import parse_request
21
+ from .response import _dataclass_to_dict, build_error_response, build_response
22
+ from .types import Request, Version
23
+ from .validation import is_batch
24
+
25
+ logger = logging.getLogger('jsonrpc-lib')
26
+
27
+
28
+ def _validate_decorator_function(func: Callable[..., Any], sig: inspect.Signature, hints: dict[str, Any]) -> None:
29
+ """Validate that function is suitable for @rpc.method decoration.
30
+
31
+ Args:
32
+ func: Function to validate
33
+ sig: Function signature from inspect.signature()
34
+ hints: Type hints from get_type_hints()
35
+
36
+ Raises:
37
+ TypeError: If function has missing type hints, unsupported parameters, or other issues
38
+ ValueError: If function signature is invalid
39
+ """
40
+ func_name = func.__name__
41
+
42
+ for param in sig.parameters.values():
43
+ if param.annotation is inspect.Parameter.empty:
44
+ raise TypeError(
45
+ f"Function '{func_name}' parameter '{param.name}' is missing type hint. "
46
+ f'The @rpc.method decorator requires type hints for all parameters. '
47
+ f'Example: def {func_name}({param.name}: int, ...) -> ReturnType'
48
+ )
49
+
50
+ if 'return' not in hints or hints['return'] is type(None):
51
+ raise TypeError(
52
+ f"Function '{func_name}' is missing return type annotation. "
53
+ f'The @rpc.method decorator requires a return type. '
54
+ f"Add '-> ReturnType' to the function signature. "
55
+ f'Example: def {func_name}(...) -> int:'
56
+ )
57
+
58
+ # Check for 'context' parameter (not supported in simplified decorator API)
59
+ if 'context' in sig.parameters:
60
+ raise TypeError(
61
+ f"Function '{func_name}' cannot have 'context' parameter. "
62
+ f'The @rpc.method decorator does not support context for simplicity. '
63
+ f'This is a prototyping-only feature. '
64
+ f'For production use with context support, create a Method subclass instead.'
65
+ )
66
+
67
+
68
+ def _create_params_dataclass(func_name: str, sig: inspect.Signature, hints: dict[str, Any]) -> type | None:
69
+ """Create dataclass for function parameters.
70
+
71
+ Args:
72
+ func_name: Function name (used for dataclass name)
73
+ sig: Function signature from inspect.signature()
74
+ hints: Type hints from get_type_hints()
75
+
76
+ Returns:
77
+ Dataclass type for parameters, or None if function has no parameters
78
+
79
+ Example:
80
+ For: def add(a: int, b: int) -> int
81
+ Returns: dataclass with fields a: int, b: int
82
+ """
83
+ field_defs: list[Any] = []
84
+
85
+ for param in sig.parameters.values():
86
+ if param.name == 'self':
87
+ continue
88
+
89
+ param_type = hints[param.name]
90
+
91
+ if param.default is inspect.Parameter.empty:
92
+ field_defs.append((param.name, param_type))
93
+ else:
94
+ field_defs.append((param.name, param_type, field(default=param.default)))
95
+
96
+ if not field_defs:
97
+ return None
98
+
99
+ # Create dataclass with capitalized function name + 'Params' suffix
100
+ # Example: 'add' -> 'AddParams', 'fetch_data' -> 'FetchDataParams'
101
+ dataclass_name = f'{func_name.title().replace("_", "")}Params'
102
+
103
+ return make_dataclass(dataclass_name, field_defs, frozen=False)
104
+
105
+
106
+ def _create_method_class(func: Callable[..., Any], params_type: type | None, return_type: type, is_async: bool) -> type:
107
+ """Create Method subclass for decorated function.
108
+
109
+ Args:
110
+ func: Original function to wrap
111
+ params_type: Dataclass type for parameters (or None if no params)
112
+ return_type: Return type annotation
113
+ is_async: Whether function is async
114
+
115
+ Returns:
116
+ Method subclass with execute() method that calls original function
117
+
118
+ The execute() method must have explicit type annotations so Method.__init_subclass__()
119
+ can extract params_type and result_type for OpenAPI generation.
120
+
121
+ The class docstring is copied from the original function for OpenAPI documentation.
122
+ """
123
+ if is_async:
124
+
125
+ class DecoratedAsyncMethod(Method):
126
+ async def execute(self, params: params_type) -> return_type: # type: ignore[valid-type, override]
127
+ if params is None:
128
+ return await func() # type: ignore[no-any-return]
129
+ kwargs = {f.name: getattr(params, f.name) for f in fields(params)}
130
+ return await func(**kwargs)
131
+
132
+ if func.__doc__:
133
+ DecoratedAsyncMethod.__doc__ = func.__doc__
134
+
135
+ return DecoratedAsyncMethod
136
+ else:
137
+
138
+ class DecoratedMethod(Method):
139
+ def execute(self, params: params_type) -> return_type: # type: ignore[valid-type, override]
140
+ if params is None:
141
+ return func() # type: ignore[no-any-return]
142
+ kwargs = {f.name: getattr(params, f.name) for f in fields(params)}
143
+ return func(**kwargs)
144
+
145
+ if func.__doc__:
146
+ DecoratedMethod.__doc__ = func.__doc__
147
+
148
+ return DecoratedMethod
149
+
150
+
151
+ class JSONRPC:
152
+ """Main JSON-RPC protocol handler.
153
+
154
+ Handles request parsing, method dispatch, and response building.
155
+ Supports both JSON-RPC 1.0 and 2.0 specifications.
156
+
157
+ Example:
158
+ >>> rpc = JSONRPC(version="2.0")
159
+ >>> math = MethodGroup()
160
+ >>> math.register("add", AddMethod())
161
+ >>> rpc.register("math", math)
162
+ >>> response = rpc.handle('{"jsonrpc":"2.0","method":"math.add","params":{"a":1,"b":2},"id":1}')
163
+ """
164
+
165
+ def __init__(
166
+ self,
167
+ version: Version = '2.0',
168
+ validate_results: bool = False,
169
+ context_type: type | None = None,
170
+ allow_batch: bool | None = None,
171
+ allow_dict_params: bool | None = None,
172
+ allow_list_params: bool | None = None,
173
+ max_batch: int = 100,
174
+ max_concurrent: int | None = None,
175
+ ) -> None:
176
+ """Initialize JSON-RPC handler.
177
+
178
+ Args:
179
+ version: Default protocol version for responses
180
+ validate_results: If True, validate method results against result_type
181
+ context_type: Expected context type for validation (None = no validation)
182
+ allow_batch: Allow batch requests (None = spec-compliant default)
183
+ allow_dict_params: Allow dict/object params (None = spec-compliant default)
184
+ allow_list_params: Allow array params (None = spec-compliant default)
185
+ max_batch: Maximum number of requests in a batch (-1 for unlimited, default: 100)
186
+ max_concurrent: Maximum concurrent coroutines in async batch (None = os.cpu_count(),
187
+ -1 for unlimited, default: None). Sync batch is unaffected.
188
+
189
+ Defaults (spec-compliant):
190
+ v1.0: allow_batch=False, allow_dict_params=False, allow_list_params=True
191
+ v2.0: allow_batch=True, allow_dict_params=True, allow_list_params=True
192
+ """
193
+ self.version = version
194
+ self.validate_results = validate_results
195
+ self.context_type = context_type
196
+ self.max_batch = max_batch
197
+ self.max_concurrent = max_concurrent
198
+
199
+ if allow_batch is None:
200
+ self.allow_batch = version == '2.0'
201
+ else:
202
+ self.allow_batch = allow_batch
203
+
204
+ if allow_dict_params is None:
205
+ self.allow_dict_params = version == '2.0'
206
+ else:
207
+ self.allow_dict_params = allow_dict_params
208
+
209
+ if allow_list_params is None:
210
+ self.allow_list_params = True
211
+ else:
212
+ self.allow_list_params = allow_list_params
213
+
214
+ if max_concurrent is not None:
215
+ self._effective_max_concurrent = max_concurrent
216
+ else:
217
+ self._effective_max_concurrent = os.cpu_count() or 4
218
+
219
+ self._root_group = MethodGroup()
220
+ self._root_group._name = None
221
+ self._root_group._inject_rpc(self)
222
+
223
+ self._has_direct_root_methods = False
224
+
225
+ def deserialize(self, data: str | bytes) -> Any:
226
+ """Deserialize incoming JSON to a Python object.
227
+
228
+ Called once per request in handle() / handle_async() before any
229
+ routing or validation. Override to substitute a faster JSON library:
230
+
231
+ Example:
232
+ >>> import orjson
233
+ >>> class FastRPC(JSONRPC):
234
+ ... def deserialize(self, data):
235
+ ... return orjson.loads(data)
236
+
237
+ Default: json.loads(data) from the standard library.
238
+ """
239
+ return json.loads(data)
240
+
241
+ def serialize(self, data: Any, **kwargs: Any) -> str:
242
+ """Serialize a response dict (or list of dicts for batch) to a JSON string.
243
+
244
+ Override to substitute a faster JSON library:
245
+
246
+ Example:
247
+ >>> import orjson
248
+ >>> class FastRPC(JSONRPC):
249
+ ... def serialize(self, data):
250
+ ... return orjson.dumps(data).decode()
251
+
252
+ Default: json.dumps(data) from the standard library.
253
+ """
254
+ return json.dumps(data, **kwargs)
255
+
256
+ def serialize_result(self, result: Any) -> Any:
257
+ """Convert method result to a JSON-serializable value.
258
+
259
+ Override to customize serialization of custom types (datetime, Decimal, etc.)
260
+ or to replace the default dataclass conversion with a faster alternative.
261
+
262
+ Default: recursively converts dataclass instances to dicts via dataclasses.asdict().
263
+
264
+ Args:
265
+ result: Raw method return value
266
+
267
+ Returns:
268
+ JSON-serializable value
269
+
270
+ Example:
271
+ >>> class MyRPC(JSONRPC):
272
+ ... def serialize_result(self, result):
273
+ ... if isinstance(result, datetime):
274
+ ... return result.isoformat()
275
+ ... if isinstance(result, Decimal):
276
+ ... return str(result)
277
+ ... return super().serialize_result(result)
278
+ """
279
+ return _dataclass_to_dict(result)
280
+
281
+ def register(self, name: str | None, target: Method | MethodGroup) -> None:
282
+ """Register method or group.
283
+
284
+ Universal registration supporting:
285
+ - Direct root methods: register('ping', PingMethod())
286
+ - Named groups: register('math', math_group)
287
+ - Explicit root group: register(None, root_group)
288
+
289
+ Args:
290
+ name: Registration name
291
+ - str (non-empty): register as named group/method
292
+ - None: register as root group (error if root already has methods)
293
+ target: Method instance or MethodGroup instance
294
+
295
+ Raises:
296
+ ValueError: If name is '' (empty string) or conflicts occur
297
+ TypeError: If target is a class or invalid type
298
+
299
+ Examples:
300
+ # Direct root method (virtual root)
301
+ rpc.register('ping', PingMethod())
302
+
303
+ # Named group
304
+ rpc.register('math', math_group)
305
+
306
+ # Explicit root group (only if no direct methods)
307
+ rpc.register(None, root_group)
308
+ """
309
+ if name == '':
310
+ raise ValueError(
311
+ 'Name cannot be empty string. Use None for root group, or non-empty string for named registration.'
312
+ )
313
+
314
+ if isinstance(target, type):
315
+ raise TypeError(
316
+ f"Cannot register class '{target.__name__}'. "
317
+ f"Must register instance: register('{name}', {target.__name__}())"
318
+ )
319
+
320
+ if name is None:
321
+ if not isinstance(target, MethodGroup):
322
+ raise TypeError(
323
+ f'Cannot register Method directly with name=None. '
324
+ f"Use a non-empty name: register('method_name', {type(target).__name__}())"
325
+ )
326
+
327
+ if self._has_direct_root_methods:
328
+ raise ValueError(
329
+ 'Cannot register explicit root group: root already has directly '
330
+ 'registered methods. Clear them first or use named groups.'
331
+ )
332
+ if self._root_group._methods or self._root_group._subgroups:
333
+ raise ValueError('Root group already exists with methods/subgroups')
334
+
335
+ target._inject_rpc(self)
336
+ target._name = None
337
+ self._root_group = target
338
+ return
339
+
340
+ if isinstance(target, Method):
341
+ if '.' in name:
342
+ raise ValueError(f"Method name cannot contain '.': '{name}'")
343
+
344
+ # Fail-fast: check if method is already registered elsewhere
345
+ if hasattr(target, 'rpc'):
346
+ raise ValueError(
347
+ f"Method instance '{target.__class__.__name__}' is already registered "
348
+ f'in another JSONRPC/MethodGroup. Create a new instance for each registration.'
349
+ )
350
+
351
+ if target.accepts_context and target.context_type is not None and self.context_type is not None:
352
+ if not issubclass(target.context_type, self.context_type):
353
+ raise TypeError(
354
+ f'Cannot register {target.__class__.__name__}: '
355
+ f'method context_type {target.context_type.__name__} must be '
356
+ f'subclass of RPC context_type {self.context_type.__name__}'
357
+ )
358
+
359
+ target.rpc = self
360
+ self._root_group._methods[name] = target
361
+ self._has_direct_root_methods = True
362
+ return
363
+
364
+ if isinstance(target, MethodGroup):
365
+ if '.' in name:
366
+ raise ValueError(f"Group name cannot contain '.': '{name}'")
367
+
368
+ if target.context_type is not None and self.context_type is not None:
369
+ if not issubclass(target.context_type, self.context_type):
370
+ raise TypeError(
371
+ f'Cannot register {target.__class__.__name__}: '
372
+ f'group context_type {target.context_type.__name__} must be '
373
+ f'subclass of RPC context_type {self.context_type.__name__}'
374
+ )
375
+
376
+ target._inject_rpc(self)
377
+ self._root_group.register(name, target)
378
+ return
379
+
380
+ raise TypeError(f'Expected Method or MethodGroup instance, got {type(target).__name__}')
381
+
382
+ def unregister(self, path: str) -> None:
383
+ """Unregister a method or group by name or dotted path.
384
+
385
+ Works for both methods and groups at any nesting level:
386
+ - 'ping' → unregister root-level method or group
387
+ - 'math' → unregister named group
388
+ - 'math.add' → unregister method inside a group
389
+
390
+ Args:
391
+ path: Name or dotted path to unregister
392
+
393
+ Raises:
394
+ ValueError: If path is empty
395
+ KeyError: If path not found
396
+ """
397
+ if not path:
398
+ raise ValueError('Path cannot be empty')
399
+
400
+ parts = path.split('.')
401
+
402
+ if len(parts) == 1:
403
+ self._root_group.unregister(path)
404
+ else:
405
+ *group_path, leaf = parts
406
+ group = self._root_group
407
+ for part in group_path:
408
+ subgroup = group.get_subgroup(part)
409
+ if subgroup is None:
410
+ raise KeyError(f"Path '{path}' not found")
411
+ group = subgroup
412
+ group.unregister(leaf)
413
+
414
+ def get_root_group(self) -> MethodGroup:
415
+ """Get root method group (for OpenAPI generator).
416
+
417
+ Returns:
418
+ Root MethodGroup instance
419
+ """
420
+ return self._root_group
421
+
422
+ def handle(self, raw_data: str | bytes, context: Any = None) -> str | None:
423
+ """Process incoming JSON-RPC message and return response.
424
+
425
+ Args:
426
+ raw_data: Raw JSON string or bytes
427
+ context: Optional context object passed to methods
428
+
429
+ Returns:
430
+ JSON response string, or None for notifications
431
+ """
432
+ try:
433
+ try:
434
+ data = self.deserialize(raw_data)
435
+ except (json.JSONDecodeError, TypeError, ValueError) as e:
436
+ err: JSONRPCError = ParseError(f'Invalid JSON: {e}')
437
+ response = build_error_response(err, None, self.version)
438
+ return self.serialize(response)
439
+
440
+ if is_batch(data):
441
+ if not self.allow_batch:
442
+ err = InvalidRequestError('Batch requests not allowed')
443
+ response = build_error_response(err, None, self.version)
444
+ return self.serialize(response)
445
+ return self._handle_batch(data, context=context)
446
+
447
+ return self._handle_single(data, context=context)
448
+
449
+ except Exception as e:
450
+ logger.error('Unhandled exception in handle()', exc_info=True)
451
+ err = InternalError(str(e))
452
+ response = build_error_response(err, None, self.version)
453
+ return self.serialize(response)
454
+
455
+ async def handle_async(self, raw_data: str | bytes, context: Any = None) -> str | None:
456
+ """Process incoming JSON-RPC message asynchronously.
457
+
458
+ Args:
459
+ raw_data: Raw JSON string or bytes
460
+ context: Optional context object passed to methods
461
+
462
+ Returns:
463
+ JSON response string, or None for notifications
464
+ """
465
+ try:
466
+ try:
467
+ data = self.deserialize(raw_data)
468
+ except (json.JSONDecodeError, TypeError, ValueError) as e:
469
+ err: JSONRPCError = ParseError(f'Invalid JSON: {e}')
470
+ response = build_error_response(err, None, self.version)
471
+ return self.serialize(response)
472
+
473
+ if is_batch(data):
474
+ if not self.allow_batch:
475
+ err = InvalidRequestError('Batch requests not allowed')
476
+ response = build_error_response(err, None, self.version)
477
+ return self.serialize(response)
478
+ return await self._handle_batch_async(data, context=context)
479
+
480
+ return await self._handle_single_async(data, context=context)
481
+
482
+ except Exception as e:
483
+ logger.error('Unhandled exception in handle_async()', exc_info=True)
484
+ err = InternalError(str(e))
485
+ response = build_error_response(err, None, self.version)
486
+ return self.serialize(response)
487
+
488
+ def _process_single(self, data: dict[str, Any], context: Any = None) -> dict[str, Any] | None:
489
+ """Process a single request and return response as dict (or None for notifications)."""
490
+ request_id: str | int | None = None
491
+ version = self.version
492
+
493
+ try:
494
+ parsed = parse_request(
495
+ data,
496
+ allow_dict_params=self.allow_dict_params,
497
+ allow_list_params=self.allow_list_params,
498
+ )
499
+ # Single dict input always returns single Request (not list)
500
+ request = cast(Request, parsed)
501
+ request_id = request.id
502
+ version = request.version
503
+
504
+ if request.is_notification:
505
+ try:
506
+ self._root_group.dispatch(
507
+ request.method,
508
+ request.params,
509
+ request.id,
510
+ validate_result=self.validate_results,
511
+ context=context,
512
+ )
513
+ except Exception:
514
+ logger.debug('Notification error suppressed (per JSON-RPC spec)', exc_info=True)
515
+ return None
516
+
517
+ result = self._root_group.dispatch(
518
+ request.method,
519
+ request.params,
520
+ request.id,
521
+ validate_result=self.validate_results,
522
+ context=context,
523
+ )
524
+
525
+ serialized = self.serialize_result(result)
526
+ return build_response(serialized, request_id, version) # type: ignore[arg-type]
527
+
528
+ except JSONRPCError as e:
529
+ return build_error_response(e, request_id, version)
530
+ except Exception as e:
531
+ logger.error('Unhandled exception in method dispatch', exc_info=True)
532
+ err = InternalError(str(e))
533
+ return build_error_response(err, request_id, version)
534
+
535
+ async def _process_single_async(self, data: dict[str, Any], context: Any = None) -> dict[str, Any] | None:
536
+ """Process a single request asynchronously and return response as dict."""
537
+ request_id: str | int | None = None
538
+ version = self.version
539
+
540
+ try:
541
+ parsed = parse_request(
542
+ data,
543
+ allow_dict_params=self.allow_dict_params,
544
+ allow_list_params=self.allow_list_params,
545
+ )
546
+ # Single dict input always returns single Request (not list)
547
+ request = cast(Request, parsed)
548
+ request_id = request.id
549
+ version = request.version
550
+
551
+ if request.is_notification:
552
+ try:
553
+ await self._root_group.dispatch_async(
554
+ request.method,
555
+ request.params,
556
+ request.id,
557
+ validate_result=self.validate_results,
558
+ context=context,
559
+ )
560
+ except Exception:
561
+ logger.debug('Notification error suppressed (per JSON-RPC spec)', exc_info=True)
562
+ return None
563
+
564
+ result = await self._root_group.dispatch_async(
565
+ request.method,
566
+ request.params,
567
+ request.id,
568
+ validate_result=self.validate_results,
569
+ context=context,
570
+ )
571
+
572
+ serialized = self.serialize_result(result)
573
+ return build_response(serialized, request_id, version) # type: ignore[arg-type]
574
+
575
+ except JSONRPCError as e:
576
+ return build_error_response(e, request_id, version)
577
+ except Exception as e:
578
+ logger.error('Unhandled exception in async method dispatch', exc_info=True)
579
+ err = InternalError(str(e))
580
+ return build_error_response(err, request_id, version)
581
+
582
+ def _handle_single(self, data: dict[str, Any], context: Any = None) -> str | None:
583
+ """Handle a single request synchronously."""
584
+ response = self._process_single(data, context=context)
585
+ if response is None:
586
+ return None
587
+ try:
588
+ return self.serialize(response)
589
+ except (TypeError, ValueError) as e:
590
+ logger.error('Serialization error in response', exc_info=True)
591
+ request_id = response.get('id')
592
+ err = InternalError(str(e))
593
+ error_response = build_error_response(err, request_id, self.version)
594
+ return self.serialize(error_response)
595
+
596
+ async def _handle_single_async(self, data: dict[str, Any], context: Any = None) -> str | None:
597
+ """Handle a single request asynchronously."""
598
+ response = await self._process_single_async(data, context=context)
599
+ if response is None:
600
+ return None
601
+ try:
602
+ return self.serialize(response)
603
+ except (TypeError, ValueError) as e:
604
+ logger.error('Serialization error in response', exc_info=True)
605
+ request_id = response.get('id')
606
+ err = InternalError(str(e))
607
+ error_response = build_error_response(err, request_id, self.version)
608
+ return self.serialize(error_response)
609
+
610
+ def _handle_batch(self, data: list[Any], context: Any = None) -> str | None:
611
+ """Handle batch request synchronously."""
612
+ if not data:
613
+ error = InvalidRequestError('Empty batch request')
614
+ response = build_error_response(error, None, self.version)
615
+ return self.serialize(response)
616
+
617
+ if self.max_batch != -1 and len(data) > self.max_batch:
618
+ error = InvalidRequestError(
619
+ f'Batch too large: {len(data)} requests, maximum is {self.max_batch}'
620
+ )
621
+ response = build_error_response(error, None, self.version)
622
+ return self.serialize(response)
623
+
624
+ responses = []
625
+ for item in data:
626
+ result = self._process_single(item, context=context)
627
+ if result is not None:
628
+ responses.append(result)
629
+
630
+ if not responses:
631
+ return None
632
+
633
+ try:
634
+ return self.serialize(responses)
635
+ except (TypeError, ValueError) as e:
636
+ logger.error('Serialization error in batch response', exc_info=True)
637
+ err = InternalError(str(e))
638
+ error_response = build_error_response(err, None, self.version)
639
+ return self.serialize(error_response)
640
+
641
+ async def _handle_batch_async(self, data: list[Any], context: Any = None) -> str | None:
642
+ """Handle batch request asynchronously (concurrent execution)."""
643
+ if not data:
644
+ error = InvalidRequestError('Empty batch request')
645
+ response = build_error_response(error, None, self.version)
646
+ return self.serialize(response)
647
+
648
+ if self.max_batch != -1 and len(data) > self.max_batch:
649
+ error = InvalidRequestError(
650
+ f'Batch too large: {len(data)} requests, maximum is {self.max_batch}'
651
+ )
652
+ response = build_error_response(error, None, self.version)
653
+ return self.serialize(response)
654
+
655
+ limit = self._effective_max_concurrent
656
+ if limit == -1:
657
+ results = await asyncio.gather(*[self._process_single_async(item, context=context) for item in data])
658
+ else:
659
+ sem = asyncio.Semaphore(limit)
660
+
661
+ async def _limited(item: Any) -> Any:
662
+ async with sem:
663
+ return await self._process_single_async(item, context=context)
664
+
665
+ results = await asyncio.gather(*[_limited(item) for item in data])
666
+ responses = [r for r in results if r is not None]
667
+
668
+ if not responses:
669
+ return None
670
+
671
+ try:
672
+ return self.serialize(responses)
673
+ except (TypeError, ValueError) as e:
674
+ logger.error('Serialization error in batch response', exc_info=True)
675
+ err = InternalError(str(e))
676
+ error_response = build_error_response(err, None, self.version)
677
+ return self.serialize(error_response)
678
+
679
+ def call_method(
680
+ self,
681
+ method: str,
682
+ params: list[Any] | dict[str, Any] | None = None,
683
+ id: str | int | None = None,
684
+ validate_result: bool | None = None,
685
+ context: Any = None,
686
+ ) -> Any:
687
+ """Call a registered method directly (no JSON serialization).
688
+
689
+ Use this for internal method-to-method calls.
690
+
691
+ Args:
692
+ method: Full method name (e.g., "math.add")
693
+ params: Method parameters
694
+ id: Request ID (optional)
695
+ validate_result: Override global validate_results setting.
696
+ None = use global setting, True/False = override.
697
+ context: Optional context object passed to method
698
+
699
+ Returns:
700
+ Raw method result
701
+
702
+ Raises:
703
+ MethodNotFoundError: If method doesn't exist
704
+ InvalidParamsError: If params validation fails
705
+ InvalidResultError: If result validation fails
706
+ """
707
+ should_validate = validate_result if validate_result is not None else self.validate_results
708
+ return self._root_group.dispatch(method, params, id, validate_result=should_validate, context=context)
709
+
710
+ async def call_method_async(
711
+ self,
712
+ method: str,
713
+ params: list[Any] | dict[str, Any] | None = None,
714
+ id: str | int | None = None,
715
+ validate_result: bool | None = None,
716
+ context: Any = None,
717
+ ) -> Any:
718
+ """Call a registered method asynchronously.
719
+
720
+ Use this for internal method-to-method calls with async methods.
721
+
722
+ Args:
723
+ method: Full method name (e.g., "math.add")
724
+ params: Method parameters
725
+ id: Request ID (optional)
726
+ validate_result: Override global validate_results setting.
727
+ None = use global setting, True/False = override.
728
+ context: Optional context object passed to method
729
+
730
+ Returns:
731
+ Raw method result
732
+
733
+ Raises:
734
+ MethodNotFoundError: If method doesn't exist
735
+ InvalidParamsError: If params validation fails
736
+ InvalidResultError: If result validation fails
737
+ """
738
+ should_validate = validate_result if validate_result is not None else self.validate_results
739
+ return await self._root_group.dispatch_async(
740
+ method, params, id, validate_result=should_validate, context=context
741
+ )
742
+
743
+ def list_methods(self) -> list[str]:
744
+ """List all registered method names (with full paths).
745
+
746
+ Returns:
747
+ Sorted list of method paths (e.g., ["ping", "math.add", "sudo.user.delete"])
748
+ """
749
+ return sorted(self._root_group.list_methods(recursive=True))
750
+
751
+ def get_method(self, path: str) -> 'Any | None':
752
+ """Get method by full path.
753
+
754
+ Args:
755
+ path: Full method path (e.g., "math.add")
756
+
757
+ Returns:
758
+ Method instance or None if not found
759
+ """
760
+ try:
761
+ _, method = self._root_group.resolve_path(path)
762
+ return method
763
+ except (KeyError, MethodNotFoundError):
764
+ return None
765
+
766
+ def method(self, name_or_func: str | Callable[..., Any] | None = None) -> Callable[..., Any]:
767
+ """Decorator to register a function as a JSON-RPC method (prototyping only).
768
+
769
+ This decorator provides a quick way to register functions as JSON-RPC
770
+ methods for prototyping and simple use cases. It automatically:
771
+ - Creates a dataclass from function parameters
772
+ - Wraps the function in a Method subclass
773
+ - Registers the method with the RPC instance
774
+
775
+ LIMITATIONS (use Method subclass for production):
776
+ - No context support
777
+ - No group registration (root level only)
778
+ - No custom middleware
779
+ - Less control over parameter validation
780
+ - No notification methods (-> None return type not supported)
781
+ - PROTOTYPING ONLY - not recommended for production use
782
+
783
+ Args:
784
+ name_or_func: Optional method name (defaults to function.__name__)
785
+ Can be used as @rpc.method or @rpc.method("custom_name")
786
+
787
+ Returns:
788
+ The original function (for testing and introspection)
789
+
790
+ Raises:
791
+ TypeError: If function lacks type hints or has unsupported parameters
792
+ ValueError: If method name contains '.' or is invalid
793
+
794
+ Example - Basic usage:
795
+ >>> rpc = JSONRPC()
796
+ >>>
797
+ >>> @rpc.method
798
+ >>> def add(a: int, b: int) -> int:
799
+ >>> '''Add two numbers.'''
800
+ >>> return a + b
801
+ >>>
802
+ >>> # Use via JSON-RPC
803
+ >>> rpc.handle('{"jsonrpc": "2.0", "method": "add", "params": {"a": 1, "b": 2}, "id": 1}')
804
+ >>> '{"jsonrpc": "2.0", "result": 3, "id": 1}'
805
+ >>>
806
+ >>> # Original function still works
807
+ >>> add(1, 2)
808
+ >>> 3
809
+
810
+ Example - Custom name:
811
+ >>> @rpc.method("custom_add")
812
+ >>> def my_add(a: int, b: int) -> int:
813
+ >>> return a + b
814
+
815
+ Example - Default values:
816
+ >>> @rpc.method
817
+ >>> def greet(name: str, greeting: str = "Hello") -> str:
818
+ >>> return f"{greeting}, {name}!"
819
+
820
+ Example - Async function:
821
+ >>> @rpc.method
822
+ >>> async def fetch(url: str) -> dict:
823
+ >>> return await http_get(url)
824
+
825
+ For production use, create Method subclasses instead:
826
+ >>> class Add(Method):
827
+ >>> def execute(self, params: AddParams) -> int:
828
+ >>> return params.a + params.b
829
+ """
830
+
831
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
832
+ if isinstance(name_or_func, str):
833
+ method_name = name_or_func
834
+ else:
835
+ method_name = func.__name__
836
+
837
+ # Decorator only supports JSON-RPC 2.0 (prototyping feature)
838
+ if self.version != '2.0':
839
+ raise ValueError(
840
+ f'The @rpc.method decorator is only available for JSON-RPC 2.0 (current version: {self.version}). '
841
+ f'This restriction is intentional - the decorator is designed exclusively for prototyping. '
842
+ f'For JSON-RPC 1.0 or production use, create Method subclasses instead:\n\n'
843
+ f' class {method_name.title()}(Method):\n'
844
+ f' def execute(self, params: ParamsType) -> ResultType:\n'
845
+ f' ...'
846
+ )
847
+
848
+ if not method_name or method_name == '':
849
+ raise ValueError(
850
+ "Method name cannot be empty. Use function name or provide explicit name: @rpc.method('name')"
851
+ )
852
+
853
+ if '.' in method_name:
854
+ raise ValueError(
855
+ f"Method name '{method_name}' cannot contain '.'. "
856
+ f'The @rpc.method decorator only supports root-level registration. '
857
+ f'Groups are not supported in this simplified API. '
858
+ f'For group registration, create Method subclasses and use MethodGroup.'
859
+ )
860
+
861
+ sig = inspect.signature(func)
862
+ hints = get_type_hints(func)
863
+
864
+ _validate_decorator_function(func, sig, hints)
865
+
866
+ params_type = _create_params_dataclass(func.__name__, sig, hints)
867
+
868
+ return_type = hints['return']
869
+
870
+ is_async = asyncio.iscoroutinefunction(func)
871
+ MethodClass = _create_method_class(func, params_type, return_type, is_async)
872
+
873
+ method_instance = MethodClass()
874
+ self.register(method_name, method_instance)
875
+
876
+ return func
877
+
878
+ if callable(name_or_func):
879
+ return decorator(name_or_func)
880
+ else:
881
+ return decorator