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.
- 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/core/generator.d.ts.map +1 -1
- package/dist/core/generator.js +40 -9
- package/dist/core/generator.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/base-bridge.d.ts +57 -0
- package/dist/runtime/base-bridge.d.ts.map +1 -0
- package/dist/runtime/base-bridge.js +72 -0
- package/dist/runtime/base-bridge.js.map +1 -0
- 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/http-transport.d.ts +11 -1
- package/dist/runtime/http-transport.d.ts.map +1 -1
- package/dist/runtime/http-transport.js +19 -0
- package/dist/runtime/http-transport.js.map +1 -1
- package/dist/runtime/http.d.ts +5 -12
- package/dist/runtime/http.d.ts.map +1 -1
- package/dist/runtime/http.js +6 -29
- package/dist/runtime/http.js.map +1 -1
- package/dist/runtime/index.d.ts +2 -2
- 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 +25 -19
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +19 -34
- package/dist/runtime/node.js.map +1 -1
- package/dist/runtime/pooled-transport.d.ts +21 -2
- package/dist/runtime/pooled-transport.d.ts.map +1 -1
- package/dist/runtime/pooled-transport.js +16 -0
- package/dist/runtime/pooled-transport.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/pyodide-transport.d.ts +12 -1
- package/dist/runtime/pyodide-transport.d.ts.map +1 -1
- package/dist/runtime/pyodide-transport.js +20 -0
- package/dist/runtime/pyodide-transport.js.map +1 -1
- package/dist/runtime/pyodide.d.ts +5 -12
- package/dist/runtime/pyodide.d.ts.map +1 -1
- package/dist/runtime/pyodide.js +6 -29
- package/dist/runtime/pyodide.js.map +1 -1
- package/dist/runtime/rpc-client.d.ts +14 -1
- package/dist/runtime/rpc-client.d.ts.map +1 -1
- package/dist/runtime/rpc-client.js +68 -6
- package/dist/runtime/rpc-client.js.map +1 -1
- package/dist/runtime/subprocess-transport.d.ts +177 -3
- package/dist/runtime/subprocess-transport.d.ts.map +1 -1
- package/dist/runtime/subprocess-transport.js +526 -26
- package/dist/runtime/subprocess-transport.js.map +1 -1
- package/dist/runtime/transport.d.ts +141 -0
- package/dist/runtime/transport.d.ts.map +1 -1
- package/dist/runtime/transport.js +21 -0
- package/dist/runtime/transport.js.map +1 -1
- package/dist/types/index.d.ts +59 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/tywrap.d.ts.map +1 -1
- package/dist/tywrap.js +204 -149
- package/dist/tywrap.js.map +1 -1
- package/dist/utils/codec.d.ts +2 -0
- package/dist/utils/codec.d.ts.map +1 -1
- package/dist/utils/codec.js +205 -6
- package/dist/utils/codec.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +7 -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 +152 -13
- package/src/core/annotation-parser.ts +2 -1
- package/src/core/emit-call.ts +1 -7
- package/src/core/generator.ts +50 -11
- package/src/dev.ts +1 -3
- package/src/index.ts +1 -0
- package/src/runtime/base-bridge.ts +106 -0
- package/src/runtime/frame-codec.ts +469 -0
- package/src/runtime/http-transport.ts +21 -1
- package/src/runtime/http.ts +7 -51
- package/src/runtime/index.ts +2 -6
- package/src/runtime/node.ts +42 -53
- package/src/runtime/pooled-transport.ts +25 -2
- package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
- package/src/runtime/pyodide-transport.ts +22 -0
- package/src/runtime/pyodide.ts +7 -52
- package/src/runtime/rpc-client.ts +91 -7
- package/src/runtime/subprocess-transport.ts +629 -30
- package/src/runtime/transport.ts +169 -0
- package/src/types/index.ts +62 -0
- package/src/tywrap.ts +265 -162
- package/src/utils/codec.ts +245 -7
- 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__':
|
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
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
|
-
|
|
882
|
-
|
|
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(
|
|
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
|
-
|
|
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 =>
|
|
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
|
|