rpc-python-toolkit 0.1.0__tar.gz → 0.2.1__tar.gz

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.
Files changed (22) hide show
  1. {rpc_python_toolkit-0.1.0/src/rpc_python_toolkit.egg-info → rpc_python_toolkit-0.2.1}/PKG-INFO +48 -3
  2. {rpc_python_toolkit-0.1.0 → rpc_python_toolkit-0.2.1}/README.md +47 -2
  3. {rpc_python_toolkit-0.1.0 → rpc_python_toolkit-0.2.1}/pyproject.toml +1 -1
  4. {rpc_python_toolkit-0.1.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit/__init__.py +2 -2
  5. {rpc_python_toolkit-0.1.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit/endpoint.py +75 -18
  6. {rpc_python_toolkit-0.1.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit/protocol.py +11 -4
  7. rpc_python_toolkit-0.2.1/src/rpc_python_toolkit/version.py +1 -0
  8. {rpc_python_toolkit-0.1.0 → rpc_python_toolkit-0.2.1/src/rpc_python_toolkit.egg-info}/PKG-INFO +48 -3
  9. {rpc_python_toolkit-0.1.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit.egg-info/SOURCES.txt +3 -1
  10. {rpc_python_toolkit-0.1.0 → rpc_python_toolkit-0.2.1}/tests/test_core.py +62 -0
  11. rpc_python_toolkit-0.2.1/tests/test_errors.py +159 -0
  12. rpc_python_toolkit-0.2.1/tests/test_protocol.py +111 -0
  13. rpc_python_toolkit-0.1.0/src/rpc_python_toolkit/version.py +0 -2
  14. {rpc_python_toolkit-0.1.0 → rpc_python_toolkit-0.2.1}/LICENSE +0 -0
  15. {rpc_python_toolkit-0.1.0 → rpc_python_toolkit-0.2.1}/setup.cfg +0 -0
  16. {rpc_python_toolkit-0.1.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit/client.py +0 -0
  17. {rpc_python_toolkit-0.1.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit/errors.py +0 -0
  18. {rpc_python_toolkit-0.1.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit/serialization.py +0 -0
  19. {rpc_python_toolkit-0.1.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit/validation.py +0 -0
  20. {rpc_python_toolkit-0.1.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit.egg-info/dependency_links.txt +0 -0
  21. {rpc_python_toolkit-0.1.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit.egg-info/requires.txt +0 -0
  22. {rpc_python_toolkit-0.1.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rpc-python-toolkit
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: Framework-agnostic Python JSON-RPC 2.0 toolkit with Safe Mode interoperability.
5
5
  Author: Nicola Carpanese
6
6
  License-Expression: MIT
@@ -25,19 +25,34 @@ Dynamic: license-file
25
25
 
26
26
  # RPC Python Toolkit
27
27
 
28
+ [![CI](https://github.com/n-car/rpc-python-toolkit/actions/workflows/ci.yml/badge.svg)](https://github.com/n-car/rpc-python-toolkit/actions/workflows/ci.yml)
29
+ [![PyPI Version](https://img.shields.io/pypi/v/rpc-python-toolkit.svg)](https://pypi.org/project/rpc-python-toolkit/)
30
+ [![Python Versions](https://img.shields.io/pypi/pyversions/rpc-python-toolkit.svg)](https://pypi.org/project/rpc-python-toolkit/)
31
+ [![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
32
+ [![Status](https://img.shields.io/badge/status-alpha-orange.svg)](https://pypi.org/project/rpc-python-toolkit/)
33
+
28
34
  Framework-agnostic JSON-RPC 2.0 toolkit for Python.
29
35
 
30
36
  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
37
 
32
38
  ## Project Status
33
39
 
34
- Small early package with the core JSON-RPC behavior implemented.
40
+ Alpha package with the core JSON-RPC behavior implemented.
35
41
 
36
42
  - Published on PyPI as `rpc-python-toolkit`.
37
43
  - Standard JSON-RPC 2.0 remains the default behavior.
38
44
  - Safe Mode follows the same `X-RPC-Safe-Enabled` and value marker conventions used by `rpc-express-toolkit`.
39
45
  - The public API may still change before a stable Python release.
40
46
 
47
+ ## Release Notes
48
+
49
+ ### 0.2.1 - 2026-10-12
50
+
51
+ - Sanitize unexpected errors and `RpcError(-32603)` by default, removing internal messages and data while preserving explicit application errors.
52
+ - Validate request envelopes and reject non-JSON numeric constants, scalar/null parameters, boolean IDs, and non-finite IDs.
53
+ - Return errors for invalid batch elements, distinguish explicit null IDs from notifications, and preserve JSON payload shapes.
54
+ - Add error-handling and protocol regression tests, and document the tightened wire contract and trusted handler context.
55
+
41
56
  ## Installation
42
57
 
43
58
  Install from PyPI:
@@ -80,7 +95,11 @@ python -m unittest discover
80
95
  ```python
81
96
  from rpc_python_toolkit import RpcEndpoint
82
97
 
83
- rpc = RpcEndpoint(enable_introspection=True)
98
+ rpc = RpcEndpoint(
99
+ enable_introspection=True,
100
+ enable_batch=True,
101
+ max_batch_size=100,
102
+ )
84
103
 
85
104
  def test(request, context, params):
86
105
  return {"ok": True, "params": params}
@@ -97,6 +116,28 @@ response = rpc.handle_payload({
97
116
  print(response.body)
98
117
  ```
99
118
 
119
+ Batch can be disabled with `enable_batch=False`. Introspection remains opt-in.
120
+ Unexpected errors and `RpcError` instances with code `-32603` return only
121
+ `Internal error` by default, without internal messages or data. Other explicit
122
+ `RpcError` codes, messages, and data are preserved. Parse errors omit diagnostic
123
+ data. Use `sanitize_errors=False` only for local debugging to restore details.
124
+
125
+ Only valid requests without an `id` are notifications. An explicit `id: null`
126
+ receives a response; malformed envelopes (including scalar/null `params` or
127
+ boolean/non-finite IDs) return `-32600` with `id: null`. Method parameter schema
128
+ errors remain `-32602`.
129
+ Malformed JSON, including the non-JSON constants `NaN`, `Infinity`, and `-Infinity`,
130
+ returns `-32700`. Invalid elements in a batch produce individual error responses;
131
+ valid notifications remain silent even when their method fails. Objects, arrays,
132
+ and null results retain their JSON shapes.
133
+
134
+ When both application and request contexts are dictionaries, handlers receive a
135
+ merged dictionary and request values take precedence. For a non-dictionary
136
+ application context plus request data, handlers receive `RpcCallContext` with
137
+ separate `application` and `request` attributes.
138
+ These contexts are supplied by application/adapter code. Client `params` remain a
139
+ separate third handler argument and cannot replace trusted context.
140
+
100
141
  Result:
101
142
 
102
143
  ```json
@@ -131,6 +172,10 @@ Safe Mode enables `X-RPC-Safe-Enabled` negotiation and recursive value encoding/
131
172
 
132
173
  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
174
 
175
+ Safe Mode transforms payload values, not method names, request IDs, error codes,
176
+ or error messages. Application `RpcError.data` is encoded like a result; sanitized
177
+ internal errors expose neither their original message nor their data.
178
+
134
179
  ## Examples
135
180
 
136
181
  Runnable examples are available in [`examples/`](examples/):
@@ -1,18 +1,33 @@
1
1
  # RPC Python Toolkit
2
2
 
3
+ [![CI](https://github.com/n-car/rpc-python-toolkit/actions/workflows/ci.yml/badge.svg)](https://github.com/n-car/rpc-python-toolkit/actions/workflows/ci.yml)
4
+ [![PyPI Version](https://img.shields.io/pypi/v/rpc-python-toolkit.svg)](https://pypi.org/project/rpc-python-toolkit/)
5
+ [![Python Versions](https://img.shields.io/pypi/pyversions/rpc-python-toolkit.svg)](https://pypi.org/project/rpc-python-toolkit/)
6
+ [![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
7
+ [![Status](https://img.shields.io/badge/status-alpha-orange.svg)](https://pypi.org/project/rpc-python-toolkit/)
8
+
3
9
  Framework-agnostic JSON-RPC 2.0 toolkit for Python.
4
10
 
5
11
  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.
6
12
 
7
13
  ## Project Status
8
14
 
9
- Small early package with the core JSON-RPC behavior implemented.
15
+ Alpha package with the core JSON-RPC behavior implemented.
10
16
 
11
17
  - Published on PyPI as `rpc-python-toolkit`.
12
18
  - Standard JSON-RPC 2.0 remains the default behavior.
13
19
  - Safe Mode follows the same `X-RPC-Safe-Enabled` and value marker conventions used by `rpc-express-toolkit`.
14
20
  - The public API may still change before a stable Python release.
15
21
 
22
+ ## Release Notes
23
+
24
+ ### 0.2.1 - 2026-10-12
25
+
26
+ - Sanitize unexpected errors and `RpcError(-32603)` by default, removing internal messages and data while preserving explicit application errors.
27
+ - Validate request envelopes and reject non-JSON numeric constants, scalar/null parameters, boolean IDs, and non-finite IDs.
28
+ - Return errors for invalid batch elements, distinguish explicit null IDs from notifications, and preserve JSON payload shapes.
29
+ - Add error-handling and protocol regression tests, and document the tightened wire contract and trusted handler context.
30
+
16
31
  ## Installation
17
32
 
18
33
  Install from PyPI:
@@ -55,7 +70,11 @@ python -m unittest discover
55
70
  ```python
56
71
  from rpc_python_toolkit import RpcEndpoint
57
72
 
58
- rpc = RpcEndpoint(enable_introspection=True)
73
+ rpc = RpcEndpoint(
74
+ enable_introspection=True,
75
+ enable_batch=True,
76
+ max_batch_size=100,
77
+ )
59
78
 
60
79
  def test(request, context, params):
61
80
  return {"ok": True, "params": params}
@@ -72,6 +91,28 @@ response = rpc.handle_payload({
72
91
  print(response.body)
73
92
  ```
74
93
 
94
+ Batch can be disabled with `enable_batch=False`. Introspection remains opt-in.
95
+ Unexpected errors and `RpcError` instances with code `-32603` return only
96
+ `Internal error` by default, without internal messages or data. Other explicit
97
+ `RpcError` codes, messages, and data are preserved. Parse errors omit diagnostic
98
+ data. Use `sanitize_errors=False` only for local debugging to restore details.
99
+
100
+ Only valid requests without an `id` are notifications. An explicit `id: null`
101
+ receives a response; malformed envelopes (including scalar/null `params` or
102
+ boolean/non-finite IDs) return `-32600` with `id: null`. Method parameter schema
103
+ errors remain `-32602`.
104
+ Malformed JSON, including the non-JSON constants `NaN`, `Infinity`, and `-Infinity`,
105
+ returns `-32700`. Invalid elements in a batch produce individual error responses;
106
+ valid notifications remain silent even when their method fails. Objects, arrays,
107
+ and null results retain their JSON shapes.
108
+
109
+ When both application and request contexts are dictionaries, handlers receive a
110
+ merged dictionary and request values take precedence. For a non-dictionary
111
+ application context plus request data, handlers receive `RpcCallContext` with
112
+ separate `application` and `request` attributes.
113
+ These contexts are supplied by application/adapter code. Client `params` remain a
114
+ separate third handler argument and cannot replace trusted context.
115
+
75
116
  Result:
76
117
 
77
118
  ```json
@@ -106,6 +147,10 @@ Safe Mode enables `X-RPC-Safe-Enabled` negotiation and recursive value encoding/
106
147
 
107
148
  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.
108
149
 
150
+ Safe Mode transforms payload values, not method names, request IDs, error codes,
151
+ or error messages. Application `RpcError.data` is encoded like a result; sanitized
152
+ internal errors expose neither their original message nor their data.
153
+
109
154
  ## Examples
110
155
 
111
156
  Runnable examples are available in [`examples/`](examples/):
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "rpc-python-toolkit"
7
- version = "0.1.0"
7
+ version = "0.2.1"
8
8
  description = "Framework-agnostic Python JSON-RPC 2.0 toolkit with Safe Mode interoperability."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,5 +1,5 @@
1
1
  from .client import RpcClient, RpcSafeClient
2
- from .endpoint import RpcEndpoint, RpcHttpResponse, RpcSafeEndpoint
2
+ from .endpoint import RpcCallContext, RpcEndpoint, RpcHttpResponse, RpcSafeEndpoint
3
3
  from .errors import RpcError, RpcRemoteError
4
4
  from .serialization import deserialize_value, serialize_value
5
5
  from .version import __version__
@@ -10,10 +10,10 @@ __all__ = [
10
10
  "serialize_value",
11
11
  "RpcClient",
12
12
  "RpcEndpoint",
13
+ "RpcCallContext",
13
14
  "RpcError",
14
15
  "RpcHttpResponse",
15
16
  "RpcRemoteError",
16
17
  "RpcSafeClient",
17
18
  "RpcSafeEndpoint",
18
19
  ]
19
-
@@ -23,6 +23,19 @@ class RpcHttpResponse:
23
23
  body: Any | None = None
24
24
 
25
25
 
26
+ @dataclass(frozen=True)
27
+ class RpcCallContext:
28
+ """Context used when application state is not a mapping.
29
+
30
+ Mapping application contexts are merged with request data for backward
31
+ compatibility. For other application context types, this wrapper keeps
32
+ application and request scopes explicit.
33
+ """
34
+
35
+ application: Any
36
+ request: dict[str, Any]
37
+
38
+
26
39
  @dataclass
27
40
  class MethodConfig:
28
41
  handler: Handler
@@ -40,8 +53,11 @@ class RpcEndpoint:
40
53
  strict_mode: bool = True,
41
54
  enable_introspection: bool = False,
42
55
  introspection_prefix: str = "__rpc",
56
+ enable_batch: bool = True,
57
+ max_batch_size: int = 100,
43
58
  max_serialization_depth: int = 100,
44
59
  max_deserialization_depth: int = 100,
60
+ sanitize_errors: bool = True,
45
61
  ):
46
62
  self.context = context
47
63
  self.methods: dict[str, MethodConfig] = {}
@@ -49,8 +65,11 @@ class RpcEndpoint:
49
65
  self.strict_mode = strict_mode
50
66
  self.enable_introspection = enable_introspection
51
67
  self.introspection_prefix = introspection_prefix
68
+ self.enable_batch = enable_batch
69
+ self.max_batch_size = max_batch_size
52
70
  self.max_serialization_depth = max_serialization_depth
53
71
  self.max_deserialization_depth = max_deserialization_depth
72
+ self.sanitize_errors = sanitize_errors
54
73
  self.validator = SchemaValidator()
55
74
 
56
75
  if self.enable_introspection:
@@ -100,17 +119,13 @@ class RpcEndpoint:
100
119
  try:
101
120
  request = self._decode_payload(payload)
102
121
  except Exception as exc:
122
+ error = {"code": -32700, "message": "Parse error"}
123
+ if not self.sanitize_errors:
124
+ error["data"] = {"message": str(exc)}
103
125
  return RpcHttpResponse(
104
126
  200,
105
127
  headers,
106
- make_response(
107
- None,
108
- error={
109
- "code": -32700,
110
- "message": "Parse error",
111
- "data": {"message": str(exc)},
112
- },
113
- ),
128
+ make_response(None, error=error),
114
129
  )
115
130
 
116
131
  request_context = context or {}
@@ -159,6 +174,29 @@ class RpcEndpoint:
159
174
  make_response(None, error={"code": -32600, "message": "Invalid Request"}),
160
175
  )
161
176
 
177
+ if not self.enable_batch:
178
+ return RpcHttpResponse(
179
+ 200,
180
+ headers,
181
+ make_response(
182
+ None,
183
+ error={"code": -32600, "message": "Batch requests are not enabled"},
184
+ ),
185
+ )
186
+
187
+ if self.max_batch_size > 0 and len(batch) > self.max_batch_size:
188
+ return RpcHttpResponse(
189
+ 200,
190
+ headers,
191
+ make_response(
192
+ None,
193
+ error={
194
+ "code": -32600,
195
+ "message": f"Batch size exceeds maximum of {self.max_batch_size}",
196
+ },
197
+ ),
198
+ )
199
+
162
200
  responses = []
163
201
  for item in batch:
164
202
  result = self._process_single(item, context)
@@ -171,8 +209,12 @@ class RpcEndpoint:
171
209
  return RpcHttpResponse(200, headers, responses)
172
210
 
173
211
  def _process_single(self, request: Any, context: dict[str, Any]) -> dict[str, Any] | None:
212
+ # An invalid envelope is not a notification, even when it has no id.
213
+ # Do not echo an untrusted id until the complete envelope is validated.
214
+ valid_request = False
174
215
  try:
175
216
  validate_request_envelope(request)
217
+ valid_request = True
176
218
  client_safe_enabled = self._client_safe_enabled(context)
177
219
 
178
220
  if self.safe_enabled and self.strict_mode and not self._has_safe_header(context):
@@ -198,23 +240,24 @@ class RpcEndpoint:
198
240
  result=self.serialize_bigints_and_dates(result),
199
241
  )
200
242
  except RpcError as exc:
201
- if isinstance(request, dict) and not has_id(request):
243
+ if valid_request and not has_id(request):
202
244
  return None
203
- request_id = request.get("id") if isinstance(request, dict) else None
245
+ request_id = request.get("id") if valid_request else None
204
246
  return self._make_error_response(request_id, exc)
205
247
  except Exception as exc:
206
- if isinstance(request, dict) and not has_id(request):
248
+ if valid_request and not has_id(request):
207
249
  return None
208
- request_id = request.get("id") if isinstance(request, dict) else None
250
+ request_id = request.get("id") if valid_request else None
209
251
  return self._make_error_response(request_id, RpcError(str(exc), -32603))
210
252
 
211
253
  def _make_error_response(self, request_id: Any, error: RpcError) -> dict[str, Any]:
254
+ internal_error = self.sanitize_errors and error.code == -32603
212
255
  body = {
213
256
  "code": error.code,
214
- "message": str(error),
257
+ "message": "Internal error" if internal_error else str(error),
215
258
  }
216
259
 
217
- if error.data is not None:
260
+ if not internal_error and error.data is not None:
218
261
  body["data"] = self.serialize_bigints_and_dates(error.data)
219
262
 
220
263
  return make_response(request_id, error=body)
@@ -226,11 +269,23 @@ class RpcEndpoint:
226
269
  context: dict[str, Any],
227
270
  params: Any,
228
271
  ) -> Any:
229
- result = handler(request, self.context if self.context is not None else context, params)
272
+ result = handler(request, self._resolve_handler_context(context), params)
230
273
  if inspect.isawaitable(result):
231
274
  raise RpcError("Async handlers require an async adapter", -32603)
232
275
  return result
233
276
 
277
+ def _resolve_handler_context(self, request_context: dict[str, Any]) -> Any:
278
+ if self.context is None:
279
+ return request_context
280
+
281
+ if isinstance(self.context, dict):
282
+ return {**self.context, **request_context}
283
+
284
+ if request_context:
285
+ return RpcCallContext(application=self.context, request=request_context)
286
+
287
+ return self.context
288
+
234
289
  def _register_introspection_methods(self) -> None:
235
290
  prefix = self.introspection_prefix
236
291
 
@@ -255,7 +310,7 @@ class RpcEndpoint:
255
310
  f"{prefix}.capabilities",
256
311
  lambda _request, _context, _params: {
257
312
  "jsonrpc": "2.0",
258
- "batch": True,
313
+ "batch": self.enable_batch,
259
314
  "notifications": True,
260
315
  "introspection": True,
261
316
  "safeMode": self.safe_enabled,
@@ -287,7 +342,10 @@ class RpcEndpoint:
287
342
  payload = payload.decode("utf-8")
288
343
 
289
344
  if isinstance(payload, str):
290
- return json.loads(payload)
345
+ def reject_non_json_constant(value: str) -> None:
346
+ raise ValueError(f"Invalid JSON constant: {value}")
347
+
348
+ return json.loads(payload, parse_constant=reject_non_json_constant)
291
349
 
292
350
  return payload
293
351
 
@@ -311,4 +369,3 @@ class RpcSafeEndpoint(RpcEndpoint):
311
369
  options.setdefault("safe_enabled", True)
312
370
  options.setdefault("strict_mode", True)
313
371
  super().__init__(context, **options)
314
-
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import math
3
4
  from typing import Any
4
5
 
5
6
  from .errors import RpcError
@@ -35,8 +36,14 @@ def validate_request_envelope(request: Any) -> None:
35
36
  raise RpcError("Method not specified or invalid", -32600)
36
37
 
37
38
  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)
39
+ raise RpcError("Invalid params container", -32600)
40
+
41
+ if "id" in request:
42
+ request_id = request["id"]
43
+ if (
44
+ isinstance(request_id, bool)
45
+ or not isinstance(request_id, (str, int, float, type(None)))
46
+ or (isinstance(request_id, float) and not math.isfinite(request_id))
47
+ ):
48
+ raise RpcError("Invalid request id", -32600)
42
49
 
@@ -0,0 +1 @@
1
+ __version__ = "0.2.1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rpc-python-toolkit
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: Framework-agnostic Python JSON-RPC 2.0 toolkit with Safe Mode interoperability.
5
5
  Author: Nicola Carpanese
6
6
  License-Expression: MIT
@@ -25,19 +25,34 @@ Dynamic: license-file
25
25
 
26
26
  # RPC Python Toolkit
27
27
 
28
+ [![CI](https://github.com/n-car/rpc-python-toolkit/actions/workflows/ci.yml/badge.svg)](https://github.com/n-car/rpc-python-toolkit/actions/workflows/ci.yml)
29
+ [![PyPI Version](https://img.shields.io/pypi/v/rpc-python-toolkit.svg)](https://pypi.org/project/rpc-python-toolkit/)
30
+ [![Python Versions](https://img.shields.io/pypi/pyversions/rpc-python-toolkit.svg)](https://pypi.org/project/rpc-python-toolkit/)
31
+ [![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
32
+ [![Status](https://img.shields.io/badge/status-alpha-orange.svg)](https://pypi.org/project/rpc-python-toolkit/)
33
+
28
34
  Framework-agnostic JSON-RPC 2.0 toolkit for Python.
29
35
 
30
36
  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
37
 
32
38
  ## Project Status
33
39
 
34
- Small early package with the core JSON-RPC behavior implemented.
40
+ Alpha package with the core JSON-RPC behavior implemented.
35
41
 
36
42
  - Published on PyPI as `rpc-python-toolkit`.
37
43
  - Standard JSON-RPC 2.0 remains the default behavior.
38
44
  - Safe Mode follows the same `X-RPC-Safe-Enabled` and value marker conventions used by `rpc-express-toolkit`.
39
45
  - The public API may still change before a stable Python release.
40
46
 
47
+ ## Release Notes
48
+
49
+ ### 0.2.1 - 2026-10-12
50
+
51
+ - Sanitize unexpected errors and `RpcError(-32603)` by default, removing internal messages and data while preserving explicit application errors.
52
+ - Validate request envelopes and reject non-JSON numeric constants, scalar/null parameters, boolean IDs, and non-finite IDs.
53
+ - Return errors for invalid batch elements, distinguish explicit null IDs from notifications, and preserve JSON payload shapes.
54
+ - Add error-handling and protocol regression tests, and document the tightened wire contract and trusted handler context.
55
+
41
56
  ## Installation
42
57
 
43
58
  Install from PyPI:
@@ -80,7 +95,11 @@ python -m unittest discover
80
95
  ```python
81
96
  from rpc_python_toolkit import RpcEndpoint
82
97
 
83
- rpc = RpcEndpoint(enable_introspection=True)
98
+ rpc = RpcEndpoint(
99
+ enable_introspection=True,
100
+ enable_batch=True,
101
+ max_batch_size=100,
102
+ )
84
103
 
85
104
  def test(request, context, params):
86
105
  return {"ok": True, "params": params}
@@ -97,6 +116,28 @@ response = rpc.handle_payload({
97
116
  print(response.body)
98
117
  ```
99
118
 
119
+ Batch can be disabled with `enable_batch=False`. Introspection remains opt-in.
120
+ Unexpected errors and `RpcError` instances with code `-32603` return only
121
+ `Internal error` by default, without internal messages or data. Other explicit
122
+ `RpcError` codes, messages, and data are preserved. Parse errors omit diagnostic
123
+ data. Use `sanitize_errors=False` only for local debugging to restore details.
124
+
125
+ Only valid requests without an `id` are notifications. An explicit `id: null`
126
+ receives a response; malformed envelopes (including scalar/null `params` or
127
+ boolean/non-finite IDs) return `-32600` with `id: null`. Method parameter schema
128
+ errors remain `-32602`.
129
+ Malformed JSON, including the non-JSON constants `NaN`, `Infinity`, and `-Infinity`,
130
+ returns `-32700`. Invalid elements in a batch produce individual error responses;
131
+ valid notifications remain silent even when their method fails. Objects, arrays,
132
+ and null results retain their JSON shapes.
133
+
134
+ When both application and request contexts are dictionaries, handlers receive a
135
+ merged dictionary and request values take precedence. For a non-dictionary
136
+ application context plus request data, handlers receive `RpcCallContext` with
137
+ separate `application` and `request` attributes.
138
+ These contexts are supplied by application/adapter code. Client `params` remain a
139
+ separate third handler argument and cannot replace trusted context.
140
+
100
141
  Result:
101
142
 
102
143
  ```json
@@ -131,6 +172,10 @@ Safe Mode enables `X-RPC-Safe-Enabled` negotiation and recursive value encoding/
131
172
 
132
173
  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
174
 
175
+ Safe Mode transforms payload values, not method names, request IDs, error codes,
176
+ or error messages. Application `RpcError.data` is encoded like a result; sanitized
177
+ internal errors expose neither their original message nor their data.
178
+
134
179
  ## Examples
135
180
 
136
181
  Runnable examples are available in [`examples/`](examples/):
@@ -14,4 +14,6 @@ src/rpc_python_toolkit.egg-info/SOURCES.txt
14
14
  src/rpc_python_toolkit.egg-info/dependency_links.txt
15
15
  src/rpc_python_toolkit.egg-info/requires.txt
16
16
  src/rpc_python_toolkit.egg-info/top_level.txt
17
- tests/test_core.py
17
+ tests/test_core.py
18
+ tests/test_errors.py
19
+ tests/test_protocol.py
@@ -8,6 +8,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
8
8
 
9
9
  from rpc_python_toolkit import (
10
10
  RpcClient,
11
+ RpcCallContext,
11
12
  RpcEndpoint,
12
13
  RpcRemoteError,
13
14
  RpcSafeClient,
@@ -68,6 +69,67 @@ class CoreTests(unittest.TestCase):
68
69
  [{"jsonrpc": "2.0", "id": "a", "result": {"value": 1}}],
69
70
  )
70
71
 
72
+ def test_can_disable_batch_requests(self):
73
+ rpc = RpcEndpoint(enable_batch=False)
74
+ rpc.add_method("echo", lambda _request, _context, params: params)
75
+
76
+ response = rpc.handle_payload(
77
+ [{"jsonrpc": "2.0", "method": "echo", "id": 1}]
78
+ )
79
+
80
+ self.assertEqual(response.body["error"]["code"], -32600)
81
+ self.assertIn("not enabled", response.body["error"]["message"])
82
+
83
+ def test_enforces_maximum_batch_size(self):
84
+ rpc = RpcEndpoint(max_batch_size=1)
85
+ rpc.add_method("echo", lambda _request, _context, params: params)
86
+
87
+ response = rpc.handle_payload(
88
+ [
89
+ {"jsonrpc": "2.0", "method": "echo", "id": 1},
90
+ {"jsonrpc": "2.0", "method": "echo", "id": 2},
91
+ ]
92
+ )
93
+
94
+ self.assertEqual(response.body["error"]["code"], -32600)
95
+ self.assertIn("maximum of 1", response.body["error"]["message"])
96
+
97
+ def test_merges_application_and_request_mapping_contexts(self):
98
+ rpc = RpcEndpoint({"service": "orders", "user": "application"})
99
+ rpc.add_method("context", lambda _request, context, _params: context)
100
+
101
+ response = rpc.handle_payload(
102
+ {"jsonrpc": "2.0", "method": "context", "id": 1},
103
+ {"user": "request", "request_id": "abc"},
104
+ )
105
+
106
+ self.assertEqual(
107
+ response.body["result"],
108
+ {"service": "orders", "user": "request", "request_id": "abc"},
109
+ )
110
+
111
+ def test_preserves_both_context_scopes_for_non_mapping_application_context(self):
112
+ application = object()
113
+ rpc = RpcEndpoint(application)
114
+ captured = []
115
+
116
+ def capture_context(_request, context, _params):
117
+ captured.append(context)
118
+ return "ok"
119
+
120
+ rpc.add_method("context", capture_context)
121
+
122
+ response = rpc.handle_payload(
123
+ {"jsonrpc": "2.0", "method": "context", "id": 1},
124
+ {"request_id": "abc"},
125
+ )
126
+ context = captured[0]
127
+
128
+ self.assertEqual(response.body["result"], "ok")
129
+ self.assertIsInstance(context, RpcCallContext)
130
+ self.assertIs(context.application, application)
131
+ self.assertEqual(context.request["request_id"], "abc")
132
+
71
133
  def test_enforces_safe_mode_header_in_strict_mode(self):
72
134
  rpc = RpcSafeEndpoint()
73
135
  rpc.add_method("echo", lambda _request, _context, params: params)
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+ from unittest.mock import patch
5
+
6
+ from rpc_python_toolkit import RpcEndpoint, RpcError, RpcSafeEndpoint
7
+
8
+
9
+ class ErrorSanitizationTests(unittest.TestCase):
10
+ def test_unexpected_handler_error_is_generic_even_with_code_and_data(self):
11
+ rpc = RpcEndpoint()
12
+ failure = RuntimeError("Synthetic private configuration detail")
13
+ failure.code = -32042
14
+ failure.data = {"path": "/private/example/config.ini"}
15
+
16
+ def fail(_request, _context, _params):
17
+ raise failure
18
+
19
+ rpc.add_method("fail", fail)
20
+
21
+ response = rpc.handle_payload({"jsonrpc": "2.0", "method": "fail", "id": 1})
22
+
23
+ self.assertEqual(response.status, 200)
24
+ self.assertEqual(
25
+ response.body,
26
+ {
27
+ "jsonrpc": "2.0",
28
+ "id": 1,
29
+ "error": {"code": -32603, "message": "Internal error"},
30
+ },
31
+ )
32
+
33
+ def test_explicit_internal_error_also_hides_message_and_data(self):
34
+ rpc = RpcEndpoint()
35
+
36
+ def fail(_request, _context, _params):
37
+ raise RpcError("Synthetic database detail", -32603, {"path": "/private/example"})
38
+
39
+ rpc.add_method("fail", fail)
40
+
41
+ response = rpc.handle_payload({"jsonrpc": "2.0", "method": "fail", "id": 2})
42
+
43
+ self.assertEqual(response.body["error"], {"code": -32603, "message": "Internal error"})
44
+
45
+ def test_explicit_application_error_preserves_code_message_and_data(self):
46
+ rpc = RpcEndpoint()
47
+
48
+ def fail(_request, _context, _params):
49
+ raise RpcError("Quota exceeded", -32042, {"limit": 10})
50
+
51
+ rpc.add_method("fail", fail)
52
+
53
+ response = rpc.handle_payload({"jsonrpc": "2.0", "method": "fail", "id": 3})
54
+
55
+ self.assertEqual(
56
+ response.body["error"],
57
+ {"code": -32042, "message": "Quota exceeded", "data": {"limit": 10}},
58
+ )
59
+
60
+ def test_batch_sanitizes_errors_without_responding_to_failed_notifications(self):
61
+ rpc = RpcEndpoint()
62
+
63
+ def fail(_request, _context, _params):
64
+ raise RuntimeError("Synthetic batch detail")
65
+
66
+ rpc.add_method("fail", fail)
67
+ rpc.add_method("echo", lambda _request, _context, params: params)
68
+
69
+ response = rpc.handle_payload(
70
+ [
71
+ {"jsonrpc": "2.0", "method": "fail", "id": 1},
72
+ {"jsonrpc": "2.0", "method": "fail"},
73
+ {"jsonrpc": "2.0", "method": "echo", "params": [1, 2], "id": 2},
74
+ ]
75
+ )
76
+
77
+ self.assertEqual(
78
+ response.body,
79
+ [
80
+ {"jsonrpc": "2.0", "id": 1, "error": {"code": -32603, "message": "Internal error"}},
81
+ {"jsonrpc": "2.0", "id": 2, "result": [1, 2]},
82
+ ],
83
+ )
84
+
85
+ def test_unexpected_outer_error_is_sanitized(self):
86
+ rpc = RpcEndpoint()
87
+
88
+ with patch.object(rpc, "_process_batch", side_effect=RuntimeError("Synthetic adapter detail")):
89
+ response = rpc.handle_payload([{"jsonrpc": "2.0", "method": "echo", "id": 1}])
90
+
91
+ self.assertEqual(
92
+ response.body,
93
+ {"jsonrpc": "2.0", "id": None, "error": {"code": -32603, "message": "Internal error"}},
94
+ )
95
+
96
+ def test_unexpected_validation_error_is_sanitized(self):
97
+ rpc = RpcEndpoint()
98
+ rpc.add_method("echo", lambda _request, _context, params: params)
99
+
100
+ with patch.object(rpc.validator, "validate", side_effect=RuntimeError("Synthetic validator detail")):
101
+ response = rpc.handle_payload({"jsonrpc": "2.0", "method": "echo", "id": 1})
102
+
103
+ self.assertEqual(response.body["error"], {"code": -32603, "message": "Internal error"})
104
+
105
+ def test_parse_error_omits_diagnostic_data_by_default(self):
106
+ response = RpcEndpoint().handle_payload("{")
107
+
108
+ self.assertEqual(response.body["error"], {"code": -32700, "message": "Parse error"})
109
+
110
+ debug_response = RpcEndpoint(sanitize_errors=False).handle_payload("{")
111
+ self.assertEqual(debug_response.body["error"]["code"], -32700)
112
+ self.assertTrue(debug_response.body["error"]["data"]["message"])
113
+
114
+ def test_debug_option_preserves_internal_details(self):
115
+ for failure, expected in [
116
+ (RuntimeError("Synthetic debug detail"), {"code": -32603, "message": "Synthetic debug detail"}),
117
+ (
118
+ RpcError("Synthetic debug detail", -32603, {"path": "/private/example"}),
119
+ {"code": -32603, "message": "Synthetic debug detail", "data": {"path": "/private/example"}},
120
+ ),
121
+ ]:
122
+ with self.subTest(error_type=type(failure).__name__):
123
+ rpc = RpcEndpoint(sanitize_errors=False)
124
+
125
+ def fail(_request, _context, _params):
126
+ raise failure
127
+
128
+ rpc.add_method("fail", fail)
129
+ response = rpc.handle_payload({"jsonrpc": "2.0", "method": "fail", "id": 1})
130
+
131
+ self.assertEqual(response.body["error"], expected)
132
+
133
+ def test_safe_mode_preserves_application_data_and_sanitizes_internal_errors(self):
134
+ rpc = RpcSafeEndpoint()
135
+
136
+ def fail(_request, _context, params):
137
+ if params["internal"]:
138
+ raise RuntimeError("Synthetic Safe Mode detail")
139
+ raise RpcError("Quota exceeded", -32042, {"reason": "limit"})
140
+
141
+ rpc.add_method("fail", fail)
142
+
143
+ response = rpc.handle_payload(
144
+ [
145
+ {"jsonrpc": "2.0", "method": "fail", "params": {"internal": False}, "id": 1},
146
+ {"jsonrpc": "2.0", "method": "fail", "params": {"internal": True}, "id": 2},
147
+ ],
148
+ {"headers": {"x-rpc-safe-enabled": "true"}},
149
+ )
150
+
151
+ self.assertEqual(
152
+ response.body[0]["error"],
153
+ {"code": -32042, "message": "Quota exceeded", "data": {"reason": "S:limit"}},
154
+ )
155
+ self.assertEqual(response.body[1]["error"], {"code": -32603, "message": "Internal error"})
156
+
157
+
158
+ if __name__ == "__main__":
159
+ unittest.main()
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+
5
+ from rpc_python_toolkit import RpcEndpoint, RpcSafeEndpoint
6
+
7
+
8
+ class ProtocolTests(unittest.TestCase):
9
+ def setUp(self):
10
+ self.rpc = RpcEndpoint()
11
+ self.calls = []
12
+ self.rpc.add_method("echo", self.echo)
13
+
14
+ def echo(self, _request, _context, params):
15
+ self.calls.append(params)
16
+ return params
17
+
18
+ def test_invalid_envelopes_are_errors_not_notifications(self):
19
+ for request in [None, 1, True, "echo", {}, {"method": "echo"},
20
+ {"jsonrpc": "1.0", "method": "echo"},
21
+ {"jsonrpc": "2.0", "method": True}]:
22
+ with self.subTest(request=request):
23
+ response = self.rpc.handle_payload(request)
24
+ self.assertEqual(response.status, 200)
25
+ # A bare string is decoded as JSON; exercise structured invalid
26
+ # envelopes separately from JSON parse failures.
27
+ expected_code = -32700 if request == "echo" else -32600
28
+ self.assertEqual(response.body["error"]["code"], expected_code)
29
+ self.assertIsNone(response.body["id"])
30
+ self.assertEqual(self.calls, [])
31
+
32
+ def test_rejects_invalid_ids_without_echoing_them(self):
33
+ for request_id in [True, False, {}, [], float("nan"), float("inf"), -float("inf")]:
34
+ with self.subTest(request_id=request_id):
35
+ response = self.rpc.handle_payload(
36
+ {"jsonrpc": "2.0", "method": "echo", "id": request_id}
37
+ )
38
+ self.assertEqual(response.body["error"]["code"], -32600)
39
+ self.assertIsNone(response.body["id"])
40
+ self.assertEqual(self.calls, [])
41
+
42
+ def test_non_json_constants_on_wire_are_parse_errors(self):
43
+ for token in ["NaN", "Infinity", "-Infinity"]:
44
+ for member in ['"params":{"value":' + token + '}', '"id":' + token]:
45
+ with self.subTest(token=token, member=member):
46
+ response = self.rpc.handle_payload(
47
+ '{"jsonrpc":"2.0","method":"echo",' + member + '}'
48
+ )
49
+ self.assertEqual(response.body,
50
+ {"jsonrpc": "2.0", "id": None,
51
+ "error": {"code": -32700, "message": "Parse error"}})
52
+ self.assertEqual(self.calls, [])
53
+
54
+ def test_valid_json_number_overflow_in_id_is_invalid_request(self):
55
+ response = self.rpc.handle_payload('{"jsonrpc":"2.0","method":"echo","id":1e999}')
56
+ self.assertEqual(response.body["error"]["code"], -32600)
57
+ self.assertIsNone(response.body["id"])
58
+ self.assertEqual(self.calls, [])
59
+
60
+ def test_explicit_null_and_other_valid_ids_receive_responses(self):
61
+ for request_id in [None, 0, 1.5, "", "S:literal", "D:2026-01-01", "123n"]:
62
+ with self.subTest(request_id=request_id):
63
+ response = self.rpc.handle_payload(
64
+ {"jsonrpc": "2.0", "method": "echo", "params": {}, "id": request_id}
65
+ )
66
+ self.assertEqual(response.body, {"jsonrpc": "2.0", "id": request_id, "result": {}})
67
+
68
+ def test_params_must_be_object_or_array_when_present(self):
69
+ for params in [None, True, 1, "text"]:
70
+ for with_id in [True, False]:
71
+ with self.subTest(params=params, with_id=with_id):
72
+ request = {"jsonrpc": "2.0", "method": "echo", "params": params}
73
+ if with_id:
74
+ request["id"] = 7
75
+ response = self.rpc.handle_payload(request)
76
+ self.assertEqual(response.body["error"]["code"], -32600)
77
+ self.assertIsNone(response.body["id"])
78
+ self.assertEqual(self.calls, [])
79
+
80
+ def test_mixed_batch_retains_invalid_elements_and_explicit_null_calls(self):
81
+ response = self.rpc.handle_payload([
82
+ {},
83
+ {"jsonrpc": "2.0", "method": "echo"},
84
+ {"jsonrpc": "2.0", "method": "echo", "params": [], "id": None},
85
+ {"jsonrpc": "2.0", "method": "missing"},
86
+ ])
87
+ self.assertEqual(len(response.body), 2)
88
+ self.assertEqual(response.body[0]["error"]["code"], -32600)
89
+ self.assertEqual(response.body[1], {"jsonrpc": "2.0", "id": None, "result": []})
90
+
91
+ def test_all_valid_notifications_have_no_response(self):
92
+ response = self.rpc.handle_payload([
93
+ {"jsonrpc": "2.0", "method": "echo"},
94
+ {"jsonrpc": "2.0", "method": "missing"},
95
+ ])
96
+ self.assertEqual(response.status, 204)
97
+ self.assertIsNone(response.body)
98
+
99
+ def test_safe_mode_keeps_envelope_literals_and_nested_empty_containers(self):
100
+ rpc = RpcSafeEndpoint()
101
+ rpc.add_method("S:echo", self.echo)
102
+ response = rpc.handle_payload(
103
+ {"jsonrpc": "2.0", "method": "S:echo", "params": {"object": {}, "array": []}, "id": "123n"},
104
+ {"headers": {"x-rpc-safe-enabled": "true"}},
105
+ )
106
+ self.assertEqual(response.body,
107
+ {"jsonrpc": "2.0", "id": "123n", "result": {"object": {}, "array": []}})
108
+
109
+
110
+ if __name__ == "__main__":
111
+ unittest.main()
@@ -1,2 +0,0 @@
1
- __version__ = "0.1.0"
2
-