tywrap 0.1.0 → 0.1.2

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 (63) hide show
  1. package/README.md +54 -221
  2. package/dist/cli.js +70 -3
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config/index.d.ts.map +1 -1
  5. package/dist/config/index.js +56 -12
  6. package/dist/config/index.js.map +1 -1
  7. package/dist/core/generator.d.ts.map +1 -1
  8. package/dist/core/generator.js +6 -2
  9. package/dist/core/generator.js.map +1 -1
  10. package/dist/core/mapper.d.ts.map +1 -1
  11. package/dist/core/mapper.js +20 -5
  12. package/dist/core/mapper.js.map +1 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/runtime/bridge-core.d.ts +64 -0
  18. package/dist/runtime/bridge-core.d.ts.map +1 -0
  19. package/dist/runtime/bridge-core.js +344 -0
  20. package/dist/runtime/bridge-core.js.map +1 -0
  21. package/dist/runtime/node.d.ts +5 -6
  22. package/dist/runtime/node.d.ts.map +1 -1
  23. package/dist/runtime/node.js +95 -193
  24. package/dist/runtime/node.js.map +1 -1
  25. package/dist/runtime/optimized-node.d.ts +4 -7
  26. package/dist/runtime/optimized-node.d.ts.map +1 -1
  27. package/dist/runtime/optimized-node.js +117 -186
  28. package/dist/runtime/optimized-node.js.map +1 -1
  29. package/dist/runtime/timed-out-request-tracker.d.ts +20 -0
  30. package/dist/runtime/timed-out-request-tracker.d.ts.map +1 -0
  31. package/dist/runtime/timed-out-request-tracker.js +48 -0
  32. package/dist/runtime/timed-out-request-tracker.js.map +1 -0
  33. package/dist/types/index.d.ts +3 -0
  34. package/dist/types/index.d.ts.map +1 -1
  35. package/dist/tywrap.d.ts +17 -4
  36. package/dist/tywrap.d.ts.map +1 -1
  37. package/dist/tywrap.js +68 -35
  38. package/dist/tywrap.js.map +1 -1
  39. package/dist/utils/codec.d.ts +22 -0
  40. package/dist/utils/codec.d.ts.map +1 -1
  41. package/dist/utils/codec.js +245 -55
  42. package/dist/utils/codec.js.map +1 -1
  43. package/dist/utils/logger.d.ts.map +1 -1
  44. package/dist/utils/logger.js +7 -4
  45. package/dist/utils/logger.js.map +1 -1
  46. package/dist/utils/python.d.ts.map +1 -1
  47. package/dist/utils/python.js.map +1 -1
  48. package/package.json +11 -2
  49. package/runtime/python_bridge.py +450 -69
  50. package/src/cli.ts +81 -5
  51. package/src/config/index.ts +67 -16
  52. package/src/core/generator.ts +6 -2
  53. package/src/core/mapper.ts +21 -8
  54. package/src/index.ts +2 -1
  55. package/src/runtime/bridge-core.ts +454 -0
  56. package/src/runtime/node.ts +117 -224
  57. package/src/runtime/optimized-node.ts +160 -235
  58. package/src/runtime/timed-out-request-tracker.ts +58 -0
  59. package/src/types/index.ts +3 -0
  60. package/src/tywrap.ts +91 -36
  61. package/src/utils/codec.ts +306 -83
  62. package/src/utils/logger.ts +11 -7
  63. package/src/utils/python.ts +0 -1
@@ -2,6 +2,7 @@
2
2
  import sys
3
3
  import json
4
4
  import importlib
5
+ import importlib.util
5
6
  import os
6
7
  import traceback
7
8
  import base64
@@ -10,27 +11,177 @@ import decimal
10
11
  import uuid
11
12
  from pathlib import Path, PurePath
12
13
 
14
+ # Ensure the working directory is importable so local modules can be resolved when
15
+ # the bridge is launched as a script from a different directory.
16
+ try:
17
+ cwd = os.getcwd()
18
+ if cwd and cwd not in sys.path:
19
+ sys.path.insert(0, cwd)
20
+ except (OSError, ValueError, TypeError, AttributeError) as exc:
21
+ # Non-fatal: continue without cwd in path.
22
+ try:
23
+ sys.stderr.write(f'[tywrap] Warning: could not add cwd to sys.path: {exc}\n')
24
+ except (OSError, ValueError):
25
+ pass
26
+
13
27
  instances = {}
14
28
 
15
29
  FALLBACK_JSON = os.environ.get('TYWRAP_CODEC_FALLBACK', '').lower() == 'json'
16
30
  PROTOCOL = 'tywrap/1'
17
31
  PROTOCOL_VERSION = 1
18
32
  BRIDGE_NAME = 'python-subprocess'
33
+ # Why: include a stable version in envelopes so decoders can reject incompatible changes.
34
+ CODEC_VERSION = 1
35
+
36
+
37
+ class CodecConfigError(ValueError):
38
+ """Codec configuration error."""
39
+
40
+
41
+ class CodecMaxBytesParseError(CodecConfigError):
42
+ """Invalid TYWRAP_CODEC_MAX_BYTES value."""
43
+
44
+ def __init__(self) -> None:
45
+ super().__init__('TYWRAP_CODEC_MAX_BYTES must be an integer byte count')
46
+
47
+
48
+ class PayloadTooLargeError(ValueError):
49
+ """Response payload exceeds configured size limit."""
50
+
51
+ def __init__(self, payload_bytes: int, max_bytes: int) -> None:
52
+ super().__init__(
53
+ f'Response payload is {payload_bytes} bytes which exceeds TYWRAP_CODEC_MAX_BYTES={max_bytes}'
54
+ )
55
+
56
+
57
+ class RequestMaxBytesParseError(CodecConfigError):
58
+ """Invalid TYWRAP_REQUEST_MAX_BYTES value."""
59
+
60
+ def __init__(self) -> None:
61
+ super().__init__('TYWRAP_REQUEST_MAX_BYTES must be an integer byte count')
62
+
63
+
64
+ class RequestTooLargeError(ValueError):
65
+ """Request payload exceeds configured size limit."""
66
+
67
+ def __init__(self, payload_bytes: int, max_bytes: int) -> None:
68
+ super().__init__(
69
+ f'Request payload is {payload_bytes} bytes which exceeds TYWRAP_REQUEST_MAX_BYTES={max_bytes}'
70
+ )
71
+
72
+
73
+ def get_codec_max_bytes():
74
+ """
75
+ Return the optional max payload size (bytes) for JSONL responses.
76
+
77
+ Why: the subprocess transport writes a single JSON line per response; limiting size avoids
78
+ accidental large payloads that can spike memory or clog IPC, and keeps failures explicit.
79
+ """
80
+ raw = os.environ.get('TYWRAP_CODEC_MAX_BYTES')
81
+ if raw is None:
82
+ return None
83
+ raw = str(raw).strip()
84
+ if not raw:
85
+ return None
86
+ try:
87
+ value = int(raw)
88
+ except Exception as exc:
89
+ raise CodecMaxBytesParseError() from exc
90
+ if value <= 0:
91
+ return None
92
+ return value
93
+
94
+
95
+ # Why: parse once at startup to avoid per-response env lookups.
96
+ CODEC_MAX_BYTES = get_codec_max_bytes()
97
+
98
+
99
+ def get_request_max_bytes():
100
+ """
101
+ Return the optional max payload size (bytes) for JSONL requests.
102
+
103
+ Why: cap request sizes to avoid oversized JSON payloads that can exhaust memory or hang
104
+ downstream parsers. This keeps the bridge failure mode explicit.
105
+ """
106
+ raw = os.environ.get('TYWRAP_REQUEST_MAX_BYTES')
107
+ if raw is None:
108
+ return None
109
+ raw = str(raw).strip()
110
+ if not raw:
111
+ return None
112
+ try:
113
+ value = int(raw)
114
+ except Exception as exc:
115
+ raise RequestMaxBytesParseError() from exc
116
+ if value <= 0:
117
+ return None
118
+ return value
119
+
120
+
121
+ # Why: parse once at startup to avoid per-request env lookups.
122
+ REQUEST_MAX_BYTES = get_request_max_bytes()
19
123
 
20
124
 
21
125
  class ProtocolError(Exception):
22
126
  pass
23
127
 
24
128
 
25
- def arrow_available():
129
+ _PROTOCOL_DIAGNOSTIC_MAX = 2048
130
+
131
+
132
+ def emit_protocol_diagnostic(message: str) -> None:
133
+ """
134
+ Write bounded protocol diagnostics to stderr.
135
+
136
+ Why: provide context for malformed requests without flooding stderr or breaking the JSONL
137
+ stream expected by the JS side.
138
+ """
26
139
  try:
27
- import pyarrow # noqa: F401
140
+ msg = str(message)
141
+ if len(msg) > _PROTOCOL_DIAGNOSTIC_MAX:
142
+ msg = msg[:_PROTOCOL_DIAGNOSTIC_MAX] + '...'
143
+ sys.stderr.write(f'[tywrap] Protocol error: {msg}\n')
144
+ sys.stderr.flush()
28
145
  except Exception:
146
+ # Avoid raising from diagnostics
147
+ pass
148
+
149
+
150
+ def arrow_available():
151
+ """
152
+ Return True when pyarrow can be imported.
153
+
154
+ Why: advertise Arrow capability to the TS side without crashing startup when
155
+ pyarrow is optional or missing.
156
+ """
157
+ try:
158
+ import pyarrow
159
+ except (ImportError, OSError):
29
160
  return False
30
161
  return True
31
162
 
32
163
 
164
+ def module_available(module_name: str) -> bool:
165
+ """
166
+ Lightweight feature detection for optional codec dependencies.
167
+
168
+ Why: exposes availability in bridge metadata without importing heavy modules or triggering
169
+ side effects, so the TS side can decide when to rely on optional codecs. These flags are
170
+ best-effort hints; serialization still performs its own import checks for correctness.
171
+ """
172
+ try:
173
+ return importlib.util.find_spec(module_name) is not None
174
+ except (ImportError, AttributeError, TypeError, ValueError):
175
+ # Why: guard against unusual importlib edge cases without masking other failures.
176
+ return False
177
+
178
+
33
179
  def is_numpy_array(obj):
180
+ """
181
+ Detect numpy arrays when NumPy is installed.
182
+
183
+ Why: keep NumPy optional while enabling ndarray serialization.
184
+ """
34
185
  try:
35
186
  import numpy as np # noqa: F401
36
187
  except Exception:
@@ -39,6 +190,11 @@ def is_numpy_array(obj):
39
190
 
40
191
 
41
192
  def is_pandas_dataframe(obj):
193
+ """
194
+ Detect pandas DataFrame instances when pandas is installed.
195
+
196
+ Why: avoid hard pandas dependency while enabling dataframe encoding.
197
+ """
42
198
  try:
43
199
  import pandas as pd # noqa: F401
44
200
  except Exception:
@@ -47,6 +203,11 @@ def is_pandas_dataframe(obj):
47
203
 
48
204
 
49
205
  def is_pandas_series(obj):
206
+ """
207
+ Detect pandas Series instances when pandas is installed.
208
+
209
+ Why: avoid hard pandas dependency while enabling series encoding.
210
+ """
50
211
  try:
51
212
  import pandas as pd # noqa: F401
52
213
  except Exception:
@@ -55,6 +216,11 @@ def is_pandas_series(obj):
55
216
 
56
217
 
57
218
  def is_scipy_sparse(obj):
219
+ """
220
+ Detect scipy sparse matrices when scipy is installed.
221
+
222
+ Why: allow sparse matrix encoding without importing scipy in all environments.
223
+ """
58
224
  try:
59
225
  import scipy.sparse as sp # noqa: F401
60
226
  except Exception:
@@ -66,6 +232,11 @@ def is_scipy_sparse(obj):
66
232
 
67
233
 
68
234
  def is_torch_tensor(obj):
235
+ """
236
+ Detect torch tensors when torch is installed.
237
+
238
+ Why: allow tensor encoding without a hard torch dependency.
239
+ """
69
240
  try:
70
241
  import torch # noqa: F401
71
242
  except Exception:
@@ -77,6 +248,11 @@ def is_torch_tensor(obj):
77
248
 
78
249
 
79
250
  def is_sklearn_estimator(obj):
251
+ """
252
+ Detect sklearn estimators for metadata-only serialization.
253
+
254
+ Why: allow feature-gated estimator metadata without importing sklearn by default.
255
+ """
80
256
  try:
81
257
  from sklearn.base import BaseEstimator # noqa: F401
82
258
  except Exception:
@@ -85,23 +261,32 @@ def is_sklearn_estimator(obj):
85
261
 
86
262
 
87
263
  def serialize_ndarray(obj):
264
+ """
265
+ Encode a NumPy ndarray for transport over the JSONL bridge.
266
+
267
+ Why: Arrow IPC gives a compact, lossless binary payload that the JS side can decode as a
268
+ Table. If JSON fallback is explicitly requested, honor it even when pyarrow is installed so
269
+ callers don't unexpectedly need an Arrow decoder on the TypeScript side.
270
+ """
271
+ if FALLBACK_JSON:
272
+ return serialize_ndarray_json(obj)
88
273
  try:
89
274
  import pyarrow as pa # type: ignore
90
275
  except Exception as exc:
91
- if FALLBACK_JSON:
92
- return serialize_ndarray_json(obj)
93
276
  raise RuntimeError(
94
277
  'Arrow encoding unavailable for ndarray; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'
95
278
  ) from exc
96
279
  try:
97
280
  arr = pa.array(obj)
281
+ table = pa.Table.from_arrays([arr], names=['value'])
98
282
  sink = pa.BufferOutputStream()
99
- with pa.ipc.new_stream(sink, arr.type) as writer:
100
- writer.write(arr)
283
+ with pa.ipc.new_stream(sink, table.schema) as writer:
284
+ writer.write_table(table)
101
285
  buf = sink.getvalue()
102
286
  b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')
103
287
  return {
104
288
  '__tywrap__': 'ndarray',
289
+ 'codecVersion': CODEC_VERSION,
105
290
  'encoding': 'arrow',
106
291
  'b64': b64,
107
292
  'shape': getattr(obj, 'shape', None),
@@ -113,12 +298,19 @@ def serialize_ndarray(obj):
113
298
 
114
299
 
115
300
  def serialize_ndarray_json(obj):
301
+ """
302
+ JSON fallback for ndarray encoding.
303
+
304
+ Why: this keeps the bridge usable in environments without pyarrow/Arrow decoding, at the
305
+ cost of larger payloads and potential dtype loss.
306
+ """
116
307
  try:
117
308
  data = obj.tolist()
118
309
  except Exception as exc:
119
310
  raise RuntimeError('JSON fallback failed for ndarray') from exc
120
311
  return {
121
312
  '__tywrap__': 'ndarray',
313
+ 'codecVersion': CODEC_VERSION,
122
314
  'encoding': 'json',
123
315
  'data': data,
124
316
  'shape': getattr(obj, 'shape', None),
@@ -126,23 +318,33 @@ def serialize_ndarray_json(obj):
126
318
 
127
319
 
128
320
  def serialize_dataframe(obj):
321
+ """
322
+ Encode a pandas DataFrame for transport.
323
+
324
+ Why: we emit Feather (Arrow IPC file) as *uncompressed* because the JS apache-arrow reader
325
+ does not implement record batch compression. Keeping this uncompressed makes Arrow mode
326
+ work out-of-the-box for Node decoders.
327
+ """
328
+ if FALLBACK_JSON:
329
+ return serialize_dataframe_json(obj)
129
330
  try:
130
331
  import pyarrow as pa # type: ignore
131
332
  import pyarrow.feather as feather # type: ignore
132
333
  except Exception as exc:
133
- if FALLBACK_JSON:
134
- return serialize_dataframe_json(obj)
135
334
  raise RuntimeError(
136
335
  'Arrow encoding unavailable for pandas.DataFrame; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'
137
336
  ) from exc
138
337
  try:
139
338
  table = pa.Table.from_pandas(obj) # type: ignore
140
339
  sink = pa.BufferOutputStream()
141
- feather.write_feather(table, sink)
340
+ # Use explicit uncompressed payloads so JS decoders (apache-arrow) can read them
341
+ # without optional compression dependencies.
342
+ feather.write_feather(table, sink, compression='uncompressed')
142
343
  buf = sink.getvalue()
143
344
  b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')
144
345
  return {
145
346
  '__tywrap__': 'dataframe',
347
+ 'codecVersion': CODEC_VERSION,
146
348
  'encoding': 'arrow',
147
349
  'b64': b64,
148
350
  }
@@ -153,35 +355,50 @@ def serialize_dataframe(obj):
153
355
 
154
356
 
155
357
  def serialize_dataframe_json(obj):
358
+ """
359
+ JSON fallback for DataFrame encoding.
360
+
361
+ Why: this keeps the example/runtime working without Arrow; it is easy to inspect but larger
362
+ than Arrow and may not preserve all dtypes exactly.
363
+ """
156
364
  try:
157
365
  data = obj.to_dict(orient='records')
158
366
  except Exception as exc:
159
367
  raise RuntimeError('JSON fallback failed for pandas.DataFrame') from exc
160
368
  return {
161
369
  '__tywrap__': 'dataframe',
370
+ 'codecVersion': CODEC_VERSION,
162
371
  'encoding': 'json',
163
372
  'data': data,
164
373
  }
165
374
 
166
375
 
167
376
  def serialize_series(obj):
377
+ """
378
+ Encode a pandas Series for transport.
379
+
380
+ Why: encode as a single-column Arrow Table stream (not a raw Array schema) because the JS
381
+ decoder contract is "table-like" and pyarrow's IPC writer expects a Schema, not a DataType.
382
+ """
383
+ if FALLBACK_JSON:
384
+ return serialize_series_json(obj)
168
385
  try:
169
386
  import pyarrow as pa # type: ignore
170
387
  except Exception as exc:
171
- if FALLBACK_JSON:
172
- return serialize_series_json(obj)
173
388
  raise RuntimeError(
174
389
  'Arrow encoding unavailable for pandas.Series; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'
175
390
  ) from exc
176
391
  try:
177
392
  arr = pa.Array.from_pandas(obj) # type: ignore
393
+ table = pa.Table.from_arrays([arr], names=['value'])
178
394
  sink = pa.BufferOutputStream()
179
- with pa.ipc.new_stream(sink, arr.type) as writer:
180
- writer.write(arr)
395
+ with pa.ipc.new_stream(sink, table.schema) as writer:
396
+ writer.write_table(table)
181
397
  buf = sink.getvalue()
182
398
  b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')
183
399
  return {
184
400
  '__tywrap__': 'series',
401
+ 'codecVersion': CODEC_VERSION,
185
402
  'encoding': 'arrow',
186
403
  'b64': b64,
187
404
  'name': getattr(obj, 'name', None),
@@ -193,6 +410,12 @@ def serialize_series(obj):
193
410
 
194
411
 
195
412
  def serialize_series_json(obj):
413
+ """
414
+ JSON fallback for Series encoding.
415
+
416
+ Why: avoids requiring Arrow decoding support, at the cost of potentially lossy dtype/NA
417
+ representation compared to Arrow.
418
+ """
196
419
  try:
197
420
  data = obj.to_list() # type: ignore
198
421
  except Exception:
@@ -202,6 +425,7 @@ def serialize_series_json(obj):
202
425
  raise RuntimeError('JSON fallback failed for pandas.Series') from exc
203
426
  return {
204
427
  '__tywrap__': 'series',
428
+ 'codecVersion': CODEC_VERSION,
205
429
  'encoding': 'json',
206
430
  'data': data,
207
431
  'name': getattr(obj, 'name', None),
@@ -209,6 +433,12 @@ def serialize_series_json(obj):
209
433
 
210
434
 
211
435
  def serialize_sparse_matrix(obj):
436
+ """
437
+ Serialize scipy sparse matrices into structured JSON envelopes.
438
+
439
+ Why: preserve sparsity and matrix shape without implicit dense conversion, keeping
440
+ failures explicit when unsupported formats or dtypes are encountered.
441
+ """
212
442
  try:
213
443
  fmt = obj.getformat()
214
444
  except Exception as exc:
@@ -231,6 +461,7 @@ def serialize_sparse_matrix(obj):
231
461
  indptr = obj.indptr.tolist()
232
462
  return {
233
463
  '__tywrap__': 'scipy.sparse',
464
+ 'codecVersion': CODEC_VERSION,
234
465
  'encoding': 'json',
235
466
  'format': fmt,
236
467
  'shape': list(obj.shape),
@@ -246,6 +477,7 @@ def serialize_sparse_matrix(obj):
246
477
  col = obj.col.tolist()
247
478
  return {
248
479
  '__tywrap__': 'scipy.sparse',
480
+ 'codecVersion': CODEC_VERSION,
249
481
  'encoding': 'json',
250
482
  'format': fmt,
251
483
  'shape': list(obj.shape),
@@ -257,6 +489,11 @@ def serialize_sparse_matrix(obj):
257
489
 
258
490
 
259
491
  def serialize_torch_tensor(obj):
492
+ """
493
+ Serialize torch.Tensor values via the ndarray envelope.
494
+
495
+ Why: ensure CPU-only transport by default and make device/copy behavior explicit to callers.
496
+ """
260
497
  allow_copy = os.environ.get('TYWRAP_TORCH_ALLOW_COPY', '').lower() in ('1', 'true', 'yes')
261
498
  tensor = obj.detach()
262
499
  if getattr(tensor, 'device', None) is not None and tensor.device.type != 'cpu':
@@ -278,6 +515,7 @@ def serialize_torch_tensor(obj):
278
515
 
279
516
  return {
280
517
  '__tywrap__': 'torch.tensor',
518
+ 'codecVersion': CODEC_VERSION,
281
519
  'encoding': 'ndarray',
282
520
  'value': serialize_ndarray(arr),
283
521
  'shape': list(tensor.shape),
@@ -287,6 +525,11 @@ def serialize_torch_tensor(obj):
287
525
 
288
526
 
289
527
  def serialize_sklearn_estimator(obj):
528
+ """
529
+ Serialize sklearn estimators as metadata only.
530
+
531
+ Why: avoid unsafe pickling while still exposing model identity and params to TypeScript.
532
+ """
290
533
  try:
291
534
  import sklearn # noqa: F401
292
535
  except Exception as exc:
@@ -302,6 +545,7 @@ def serialize_sklearn_estimator(obj):
302
545
 
303
546
  return {
304
547
  '__tywrap__': 'sklearn.estimator',
548
+ 'codecVersion': CODEC_VERSION,
305
549
  'encoding': 'json',
306
550
  'className': obj.__class__.__name__,
307
551
  'module': obj.__class__.__module__,
@@ -309,6 +553,29 @@ def serialize_sklearn_estimator(obj):
309
553
  'params': params,
310
554
  }
311
555
 
556
+
557
+ _NO_PYDANTIC = object()
558
+
559
+
560
+ def serialize_pydantic(obj):
561
+ """
562
+ Serialize Pydantic v2 models without importing Pydantic.
563
+
564
+ Why: returning BaseModel instances is common in typed Python APIs. Converting via
565
+ `model_dump` keeps Python type hints accurate (return the model), while the bridge still
566
+ emits a JSON-serializable payload. We default to `by_alias=True` so alias_generator-based
567
+ camelCase schemas round-trip cleanly to TypeScript.
568
+ """
569
+
570
+ model_dump = getattr(obj, 'model_dump', None)
571
+ if not callable(model_dump):
572
+ return _NO_PYDANTIC
573
+ try:
574
+ return model_dump(by_alias=True, mode='json')
575
+ except TypeError:
576
+ # Older Pydantic versions may not support `mode=...`.
577
+ return model_dump(by_alias=True)
578
+
312
579
  def serialize(obj):
313
580
  if is_numpy_array(obj):
314
581
  return serialize_ndarray(obj)
@@ -322,6 +589,9 @@ def serialize(obj):
322
589
  return serialize_torch_tensor(obj)
323
590
  if is_sklearn_estimator(obj):
324
591
  return serialize_sklearn_estimator(obj)
592
+ pydantic_value = serialize_pydantic(obj)
593
+ if pydantic_value is not _NO_PYDANTIC:
594
+ return pydantic_value
325
595
  stdlib_value = serialize_stdlib(obj)
326
596
  if stdlib_value is not None:
327
597
  return stdlib_value
@@ -347,10 +617,10 @@ def serialize_stdlib(obj):
347
617
 
348
618
 
349
619
  def handle_call(params):
350
- module_name = params.get('module')
351
- function_name = params.get('functionName')
352
- args = params.get('args') or []
353
- kwargs = params.get('kwargs') or {}
620
+ module_name = require_str(params, 'module')
621
+ function_name = require_str(params, 'functionName')
622
+ args = coerce_list(params.get('args'), 'args')
623
+ kwargs = coerce_dict(params.get('kwargs'), 'kwargs')
354
624
  mod = importlib.import_module(module_name)
355
625
  func = getattr(mod, function_name)
356
626
  res = func(*args, **kwargs)
@@ -358,10 +628,10 @@ def handle_call(params):
358
628
 
359
629
 
360
630
  def handle_instantiate(params):
361
- module_name = params.get('module')
362
- class_name = params.get('className')
363
- args = params.get('args') or []
364
- kwargs = params.get('kwargs') or {}
631
+ module_name = require_str(params, 'module')
632
+ class_name = require_str(params, 'className')
633
+ args = coerce_list(params.get('args'), 'args')
634
+ kwargs = coerce_dict(params.get('kwargs'), 'kwargs')
365
635
  mod = importlib.import_module(module_name)
366
636
  cls = getattr(mod, class_name)
367
637
  obj = cls(*args, **kwargs)
@@ -371,10 +641,10 @@ def handle_instantiate(params):
371
641
 
372
642
 
373
643
  def handle_call_method(params):
374
- handle_id = params.get('handle')
375
- method_name = params.get('methodName')
376
- args = params.get('args') or []
377
- kwargs = params.get('kwargs') or {}
644
+ handle_id = require_str(params, 'handle')
645
+ method_name = require_str(params, 'methodName')
646
+ args = coerce_list(params.get('args'), 'args')
647
+ kwargs = coerce_dict(params.get('kwargs'), 'kwargs')
378
648
  if handle_id not in instances:
379
649
  raise KeyError(f'Unknown handle: {handle_id}')
380
650
  obj = instances[handle_id]
@@ -384,7 +654,7 @@ def handle_call_method(params):
384
654
 
385
655
 
386
656
  def handle_dispose_instance(params):
387
- handle_id = params.get('handle')
657
+ handle_id = require_str(params, 'handle')
388
658
  if handle_id not in instances:
389
659
  raise KeyError(f'Unknown handle: {handle_id}')
390
660
  del instances[handle_id]
@@ -392,6 +662,11 @@ def handle_dispose_instance(params):
392
662
 
393
663
 
394
664
  def handle_meta():
665
+ """
666
+ Return bridge metadata for capability detection.
667
+
668
+ Why: the Node side uses this to decide whether optional codecs can be used.
669
+ """
395
670
  return {
396
671
  'protocol': PROTOCOL,
397
672
  'protocolVersion': PROTOCOL_VERSION,
@@ -400,11 +675,16 @@ def handle_meta():
400
675
  'pid': os.getpid(),
401
676
  'codecFallback': 'json' if FALLBACK_JSON else 'none',
402
677
  'arrowAvailable': arrow_available(),
678
+ 'scipyAvailable': module_available('scipy'),
679
+ 'torchAvailable': module_available('torch'),
680
+ 'sklearnAvailable': module_available('sklearn'),
403
681
  'instances': len(instances),
404
682
  }
405
683
 
406
684
 
407
685
  def require_protocol(msg):
686
+ if not isinstance(msg, dict):
687
+ raise ProtocolError('Invalid request payload')
408
688
  proto = msg.get('protocol')
409
689
  if proto != PROTOCOL:
410
690
  raise ProtocolError(f'Invalid protocol: {proto}')
@@ -414,6 +694,114 @@ def require_protocol(msg):
414
694
  return mid
415
695
 
416
696
 
697
+ def require_str(params, key):
698
+ """
699
+ Return a required string parameter from a request.
700
+
701
+ Why: keep request schema validation centralized and explicit for clearer errors.
702
+ """
703
+ value = params.get(key)
704
+ if not isinstance(value, str) or not value:
705
+ raise ProtocolError(f'Missing {key}')
706
+ return value
707
+
708
+
709
+ def coerce_list(value, key):
710
+ """
711
+ Coerce an optional list parameter into a list.
712
+
713
+ Why: normalize args inputs while rejecting invalid shapes early.
714
+ """
715
+ if value is None:
716
+ return []
717
+ if not isinstance(value, list):
718
+ raise ProtocolError(f'Invalid {key}')
719
+ return value
720
+
721
+
722
+ def coerce_dict(value, key):
723
+ """
724
+ Coerce an optional dict parameter into a dict.
725
+
726
+ Why: normalize kwargs inputs while rejecting invalid shapes early.
727
+ """
728
+ if value is None:
729
+ return {}
730
+ if not isinstance(value, dict):
731
+ raise ProtocolError(f'Invalid {key}')
732
+ return value
733
+
734
+
735
+ def dispatch_request(msg):
736
+ """
737
+ Dispatch a validated request to the correct handler.
738
+
739
+ Why: keep the main loop focused on I/O while this function handles validation and routing.
740
+ """
741
+ mid = require_protocol(msg)
742
+ method = msg.get('method')
743
+ if not isinstance(method, str):
744
+ raise ProtocolError('Missing method')
745
+ params = coerce_dict(msg.get('params'), 'params')
746
+ if method == 'call':
747
+ result = handle_call(params)
748
+ elif method == 'instantiate':
749
+ result = handle_instantiate(params)
750
+ elif method == 'call_method':
751
+ result = handle_call_method(params)
752
+ elif method == 'dispose_instance':
753
+ result = handle_dispose_instance(params)
754
+ elif method == 'meta':
755
+ result = handle_meta()
756
+ else:
757
+ raise ProtocolError(f'Unknown method: {method}')
758
+ return mid, result
759
+
760
+
761
+ def build_error_payload(mid, exc, *, include_traceback):
762
+ """
763
+ Build a protocol error response.
764
+
765
+ Why: ensure error formatting stays consistent while keeping exception handling centralized.
766
+ """
767
+ error = { 'type': type(exc).__name__, 'message': str(exc) }
768
+ if include_traceback:
769
+ error['traceback'] = traceback.format_exc()
770
+ return {
771
+ 'id': mid if mid is not None else -1,
772
+ 'protocol': PROTOCOL,
773
+ 'error': error,
774
+ }
775
+
776
+
777
+ def encode_response(out):
778
+ """
779
+ Serialize the response and enforce size limits.
780
+
781
+ Why: keep payload size checks outside the main loop for clarity and lint compliance.
782
+ """
783
+ payload = json.dumps(out)
784
+ payload_bytes = len(payload.encode('utf-8'))
785
+ if CODEC_MAX_BYTES is not None and payload_bytes > CODEC_MAX_BYTES:
786
+ raise PayloadTooLargeError(payload_bytes, CODEC_MAX_BYTES)
787
+ return payload
788
+
789
+
790
+ def write_payload(payload: str) -> bool:
791
+ """
792
+ Write a JSONL payload to stdout and flush.
793
+
794
+ Why: centralize BrokenPipe handling so the main loop can exit cleanly when the
795
+ parent process goes away.
796
+ """
797
+ try:
798
+ sys.stdout.write(payload + '\n')
799
+ sys.stdout.flush()
800
+ return True
801
+ except BrokenPipeError:
802
+ return False
803
+
804
+
417
805
  def main():
418
806
  for line in sys.stdin:
419
807
  line = line.strip()
@@ -422,54 +810,47 @@ def main():
422
810
  mid = None
423
811
  out = None
424
812
  try:
813
+ if REQUEST_MAX_BYTES is not None:
814
+ payload_bytes = len(line.encode('utf-8'))
815
+ if payload_bytes > REQUEST_MAX_BYTES:
816
+ raise RequestTooLargeError(payload_bytes, REQUEST_MAX_BYTES)
425
817
  msg = json.loads(line)
426
- mid = require_protocol(msg)
427
- method = msg.get('method')
428
- if not isinstance(method, str):
429
- raise ProtocolError('Missing method')
430
- params = msg.get('params') or {}
431
- if not isinstance(params, dict):
432
- raise ProtocolError('Invalid params')
818
+ if isinstance(msg, dict):
819
+ req_id = msg.get('id')
820
+ if isinstance(req_id, int):
821
+ # Why: preserve request ids even when handlers raise.
822
+ mid = req_id
433
823
  try:
434
- if method == 'call':
435
- result = handle_call(params)
436
- elif method == 'instantiate':
437
- result = handle_instantiate(params)
438
- elif method == 'call_method':
439
- result = handle_call_method(params)
440
- elif method == 'dispose_instance':
441
- result = handle_dispose_instance(params)
442
- elif method == 'meta':
443
- result = handle_meta()
444
- else:
445
- raise ValueError('Unknown method')
824
+ mid, result = dispatch_request(msg)
446
825
  out = { 'id': mid, 'protocol': PROTOCOL, 'result': result }
447
- except Exception as e:
448
- out = {
449
- 'id': mid,
450
- 'protocol': PROTOCOL,
451
- 'error': { 'type': type(e).__name__, 'message': str(e), 'traceback': traceback.format_exc() }
452
- }
453
- except Exception as e:
454
- out = {
455
- 'id': mid if mid is not None else -1,
456
- 'protocol': PROTOCOL,
457
- 'error': { 'type': type(e).__name__, 'message': str(e) }
458
- }
826
+ except ProtocolError as e:
827
+ emit_protocol_diagnostic(str(e))
828
+ out = build_error_payload(mid, e, include_traceback=False)
829
+ except Exception as e: # noqa: BLE001
830
+ # Why: ensure any handler error becomes a protocol-compliant response.
831
+ out = build_error_payload(mid, e, include_traceback=True)
832
+ except RequestTooLargeError as e:
833
+ emit_protocol_diagnostic(str(e))
834
+ out = build_error_payload(mid, e, include_traceback=False)
835
+ except json.JSONDecodeError as e:
836
+ emit_protocol_diagnostic(f'Invalid JSON: {e}')
837
+ out = build_error_payload(mid, e, include_traceback=False)
838
+ except Exception as e: # noqa: BLE001
839
+ # Why: catch malformed input without breaking the JSONL protocol.
840
+ out = build_error_payload(mid, e, include_traceback=False)
459
841
 
460
842
  try:
461
- sys.stdout.write(json.dumps(out) + '\n')
462
- except Exception as e:
463
- err_out = {
464
- 'id': mid if mid is not None else -1,
465
- 'protocol': PROTOCOL,
466
- 'error': {
467
- 'type': type(e).__name__,
468
- 'message': f'Failed to serialize response: {e}'
469
- }
470
- }
471
- sys.stdout.write(json.dumps(err_out) + '\n')
472
- sys.stdout.flush()
843
+ payload = encode_response(out)
844
+ if not write_payload(payload):
845
+ return
846
+ except Exception as e: # noqa: BLE001
847
+ # Why: fallback error keeps responses well-formed even if serialization fails.
848
+ err_out = build_error_payload(mid, e, include_traceback=False)
849
+ try:
850
+ if not write_payload(json.dumps(err_out)):
851
+ return
852
+ except Exception:
853
+ return
473
854
 
474
855
 
475
856
  if __name__ == '__main__':