rpc-python-toolkit 0.1.0__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.
@@ -0,0 +1,19 @@
1
+ from .client import RpcClient, RpcSafeClient
2
+ from .endpoint import RpcEndpoint, RpcHttpResponse, RpcSafeEndpoint
3
+ from .errors import RpcError, RpcRemoteError
4
+ from .serialization import deserialize_value, serialize_value
5
+ from .version import __version__
6
+
7
+ __all__ = [
8
+ "__version__",
9
+ "deserialize_value",
10
+ "serialize_value",
11
+ "RpcClient",
12
+ "RpcEndpoint",
13
+ "RpcError",
14
+ "RpcHttpResponse",
15
+ "RpcRemoteError",
16
+ "RpcSafeClient",
17
+ "RpcSafeEndpoint",
18
+ ]
19
+
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import urllib.error
5
+ import urllib.request
6
+ from typing import Any
7
+
8
+ from .errors import RpcError, RpcRemoteError
9
+ from .serialization import deserialize_value, serialize_value
10
+
11
+
12
+ class RpcClient:
13
+ def __init__(
14
+ self,
15
+ url: str,
16
+ *,
17
+ headers: dict[str, str] | None = None,
18
+ timeout: float = 30,
19
+ safe_enabled: bool = False,
20
+ require_safe_header: bool = True,
21
+ ):
22
+ self.url = url
23
+ self.headers = headers or {}
24
+ self.timeout = timeout
25
+ self.safe_enabled = safe_enabled
26
+ self.require_safe_header = require_safe_header
27
+ self._next_id = 1
28
+
29
+ def call(self, method: str, params: Any = None, *, request_id: Any = None) -> Any:
30
+ request = {
31
+ "jsonrpc": "2.0",
32
+ "method": method,
33
+ "id": self._make_id() if request_id is None else request_id,
34
+ }
35
+ if params is not None:
36
+ request["params"] = self._encode(params)
37
+
38
+ response = self._send(request, expect_response=True)
39
+ if "error" in response:
40
+ error = response["error"]
41
+ data = self._decode(error.get("data"), response.get("_safe_enabled", False))
42
+ raise RpcRemoteError(error.get("message", "RPC error"), error.get("code", -32603), data)
43
+
44
+ return self._decode(response.get("result"), response.get("_safe_enabled", False))
45
+
46
+ def notify(self, method: str, params: Any = None) -> None:
47
+ request = {
48
+ "jsonrpc": "2.0",
49
+ "method": method,
50
+ }
51
+ if params is not None:
52
+ request["params"] = self._encode(params)
53
+ self._send(request, expect_response=False)
54
+
55
+ def batch(self, requests: list[dict[str, Any]]) -> list[Any]:
56
+ payload = []
57
+ for item in requests:
58
+ request = {
59
+ "jsonrpc": "2.0",
60
+ "method": item["method"],
61
+ }
62
+ if "id" in item:
63
+ request["id"] = item["id"]
64
+ if "params" in item:
65
+ request["params"] = self._encode(item["params"])
66
+ payload.append(request)
67
+
68
+ response = self._send(payload, expect_response=True)
69
+ if not response:
70
+ return []
71
+
72
+ responses = response if isinstance(response, list) else [response]
73
+ results = []
74
+ for item in responses:
75
+ if "error" in item:
76
+ error = item["error"]
77
+ data = self._decode(error.get("data"), item.get("_safe_enabled", False))
78
+ raise RpcRemoteError(error.get("message", "RPC error"), error.get("code", -32603), data)
79
+ results.append(self._decode(item.get("result"), item.get("_safe_enabled", False)))
80
+ return results
81
+
82
+ def _send(self, payload: Any, *, expect_response: bool) -> Any:
83
+ body = json.dumps(payload, separators=(",", ":"), default=str).encode("utf-8")
84
+ headers = {
85
+ "content-type": "application/json",
86
+ **self.headers,
87
+ }
88
+
89
+ if self.safe_enabled:
90
+ headers["x-rpc-safe-enabled"] = "true"
91
+ else:
92
+ headers.setdefault("x-rpc-safe-enabled", "false")
93
+
94
+ request = urllib.request.Request(self.url, data=body, headers=headers, method="POST")
95
+
96
+ try:
97
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
98
+ response_body = response.read().decode("utf-8")
99
+ server_safe_header = response.headers.get("X-RPC-Safe-Enabled")
100
+ status = response.status
101
+ except urllib.error.HTTPError as exc:
102
+ response_body = exc.read().decode("utf-8")
103
+ server_safe_header = exc.headers.get("X-RPC-Safe-Enabled")
104
+ status = exc.code
105
+
106
+ if status == 204 or response_body == "":
107
+ return [] if not expect_response else {}
108
+
109
+ if self.safe_enabled and self.require_safe_header and server_safe_header is None:
110
+ raise RpcError(
111
+ "RPC Compatibility Error: safe client expected X-RPC-Safe-Enabled response header",
112
+ -32603,
113
+ )
114
+
115
+ server_safe_enabled = str(server_safe_header).lower() == "true"
116
+ decoded = json.loads(response_body)
117
+ self._attach_safe_flag(decoded, server_safe_enabled)
118
+ return decoded
119
+
120
+ def _encode(self, value: Any) -> Any:
121
+ return serialize_value(value, safe_enabled=self.safe_enabled)
122
+
123
+ @staticmethod
124
+ def _decode(value: Any, safe_enabled: bool) -> Any:
125
+ return deserialize_value(value, safe_enabled=safe_enabled)
126
+
127
+ def _make_id(self) -> int:
128
+ request_id = self._next_id
129
+ self._next_id += 1
130
+ return request_id
131
+
132
+ @staticmethod
133
+ def _attach_safe_flag(value: Any, safe_enabled: bool) -> None:
134
+ if isinstance(value, list):
135
+ for item in value:
136
+ if isinstance(item, dict):
137
+ item["_safe_enabled"] = safe_enabled
138
+ elif isinstance(value, dict):
139
+ value["_safe_enabled"] = safe_enabled
140
+
141
+
142
+ class RpcSafeClient(RpcClient):
143
+ def __init__(self, url: str, **options: Any):
144
+ options.setdefault("safe_enabled", True)
145
+ options.setdefault("require_safe_header", True)
146
+ super().__init__(url, **options)
@@ -0,0 +1,314 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import json
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from .errors import RpcError
10
+ from .protocol import has_id, make_response, validate_request_envelope
11
+ from .serialization import deserialize_value, serialize_value
12
+ from .validation import SchemaValidator
13
+ from .version import __version__
14
+
15
+
16
+ Handler = Callable[..., Any]
17
+
18
+
19
+ @dataclass
20
+ class RpcHttpResponse:
21
+ status: int
22
+ headers: dict[str, str]
23
+ body: Any | None = None
24
+
25
+
26
+ @dataclass
27
+ class MethodConfig:
28
+ handler: Handler
29
+ schema: dict[str, Any] | None = None
30
+ description: str = ""
31
+ expose_schema: bool = False
32
+
33
+
34
+ class RpcEndpoint:
35
+ def __init__(
36
+ self,
37
+ context: Any = None,
38
+ *,
39
+ safe_enabled: bool = False,
40
+ strict_mode: bool = True,
41
+ enable_introspection: bool = False,
42
+ introspection_prefix: str = "__rpc",
43
+ max_serialization_depth: int = 100,
44
+ max_deserialization_depth: int = 100,
45
+ ):
46
+ self.context = context
47
+ self.methods: dict[str, MethodConfig] = {}
48
+ self.safe_enabled = safe_enabled
49
+ self.strict_mode = strict_mode
50
+ self.enable_introspection = enable_introspection
51
+ self.introspection_prefix = introspection_prefix
52
+ self.max_serialization_depth = max_serialization_depth
53
+ self.max_deserialization_depth = max_deserialization_depth
54
+ self.validator = SchemaValidator()
55
+
56
+ if self.enable_introspection:
57
+ self._register_introspection_methods()
58
+
59
+ def add_method(
60
+ self,
61
+ name: str,
62
+ handler: Handler,
63
+ *,
64
+ schema: dict[str, Any] | None = None,
65
+ description: str = "",
66
+ expose_schema: bool = False,
67
+ ) -> None:
68
+ if not isinstance(name, str) or not name:
69
+ raise ValueError("Method name must be a non-empty string")
70
+
71
+ self.methods[name] = MethodConfig(
72
+ handler=handler,
73
+ schema=schema,
74
+ description=description,
75
+ expose_schema=expose_schema,
76
+ )
77
+
78
+ def remove_method(self, name: str) -> None:
79
+ self.methods.pop(name, None)
80
+
81
+ def list_methods(self) -> list[str]:
82
+ return list(self.methods.keys())
83
+
84
+ def handle_request(self, payload: str | bytes | dict[str, Any] | list[Any], context: dict[str, Any] | None = None) -> str:
85
+ response = self.handle_payload(payload, context)
86
+ if response.status == 204 or response.body is None:
87
+ return ""
88
+ return json.dumps(response.body, separators=(",", ":"), default=str)
89
+
90
+ def handle_payload(
91
+ self,
92
+ payload: str | bytes | dict[str, Any] | list[Any],
93
+ context: dict[str, Any] | None = None,
94
+ ) -> RpcHttpResponse:
95
+ headers = {
96
+ "content-type": "application/json",
97
+ "x-rpc-safe-enabled": "true" if self.safe_enabled else "false",
98
+ }
99
+
100
+ try:
101
+ request = self._decode_payload(payload)
102
+ except Exception as exc:
103
+ return RpcHttpResponse(
104
+ 200,
105
+ headers,
106
+ make_response(
107
+ None,
108
+ error={
109
+ "code": -32700,
110
+ "message": "Parse error",
111
+ "data": {"message": str(exc)},
112
+ },
113
+ ),
114
+ )
115
+
116
+ request_context = context or {}
117
+ try:
118
+ if isinstance(request, list):
119
+ return self._process_batch(request, request_context, headers)
120
+
121
+ result = self._process_single(request, request_context)
122
+ if result is None:
123
+ return RpcHttpResponse(204, headers)
124
+
125
+ return RpcHttpResponse(200, headers, result)
126
+ except RpcError as exc:
127
+ return RpcHttpResponse(200, headers, self._make_error_response(None, exc))
128
+ except Exception as exc:
129
+ return RpcHttpResponse(
130
+ 200,
131
+ headers,
132
+ self._make_error_response(None, RpcError(str(exc), -32603)),
133
+ )
134
+
135
+ def serialize_bigints_and_dates(self, value: Any) -> Any:
136
+ return serialize_value(
137
+ value,
138
+ safe_enabled=self.safe_enabled,
139
+ max_depth=self.max_serialization_depth,
140
+ )
141
+
142
+ def deserialize_bigints_and_dates(self, value: Any, *, safe_enabled: bool | None = None) -> Any:
143
+ return deserialize_value(
144
+ value,
145
+ safe_enabled=self.safe_enabled if safe_enabled is None else safe_enabled,
146
+ max_depth=self.max_deserialization_depth,
147
+ )
148
+
149
+ def _process_batch(
150
+ self,
151
+ batch: list[Any],
152
+ context: dict[str, Any],
153
+ headers: dict[str, str],
154
+ ) -> RpcHttpResponse:
155
+ if not batch:
156
+ return RpcHttpResponse(
157
+ 200,
158
+ headers,
159
+ make_response(None, error={"code": -32600, "message": "Invalid Request"}),
160
+ )
161
+
162
+ responses = []
163
+ for item in batch:
164
+ result = self._process_single(item, context)
165
+ if result is not None:
166
+ responses.append(result)
167
+
168
+ if not responses:
169
+ return RpcHttpResponse(204, headers)
170
+
171
+ return RpcHttpResponse(200, headers, responses)
172
+
173
+ def _process_single(self, request: Any, context: dict[str, Any]) -> dict[str, Any] | None:
174
+ try:
175
+ validate_request_envelope(request)
176
+ client_safe_enabled = self._client_safe_enabled(context)
177
+
178
+ if self.safe_enabled and self.strict_mode and not self._has_safe_header(context):
179
+ raise RpcError("Safe Mode requires X-RPC-Safe-Enabled header", -32600)
180
+
181
+ method_name = request["method"]
182
+ method = self.methods.get(method_name)
183
+ if method is None:
184
+ raise RpcError(f"Method not found: {method_name}", -32601)
185
+
186
+ params = request.get("params", {})
187
+ if client_safe_enabled:
188
+ params = self.deserialize_bigints_and_dates(params, safe_enabled=True)
189
+
190
+ self.validator.validate(params, method.schema)
191
+ result = self._execute_handler(method.handler, request, context, params)
192
+
193
+ if not has_id(request):
194
+ return None
195
+
196
+ return make_response(
197
+ request.get("id"),
198
+ result=self.serialize_bigints_and_dates(result),
199
+ )
200
+ except RpcError as exc:
201
+ if isinstance(request, dict) and not has_id(request):
202
+ return None
203
+ request_id = request.get("id") if isinstance(request, dict) else None
204
+ return self._make_error_response(request_id, exc)
205
+ except Exception as exc:
206
+ if isinstance(request, dict) and not has_id(request):
207
+ return None
208
+ request_id = request.get("id") if isinstance(request, dict) else None
209
+ return self._make_error_response(request_id, RpcError(str(exc), -32603))
210
+
211
+ def _make_error_response(self, request_id: Any, error: RpcError) -> dict[str, Any]:
212
+ body = {
213
+ "code": error.code,
214
+ "message": str(error),
215
+ }
216
+
217
+ if error.data is not None:
218
+ body["data"] = self.serialize_bigints_and_dates(error.data)
219
+
220
+ return make_response(request_id, error=body)
221
+
222
+ def _execute_handler(
223
+ self,
224
+ handler: Handler,
225
+ request: dict[str, Any],
226
+ context: dict[str, Any],
227
+ params: Any,
228
+ ) -> Any:
229
+ result = handler(request, self.context if self.context is not None else context, params)
230
+ if inspect.isawaitable(result):
231
+ raise RpcError("Async handlers require an async adapter", -32603)
232
+ return result
233
+
234
+ def _register_introspection_methods(self) -> None:
235
+ prefix = self.introspection_prefix
236
+
237
+ self.add_method(
238
+ f"{prefix}.listMethods",
239
+ lambda _request, _context, _params: [
240
+ name for name in self.methods if not name.startswith(f"{prefix}.")
241
+ ],
242
+ )
243
+ self.add_method(
244
+ f"{prefix}.version",
245
+ lambda _request, _context, _params: {
246
+ "toolkit": "rpc-python-toolkit",
247
+ "version": __version__,
248
+ "methodCount": len(
249
+ [name for name in self.methods if not name.startswith(f"{prefix}.")]
250
+ ),
251
+ },
252
+ )
253
+ self.add_method(f"{prefix}.describe", self._describe_method)
254
+ self.add_method(
255
+ f"{prefix}.capabilities",
256
+ lambda _request, _context, _params: {
257
+ "jsonrpc": "2.0",
258
+ "batch": True,
259
+ "notifications": True,
260
+ "introspection": True,
261
+ "safeMode": self.safe_enabled,
262
+ "transport": "python-http",
263
+ },
264
+ )
265
+
266
+ def _describe_method(self, _request: dict[str, Any], _context: Any, params: Any) -> dict[str, Any]:
267
+ if not isinstance(params, dict) or not isinstance(params.get("method"), str):
268
+ raise RpcError("Invalid params: method name required", -32602)
269
+
270
+ method_name = params["method"]
271
+ if method_name.startswith(f"{self.introspection_prefix}."):
272
+ raise RpcError("Cannot describe introspection methods", -32601)
273
+
274
+ method = self.methods.get(method_name)
275
+ if method is None:
276
+ raise RpcError(f"Method not found: {method_name}", -32601)
277
+
278
+ return {
279
+ "name": method_name,
280
+ "description": method.description,
281
+ "schema": method.schema if method.expose_schema else None,
282
+ }
283
+
284
+ @staticmethod
285
+ def _decode_payload(payload: str | bytes | dict[str, Any] | list[Any]) -> Any:
286
+ if isinstance(payload, bytes):
287
+ payload = payload.decode("utf-8")
288
+
289
+ if isinstance(payload, str):
290
+ return json.loads(payload)
291
+
292
+ return payload
293
+
294
+ @staticmethod
295
+ def _normalized_headers(context: dict[str, Any]) -> dict[str, str]:
296
+ headers = context.get("headers", {}) or {}
297
+ return {str(key).lower(): str(value) for key, value in headers.items()}
298
+
299
+ def _has_safe_header(self, context: dict[str, Any]) -> bool:
300
+ headers = self._normalized_headers(context)
301
+ return "x-rpc-safe-enabled" in headers or "x-rpc-safe" in headers
302
+
303
+ def _client_safe_enabled(self, context: dict[str, Any]) -> bool:
304
+ headers = self._normalized_headers(context)
305
+ value = headers.get("x-rpc-safe-enabled", headers.get("x-rpc-safe"))
306
+ return str(value).lower() == "true"
307
+
308
+
309
+ class RpcSafeEndpoint(RpcEndpoint):
310
+ def __init__(self, context: Any = None, **options: Any):
311
+ options.setdefault("safe_enabled", True)
312
+ options.setdefault("strict_mode", True)
313
+ super().__init__(context, **options)
314
+
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class RpcError(Exception):
5
+ """JSON-RPC error with code and optional data."""
6
+
7
+ def __init__(self, message: str, code: int = -32603, data: object = None):
8
+ super().__init__(message)
9
+ self.code = code
10
+ self.data = data
11
+
12
+
13
+ class RpcRemoteError(RpcError):
14
+ """Error returned by a remote JSON-RPC endpoint."""
15
+
16
+
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .errors import RpcError
6
+
7
+
8
+ def has_id(request: dict[str, Any]) -> bool:
9
+ return "id" in request
10
+
11
+
12
+ def make_response(
13
+ request_id: Any,
14
+ *,
15
+ result: Any = None,
16
+ error: dict[str, Any] | None = None,
17
+ ) -> dict[str, Any]:
18
+ response = {"jsonrpc": "2.0", "id": request_id}
19
+ if error is not None:
20
+ response["error"] = error
21
+ else:
22
+ response["result"] = result
23
+ return response
24
+
25
+
26
+ def validate_request_envelope(request: Any) -> None:
27
+ if not isinstance(request, dict):
28
+ raise RpcError("Invalid Request", -32600)
29
+
30
+ if request.get("jsonrpc") != "2.0":
31
+ raise RpcError("Invalid JSON-RPC version", -32600)
32
+
33
+ method = request.get("method")
34
+ if not isinstance(method, str) or not method:
35
+ raise RpcError("Method not specified or invalid", -32600)
36
+
37
+ if "params" in request and not isinstance(request["params"], (dict, list)):
38
+ raise RpcError("Invalid params", -32602)
39
+
40
+ if "id" in request and not isinstance(request["id"], (str, int, float, type(None))):
41
+ raise RpcError("Invalid request id", -32600)
42
+
@@ -0,0 +1,222 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as _dt
4
+ import re
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import is_dataclass, asdict
7
+ from typing import Any
8
+
9
+ JS_SAFE_INTEGER_MAX = 9007199254740991
10
+ DEFAULT_MAX_DEPTH = 100
11
+ _BIGINT_RE = re.compile(r"^-?\d+n$")
12
+ _ISO_DATE_RE = re.compile(
13
+ r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$"
14
+ )
15
+
16
+
17
+ def serialize_value(
18
+ value: Any,
19
+ *,
20
+ safe_enabled: bool = False,
21
+ max_depth: int = DEFAULT_MAX_DEPTH,
22
+ _depth: int = 0,
23
+ _seen: set[int] | None = None,
24
+ ) -> Any:
25
+ """Recursively encode values using RPC Toolkit Safe Mode conventions."""
26
+
27
+ if _depth > max_depth:
28
+ raise ValueError("Serialization depth limit exceeded")
29
+
30
+ seen = _seen if _seen is not None else set()
31
+
32
+ if isinstance(value, _dt.datetime):
33
+ text = value.isoformat()
34
+ return f"D:{text}" if safe_enabled else text
35
+
36
+ if isinstance(value, _dt.date):
37
+ text = _dt.datetime.combine(value, _dt.time()).isoformat()
38
+ return f"D:{text}" if safe_enabled else text
39
+
40
+ if isinstance(value, bool) or value is None:
41
+ return value
42
+
43
+ if isinstance(value, int):
44
+ if abs(value) > JS_SAFE_INTEGER_MAX:
45
+ return f"{value}n"
46
+ return value
47
+
48
+ if isinstance(value, str):
49
+ return f"S:{value}" if safe_enabled else value
50
+
51
+ if isinstance(value, Mapping):
52
+ return _serialize_mapping(value, safe_enabled, max_depth, _depth, seen)
53
+
54
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
55
+ return _serialize_sequence(value, safe_enabled, max_depth, _depth, seen)
56
+
57
+ if is_dataclass(value) and not isinstance(value, type):
58
+ return serialize_value(
59
+ asdict(value),
60
+ safe_enabled=safe_enabled,
61
+ max_depth=max_depth,
62
+ _depth=_depth + 1,
63
+ _seen=seen,
64
+ )
65
+
66
+ return value
67
+
68
+
69
+ def deserialize_value(
70
+ value: Any,
71
+ *,
72
+ safe_enabled: bool = False,
73
+ max_depth: int = DEFAULT_MAX_DEPTH,
74
+ _depth: int = 0,
75
+ _seen: set[int] | None = None,
76
+ ) -> Any:
77
+ """Recursively decode values using RPC Toolkit Safe Mode conventions."""
78
+
79
+ if _depth > max_depth:
80
+ raise ValueError("Deserialization depth limit exceeded")
81
+
82
+ seen = _seen if _seen is not None else set()
83
+
84
+ if isinstance(value, str):
85
+ if safe_enabled and value.startswith("S:"):
86
+ return value[2:]
87
+
88
+ if safe_enabled and value.startswith("D:"):
89
+ return _parse_datetime(value[2:], fallback=value)
90
+
91
+ if _BIGINT_RE.match(value):
92
+ return int(value[:-1])
93
+
94
+ if not safe_enabled and _ISO_DATE_RE.match(value):
95
+ return _parse_datetime(value, fallback=value)
96
+
97
+ return value
98
+
99
+ if isinstance(value, Mapping):
100
+ return _deserialize_mapping(value, safe_enabled, max_depth, _depth, seen)
101
+
102
+ if isinstance(value, list):
103
+ return _deserialize_sequence(value, safe_enabled, max_depth, _depth, seen)
104
+
105
+ return value
106
+
107
+
108
+ def _serialize_mapping(
109
+ value: Mapping[Any, Any],
110
+ safe_enabled: bool,
111
+ max_depth: int,
112
+ depth: int,
113
+ seen: set[int],
114
+ ) -> dict[str, Any]:
115
+ marker = id(value)
116
+ if marker in seen:
117
+ raise ValueError("Circular reference detected during serialization")
118
+
119
+ seen.add(marker)
120
+ try:
121
+ return {
122
+ str(key): serialize_value(
123
+ item,
124
+ safe_enabled=safe_enabled,
125
+ max_depth=max_depth,
126
+ _depth=depth + 1,
127
+ _seen=seen,
128
+ )
129
+ for key, item in value.items()
130
+ }
131
+ finally:
132
+ seen.remove(marker)
133
+
134
+
135
+ def _serialize_sequence(
136
+ value: Sequence[Any],
137
+ safe_enabled: bool,
138
+ max_depth: int,
139
+ depth: int,
140
+ seen: set[int],
141
+ ) -> list[Any]:
142
+ marker = id(value)
143
+ if marker in seen:
144
+ raise ValueError("Circular reference detected during serialization")
145
+
146
+ seen.add(marker)
147
+ try:
148
+ return [
149
+ serialize_value(
150
+ item,
151
+ safe_enabled=safe_enabled,
152
+ max_depth=max_depth,
153
+ _depth=depth + 1,
154
+ _seen=seen,
155
+ )
156
+ for item in value
157
+ ]
158
+ finally:
159
+ seen.remove(marker)
160
+
161
+
162
+ def _deserialize_mapping(
163
+ value: Mapping[Any, Any],
164
+ safe_enabled: bool,
165
+ max_depth: int,
166
+ depth: int,
167
+ seen: set[int],
168
+ ) -> dict[str, Any]:
169
+ marker = id(value)
170
+ if marker in seen:
171
+ raise ValueError("Circular reference detected during deserialization")
172
+
173
+ seen.add(marker)
174
+ try:
175
+ return {
176
+ str(key): deserialize_value(
177
+ item,
178
+ safe_enabled=safe_enabled,
179
+ max_depth=max_depth,
180
+ _depth=depth + 1,
181
+ _seen=seen,
182
+ )
183
+ for key, item in value.items()
184
+ }
185
+ finally:
186
+ seen.remove(marker)
187
+
188
+
189
+ def _deserialize_sequence(
190
+ value: list[Any],
191
+ safe_enabled: bool,
192
+ max_depth: int,
193
+ depth: int,
194
+ seen: set[int],
195
+ ) -> list[Any]:
196
+ marker = id(value)
197
+ if marker in seen:
198
+ raise ValueError("Circular reference detected during deserialization")
199
+
200
+ seen.add(marker)
201
+ try:
202
+ return [
203
+ deserialize_value(
204
+ item,
205
+ safe_enabled=safe_enabled,
206
+ max_depth=max_depth,
207
+ _depth=depth + 1,
208
+ _seen=seen,
209
+ )
210
+ for item in value
211
+ ]
212
+ finally:
213
+ seen.remove(marker)
214
+
215
+
216
+ def _parse_datetime(value: str, *, fallback: str) -> _dt.datetime | str:
217
+ try:
218
+ normalized = value.replace("Z", "+00:00")
219
+ return _dt.datetime.fromisoformat(normalized)
220
+ except ValueError:
221
+ return fallback
222
+
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import jsonschema
6
+
7
+ from .errors import RpcError
8
+
9
+
10
+ class SchemaValidator:
11
+ def validate(self, params: Any, schema: dict[str, Any] | None) -> None:
12
+ if not schema:
13
+ return
14
+
15
+ try:
16
+ jsonschema.validate(instance=params, schema=schema)
17
+ except jsonschema.ValidationError as exc:
18
+ raise RpcError(
19
+ "Validation failed",
20
+ -32602,
21
+ {
22
+ "message": exc.message,
23
+ "path": list(exc.path),
24
+ },
25
+ ) from exc
26
+
@@ -0,0 +1,2 @@
1
+ __version__ = "0.1.0"
2
+
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.4
2
+ Name: rpc-python-toolkit
3
+ Version: 0.1.0
4
+ Summary: Framework-agnostic Python JSON-RPC 2.0 toolkit with Safe Mode interoperability.
5
+ Author: Nicola Carpanese
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/n-car/rpc-python-toolkit
8
+ Project-URL: Repository, https://github.com/n-car/rpc-python-toolkit
9
+ Project-URL: Issues, https://github.com/n-car/rpc-python-toolkit/issues
10
+ Keywords: json-rpc,json-rpc-2.0,rpc,python,http,client,server,safe-mode,iot,embedded
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Internet :: WWW/HTTP
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: jsonschema>=4.0.0
24
+ Dynamic: license-file
25
+
26
+ # RPC Python Toolkit
27
+
28
+ Framework-agnostic JSON-RPC 2.0 toolkit for Python.
29
+
30
+ Use this package when you need a small Python JSON-RPC 2.0 client/server core that can be embedded directly or adapted to HTTP frameworks. It follows the same protocol conventions used across the RPC Toolkit ecosystem.
31
+
32
+ ## Project Status
33
+
34
+ Small early package with the core JSON-RPC behavior implemented.
35
+
36
+ - Published on PyPI as `rpc-python-toolkit`.
37
+ - Standard JSON-RPC 2.0 remains the default behavior.
38
+ - Safe Mode follows the same `X-RPC-Safe-Enabled` and value marker conventions used by `rpc-express-toolkit`.
39
+ - The public API may still change before a stable Python release.
40
+
41
+ ## Installation
42
+
43
+ Install from PyPI:
44
+
45
+ ```bash
46
+ pip install rpc-python-toolkit
47
+ ```
48
+
49
+ For development snapshots, install from GitHub or from a local checkout:
50
+
51
+ ```bash
52
+ pip install git+https://github.com/n-car/rpc-python-toolkit.git
53
+ ```
54
+
55
+ For local development:
56
+
57
+ ```bash
58
+ pip install -e .
59
+ python -m unittest discover
60
+ ```
61
+
62
+ ## What Is Implemented
63
+
64
+ - Framework-independent `RpcEndpoint`
65
+ - Standard JSON-RPC calls, notifications, and batch requests
66
+ - `RpcClient` and `RpcSafeClient` over HTTP using Python standard library
67
+ - JSON Schema validation through `jsonschema`
68
+ - Optional RPC Toolkit Safe Mode over HTTP headers
69
+ - Method introspection with `__rpc.*` methods
70
+
71
+ ## What Is Not Implemented Yet
72
+
73
+ - Built-in ASGI/WSGI adapters for FastAPI, Starlette, Flask, or Django
74
+ - Async endpoint handlers
75
+ - OpenRPC export
76
+ - Built-in HTTP server adapters
77
+
78
+ ## Quick Start
79
+
80
+ ```python
81
+ from rpc_python_toolkit import RpcEndpoint
82
+
83
+ rpc = RpcEndpoint(enable_introspection=True)
84
+
85
+ def test(request, context, params):
86
+ return {"ok": True, "params": params}
87
+
88
+ rpc.add_method("test", test)
89
+
90
+ response = rpc.handle_payload({
91
+ "jsonrpc": "2.0",
92
+ "method": "test",
93
+ "params": {"value": 123},
94
+ "id": 1,
95
+ })
96
+
97
+ print(response.body)
98
+ ```
99
+
100
+ Result:
101
+
102
+ ```json
103
+ {"jsonrpc": "2.0", "id": 1, "result": {"ok": true, "params": {"value": 123}}}
104
+ ```
105
+
106
+ ## HTTP Client
107
+
108
+ ```python
109
+ from rpc_python_toolkit import RpcClient
110
+
111
+ client = RpcClient("http://localhost:3000/api")
112
+ result = client.call("test", {"value": 123})
113
+ ```
114
+
115
+ ## Safe Mode
116
+
117
+ Use `RpcSafeEndpoint` and `RpcSafeClient` when both sides support RPC Toolkit Safe Mode:
118
+
119
+ ```python
120
+ from rpc_python_toolkit import RpcSafeEndpoint, RpcSafeClient
121
+
122
+ rpc = RpcSafeEndpoint()
123
+ client = RpcSafeClient("http://localhost:3000/api")
124
+ ```
125
+
126
+ Safe Mode enables `X-RPC-Safe-Enabled` negotiation and recursive value encoding/decoding:
127
+
128
+ - strings are encoded as `S:<value>`
129
+ - datetimes are encoded as `D:<iso-date>`
130
+ - large integers are encoded as `<digits>n`
131
+
132
+ Python exposes arbitrary precision integers as `int`. BigInt marker strings from other RPC Toolkit implementations are decoded to Python `int` values when they are real markers, while literal marker-like strings are protected by the `S:` prefix in Safe Mode.
133
+
134
+ ## Examples
135
+
136
+ Runnable examples are available in [`examples/`](examples/):
137
+
138
+ - [`basic_server.py`](examples/basic_server.py) - direct endpoint usage without an HTTP adapter.
139
+ - [`basic_client.py`](examples/basic_client.py) - standard HTTP client call against an in-process endpoint.
140
+ - [`schema_validation.py`](examples/schema_validation.py) - method schema validation and JSON-RPC error output.
141
+ - [`safe_mode.py`](examples/safe_mode.py) - `RpcSafeClient` to `RpcSafeEndpoint` round-trip for strings, dates, and large integers.
142
+
143
+ For local checkout usage:
144
+
145
+ ```bash
146
+ PYTHONPATH=src python3 examples/basic_server.py
147
+ PYTHONPATH=src python3 examples/basic_client.py
148
+ PYTHONPATH=src python3 examples/schema_validation.py
149
+ PYTHONPATH=src python3 examples/safe_mode.py
150
+ ```
151
+
152
+ ## Related Projects
153
+
154
+ - [rpc-express-toolkit](https://github.com/n-car/rpc-express-toolkit)
155
+ - [rpc-node-toolkit](https://github.com/n-car/rpc-node-toolkit)
156
+ - [rpc-toolkit-js-client](https://github.com/n-car/rpc-toolkit-js-client)
157
+ - [rpc-dotnet-toolkit](https://github.com/n-car/rpc-dotnet-toolkit)
158
+ - [rpc-java-toolkit](https://github.com/n-car/rpc-java-toolkit)
159
+ - [rpc-php-toolkit](https://github.com/n-car/rpc-php-toolkit)
160
+ - [rpc-arduino-toolkit](https://github.com/n-car/rpc-arduino-toolkit)
161
+ - [node-red-contrib-rpc-toolkit](https://github.com/n-car/node-red-contrib-rpc-toolkit)
162
+
163
+ ## License
164
+
165
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,13 @@
1
+ rpc_python_toolkit/__init__.py,sha256=ph_tY5IvR-ihwa9ubr-vKWIhS4ru73RmHVbRjt-7YME,477
2
+ rpc_python_toolkit/client.py,sha256=QD3vf8OSdDe42522nwWEy2gEyNAjNJzayezvqN6jP5A,5276
3
+ rpc_python_toolkit/endpoint.py,sha256=vMizSI0lQH2HpocWyZS4238NE2wK2peQmkjSvYArVXo,10867
4
+ rpc_python_toolkit/errors.py,sha256=en8mmO56IeT8OfRCTheMxNOUN09RpdLqR8X4BQTB6gk,374
5
+ rpc_python_toolkit/protocol.py,sha256=MMQ1tF1waizZ0dlq3LZc_Dx9tVN00KFLaWh70wR9g7A,1145
6
+ rpc_python_toolkit/serialization.py,sha256=gWI0UyxzNj-EATaBkSG9i-b-De-bH49igqBmzkfE9fM,5755
7
+ rpc_python_toolkit/validation.py,sha256=MXibLokBpBq_mDo7yxZ_64S6Mc_S1LWPM6UYsdxTXZg,617
8
+ rpc_python_toolkit/version.py,sha256=p4MxVO8ih2-hnh7INVBkrqMKBDGTkMso1LEbM6mx3jg,23
9
+ rpc_python_toolkit-0.1.0.dist-info/licenses/LICENSE,sha256=-JOXJbeUglpNN6FaH-5OkUacdU1e8cXsttGJxO8EAAY,1074
10
+ rpc_python_toolkit-0.1.0.dist-info/METADATA,sha256=6SM4BQFhVt9QUCPJmp3k20qKy5Zcugd0hEhYQPPkm08,5317
11
+ rpc_python_toolkit-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ rpc_python_toolkit-0.1.0.dist-info/top_level.txt,sha256=KeHhMmqjDgfOlP13qPd9_FhW3vXFe2xTL3HzuDAqiv4,19
13
+ rpc_python_toolkit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nicola Carpanese
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
@@ -0,0 +1 @@
1
+ rpc_python_toolkit