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/__init__.py +83 -0
- jsonrpc/errors.py +131 -0
- jsonrpc/jsonrpc.py +881 -0
- jsonrpc/method.py +561 -0
- jsonrpc/openapi.py +505 -0
- jsonrpc/py.typed +0 -0
- jsonrpc/request.py +164 -0
- jsonrpc/response.py +169 -0
- jsonrpc/types.py +53 -0
- jsonrpc/validation.py +297 -0
- python_jsonrpc_lib-0.3.1.dist-info/METADATA +141 -0
- python_jsonrpc_lib-0.3.1.dist-info/RECORD +14 -0
- python_jsonrpc_lib-0.3.1.dist-info/WHEEL +4 -0
- python_jsonrpc_lib-0.3.1.dist-info/licenses/LICENSE +21 -0
jsonrpc/response.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Response building and parsing for JSON-RPC protocol."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, is_dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .errors import (
|
|
8
|
+
InvalidRequestError,
|
|
9
|
+
JSONRPCError,
|
|
10
|
+
ParseError,
|
|
11
|
+
RPCError,
|
|
12
|
+
)
|
|
13
|
+
from .types import ErrorResponse, Response, Version
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _dataclass_to_dict(value: Any) -> Any:
|
|
17
|
+
"""Convert dataclass instances to dicts recursively for JSON serialization.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
value: Any value (dataclass, list, dict, or primitive)
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Value with all dataclasses converted to dicts
|
|
24
|
+
"""
|
|
25
|
+
if is_dataclass(value) and not isinstance(value, type):
|
|
26
|
+
return asdict(value)
|
|
27
|
+
|
|
28
|
+
if isinstance(value, list):
|
|
29
|
+
return [_dataclass_to_dict(item) for item in value]
|
|
30
|
+
|
|
31
|
+
if isinstance(value, dict):
|
|
32
|
+
return {key: _dataclass_to_dict(val) for key, val in value.items()}
|
|
33
|
+
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_response(
|
|
38
|
+
result: Any,
|
|
39
|
+
id: str | int,
|
|
40
|
+
version: Version = '2.0',
|
|
41
|
+
) -> dict[str, Any]:
|
|
42
|
+
"""Build a JSON-RPC success response dict.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
result: Method result (can be dataclass, which will be converted to dict)
|
|
46
|
+
id: Request ID
|
|
47
|
+
version: Protocol version
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Response dict ready for JSON serialization
|
|
51
|
+
"""
|
|
52
|
+
if version == '2.0':
|
|
53
|
+
return {
|
|
54
|
+
'jsonrpc': '2.0',
|
|
55
|
+
'result': result,
|
|
56
|
+
'id': id,
|
|
57
|
+
}
|
|
58
|
+
else:
|
|
59
|
+
# v1: always has both result and error fields
|
|
60
|
+
return {
|
|
61
|
+
'result': result,
|
|
62
|
+
'error': None,
|
|
63
|
+
'id': id,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_error_response(
|
|
68
|
+
error: JSONRPCError | RPCError,
|
|
69
|
+
id: str | int | None,
|
|
70
|
+
version: Version = '2.0',
|
|
71
|
+
) -> dict[str, Any]:
|
|
72
|
+
"""Build a JSON-RPC error response dict.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
error: Error object or exception
|
|
76
|
+
id: Request ID (can be None for parse errors)
|
|
77
|
+
version: Protocol version
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Error response dict ready for JSON serialization
|
|
81
|
+
"""
|
|
82
|
+
error_dict = error.to_dict()
|
|
83
|
+
|
|
84
|
+
if version == '2.0':
|
|
85
|
+
return {
|
|
86
|
+
'jsonrpc': '2.0',
|
|
87
|
+
'error': error_dict,
|
|
88
|
+
'id': id,
|
|
89
|
+
}
|
|
90
|
+
else:
|
|
91
|
+
# v1: always has both result and error fields
|
|
92
|
+
return {
|
|
93
|
+
'result': None,
|
|
94
|
+
'error': error_dict,
|
|
95
|
+
'id': id,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_response(
|
|
100
|
+
data: str | bytes | dict[str, Any],
|
|
101
|
+
) -> Response | ErrorResponse:
|
|
102
|
+
"""Parse JSON-RPC response (for client-side usage).
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
data: JSON string, bytes, or already parsed dict
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Response or ErrorResponse object
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
ParseError: If JSON is invalid
|
|
112
|
+
InvalidRequestError: If response structure is invalid
|
|
113
|
+
"""
|
|
114
|
+
if isinstance(data, (str, bytes)):
|
|
115
|
+
try:
|
|
116
|
+
parsed = json.loads(data)
|
|
117
|
+
except json.JSONDecodeError as e:
|
|
118
|
+
raise ParseError(f'Invalid JSON: {e}') from e
|
|
119
|
+
else:
|
|
120
|
+
parsed = data
|
|
121
|
+
|
|
122
|
+
if not isinstance(parsed, dict):
|
|
123
|
+
raise InvalidRequestError(f'Response must be object, got {type(parsed).__name__}')
|
|
124
|
+
|
|
125
|
+
if 'jsonrpc' in parsed:
|
|
126
|
+
if parsed['jsonrpc'] != '2.0':
|
|
127
|
+
raise InvalidRequestError(f'Invalid jsonrpc version: {parsed["jsonrpc"]!r}')
|
|
128
|
+
version: Version = '2.0'
|
|
129
|
+
else:
|
|
130
|
+
version = '1.0'
|
|
131
|
+
|
|
132
|
+
response_id = parsed.get('id')
|
|
133
|
+
|
|
134
|
+
if 'error' in parsed and parsed['error'] is not None:
|
|
135
|
+
error_data = parsed['error']
|
|
136
|
+
if not isinstance(error_data, dict):
|
|
137
|
+
raise InvalidRequestError(f"Field 'error' must be object, got {type(error_data).__name__}")
|
|
138
|
+
|
|
139
|
+
code = error_data.get('code')
|
|
140
|
+
if not isinstance(code, int):
|
|
141
|
+
raise InvalidRequestError(f"Error 'code' must be integer, got {type(code).__name__}")
|
|
142
|
+
|
|
143
|
+
message = error_data.get('message', '')
|
|
144
|
+
if not isinstance(message, str):
|
|
145
|
+
raise InvalidRequestError(f"Error 'message' must be string, got {type(message).__name__}")
|
|
146
|
+
|
|
147
|
+
error = RPCError(
|
|
148
|
+
code=code,
|
|
149
|
+
message=message,
|
|
150
|
+
data=error_data.get('data'),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return ErrorResponse(
|
|
154
|
+
error=error,
|
|
155
|
+
id=response_id,
|
|
156
|
+
version=version,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if 'result' not in parsed:
|
|
160
|
+
raise InvalidRequestError("Response must have 'result' or 'error' field")
|
|
161
|
+
|
|
162
|
+
if response_id is None:
|
|
163
|
+
raise InvalidRequestError("Success response must have 'id' field")
|
|
164
|
+
|
|
165
|
+
return Response(
|
|
166
|
+
result=parsed['result'],
|
|
167
|
+
id=response_id,
|
|
168
|
+
version=version,
|
|
169
|
+
)
|
jsonrpc/types.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Core type definitions for JSON-RPC protocol."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from .errors import RPCError
|
|
7
|
+
|
|
8
|
+
Version = Literal['1.0', '2.0']
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Request:
|
|
13
|
+
"""JSON-RPC request object."""
|
|
14
|
+
|
|
15
|
+
method: str
|
|
16
|
+
params: list[Any] | dict[str, Any] | None
|
|
17
|
+
id: str | int | None
|
|
18
|
+
version: Version
|
|
19
|
+
id_was_present: bool = True
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def is_notification(self) -> bool:
|
|
23
|
+
"""Check if this request is a notification (no response expected).
|
|
24
|
+
|
|
25
|
+
JSON-RPC 2.0: notification when id field is missing (not just null).
|
|
26
|
+
JSON-RPC 1.0: NO notifications - all requests get responses.
|
|
27
|
+
|
|
28
|
+
Note: v2.0 distinguishes:
|
|
29
|
+
- Missing id field → notification (no response)
|
|
30
|
+
- id=null → normal request (returns response with id=null)
|
|
31
|
+
"""
|
|
32
|
+
if self.version == '2.0':
|
|
33
|
+
return not self.id_was_present
|
|
34
|
+
else:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Response:
|
|
40
|
+
"""JSON-RPC success response object."""
|
|
41
|
+
|
|
42
|
+
result: Any
|
|
43
|
+
id: str | int
|
|
44
|
+
version: Version
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ErrorResponse:
|
|
49
|
+
"""JSON-RPC error response object."""
|
|
50
|
+
|
|
51
|
+
error: RPCError
|
|
52
|
+
id: str | int | None
|
|
53
|
+
version: Version
|
jsonrpc/validation.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Type validation, conversion, and utility functions for JSON-RPC."""
|
|
2
|
+
|
|
3
|
+
import types
|
|
4
|
+
from dataclasses import MISSING, fields, is_dataclass
|
|
5
|
+
from typing import (
|
|
6
|
+
Any,
|
|
7
|
+
Literal,
|
|
8
|
+
Union,
|
|
9
|
+
get_args,
|
|
10
|
+
get_origin,
|
|
11
|
+
get_type_hints,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from .errors import InvalidParamsError, InvalidResultError
|
|
15
|
+
|
|
16
|
+
MAX_NESTING_DEPTH = 64
|
|
17
|
+
|
|
18
|
+
# Cache for dataclass introspection data: type_hints, field_list, field_names, required_fields
|
|
19
|
+
_params_type_cache: dict[type, tuple[dict[str, Any], tuple[Any, ...], list[str], list[str]]] = {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_params_type_info(params_type: type) -> tuple[dict[str, Any], tuple[Any, ...], list[str], list[str]]:
|
|
23
|
+
"""Get cached introspection data for a params dataclass type."""
|
|
24
|
+
info = _params_type_cache.get(params_type)
|
|
25
|
+
if info is not None:
|
|
26
|
+
return info
|
|
27
|
+
type_hints = get_type_hints(params_type)
|
|
28
|
+
field_list = fields(params_type)
|
|
29
|
+
field_names = [f.name for f in field_list]
|
|
30
|
+
required_fields = [f.name for f in field_list if f.default is MISSING and f.default_factory is MISSING]
|
|
31
|
+
info = (type_hints, field_list, field_names, required_fields)
|
|
32
|
+
_params_type_cache[params_type] = info
|
|
33
|
+
return info
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_batch(data: Any) -> bool:
|
|
37
|
+
"""Check if data represents a batch request.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
data: Parsed JSON data
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
True if data is a list (batch request)
|
|
44
|
+
"""
|
|
45
|
+
return isinstance(data, list)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _type_name(t: type) -> str:
|
|
49
|
+
"""Get human-readable name for a type."""
|
|
50
|
+
origin = get_origin(t)
|
|
51
|
+
if origin is Union:
|
|
52
|
+
args = get_args(t)
|
|
53
|
+
# Handle Optional (T | None)
|
|
54
|
+
if len(args) == 2 and type(None) in args:
|
|
55
|
+
other = args[0] if args[1] is type(None) else args[1]
|
|
56
|
+
return f'{_type_name(other)} | None'
|
|
57
|
+
return ' | '.join(_type_name(a) for a in args)
|
|
58
|
+
if origin is list:
|
|
59
|
+
args = get_args(t)
|
|
60
|
+
if args:
|
|
61
|
+
return f'list[{_type_name(args[0])}]'
|
|
62
|
+
return 'list'
|
|
63
|
+
if origin is dict:
|
|
64
|
+
args = get_args(t)
|
|
65
|
+
if args:
|
|
66
|
+
return f'dict[{_type_name(args[0])}, {_type_name(args[1])}]'
|
|
67
|
+
return 'dict'
|
|
68
|
+
if origin is Literal:
|
|
69
|
+
args = get_args(t)
|
|
70
|
+
return f'Literal{list(args)}'
|
|
71
|
+
if hasattr(t, '__name__'):
|
|
72
|
+
return t.__name__
|
|
73
|
+
return str(t)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _check_type(value: Any, expected_type: type) -> bool:
|
|
77
|
+
"""Check if value matches expected type.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
value: Value to check
|
|
81
|
+
expected_type: Expected type annotation
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
True if value matches type, False otherwise
|
|
85
|
+
"""
|
|
86
|
+
if value is None:
|
|
87
|
+
origin = get_origin(expected_type)
|
|
88
|
+
# Check for Union (typing.Union) or UnionType (T | None syntax)
|
|
89
|
+
if origin is Union or isinstance(expected_type, types.UnionType):
|
|
90
|
+
return type(None) in get_args(expected_type)
|
|
91
|
+
return expected_type is type(None)
|
|
92
|
+
|
|
93
|
+
origin = get_origin(expected_type)
|
|
94
|
+
|
|
95
|
+
if origin is Union or isinstance(expected_type, types.UnionType):
|
|
96
|
+
args = get_args(expected_type)
|
|
97
|
+
return any(_check_type(value, arg) for arg in args)
|
|
98
|
+
|
|
99
|
+
if origin is Literal:
|
|
100
|
+
return value in get_args(expected_type)
|
|
101
|
+
|
|
102
|
+
if origin is list:
|
|
103
|
+
if not isinstance(value, list):
|
|
104
|
+
return False
|
|
105
|
+
args = get_args(expected_type)
|
|
106
|
+
if args:
|
|
107
|
+
item_type = args[0]
|
|
108
|
+
return all(_check_type(item, item_type) for item in value)
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
if origin is dict:
|
|
112
|
+
if not isinstance(value, dict):
|
|
113
|
+
return False
|
|
114
|
+
args = get_args(expected_type)
|
|
115
|
+
if args:
|
|
116
|
+
key_type, val_type = args
|
|
117
|
+
return all(_check_type(k, key_type) and _check_type(v, val_type) for k, v in value.items())
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
if expected_type is Any:
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
if expected_type is int:
|
|
124
|
+
# int should not match bool (bool is subclass of int)
|
|
125
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
126
|
+
if expected_type is float:
|
|
127
|
+
# float accepts int values
|
|
128
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
129
|
+
if expected_type is bool:
|
|
130
|
+
return isinstance(value, bool)
|
|
131
|
+
if expected_type is str:
|
|
132
|
+
return isinstance(value, str)
|
|
133
|
+
|
|
134
|
+
if is_dataclass(expected_type) and isinstance(expected_type, type):
|
|
135
|
+
# Accept both dict (named params) and list (positional params)
|
|
136
|
+
return isinstance(value, (dict, list)) # Will be converted
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
return isinstance(value, expected_type)
|
|
140
|
+
except TypeError:
|
|
141
|
+
raise InvalidParamsError(
|
|
142
|
+
f"Cannot validate type '{_type_name(expected_type)}' for value of type '{type(value).__name__}'. "
|
|
143
|
+
f'Unsupported type annotation.'
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def validate_params(
|
|
148
|
+
params: list[Any] | dict[str, Any] | None,
|
|
149
|
+
params_type: type | None,
|
|
150
|
+
_depth: int = 0,
|
|
151
|
+
) -> Any:
|
|
152
|
+
"""Validate and convert params to dataclass instance.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
params: Raw params from JSON-RPC request
|
|
156
|
+
params_type: Target dataclass type (or None for no-params methods)
|
|
157
|
+
_depth: Internal recursion depth counter (do not set manually)
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Validated dataclass instance, or None if params_type is None
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
InvalidParamsError: With descriptive message on validation failure
|
|
164
|
+
"""
|
|
165
|
+
if _depth > MAX_NESTING_DEPTH:
|
|
166
|
+
raise InvalidParamsError(f'Maximum nesting depth ({MAX_NESTING_DEPTH}) exceeded')
|
|
167
|
+
|
|
168
|
+
if params_type is None or params_type is type(None):
|
|
169
|
+
if params is not None and params != [] and params != {}:
|
|
170
|
+
raise InvalidParamsError(f'Method accepts no parameters, but received: {params}')
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
if not is_dataclass(params_type):
|
|
174
|
+
raise InvalidParamsError(f'params_type must be a dataclass, got {type(params_type).__name__}')
|
|
175
|
+
|
|
176
|
+
type_hints, field_list, field_names, required_fields = _get_params_type_info(params_type)
|
|
177
|
+
|
|
178
|
+
if params is None:
|
|
179
|
+
if required_fields:
|
|
180
|
+
raise InvalidParamsError(f'Missing required parameters: {required_fields}')
|
|
181
|
+
return params_type()
|
|
182
|
+
|
|
183
|
+
if isinstance(params, list):
|
|
184
|
+
if len(params) > len(field_names):
|
|
185
|
+
raise InvalidParamsError(f'Too many positional parameters: expected {len(field_names)}, got {len(params)}')
|
|
186
|
+
params = dict(zip(field_names, params))
|
|
187
|
+
|
|
188
|
+
if not isinstance(params, dict):
|
|
189
|
+
raise InvalidParamsError(f'Parameters must be object or array, got {type(params).__name__}')
|
|
190
|
+
|
|
191
|
+
for field_name in params:
|
|
192
|
+
if field_name not in type_hints:
|
|
193
|
+
raise InvalidParamsError(f"Unknown parameter: '{field_name}'")
|
|
194
|
+
|
|
195
|
+
for field_name, value in params.items():
|
|
196
|
+
expected_type = type_hints[field_name]
|
|
197
|
+
if not _check_type(value, expected_type):
|
|
198
|
+
raise InvalidParamsError(
|
|
199
|
+
f"Parameter '{field_name}' expected type '{_type_name(expected_type)}', got '{type(value).__name__}'"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
for f in field_list:
|
|
203
|
+
if f.name not in params:
|
|
204
|
+
if f.default is MISSING and f.default_factory is MISSING:
|
|
205
|
+
raise InvalidParamsError(f"Missing required parameter: '{f.name}'")
|
|
206
|
+
|
|
207
|
+
converted_params = {}
|
|
208
|
+
for field_name, value in params.items():
|
|
209
|
+
expected_type = type_hints[field_name]
|
|
210
|
+
converted_params[field_name] = _convert_value(value, expected_type, _depth=_depth)
|
|
211
|
+
|
|
212
|
+
return params_type(**converted_params)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _convert_value(value: Any, expected_type: type, _depth: int = 0) -> Any:
|
|
216
|
+
"""Convert value to expected type, handling nested dataclasses."""
|
|
217
|
+
if value is None:
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
origin = get_origin(expected_type)
|
|
221
|
+
|
|
222
|
+
if origin is Union or isinstance(expected_type, types.UnionType):
|
|
223
|
+
args = get_args(expected_type)
|
|
224
|
+
# Try non-None types first
|
|
225
|
+
for arg in args:
|
|
226
|
+
if arg is not type(None):
|
|
227
|
+
try:
|
|
228
|
+
return _convert_value(value, arg, _depth=_depth)
|
|
229
|
+
except (TypeError, ValueError):
|
|
230
|
+
continue
|
|
231
|
+
return value
|
|
232
|
+
|
|
233
|
+
if origin is list and isinstance(value, list):
|
|
234
|
+
args = get_args(expected_type)
|
|
235
|
+
if args:
|
|
236
|
+
item_type = args[0]
|
|
237
|
+
return [_convert_value(item, item_type, _depth=_depth) for item in value]
|
|
238
|
+
return value
|
|
239
|
+
|
|
240
|
+
if origin is dict and isinstance(value, dict):
|
|
241
|
+
args = get_args(expected_type)
|
|
242
|
+
if args and len(args) == 2:
|
|
243
|
+
val_type = args[1]
|
|
244
|
+
return {k: _convert_value(v, val_type, _depth=_depth) for k, v in value.items()}
|
|
245
|
+
return value
|
|
246
|
+
|
|
247
|
+
if is_dataclass(expected_type) and isinstance(expected_type, type):
|
|
248
|
+
if isinstance(value, (dict, list)):
|
|
249
|
+
# Recursively validate nested dataclass
|
|
250
|
+
return validate_params(value, expected_type, _depth=_depth + 1)
|
|
251
|
+
|
|
252
|
+
return value
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def validate_result_type(result: Any, result_type: type) -> None:
|
|
256
|
+
"""Validate that result matches expected type.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
result: Actual return value from execute()
|
|
260
|
+
result_type: Expected type (basic type or dataclass)
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
InvalidResultError: If result doesn't match expected type
|
|
264
|
+
"""
|
|
265
|
+
if not _check_type(result, result_type):
|
|
266
|
+
raise InvalidResultError(f"Expected return type '{_type_name(result_type)}', got '{type(result).__name__}'")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _unwrap_optional(type_hint: type) -> type:
|
|
270
|
+
"""Unwrap Optional[T] or T | None to T.
|
|
271
|
+
|
|
272
|
+
For params, we want the actual type, not Optional wrapper.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
type_hint: Type annotation that may be Optional
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
Unwrapped type (T from Optional[T] or T | None)
|
|
279
|
+
|
|
280
|
+
Examples:
|
|
281
|
+
Optional[AddParams] -> AddParams
|
|
282
|
+
AddParams | None -> AddParams
|
|
283
|
+
AddParams -> AddParams
|
|
284
|
+
None -> type(None)
|
|
285
|
+
"""
|
|
286
|
+
origin = get_origin(type_hint)
|
|
287
|
+
|
|
288
|
+
# Handle Union types (including T | None syntax)
|
|
289
|
+
if origin is Union or isinstance(type_hint, types.UnionType):
|
|
290
|
+
args = get_args(type_hint)
|
|
291
|
+
|
|
292
|
+
# If it's a 2-arg Union with None, return the non-None type
|
|
293
|
+
if len(args) == 2 and type(None) in args:
|
|
294
|
+
result: type = args[0] if args[1] is type(None) else args[1]
|
|
295
|
+
return result
|
|
296
|
+
|
|
297
|
+
return type_hint
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-jsonrpc-lib
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: Simple, yet solid - Type-safe JSON-RPC 1.0/2.0 with OpenAPI support
|
|
5
|
+
Project-URL: Homepage, https://github.com/uandysmith/python-jsonrpc-lib
|
|
6
|
+
Project-URL: Documentation, https://uandysmith.github.io/python-jsonrpc-lib/
|
|
7
|
+
Project-URL: Repository, https://github.com/uandysmith/python-jsonrpc-lib
|
|
8
|
+
Project-URL: Issues, https://github.com/uandysmith/python-jsonrpc-lib/issues
|
|
9
|
+
Author: Andy Smith
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: dataclass,json-rpc,jsonrpc,protocol,rpc,validation
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff>=0.15.0; extra == 'dev'
|
|
27
|
+
Provides-Extra: docs
|
|
28
|
+
Requires-Dist: mkdocs-material>=9.5.0; extra == 'docs'
|
|
29
|
+
Requires-Dist: mkdocs>=1.5.0; extra == 'docs'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# python-jsonrpc-lib
|
|
33
|
+
|
|
34
|
+
**Simple, yet solid.** JSON-RPC 1.0/2.0 for Python.
|
|
35
|
+
|
|
36
|
+
JSON-RPC is a small protocol: a method name, some parameters, a result. python-jsonrpc-lib keeps it that way. You write ordinary Python functions and dataclasses; the library handles validation, routing, error responses, and API documentation. No framework lock-in, no external dependencies, no boilerplate.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install python-jsonrpc-lib
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quickstart
|
|
45
|
+
|
|
46
|
+
Define methods as classes with typed parameters. The library validates inputs, routes calls, and builds responses automatically.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
from jsonrpc import JSONRPC, Method, MethodGroup
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class AddParams:
|
|
54
|
+
a: int
|
|
55
|
+
b: int
|
|
56
|
+
|
|
57
|
+
class Add(Method):
|
|
58
|
+
def execute(self, params: AddParams) -> int:
|
|
59
|
+
return params.a + params.b
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class GreetParams:
|
|
63
|
+
name: str
|
|
64
|
+
greeting: str = 'Hello'
|
|
65
|
+
|
|
66
|
+
class Greet(Method):
|
|
67
|
+
def execute(self, params: GreetParams) -> str:
|
|
68
|
+
return f'{params.greeting}, {params.name}!'
|
|
69
|
+
|
|
70
|
+
rpc = JSONRPC(version='2.0')
|
|
71
|
+
rpc.register('add', Add())
|
|
72
|
+
rpc.register('greet', Greet())
|
|
73
|
+
|
|
74
|
+
response = rpc.handle('{"jsonrpc": "2.0", "method": "add", "params": {"a": 5, "b": 3}, "id": 1}')
|
|
75
|
+
# '{"jsonrpc": "2.0", "result": 8, "id": 1}'
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Pass in a JSON string, get a JSON string back. What carries it over the wire is up to you.
|
|
79
|
+
|
|
80
|
+
If `a` is `"five"` instead of `5`, the caller receives a `-32602 Invalid params` error immediately — no exception handling on your end.
|
|
81
|
+
|
|
82
|
+
The same `AddParams` dataclass drives validation, IDE autocomplete, and the OpenAPI schema.
|
|
83
|
+
|
|
84
|
+
## Why python-jsonrpc-lib?
|
|
85
|
+
|
|
86
|
+
- **Zero dependencies** — pure Python 3.11+. Nothing to pin, nothing to audit beyond the library itself.
|
|
87
|
+
- **Type validation from dataclasses** — declare parameters as a dataclass, get automatic validation and clear error messages for free.
|
|
88
|
+
- **OpenAPI docs auto-generated** — type hints and docstrings you already wrote become a full OpenAPI 3.0 spec. Point any Swagger-compatible UI at it and your API is self-documented.
|
|
89
|
+
- **Transport-agnostic** — `rpc.handle(json_string)` returns a string. HTTP, WebSocket, TCP, message queue: your choice.
|
|
90
|
+
- **Spec-compliant by default** — v1.0 and v2.0 rules enforced out of the box, configurable when you need to support legacy clients.
|
|
91
|
+
|
|
92
|
+
## Namespacing and Middleware
|
|
93
|
+
|
|
94
|
+
Use `MethodGroup` to organize methods into namespaces and add cross-cutting concerns:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
math = MethodGroup()
|
|
98
|
+
math.register('add', Add())
|
|
99
|
+
|
|
100
|
+
rpc = JSONRPC(version='2.0')
|
|
101
|
+
rpc.register('math', math)
|
|
102
|
+
|
|
103
|
+
# "math.add" is now available
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Quick Prototyping
|
|
107
|
+
|
|
108
|
+
For scripts and throwaway code, the `@rpc.method` decorator registers functions directly (v2.0 only):
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
rpc = JSONRPC(version='2.0')
|
|
112
|
+
|
|
113
|
+
@rpc.method
|
|
114
|
+
def add(a: int, b: int) -> int:
|
|
115
|
+
return a + b
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
For production use, prefer `Method` classes — they support context, middleware, and groups.
|
|
119
|
+
|
|
120
|
+
## Documentation
|
|
121
|
+
|
|
122
|
+
Full documentation with tutorials, integration guides, and API reference:
|
|
123
|
+
|
|
124
|
+
- [Tutorial: Hello World](docs/tutorial/01-hello-world.md) — first method, explained
|
|
125
|
+
- [Tutorial: Parameters](docs/tutorial/03-parameters.md) — dataclass validation in detail
|
|
126
|
+
- [Tutorial: Context](docs/tutorial/05-context.md) — authentication and per-request data
|
|
127
|
+
- [Tutorial: OpenAPI](docs/tutorial/07-openapi.md) — interactive API documentation
|
|
128
|
+
- [Flask integration](docs/integrations/flask.md)
|
|
129
|
+
- [FastAPI integration](docs/integrations/fastapi.md)
|
|
130
|
+
- [Philosophy](docs/philosophy.md) — design decisions and trade-offs
|
|
131
|
+
- [API Reference](docs/api-reference.md)
|
|
132
|
+
|
|
133
|
+
## Claude Code Integration
|
|
134
|
+
|
|
135
|
+
If you use [Claude Code](https://claude.ai/claude-code), a skill for this library is available. It gives Claude built-in knowledge of jsonrpc-lib's API: creating methods, registering them, organizing with groups, handling errors, and adding context and middleware — without having to look up docs.
|
|
136
|
+
|
|
137
|
+
To use it, add the skill file to your project's `.claude/skills/` directory.
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
jsonrpc/__init__.py,sha256=dzCKUITaRkigRAf_n84Ah_ffHgf9sGYK7Z2h9nB4-Dc,2106
|
|
2
|
+
jsonrpc/errors.py,sha256=fJ1HBSKtvgSKmZym7-7BXZQfYfBhXtnRu0vXM07JKwI,3401
|
|
3
|
+
jsonrpc/jsonrpc.py,sha256=Yd1mx6ftzAWV78zEKcXGXxlDje6BfignfCP8qGfltyQ,34432
|
|
4
|
+
jsonrpc/method.py,sha256=aLGj3QkpckEoGQT5PPt6hgSHWKoLBQjwjMwZXkIWsdg,20245
|
|
5
|
+
jsonrpc/openapi.py,sha256=gPnjNmcum7seZ_QtVWHaNsqQ3DYGHS1l46v461tRMcQ,16455
|
|
6
|
+
jsonrpc/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
jsonrpc/request.py,sha256=ki9L0dv8H1Gxffp5OEAaWgsFHih1P476CC0OZdE33ms,5106
|
|
8
|
+
jsonrpc/response.py,sha256=JPLD-xXo9SR0LtmHaa2dTn7Sp0L4H2ZvHI_eR578Uzw,4506
|
|
9
|
+
jsonrpc/types.py,sha256=qk73MfkyyXMOOYmJAg6I45FZcFzDmgUQajj4eqbtiZU,1231
|
|
10
|
+
jsonrpc/validation.py,sha256=zm2TAyTx8Hp3oJR77_7D9d-lRI1QD5BPlAnf3s2XCjw,10130
|
|
11
|
+
python_jsonrpc_lib-0.3.1.dist-info/METADATA,sha256=merT9rsOAFkY70K8I99CH44x0E6q_oEHaXWBrEt7y-4,5326
|
|
12
|
+
python_jsonrpc_lib-0.3.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
13
|
+
python_jsonrpc_lib-0.3.1.dist-info/licenses/LICENSE,sha256=NQXsc3PwCv4Fetw7R3qXdaoLue9xiSYf_dMAzKU2jYA,1063
|
|
14
|
+
python_jsonrpc_lib-0.3.1.dist-info/RECORD,,
|