tywrap 0.7.0 → 0.8.0

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 (73) hide show
  1. package/README.md +15 -5
  2. package/dist/core/annotation-parser.d.ts.map +1 -1
  3. package/dist/core/annotation-parser.js.map +1 -1
  4. package/dist/core/emit-call.d.ts.map +1 -1
  5. package/dist/core/emit-call.js +1 -1
  6. package/dist/core/emit-call.js.map +1 -1
  7. package/dist/dev.d.ts.map +1 -1
  8. package/dist/dev.js +1 -3
  9. package/dist/dev.js.map +1 -1
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/runtime/frame-codec.d.ts +111 -0
  14. package/dist/runtime/frame-codec.d.ts.map +1 -0
  15. package/dist/runtime/frame-codec.js +352 -0
  16. package/dist/runtime/frame-codec.js.map +1 -0
  17. package/dist/runtime/index.d.ts +1 -1
  18. package/dist/runtime/index.d.ts.map +1 -1
  19. package/dist/runtime/index.js +1 -1
  20. package/dist/runtime/index.js.map +1 -1
  21. package/dist/runtime/node.d.ts +13 -0
  22. package/dist/runtime/node.d.ts.map +1 -1
  23. package/dist/runtime/node.js +5 -0
  24. package/dist/runtime/node.js.map +1 -1
  25. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  26. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  27. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  28. package/dist/runtime/rpc-client.d.ts.map +1 -1
  29. package/dist/runtime/rpc-client.js +53 -6
  30. package/dist/runtime/rpc-client.js.map +1 -1
  31. package/dist/runtime/subprocess-transport.d.ts +172 -7
  32. package/dist/runtime/subprocess-transport.d.ts.map +1 -1
  33. package/dist/runtime/subprocess-transport.js +513 -31
  34. package/dist/runtime/subprocess-transport.js.map +1 -1
  35. package/dist/runtime/transport.d.ts +85 -3
  36. package/dist/runtime/transport.d.ts.map +1 -1
  37. package/dist/runtime/transport.js +20 -0
  38. package/dist/runtime/transport.js.map +1 -1
  39. package/dist/types/index.d.ts +24 -0
  40. package/dist/types/index.d.ts.map +1 -1
  41. package/dist/tywrap.js +1 -9
  42. package/dist/tywrap.js.map +1 -1
  43. package/dist/utils/codec.d.ts.map +1 -1
  44. package/dist/utils/codec.js +152 -4
  45. package/dist/utils/codec.js.map +1 -1
  46. package/dist/version.js +1 -1
  47. package/package.json +1 -1
  48. package/runtime/__pycache__/_tywrap_conformance_chunking_fixtures.cpython-311.pyc +0 -0
  49. package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
  50. package/runtime/__pycache__/_tywrap_w4_chunking_fixture.cpython-311.pyc +0 -0
  51. package/runtime/__pycache__/_tywrap_w5_request_chunking_fixture.cpython-311.pyc +0 -0
  52. package/runtime/__pycache__/_tywrap_w6_pool_chunking_fixture.cpython-311.pyc +0 -0
  53. package/runtime/__pycache__/frame_codec.cpython-311.pyc +0 -0
  54. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  55. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  56. package/runtime/frame_codec.py +424 -0
  57. package/runtime/python_bridge.py +241 -42
  58. package/runtime/tywrap_bridge_core.py +97 -10
  59. package/src/core/annotation-parser.ts +2 -1
  60. package/src/core/emit-call.ts +1 -7
  61. package/src/dev.ts +1 -3
  62. package/src/index.ts +1 -0
  63. package/src/runtime/frame-codec.ts +469 -0
  64. package/src/runtime/index.ts +1 -6
  65. package/src/runtime/node.ts +25 -1
  66. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  67. package/src/runtime/rpc-client.ts +74 -7
  68. package/src/runtime/subprocess-transport.ts +615 -35
  69. package/src/runtime/transport.ts +101 -3
  70. package/src/types/index.ts +25 -0
  71. package/src/tywrap.ts +1 -9
  72. package/src/utils/codec.ts +184 -3
  73. package/src/version.ts +1 -1
@@ -40,6 +40,7 @@ import importlib # noqa: F401 (re-exported for compat / used by handlers via c
40
40
  from safe_codec import BridgeCodec, CodecError
41
41
 
42
42
  import tywrap_bridge_core as core
43
+ from frame_codec import FRAME_PROTOCOL_ID, FrameError, Reassembler, encode_frames
43
44
 
44
45
  # Re-export the shared protocol/serialization surface so existing importers of
45
46
  # python_bridge keep working after the extraction (codex-flagged: runtime/ ships).
@@ -221,6 +222,72 @@ def get_request_max_bytes():
221
222
  REQUEST_MAX_BYTES = get_request_max_bytes()
222
223
 
223
224
 
225
+ class TransportFrameBytesParseError(CodecConfigError):
226
+ """Invalid TYWRAP_TRANSPORT_MAX_FRAME_BYTES value."""
227
+
228
+ def __init__(self) -> None:
229
+ super().__init__('TYWRAP_TRANSPORT_MAX_FRAME_BYTES must be a positive integer byte count')
230
+
231
+
232
+ def negotiate_chunking():
233
+ """
234
+ Resolve the chunked-transport (``tywrap-frame/1``) negotiation from env.
235
+
236
+ The subprocess transport spawns this bridge with three env vars
237
+ (see docs/transport-framing.md):
238
+
239
+ * ``TYWRAP_TRANSPORT_CHUNKING=1`` -- enable framing
240
+ * ``TYWRAP_TRANSPORT_FRAME_PROTOCOL`` -- must equal ``tywrap-frame/1``
241
+ * ``TYWRAP_TRANSPORT_MAX_FRAME_BYTES`` -- the JS-side JSONL line ceiling
242
+
243
+ Returns ``(enabled, max_frame_bytes)``. Chunking is enabled ONLY when all
244
+ three agree: the flag is truthy, the advertised frame protocol matches the
245
+ one this bridge implements, and the max-frame size is a positive integer.
246
+ A mismatched frame protocol (a future framing version this bridge does not
247
+ speak) leaves chunking disabled -- the bridge then advertises
248
+ ``supportsChunking: false`` and oversize responses fail LOUD (no silent
249
+ single-frame fallback), exactly as an old un-negotiated bridge would.
250
+
251
+ :raises TransportFrameBytesParseError: if the flag/protocol are set to enable
252
+ chunking but the max-frame-bytes value is not a positive integer.
253
+ """
254
+ flag = os.environ.get('TYWRAP_TRANSPORT_CHUNKING', '').lower() in ('1', 'true', 'yes')
255
+ if not flag:
256
+ return False, None
257
+ frame_protocol = os.environ.get('TYWRAP_TRANSPORT_FRAME_PROTOCOL', '')
258
+ if frame_protocol != FRAME_PROTOCOL_ID:
259
+ # A framing protocol this bridge does not implement: stay single-frame.
260
+ return False, None
261
+ raw = os.environ.get('TYWRAP_TRANSPORT_MAX_FRAME_BYTES', '')
262
+ raw = str(raw).strip()
263
+ try:
264
+ value = int(raw)
265
+ except Exception as exc:
266
+ raise TransportFrameBytesParseError() from exc
267
+ if value <= 0:
268
+ raise TransportFrameBytesParseError()
269
+ return True, value
270
+
271
+
272
+ # Why: parse once at startup. CHUNKING_ENABLED gates the chunked write path and
273
+ # the transport block advertised in meta; MAX_FRAME_BYTES is the negotiated
274
+ # per-frame UTF-8 byte ceiling.
275
+ CHUNKING_ENABLED, MAX_FRAME_BYTES = negotiate_chunking()
276
+
277
+ # The transport negotiation block echoed back in the `meta` response so the JS
278
+ # side learns this bridge can reassemble chunked frames. None => omitted (an old
279
+ # bridge / un-negotiated process is indistinguishable on the wire).
280
+ TRANSPORT_INFO = (
281
+ {
282
+ 'frameProtocol': FRAME_PROTOCOL_ID,
283
+ 'supportsChunking': True,
284
+ 'maxFrameBytes': MAX_FRAME_BYTES,
285
+ }
286
+ if CHUNKING_ENABLED
287
+ else None
288
+ )
289
+
290
+
224
291
  def serialize(obj):
225
292
  """
226
293
  Backward-compatible result serializer (subprocess identity).
@@ -267,6 +334,7 @@ def handle_meta():
267
334
  pid=os.getpid(),
268
335
  python_version=sys.version.split()[0],
269
336
  codec_fallback='json' if FALLBACK_JSON else 'none',
337
+ transport_info=TRANSPORT_INFO,
270
338
  )
271
339
 
272
340
 
@@ -289,6 +357,7 @@ def dispatch_request(msg):
289
357
  torch_allow_copy=TORCH_ALLOW_COPY,
290
358
  allowed_modules=ALLOWED_MODULES,
291
359
  allow_private_attrs=ALLOW_PRIVATE_ATTRS,
360
+ transport_info=TRANSPORT_INFO,
292
361
  )
293
362
  return out['id'], out['result']
294
363
 
@@ -326,55 +395,185 @@ def write_payload(payload: str) -> bool:
326
395
  return False
327
396
 
328
397
 
329
- def main():
330
- for line in sys.stdin:
331
- line = line.strip()
332
- if not line:
333
- continue
334
- mid = None
335
- out = None
398
+ def write_response(payload: str, response_id) -> bool:
399
+ """
400
+ Write a fully-encoded JSONL response, fragmenting it into ``tywrap-frame/1``
401
+ frames when chunking is negotiated and the payload exceeds the per-frame
402
+ ceiling.
403
+
404
+ Why: a single response can exceed the JS-side JSONL line ceiling
405
+ (``maxLineLength``). When the subprocess transport negotiated chunking (the
406
+ three ``TYWRAP_TRANSPORT_*`` env vars), an oversize response is split into
407
+ frames, each written as its own JSONL line and flushed one at a time so the
408
+ OS pipe provides backpressure. The TS reassembler (W3) rebuilds the single
409
+ logical response before the codec ever sees it. Small responses (or any
410
+ response when chunking was not negotiated) keep going out as one JSONL line
411
+ exactly as before. There is NO silent single-frame fallback for an oversize
412
+ response on an un-negotiated bridge: it is written whole and the JS line
413
+ ceiling rejects it LOUD, by design.
414
+
415
+ Frames require an integer correlation id. A response with a non-integer id
416
+ (only reachable on a malformed request whose error envelope carries id=None)
417
+ is never large enough to chunk, so it is always written as a single line.
418
+
419
+ Returns False if the parent's stdin/our stdout closed mid-write (BrokenPipe),
420
+ so the caller can exit the loop cleanly.
421
+ """
422
+ if not CHUNKING_ENABLED or MAX_FRAME_BYTES is None:
423
+ return write_payload(payload)
424
+
425
+ payload_bytes = len(payload.encode('utf-8'))
426
+ if payload_bytes <= MAX_FRAME_BYTES:
427
+ return write_payload(payload)
428
+
429
+ if not isinstance(response_id, int) or isinstance(response_id, bool):
430
+ # Cannot correlate frames without an integer id; emit as one line. This
431
+ # only happens for tiny malformed-request error envelopes (id=None),
432
+ # which never exceed a sane frame ceiling.
433
+ return write_payload(payload)
434
+
435
+ frames = encode_frames(
436
+ payload,
437
+ id=response_id,
438
+ stream='response',
439
+ max_frame_bytes=MAX_FRAME_BYTES,
440
+ )
441
+ for frame in frames:
442
+ # One frame per JSONL line; flush per frame so the pipe backpressures
443
+ # and the TS reader can interleave reassembly with the write.
444
+ if not write_payload(_response_codec.encode(frame)):
445
+ return False
446
+ return True
447
+
448
+
449
+ # Reassembles `tywrap-frame/1` REQUEST frames (W5) back into a single logical
450
+ # request line. Created only when chunking is negotiated; None means no request
451
+ # framing is expected and every line is a normal single-line request. Restricted
452
+ # to the 'request' stream. No reassembly-bytes cap here: the request size is
453
+ # already bounded by the TS codec's maxPayloadBytes on the sending side, and
454
+ # TYWRAP_REQUEST_MAX_BYTES is enforced on the complete payload after reassembly
455
+ # (process_request_line), preserving the W5 post-reassembly semantics.
456
+ _request_reassembler = (
457
+ Reassembler(expected_stream='request') if CHUNKING_ENABLED else None
458
+ )
459
+
460
+
461
+ def _try_parse_frame_line(line):
462
+ """
463
+ Return a frame dict if ``line`` is a ``tywrap-frame/1`` envelope, else None.
464
+
465
+ Why: a request frame is a JSON object carrying ``__tywrap_frame__``. We only
466
+ treat a line as a frame when chunking was negotiated AND it parses as such an
467
+ object; anything else (including invalid JSON) falls through to the normal
468
+ single-line request path, which reports the JSON error exactly as before.
469
+ Structural validity (protocol, seq/total ranges, etc.) is enforced by the
470
+ Reassembler, not here.
471
+ """
472
+ try:
473
+ parsed = json.loads(line)
474
+ except json.JSONDecodeError:
475
+ return None
476
+ if isinstance(parsed, dict) and '__tywrap_frame__' in parsed:
477
+ return parsed
478
+ return None
479
+
480
+
481
+ def process_request_line(line):
482
+ """
483
+ Process one complete logical request line and write its response.
484
+
485
+ ``line`` is the full logical JSON request: either a single line read from
486
+ stdin, or the payload reassembled from ``tywrap-frame/1`` request frames.
487
+ TYWRAP_REQUEST_MAX_BYTES is enforced on this complete payload (so for a
488
+ chunked request the limit applies to the REASSEMBLED size, not per frame).
489
+
490
+ Returns True to keep the loop running, or False if the parent's stdin / our
491
+ stdout closed mid-write (BrokenPipe), so main() can exit cleanly.
492
+ """
493
+ mid = None
494
+ out = None
495
+ try:
496
+ if REQUEST_MAX_BYTES is not None:
497
+ payload_bytes = len(line.encode('utf-8'))
498
+ if payload_bytes > REQUEST_MAX_BYTES:
499
+ raise RequestTooLargeError(payload_bytes, REQUEST_MAX_BYTES)
500
+ msg = json.loads(line)
501
+ if isinstance(msg, dict):
502
+ req_id = msg.get('id')
503
+ if isinstance(req_id, int):
504
+ # Why: preserve request ids even when handlers raise.
505
+ mid = req_id
336
506
  try:
337
- if REQUEST_MAX_BYTES is not None:
338
- payload_bytes = len(line.encode('utf-8'))
339
- if payload_bytes > REQUEST_MAX_BYTES:
340
- raise RequestTooLargeError(payload_bytes, REQUEST_MAX_BYTES)
341
- msg = json.loads(line)
342
- if isinstance(msg, dict):
343
- req_id = msg.get('id')
344
- if isinstance(req_id, int):
345
- # Why: preserve request ids even when handlers raise.
346
- mid = req_id
347
- try:
348
- mid, result = dispatch_request(msg)
349
- out = {'id': mid, 'protocol': PROTOCOL, 'result': result}
350
- except ProtocolError as e:
351
- emit_protocol_diagnostic(str(e))
352
- out = build_error_payload(mid, e, include_traceback=False)
353
- except Exception as e: # noqa: BLE001
354
- # Why: ensure any handler error becomes a protocol-compliant response.
355
- out = build_error_payload(mid, e, include_traceback=True)
356
- except RequestTooLargeError as e:
507
+ mid, result = dispatch_request(msg)
508
+ out = {'id': mid, 'protocol': PROTOCOL, 'result': result}
509
+ except ProtocolError as e:
357
510
  emit_protocol_diagnostic(str(e))
358
511
  out = build_error_payload(mid, e, include_traceback=False)
359
- except json.JSONDecodeError as e:
360
- emit_protocol_diagnostic(f'Invalid JSON: {e}')
361
- out = build_error_payload(mid, e, include_traceback=False)
362
512
  except Exception as e: # noqa: BLE001
363
- # Why: catch malformed input without breaking the JSONL protocol.
364
- out = build_error_payload(mid, e, include_traceback=False)
513
+ # Why: ensure any handler error becomes a protocol-compliant response.
514
+ out = build_error_payload(mid, e, include_traceback=True)
515
+ except RequestTooLargeError as e:
516
+ emit_protocol_diagnostic(str(e))
517
+ out = build_error_payload(mid, e, include_traceback=False)
518
+ except json.JSONDecodeError as e:
519
+ emit_protocol_diagnostic(f'Invalid JSON: {e}')
520
+ out = build_error_payload(mid, e, include_traceback=False)
521
+ except Exception as e: # noqa: BLE001
522
+ # Why: catch malformed input without breaking the JSONL protocol.
523
+ out = build_error_payload(mid, e, include_traceback=False)
365
524
 
525
+ try:
526
+ payload = encode_response(out)
527
+ # Correlate frames by the response id when chunking; out always
528
+ # carries the request id (or None for a malformed-request envelope).
529
+ response_id = out.get('id') if isinstance(out, dict) else None
530
+ if not write_response(payload, response_id):
531
+ return False
532
+ except Exception as e: # noqa: BLE001
533
+ # Why: fallback error keeps responses well-formed even if serialization fails.
534
+ err_out = build_error_payload(mid, e, include_traceback=False)
366
535
  try:
367
- payload = encode_response(out)
368
- if not write_payload(payload):
369
- return
370
- except Exception as e: # noqa: BLE001
371
- # Why: fallback error keeps responses well-formed even if serialization fails.
372
- err_out = build_error_payload(mid, e, include_traceback=False)
373
- try:
374
- if not write_payload(json.dumps(err_out)):
536
+ # Error envelopes are tiny; write as a single line regardless of
537
+ # chunking so a serialization failure never recurses into framing.
538
+ if not write_payload(json.dumps(err_out)):
539
+ return False
540
+ except Exception:
541
+ return False
542
+ return True
543
+
544
+
545
+ def main():
546
+ for line in sys.stdin:
547
+ line = line.strip()
548
+ if not line:
549
+ continue
550
+
551
+ # W5: when chunking is negotiated, a line may be a `tywrap-frame/1`
552
+ # REQUEST frame. Reassemble per id; only the completed logical request is
553
+ # handed to process_request_line (which then enforces the request-size
554
+ # guard on the reassembled payload). Non-frame lines are normal requests.
555
+ if _request_reassembler is not None:
556
+ frame = _try_parse_frame_line(line)
557
+ if frame is not None:
558
+ try:
559
+ reassembled = _request_reassembler.accept(frame)
560
+ except FrameError as exc:
561
+ # A framing-protocol violation desyncs the request stream and
562
+ # there is no correlatable response to write (the id may be
563
+ # malformed). Fail LOUD on stderr and stop the loop so the
564
+ # transport restarts the bridge rather than silently
565
+ # mis-parsing subsequent frames.
566
+ emit_protocol_diagnostic(f'Request frame error: {exc}')
567
+ return
568
+ if reassembled is None:
569
+ # More frames needed for this id; await the rest.
570
+ continue
571
+ if not process_request_line(reassembled):
375
572
  return
376
- except Exception:
377
- return
573
+ continue
574
+
575
+ if not process_request_line(line):
576
+ return
378
577
 
379
578
 
380
579
  if __name__ == '__main__':
@@ -536,7 +536,10 @@ def serialize_sparse_matrix(obj):
536
536
  raise RuntimeError('Failed to inspect scipy sparse matrix format') from exc
537
537
 
538
538
  if fmt not in ('csr', 'csc', 'coo'):
539
- raise RuntimeError(f'Unsupported scipy sparse format: {fmt}')
539
+ raise RuntimeError(
540
+ f'Unsupported scipy sparse format: {fmt}; only csr/csc/coo are supported. '
541
+ 'Convert explicitly (e.g. matrix.tocsr()) before returning'
542
+ )
540
543
 
541
544
  dtype = None
542
545
  try:
@@ -544,7 +547,10 @@ def serialize_sparse_matrix(obj):
544
547
  except Exception:
545
548
  dtype = None
546
549
  if getattr(obj.dtype, 'kind', None) == 'c':
547
- raise RuntimeError('Complex sparse matrices are not supported by JSON codec')
550
+ raise RuntimeError(
551
+ 'Complex scipy sparse matrices are not supported by the JSON codec; '
552
+ 'split into real/imag components explicitly before returning'
553
+ )
548
554
 
549
555
  if fmt in ('csr', 'csc'):
550
556
  data = obj.data.tolist()
@@ -584,8 +590,58 @@ def serialize_torch_tensor(obj, *, force_json_markers, torch_allow_copy=False):
584
590
  Serialize torch.Tensor values via the nested ndarray envelope. CPU-only by
585
591
  default; device/copy behavior is explicit. force_json_markers is threaded
586
592
  into the nested ndarray serialization so Pyodide gets a JSON ndarray value.
593
+
594
+ Rejection order is significant: the categorical rejections (sparse / quantized
595
+ / meta / complex) are checked BEFORE the device/contiguous opt-in branch so
596
+ they fail with a clear, specific message and are NOT bypassable by
597
+ TYWRAP_TORCH_ALLOW_COPY. The opt-in only governs the lossy-but-lossless device
598
+ transfer and contiguous copy, never an unrepresentable layout/dtype.
587
599
  """
600
+ import torch # already importable: is_torch_tensor() gated the dispatch
601
+
588
602
  tensor = obj.detach()
603
+
604
+ # Sparse tensors (COO/CSR/CSC/BSR/BSC -> any non-strided layout) have no dense
605
+ # numpy representation without a densify step, which is not the round-trip this
606
+ # envelope promises. Reject explicitly rather than emitting a misleading
607
+ # "not contiguous" error or silently densifying.
608
+ layout = getattr(tensor, 'layout', None)
609
+ if getattr(tensor, 'is_sparse', False) or (
610
+ layout is not None and layout != torch.strided
611
+ ):
612
+ raise RuntimeError(
613
+ f'Torch sparse tensors are not supported (layout={layout}); '
614
+ 'convert to a dense CPU tensor explicitly (e.g. tensor.to_dense()) before returning'
615
+ )
616
+
617
+ # Quantized tensors carry a qscheme/scale/zero_point that numpy() cannot
618
+ # represent; .numpy() raises an opaque "unsupported ScalarType" deep in torch.
619
+ # Reject up front with an actionable message.
620
+ if getattr(tensor, 'is_quantized', False):
621
+ raise RuntimeError(
622
+ 'Torch quantized tensors are not supported; dequantize explicitly '
623
+ '(e.g. tensor.dequantize()) before returning'
624
+ )
625
+
626
+ # Meta tensors have shape/dtype but NO storage; copying to CPU yields garbage,
627
+ # so this is never a lossy-but-honest transfer the opt-in could authorize.
628
+ if getattr(tensor, 'is_meta', False) or (
629
+ getattr(tensor, 'device', None) is not None and tensor.device.type == 'meta'
630
+ ):
631
+ raise RuntimeError(
632
+ 'Torch meta tensors carry no data and cannot be serialized; '
633
+ 'materialize the tensor on a real device before returning'
634
+ )
635
+
636
+ # Complex tensors round-trip to numpy complex arrays, which are not
637
+ # JSON-serializable and have no codec envelope. Reject explicitly instead of
638
+ # emitting Python complex tuples that the JS decoder cannot parse.
639
+ if torch.is_complex(tensor):
640
+ raise RuntimeError(
641
+ f'Torch complex tensors are not supported (dtype={tensor.dtype}); '
642
+ 'split into real/imag components explicitly before returning'
643
+ )
644
+
589
645
  if getattr(tensor, 'device', None) is not None and tensor.device.type != 'cpu':
590
646
  if not torch_allow_copy:
591
647
  raise RuntimeError(
@@ -622,12 +678,22 @@ def serialize_sklearn_estimator(obj):
622
678
  raise RuntimeError('scikit-learn is not available') from exc
623
679
 
624
680
  params = obj.get_params(deep=False)
625
- try:
626
- json.dumps(params)
627
- except Exception as exc:
628
- raise RuntimeError(
629
- 'scikit-learn estimator params are not JSON-serializable; avoid returning estimators or sanitize params'
630
- ) from exc
681
+
682
+ # Metadata-only: NEVER pickle/joblib. Every param value must be plain JSON
683
+ # (no callables, nested estimators, numpy arrays, or other objects). Probe
684
+ # each value individually so the error names the offending param instead of
685
+ # failing opaquely on the whole dict. allow_nan=False also rejects NaN/Inf
686
+ # params here for parity with the response codec.
687
+ for key, value in params.items():
688
+ try:
689
+ json.dumps(value, allow_nan=False)
690
+ except (TypeError, ValueError) as exc:
691
+ raise RuntimeError(
692
+ f'scikit-learn estimator param {key!r} is not JSON-serializable '
693
+ f'(got {type(value).__name__}); estimators are serialized as metadata only '
694
+ '(no pickle/joblib), so every param must be a plain JSON value. '
695
+ 'Sanitize or drop the param before returning'
696
+ ) from exc
631
697
 
632
698
  return {
633
699
  '__tywrap__': 'sklearn.estimator',
@@ -943,7 +1009,16 @@ def handle_dispose_instance(params, instances):
943
1009
  return True
944
1010
 
945
1011
 
946
- def build_meta(instances, *, bridge, pid, python_version, codec_fallback, arrow_available_override=None):
1012
+ def build_meta(
1013
+ instances,
1014
+ *,
1015
+ bridge,
1016
+ pid,
1017
+ python_version,
1018
+ codec_fallback,
1019
+ arrow_available_override=None,
1020
+ transport_info=None,
1021
+ ):
947
1022
  """
948
1023
  Build the bridge metadata payload.
949
1024
 
@@ -956,9 +1031,16 @@ def build_meta(instances, *, bridge, pid, python_version, codec_fallback, arrow_
956
1031
  instead of probing pyarrow. The Pyodide server forces markers to JSON
957
1032
  unconditionally, so it advertises arrowAvailable=False regardless of whether
958
1033
  pyarrow happens to be importable in the WASM environment.
1034
+
1035
+ transport_info: optional chunked-transport negotiation block (BridgeInfo
1036
+ .transport). Core stays oblivious to framing policy -- it only echoes what
1037
+ the I/O layer tells it. The subprocess server passes a {'frameProtocol',
1038
+ 'supportsChunking', 'maxFrameBytes'} dict when chunking is negotiated; the
1039
+ Pyodide server passes None (single-frame, in-memory). When None the block is
1040
+ omitted entirely (backward compatible: old bridges never emit it).
959
1041
  """
960
1042
  arrow = arrow_available() if arrow_available_override is None else arrow_available_override
961
- return {
1043
+ meta = {
962
1044
  'protocol': PROTOCOL,
963
1045
  'protocolVersion': PROTOCOL_VERSION,
964
1046
  'bridge': bridge,
@@ -971,6 +1053,9 @@ def build_meta(instances, *, bridge, pid, python_version, codec_fallback, arrow_
971
1053
  'sklearnAvailable': module_available('sklearn'),
972
1054
  'instances': len(instances),
973
1055
  }
1056
+ if transport_info is not None:
1057
+ meta['transport'] = transport_info
1058
+ return meta
974
1059
 
975
1060
 
976
1061
  def dispatch_request(
@@ -986,6 +1071,7 @@ def dispatch_request(
986
1071
  arrow_available_override=None,
987
1072
  allowed_modules=None,
988
1073
  allow_private_attrs=False,
1074
+ transport_info=None,
989
1075
  ):
990
1076
  """
991
1077
  Validate and route a request, returning the fully-serialized response dict
@@ -1042,6 +1128,7 @@ def dispatch_request(
1042
1128
  python_version=python_version,
1043
1129
  codec_fallback=codec_fallback,
1044
1130
  arrow_available_override=arrow_available_override,
1131
+ transport_info=transport_info,
1045
1132
  )
1046
1133
  else:
1047
1134
  raise ProtocolError(f'Unknown method: {method}')
@@ -253,7 +253,8 @@ export function parseAnnotationToPythonType(
253
253
  // Returns the content between the first `[` and the last `]` of a special-form
254
254
  // annotation (e.g. the `int, str` of `Union[int, str]`). Callers gate this on a
255
255
  // prefix check, so the brackets are known to be present.
256
- const bracketInner = (raw: string): string => raw.slice(raw.indexOf('[') + 1, raw.lastIndexOf(']'));
256
+ const bracketInner = (raw: string): string =>
257
+ raw.slice(raw.indexOf('[') + 1, raw.lastIndexOf(']'));
257
258
 
258
259
  // True when `raw` opens with one of the supported module prefixes followed by
259
260
  // `name[`. Mirrors the inlined `raw.startsWith('typing.Name[') || ...` checks so
@@ -132,13 +132,7 @@ export function emitCallPrelude(desc: CallDescriptor, helpers: CallEmitHelpers):
132
132
  * keywords and enforce required keyword-only arguments.
133
133
  */
134
134
  export function emitArgGuards(desc: CallDescriptor): string[] {
135
- const {
136
- hasKwArgs,
137
- positionalOnlyNames,
138
- requiredKwOnlyNames,
139
- indent: i,
140
- errorLabel,
141
- } = desc;
135
+ const { hasKwArgs, positionalOnlyNames, requiredKwOnlyNames, indent: i, errorLabel } = desc;
142
136
  const i2 = `${i} `;
143
137
  const i3 = `${i2} `;
144
138
 
package/src/dev.ts CHANGED
@@ -343,9 +343,7 @@ async function listChildDirectories(current: string): Promise<string[]> {
343
343
  return [];
344
344
  }
345
345
 
346
- return entries
347
- .filter(entry => entry.isDirectory())
348
- .map(entry => join(current, entry.name));
346
+ return entries.filter(entry => entry.isDirectory()).map(entry => join(current, entry.name));
349
347
  }
350
348
 
351
349
  async function isWatchableDirectory(current: string, ignoredPaths: string[]): Promise<boolean> {
package/src/index.ts CHANGED
@@ -68,6 +68,7 @@ export type {
68
68
  TypeMappingConfig,
69
69
  TypePreset,
70
70
  BridgeInfo,
71
+ BridgeTransportInfo,
71
72
  AnalysisResult,
72
73
  AnalysisError,
73
74
  AnalysisWarning,