tywrap 0.6.1 → 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 (110) 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/core/generator.d.ts.map +1 -1
  8. package/dist/core/generator.js +40 -9
  9. package/dist/core/generator.js.map +1 -1
  10. package/dist/dev.d.ts.map +1 -1
  11. package/dist/dev.js +1 -3
  12. package/dist/dev.js.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/runtime/base-bridge.d.ts +57 -0
  17. package/dist/runtime/base-bridge.d.ts.map +1 -0
  18. package/dist/runtime/base-bridge.js +72 -0
  19. package/dist/runtime/base-bridge.js.map +1 -0
  20. package/dist/runtime/frame-codec.d.ts +111 -0
  21. package/dist/runtime/frame-codec.d.ts.map +1 -0
  22. package/dist/runtime/frame-codec.js +352 -0
  23. package/dist/runtime/frame-codec.js.map +1 -0
  24. package/dist/runtime/http-transport.d.ts +11 -1
  25. package/dist/runtime/http-transport.d.ts.map +1 -1
  26. package/dist/runtime/http-transport.js +19 -0
  27. package/dist/runtime/http-transport.js.map +1 -1
  28. package/dist/runtime/http.d.ts +5 -12
  29. package/dist/runtime/http.d.ts.map +1 -1
  30. package/dist/runtime/http.js +6 -29
  31. package/dist/runtime/http.js.map +1 -1
  32. package/dist/runtime/index.d.ts +2 -2
  33. package/dist/runtime/index.d.ts.map +1 -1
  34. package/dist/runtime/index.js +1 -1
  35. package/dist/runtime/index.js.map +1 -1
  36. package/dist/runtime/node.d.ts +25 -19
  37. package/dist/runtime/node.d.ts.map +1 -1
  38. package/dist/runtime/node.js +19 -34
  39. package/dist/runtime/node.js.map +1 -1
  40. package/dist/runtime/pooled-transport.d.ts +21 -2
  41. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  42. package/dist/runtime/pooled-transport.js +16 -0
  43. package/dist/runtime/pooled-transport.js.map +1 -1
  44. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  45. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  46. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  47. package/dist/runtime/pyodide-transport.d.ts +12 -1
  48. package/dist/runtime/pyodide-transport.d.ts.map +1 -1
  49. package/dist/runtime/pyodide-transport.js +20 -0
  50. package/dist/runtime/pyodide-transport.js.map +1 -1
  51. package/dist/runtime/pyodide.d.ts +5 -12
  52. package/dist/runtime/pyodide.d.ts.map +1 -1
  53. package/dist/runtime/pyodide.js +6 -29
  54. package/dist/runtime/pyodide.js.map +1 -1
  55. package/dist/runtime/rpc-client.d.ts +14 -1
  56. package/dist/runtime/rpc-client.d.ts.map +1 -1
  57. package/dist/runtime/rpc-client.js +68 -6
  58. package/dist/runtime/rpc-client.js.map +1 -1
  59. package/dist/runtime/subprocess-transport.d.ts +177 -3
  60. package/dist/runtime/subprocess-transport.d.ts.map +1 -1
  61. package/dist/runtime/subprocess-transport.js +526 -26
  62. package/dist/runtime/subprocess-transport.js.map +1 -1
  63. package/dist/runtime/transport.d.ts +141 -0
  64. package/dist/runtime/transport.d.ts.map +1 -1
  65. package/dist/runtime/transport.js +21 -0
  66. package/dist/runtime/transport.js.map +1 -1
  67. package/dist/types/index.d.ts +59 -0
  68. package/dist/types/index.d.ts.map +1 -1
  69. package/dist/tywrap.d.ts.map +1 -1
  70. package/dist/tywrap.js +204 -149
  71. package/dist/tywrap.js.map +1 -1
  72. package/dist/utils/codec.d.ts +2 -0
  73. package/dist/utils/codec.d.ts.map +1 -1
  74. package/dist/utils/codec.js +205 -6
  75. package/dist/utils/codec.js.map +1 -1
  76. package/dist/version.js +1 -1
  77. package/package.json +7 -1
  78. package/runtime/__pycache__/_tywrap_conformance_chunking_fixtures.cpython-311.pyc +0 -0
  79. package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
  80. package/runtime/__pycache__/_tywrap_w4_chunking_fixture.cpython-311.pyc +0 -0
  81. package/runtime/__pycache__/_tywrap_w5_request_chunking_fixture.cpython-311.pyc +0 -0
  82. package/runtime/__pycache__/_tywrap_w6_pool_chunking_fixture.cpython-311.pyc +0 -0
  83. package/runtime/__pycache__/frame_codec.cpython-311.pyc +0 -0
  84. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  85. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  86. package/runtime/frame_codec.py +424 -0
  87. package/runtime/python_bridge.py +241 -42
  88. package/runtime/tywrap_bridge_core.py +152 -13
  89. package/src/core/annotation-parser.ts +2 -1
  90. package/src/core/emit-call.ts +1 -7
  91. package/src/core/generator.ts +50 -11
  92. package/src/dev.ts +1 -3
  93. package/src/index.ts +1 -0
  94. package/src/runtime/base-bridge.ts +106 -0
  95. package/src/runtime/frame-codec.ts +469 -0
  96. package/src/runtime/http-transport.ts +21 -1
  97. package/src/runtime/http.ts +7 -51
  98. package/src/runtime/index.ts +2 -6
  99. package/src/runtime/node.ts +42 -53
  100. package/src/runtime/pooled-transport.ts +25 -2
  101. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  102. package/src/runtime/pyodide-transport.ts +22 -0
  103. package/src/runtime/pyodide.ts +7 -52
  104. package/src/runtime/rpc-client.ts +91 -7
  105. package/src/runtime/subprocess-transport.ts +629 -30
  106. package/src/runtime/transport.ts +169 -0
  107. package/src/types/index.ts +62 -0
  108. package/src/tywrap.ts +265 -162
  109. package/src/utils/codec.ts +245 -7
  110. 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__':
@@ -43,8 +43,10 @@ derived from TYWRAP_CODEC_FALLBACK=json so that "Node in json-fallback mode" and
43
43
  import base64
44
44
  import datetime as dt
45
45
  import decimal
46
+ import functools
46
47
  import importlib
47
48
  import importlib.util
49
+ import inspect
48
50
  import json
49
51
  import math
50
52
  import traceback
@@ -183,6 +185,41 @@ def get_allowed_attr(obj, attr_name, *, allow_private_attrs):
183
185
  return getattr(obj, attr_name)
184
186
 
185
187
 
188
+ def resolve_allowed_attr_path(root, dotted_name, *, allow_private_attrs):
189
+ """
190
+ Resolve a possibly-dotted attribute path from root, applying the
191
+ private/dunder getattr guard to EVERY segment.
192
+
193
+ A single segment (the common case, e.g. a module-level function) behaves
194
+ exactly like get_allowed_attr. Dotted names exist because @classmethod and
195
+ @staticmethod are invoked through their owning class: the generated wrapper
196
+ emits call(module, 'Class.method', ...), so the bridge must walk
197
+ module -> Class -> method. Guarding each segment means 'Class._secret' or
198
+ '_Hidden.method' are rejected exactly as a direct private getattr would be —
199
+ the dotted path opens no access the single-getattr path did not already.
200
+ """
201
+ obj = root
202
+ for segment in dotted_name.split('.'):
203
+ obj = get_allowed_attr(obj, segment, allow_private_attrs=allow_private_attrs)
204
+ return obj
205
+
206
+
207
+ def is_accessor_attr(obj, attr_name):
208
+ """
209
+ True when attr_name resolves to a @property or functools.cached_property on
210
+ obj's type — i.e. it is read by attribute access, not called.
211
+
212
+ Inspects type(obj)'s MRO via getattr_static (which never triggers the
213
+ descriptor protocol), NOT the instance dict. That matters for
214
+ cached_property: after the first read it stores its value in the instance
215
+ __dict__, so an instance-level static lookup would return the cached value
216
+ rather than the descriptor and misclassify it as a method on the next read.
217
+ Reading from the type keeps the classification stable across repeated reads.
218
+ """
219
+ descriptor = inspect.getattr_static(type(obj), attr_name, None)
220
+ return isinstance(descriptor, (property, functools.cached_property))
221
+
222
+
186
223
  class CodecError(Exception):
187
224
  """Raised when value encoding fails (e.g. NaN/Infinity not allowed)."""
188
225
 
@@ -499,7 +536,10 @@ def serialize_sparse_matrix(obj):
499
536
  raise RuntimeError('Failed to inspect scipy sparse matrix format') from exc
500
537
 
501
538
  if fmt not in ('csr', 'csc', 'coo'):
502
- 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
+ )
503
543
 
504
544
  dtype = None
505
545
  try:
@@ -507,7 +547,10 @@ def serialize_sparse_matrix(obj):
507
547
  except Exception:
508
548
  dtype = None
509
549
  if getattr(obj.dtype, 'kind', None) == 'c':
510
- 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
+ )
511
554
 
512
555
  if fmt in ('csr', 'csc'):
513
556
  data = obj.data.tolist()
@@ -547,8 +590,58 @@ def serialize_torch_tensor(obj, *, force_json_markers, torch_allow_copy=False):
547
590
  Serialize torch.Tensor values via the nested ndarray envelope. CPU-only by
548
591
  default; device/copy behavior is explicit. force_json_markers is threaded
549
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.
550
599
  """
600
+ import torch # already importable: is_torch_tensor() gated the dispatch
601
+
551
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
+
552
645
  if getattr(tensor, 'device', None) is not None and tensor.device.type != 'cpu':
553
646
  if not torch_allow_copy:
554
647
  raise RuntimeError(
@@ -585,12 +678,22 @@ def serialize_sklearn_estimator(obj):
585
678
  raise RuntimeError('scikit-learn is not available') from exc
586
679
 
587
680
  params = obj.get_params(deep=False)
588
- try:
589
- json.dumps(params)
590
- except Exception as exc:
591
- raise RuntimeError(
592
- 'scikit-learn estimator params are not JSON-serializable; avoid returning estimators or sanitize params'
593
- ) 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
594
697
 
595
698
  return {
596
699
  '__tywrap__': 'sklearn.estimator',
@@ -852,7 +955,10 @@ def handle_call(params, *, force_json_markers, torch_allow_copy, allowed_modules
852
955
  args = deserialize(coerce_list(params.get('args'), 'args'))
853
956
  kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))
854
957
  mod = import_allowed_module(module_name, allowed_modules)
855
- func = get_allowed_attr(mod, function_name, allow_private_attrs=allow_private_attrs)
958
+ # function_name may be dotted ('Class.method') for @classmethod/@staticmethod
959
+ # calls, which the generated wrapper routes through call() rather than an
960
+ # instance handle. resolve_allowed_attr_path guards each segment.
961
+ func = resolve_allowed_attr_path(mod, function_name, allow_private_attrs=allow_private_attrs)
856
962
  res = func(*args, **kwargs)
857
963
  return serialize(res, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy)
858
964
 
@@ -878,8 +984,20 @@ def handle_call_method(params, instances, *, force_json_markers, torch_allow_cop
878
984
  if handle_id not in instances:
879
985
  raise InstanceHandleError(f'Unknown instance handle: {handle_id}')
880
986
  obj = instances[handle_id]
881
- func = get_allowed_attr(obj, method_name, allow_private_attrs=allow_private_attrs)
882
- res = func(*args, **kwargs)
987
+ # A @property / functools.cached_property is read, not called: the generated
988
+ # `get prop()` accessor emits callMethod(handle, name, []). Classify before
989
+ # touching the value (so cached_property is detected on its first read) and
990
+ # return the attribute directly; everything else is a bound method to call.
991
+ if is_accessor_attr(obj, method_name):
992
+ # An accessor is read, never called: a generated `get prop()` always
993
+ # sends empty args. Reject a malformed request that supplies any so it
994
+ # fails loudly instead of silently dropping the arguments.
995
+ if args or kwargs:
996
+ raise ProtocolError(f'Accessor {method_name!r} does not accept arguments')
997
+ res = get_allowed_attr(obj, method_name, allow_private_attrs=allow_private_attrs)
998
+ else:
999
+ func = get_allowed_attr(obj, method_name, allow_private_attrs=allow_private_attrs)
1000
+ res = func(*args, **kwargs)
883
1001
  return serialize(res, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy)
884
1002
 
885
1003
 
@@ -891,7 +1009,16 @@ def handle_dispose_instance(params, instances):
891
1009
  return True
892
1010
 
893
1011
 
894
- 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
+ ):
895
1022
  """
896
1023
  Build the bridge metadata payload.
897
1024
 
@@ -904,9 +1031,16 @@ def build_meta(instances, *, bridge, pid, python_version, codec_fallback, arrow_
904
1031
  instead of probing pyarrow. The Pyodide server forces markers to JSON
905
1032
  unconditionally, so it advertises arrowAvailable=False regardless of whether
906
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).
907
1041
  """
908
1042
  arrow = arrow_available() if arrow_available_override is None else arrow_available_override
909
- return {
1043
+ meta = {
910
1044
  'protocol': PROTOCOL,
911
1045
  'protocolVersion': PROTOCOL_VERSION,
912
1046
  'bridge': bridge,
@@ -919,6 +1053,9 @@ def build_meta(instances, *, bridge, pid, python_version, codec_fallback, arrow_
919
1053
  'sklearnAvailable': module_available('sklearn'),
920
1054
  'instances': len(instances),
921
1055
  }
1056
+ if transport_info is not None:
1057
+ meta['transport'] = transport_info
1058
+ return meta
922
1059
 
923
1060
 
924
1061
  def dispatch_request(
@@ -934,6 +1071,7 @@ def dispatch_request(
934
1071
  arrow_available_override=None,
935
1072
  allowed_modules=None,
936
1073
  allow_private_attrs=False,
1074
+ transport_info=None,
937
1075
  ):
938
1076
  """
939
1077
  Validate and route a request, returning the fully-serialized response dict
@@ -990,6 +1128,7 @@ def dispatch_request(
990
1128
  python_version=python_version,
991
1129
  codec_fallback=codec_fallback,
992
1130
  arrow_available_override=arrow_available_override,
1131
+ transport_info=transport_info,
993
1132
  )
994
1133
  else:
995
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