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.
- package/README.md +15 -5
- package/dist/core/annotation-parser.d.ts.map +1 -1
- package/dist/core/annotation-parser.js.map +1 -1
- package/dist/core/emit-call.d.ts.map +1 -1
- package/dist/core/emit-call.js +1 -1
- package/dist/core/emit-call.js.map +1 -1
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +1 -3
- package/dist/dev.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/runtime/frame-codec.d.ts +111 -0
- package/dist/runtime/frame-codec.d.ts.map +1 -0
- package/dist/runtime/frame-codec.js +352 -0
- package/dist/runtime/frame-codec.js.map +1 -0
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/node.d.ts +13 -0
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +5 -0
- package/dist/runtime/node.js.map +1 -1
- package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
- package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
- package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
- package/dist/runtime/rpc-client.d.ts.map +1 -1
- package/dist/runtime/rpc-client.js +53 -6
- package/dist/runtime/rpc-client.js.map +1 -1
- package/dist/runtime/subprocess-transport.d.ts +172 -7
- package/dist/runtime/subprocess-transport.d.ts.map +1 -1
- package/dist/runtime/subprocess-transport.js +513 -31
- package/dist/runtime/subprocess-transport.js.map +1 -1
- package/dist/runtime/transport.d.ts +85 -3
- package/dist/runtime/transport.d.ts.map +1 -1
- package/dist/runtime/transport.js +20 -0
- package/dist/runtime/transport.js.map +1 -1
- package/dist/types/index.d.ts +24 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/tywrap.js +1 -9
- package/dist/tywrap.js.map +1 -1
- package/dist/utils/codec.d.ts.map +1 -1
- package/dist/utils/codec.js +152 -4
- package/dist/utils/codec.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/runtime/__pycache__/_tywrap_conformance_chunking_fixtures.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_w4_chunking_fixture.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_w5_request_chunking_fixture.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_w6_pool_chunking_fixture.cpython-311.pyc +0 -0
- package/runtime/__pycache__/frame_codec.cpython-311.pyc +0 -0
- package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
- package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
- package/runtime/frame_codec.py +424 -0
- package/runtime/python_bridge.py +241 -42
- package/runtime/tywrap_bridge_core.py +97 -10
- package/src/core/annotation-parser.ts +2 -1
- package/src/core/emit-call.ts +1 -7
- package/src/dev.ts +1 -3
- package/src/index.ts +1 -0
- package/src/runtime/frame-codec.ts +469 -0
- package/src/runtime/index.ts +1 -6
- package/src/runtime/node.ts +25 -1
- package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
- package/src/runtime/rpc-client.ts +74 -7
- package/src/runtime/subprocess-transport.ts +615 -35
- package/src/runtime/transport.ts +101 -3
- package/src/types/index.ts +25 -0
- package/src/tywrap.ts +1 -9
- package/src/utils/codec.ts +184 -3
- package/src/version.ts +1 -1
package/runtime/python_bridge.py
CHANGED
|
@@ -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
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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:
|
|
364
|
-
out = build_error_payload(mid, e, include_traceback=
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
377
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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(
|
|
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
|
-
|
|
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 =>
|
|
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
|
package/src/core/emit-call.ts
CHANGED
|
@@ -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> {
|