tywrap 0.8.0 → 0.10.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 +42 -22
- package/SECURITY.md +38 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +8 -0
- package/dist/config/index.js.map +1 -1
- package/dist/core/annotation-parser.d.ts +2 -1
- package/dist/core/annotation-parser.d.ts.map +1 -1
- package/dist/core/annotation-parser.js +6 -3
- package/dist/core/annotation-parser.js.map +1 -1
- package/dist/core/generator.d.ts +23 -4
- package/dist/core/generator.d.ts.map +1 -1
- package/dist/core/generator.js +254 -170
- package/dist/core/generator.js.map +1 -1
- package/dist/core/mapper.d.ts +3 -2
- package/dist/core/mapper.d.ts.map +1 -1
- package/dist/core/mapper.js +5 -5
- package/dist/core/mapper.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/runtime/base-bridge.d.ts +3 -7
- package/dist/runtime/base-bridge.d.ts.map +1 -1
- package/dist/runtime/base-bridge.js +4 -17
- package/dist/runtime/base-bridge.js.map +1 -1
- package/dist/runtime/bounded-context.d.ts +3 -22
- package/dist/runtime/bounded-context.d.ts.map +1 -1
- package/dist/runtime/bounded-context.js +13 -53
- package/dist/runtime/bounded-context.js.map +1 -1
- package/dist/runtime/bridge-codec.d.ts +3 -3
- package/dist/runtime/bridge-codec.d.ts.map +1 -1
- package/dist/runtime/bridge-codec.js +129 -62
- package/dist/runtime/bridge-codec.js.map +1 -1
- package/dist/runtime/errors.d.ts +16 -0
- package/dist/runtime/errors.d.ts.map +1 -1
- package/dist/runtime/errors.js +17 -0
- package/dist/runtime/errors.js.map +1 -1
- package/dist/runtime/http-transport.d.ts +1 -1
- package/dist/runtime/http-transport.d.ts.map +1 -1
- package/dist/runtime/http-transport.js +1 -1
- package/dist/runtime/http-transport.js.map +1 -1
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/node.d.ts +7 -36
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +2 -80
- package/dist/runtime/node.js.map +1 -1
- package/dist/runtime/pooled-transport.d.ts +120 -59
- package/dist/runtime/pooled-transport.d.ts.map +1 -1
- package/dist/runtime/pooled-transport.js +345 -78
- 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 +1 -1
- package/dist/runtime/pyodide-transport.d.ts.map +1 -1
- package/dist/runtime/pyodide-transport.js +2 -3
- package/dist/runtime/pyodide-transport.js.map +1 -1
- package/dist/runtime/rpc-client.d.ts +7 -36
- package/dist/runtime/rpc-client.d.ts.map +1 -1
- package/dist/runtime/rpc-client.js +20 -102
- package/dist/runtime/rpc-client.js.map +1 -1
- package/dist/runtime/subprocess-transport.d.ts +30 -69
- package/dist/runtime/subprocess-transport.d.ts.map +1 -1
- package/dist/runtime/subprocess-transport.js +179 -236
- package/dist/runtime/subprocess-transport.js.map +1 -1
- package/dist/runtime/timed-out-request-tracker.d.ts +2 -1
- package/dist/runtime/timed-out-request-tracker.d.ts.map +1 -1
- package/dist/runtime/transport.d.ts +9 -19
- package/dist/runtime/transport.d.ts.map +1 -1
- package/dist/runtime/transport.js +1 -1
- package/dist/runtime/transport.js.map +1 -1
- package/dist/runtime/validators.d.ts +50 -0
- package/dist/runtime/validators.d.ts.map +1 -1
- package/dist/runtime/validators.js +154 -0
- package/dist/runtime/validators.js.map +1 -1
- package/dist/types/index.d.ts +14 -39
- package/dist/types/index.d.ts.map +1 -1
- package/dist/tywrap.d.ts +3 -2
- package/dist/tywrap.d.ts.map +1 -1
- package/dist/tywrap.js +140 -13
- package/dist/tywrap.js.map +1 -1
- package/dist/utils/cache.d.ts +3 -16
- package/dist/utils/cache.d.ts.map +1 -1
- package/dist/utils/codec.d.ts +17 -0
- package/dist/utils/codec.d.ts.map +1 -1
- package/dist/utils/codec.js +500 -85
- package/dist/utils/codec.js.map +1 -1
- package/dist/utils/ir-cache.d.ts +2 -1
- package/dist/utils/ir-cache.d.ts.map +1 -1
- package/dist/utils/runtime.d.ts +0 -29
- package/dist/utils/runtime.d.ts.map +1 -1
- package/dist/utils/runtime.js +16 -107
- package/dist/utils/runtime.js.map +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +7 -6
- package/runtime/frame_codec.py +7 -1
- package/runtime/python_bridge.py +85 -125
- package/runtime/tywrap_bridge_core.py +515 -187
- package/src/config/index.ts +11 -0
- package/src/core/annotation-parser.ts +7 -4
- package/src/core/generator.ts +330 -205
- package/src/core/mapper.ts +8 -8
- package/src/index.ts +7 -4
- package/src/runtime/base-bridge.ts +5 -30
- package/src/runtime/bounded-context.ts +12 -67
- package/src/runtime/bridge-codec.ts +158 -74
- package/src/runtime/errors.ts +21 -0
- package/src/runtime/http-transport.ts +6 -1
- package/src/runtime/index.ts +6 -0
- package/src/runtime/node.ts +9 -120
- package/src/runtime/pooled-transport.ts +424 -90
- package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
- package/src/runtime/pyodide-transport.ts +7 -3
- package/src/runtime/rpc-client.ts +37 -139
- package/src/runtime/subprocess-transport.ts +210 -285
- package/src/runtime/timed-out-request-tracker.ts +1 -1
- package/src/runtime/transport.ts +15 -23
- package/src/runtime/validators.ts +212 -0
- package/src/types/index.ts +21 -60
- package/src/tywrap.ts +157 -22
- package/src/utils/cache.ts +3 -3
- package/src/utils/codec.ts +749 -112
- package/src/utils/ir-cache.ts +1 -1
- package/src/utils/runtime.ts +17 -128
- package/src/version.ts +1 -1
- package/dist/core/discovery.d.ts +0 -103
- package/dist/core/discovery.d.ts.map +0 -1
- package/dist/core/discovery.js +0 -380
- package/dist/core/discovery.js.map +0 -1
- package/dist/core/validation.d.ts +0 -102
- package/dist/core/validation.d.ts.map +0 -1
- package/dist/core/validation.js +0 -490
- package/dist/core/validation.js.map +0 -1
- package/dist/runtime/base.d.ts +0 -22
- package/dist/runtime/base.d.ts.map +0 -1
- package/dist/runtime/base.js +0 -23
- package/dist/runtime/base.js.map +0 -1
- package/dist/runtime/transport-pool.d.ts +0 -196
- package/dist/runtime/transport-pool.d.ts.map +0 -1
- package/dist/runtime/transport-pool.js +0 -418
- package/dist/runtime/transport-pool.js.map +0 -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/safe_codec.py +0 -352
- package/src/core/discovery.ts +0 -477
- package/src/core/validation.ts +0 -729
- package/src/runtime/base.ts +0 -24
- package/src/runtime/transport-pool.ts +0 -538
|
@@ -43,12 +43,12 @@ 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
|
|
47
46
|
import importlib
|
|
48
47
|
import importlib.util
|
|
49
|
-
import inspect
|
|
50
48
|
import json
|
|
51
49
|
import math
|
|
50
|
+
import re
|
|
51
|
+
import sys
|
|
52
52
|
import traceback
|
|
53
53
|
import uuid
|
|
54
54
|
from pathlib import Path, PurePath
|
|
@@ -58,16 +58,16 @@ from pathlib import Path, PurePath
|
|
|
58
58
|
PROTOCOL = 'tywrap/1'
|
|
59
59
|
PROTOCOL_VERSION = 1
|
|
60
60
|
CODEC_VERSION = 1
|
|
61
|
+
MAX_SERIALIZE_DEPTH = 900
|
|
62
|
+
MAX_SERIALIZE_NODES = 1_000_000
|
|
63
|
+
JS_SAFE_INTEGER_MAX = 2**53 - 1
|
|
64
|
+
_SERIALIZE_PATH_IDENTIFIER = re.compile(r'^[A-Za-z_$][\w$]*$', re.ASCII)
|
|
61
65
|
|
|
62
66
|
|
|
63
67
|
class ProtocolError(Exception):
|
|
64
68
|
"""Raised for malformed requests (bad protocol/id/method/params)."""
|
|
65
69
|
|
|
66
70
|
|
|
67
|
-
class InstanceHandleError(ValueError):
|
|
68
|
-
"""Raised when an instance handle is unknown or no longer valid."""
|
|
69
|
-
|
|
70
|
-
|
|
71
71
|
class ImportNotAllowedError(PermissionError):
|
|
72
72
|
"""Raised when a requested module import is not on the active allowlist."""
|
|
73
73
|
|
|
@@ -96,8 +96,8 @@ class AttributeNotAllowedError(PermissionError):
|
|
|
96
96
|
# IMPORT / ATTRIBUTE ALLOWLIST (trust boundary enforcement)
|
|
97
97
|
# =============================================================================
|
|
98
98
|
#
|
|
99
|
-
# The bridge dispatches call
|
|
100
|
-
#
|
|
99
|
+
# The bridge dispatches call requests by importing the requested module and
|
|
100
|
+
# getattr-ing the requested function/class method. That is an
|
|
101
101
|
# arbitrary import+getattr+call surface, so two complementary guards live here.
|
|
102
102
|
# Both are PURE (no env reads) so the rules behave identically under the
|
|
103
103
|
# subprocess server and the in-WASM Pyodide server; the subprocess server derives
|
|
@@ -204,22 +204,6 @@ def resolve_allowed_attr_path(root, dotted_name, *, allow_private_attrs):
|
|
|
204
204
|
return obj
|
|
205
205
|
|
|
206
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
|
-
|
|
223
207
|
class CodecError(Exception):
|
|
224
208
|
"""Raised when value encoding fails (e.g. NaN/Infinity not allowed)."""
|
|
225
209
|
|
|
@@ -269,22 +253,24 @@ def _deserialize_bytes_envelope(value):
|
|
|
269
253
|
return _NO_DESERIALIZE
|
|
270
254
|
|
|
271
255
|
|
|
272
|
-
def deserialize(value):
|
|
256
|
+
def deserialize(value, *, has_envelope_markers=True):
|
|
273
257
|
"""
|
|
274
258
|
Recursively deserialize request values into Python-native types.
|
|
275
259
|
|
|
276
260
|
Why: requests are JSON-only; we need a small set of explicit decoders
|
|
277
261
|
(currently bytes) to restore Python semantics at the boundary.
|
|
278
262
|
"""
|
|
263
|
+
if not has_envelope_markers:
|
|
264
|
+
return value
|
|
279
265
|
decoded = _deserialize_bytes_envelope(value)
|
|
280
266
|
if decoded is not _NO_DESERIALIZE:
|
|
281
267
|
return decoded
|
|
282
268
|
|
|
283
269
|
if isinstance(value, list):
|
|
284
|
-
return [deserialize(item) for item in value]
|
|
270
|
+
return [deserialize(item, has_envelope_markers=True) for item in value]
|
|
285
271
|
if isinstance(value, dict):
|
|
286
272
|
# Preserve dict shape while decoding nested values.
|
|
287
|
-
return {k: deserialize(v) for k, v in value.items()}
|
|
273
|
+
return {k: deserialize(v, has_envelope_markers=True) for k, v in value.items()}
|
|
288
274
|
return value
|
|
289
275
|
|
|
290
276
|
|
|
@@ -314,33 +300,33 @@ def module_available(module_name):
|
|
|
314
300
|
|
|
315
301
|
|
|
316
302
|
def is_numpy_array(obj):
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
303
|
+
"""Return whether obj is an ndarray without importing NumPy."""
|
|
304
|
+
np = sys.modules.get('numpy')
|
|
305
|
+
if np is None:
|
|
320
306
|
return False
|
|
321
307
|
return isinstance(obj, np.ndarray)
|
|
322
308
|
|
|
323
309
|
|
|
324
310
|
def is_pandas_dataframe(obj):
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
311
|
+
"""Return whether obj is a DataFrame without importing pandas."""
|
|
312
|
+
pd = sys.modules.get('pandas')
|
|
313
|
+
if pd is None:
|
|
328
314
|
return False
|
|
329
315
|
return isinstance(obj, pd.DataFrame)
|
|
330
316
|
|
|
331
317
|
|
|
332
318
|
def is_pandas_series(obj):
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
319
|
+
"""Return whether obj is a Series without importing pandas."""
|
|
320
|
+
pd = sys.modules.get('pandas')
|
|
321
|
+
if pd is None:
|
|
336
322
|
return False
|
|
337
323
|
return isinstance(obj, pd.Series)
|
|
338
324
|
|
|
339
325
|
|
|
340
326
|
def is_scipy_sparse(obj):
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
327
|
+
"""Return whether obj is sparse without importing SciPy."""
|
|
328
|
+
sp = sys.modules.get('scipy.sparse')
|
|
329
|
+
if sp is None:
|
|
344
330
|
return False
|
|
345
331
|
try:
|
|
346
332
|
return sp.issparse(obj)
|
|
@@ -349,9 +335,9 @@ def is_scipy_sparse(obj):
|
|
|
349
335
|
|
|
350
336
|
|
|
351
337
|
def is_torch_tensor(obj):
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
338
|
+
"""Return whether obj is a Tensor without importing PyTorch."""
|
|
339
|
+
torch = sys.modules.get('torch')
|
|
340
|
+
if torch is None:
|
|
355
341
|
return False
|
|
356
342
|
try:
|
|
357
343
|
return torch.is_tensor(obj)
|
|
@@ -360,11 +346,11 @@ def is_torch_tensor(obj):
|
|
|
360
346
|
|
|
361
347
|
|
|
362
348
|
def is_sklearn_estimator(obj):
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
349
|
+
"""Return whether obj is an estimator without importing scikit-learn."""
|
|
350
|
+
sklearn_base = sys.modules.get('sklearn.base')
|
|
351
|
+
if sklearn_base is None:
|
|
366
352
|
return False
|
|
367
|
-
return isinstance(obj, BaseEstimator)
|
|
353
|
+
return isinstance(obj, sklearn_base.BaseEstimator)
|
|
368
354
|
|
|
369
355
|
|
|
370
356
|
# =============================================================================
|
|
@@ -395,7 +381,7 @@ def serialize_ndarray(obj, *, force_json_markers):
|
|
|
395
381
|
) from exc
|
|
396
382
|
try:
|
|
397
383
|
original_shape = list(obj.shape) if hasattr(obj, 'shape') else None
|
|
398
|
-
flat = obj.flatten() if hasattr(obj, 'ndim') and obj.ndim
|
|
384
|
+
flat = obj.flatten() if hasattr(obj, 'ndim') and obj.ndim != 1 else obj
|
|
399
385
|
arr = pa.array(flat)
|
|
400
386
|
table = pa.Table.from_arrays([arr], names=['value'])
|
|
401
387
|
sink = pa.BufferOutputStream()
|
|
@@ -416,7 +402,80 @@ def serialize_ndarray(obj, *, force_json_markers):
|
|
|
416
402
|
|
|
417
403
|
|
|
418
404
|
def serialize_ndarray_json(obj):
|
|
419
|
-
"""JSON fallback for ndarray
|
|
405
|
+
"""JSON fallback for ndarray values that JavaScript can represent safely."""
|
|
406
|
+
dtype = obj.dtype
|
|
407
|
+
dtype_label = str(dtype)
|
|
408
|
+
|
|
409
|
+
if not dtype.isnative:
|
|
410
|
+
raise RuntimeError(
|
|
411
|
+
f'JSON ndarray encoding does not support big-endian dtype={dtype_label}; '
|
|
412
|
+
"convert to native byte order with a.byteswap().view(a.dtype.newbyteorder('='))"
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
if dtype.kind in ('i', 'u'):
|
|
416
|
+
# JSON numbers become JavaScript Number values. Scan only on this explicit
|
|
417
|
+
# fallback path and reject before tolist() can silently round an integer.
|
|
418
|
+
if obj.size and (
|
|
419
|
+
(obj < -JS_SAFE_INTEGER_MAX).any() or (obj > JS_SAFE_INTEGER_MAX).any()
|
|
420
|
+
):
|
|
421
|
+
raise RuntimeError(
|
|
422
|
+
f'JSON ndarray encoding cannot safely represent dtype={dtype_label} values '
|
|
423
|
+
'outside the JavaScript safe integer range; use Arrow encoding or '
|
|
424
|
+
"cast/encode explicitly (e.g. .astype('float64') or str)"
|
|
425
|
+
)
|
|
426
|
+
elif dtype.kind == 'M':
|
|
427
|
+
raise RuntimeError(
|
|
428
|
+
f'JSON ndarray encoding does not support dtype={dtype_label}; use Arrow encoding '
|
|
429
|
+
"or convert explicitly (e.g. .astype('datetime64[ms]').astype(str) or int with "
|
|
430
|
+
'declared unit)'
|
|
431
|
+
)
|
|
432
|
+
elif dtype.kind == 'm':
|
|
433
|
+
raise RuntimeError(
|
|
434
|
+
f'JSON ndarray encoding does not support dtype={dtype_label}; use Arrow encoding '
|
|
435
|
+
"or convert explicitly (e.g. .astype('timedelta64[ms]').astype(str) or int with "
|
|
436
|
+
'declared unit)'
|
|
437
|
+
)
|
|
438
|
+
elif dtype.kind == 'V':
|
|
439
|
+
if dtype.fields is not None:
|
|
440
|
+
raise RuntimeError(
|
|
441
|
+
f'JSON ndarray encoding does not support structured dtype={dtype_label}; '
|
|
442
|
+
'encode each named field explicitly (e.g. as a plain JSON object)'
|
|
443
|
+
)
|
|
444
|
+
raise RuntimeError(
|
|
445
|
+
f'JSON ndarray encoding does not support void dtype={dtype_label}; convert the '
|
|
446
|
+
"raw bytes explicitly (e.g. .view('uint8'))"
|
|
447
|
+
)
|
|
448
|
+
elif dtype.kind == 'O':
|
|
449
|
+
raise RuntimeError(
|
|
450
|
+
f'JSON ndarray encoding does not support object dtype={dtype_label}; cast to a '
|
|
451
|
+
'concrete numeric dtype or encode elements explicitly as plain JSON'
|
|
452
|
+
)
|
|
453
|
+
elif dtype.kind == 'S':
|
|
454
|
+
raise RuntimeError(
|
|
455
|
+
f'JSON ndarray encoding does not support byte-string dtype={dtype_label}; '
|
|
456
|
+
'decode elements and return a plain JSON list explicitly'
|
|
457
|
+
)
|
|
458
|
+
elif dtype.kind == 'U':
|
|
459
|
+
raise RuntimeError(
|
|
460
|
+
f'JSON ndarray encoding does not support unicode dtype={dtype_label}; convert '
|
|
461
|
+
'explicitly to plain JSON strings with .tolist()'
|
|
462
|
+
)
|
|
463
|
+
elif dtype.kind == 'c':
|
|
464
|
+
raise RuntimeError(
|
|
465
|
+
f'JSON ndarray encoding does not support complex dtype={dtype_label}; encode '
|
|
466
|
+
'.real and .imag arrays explicitly'
|
|
467
|
+
)
|
|
468
|
+
elif dtype.kind == 'f' and dtype.itemsize > 8:
|
|
469
|
+
raise RuntimeError(
|
|
470
|
+
f'JSON ndarray encoding does not support float dtype={dtype_label} wider than '
|
|
471
|
+
"64 bits; cast explicitly (e.g. .astype('float64')) or use Arrow encoding"
|
|
472
|
+
)
|
|
473
|
+
elif dtype.kind not in ('b', 'f'):
|
|
474
|
+
raise RuntimeError(
|
|
475
|
+
f'JSON ndarray encoding does not support dtype={dtype_label}; cast to bool, '
|
|
476
|
+
'integer, or float dtype, or encode values explicitly as plain JSON'
|
|
477
|
+
)
|
|
478
|
+
|
|
420
479
|
try:
|
|
421
480
|
data = obj.tolist()
|
|
422
481
|
except Exception as exc:
|
|
@@ -427,6 +486,7 @@ def serialize_ndarray_json(obj):
|
|
|
427
486
|
'encoding': 'json',
|
|
428
487
|
'data': data,
|
|
429
488
|
'shape': getattr(obj, 'shape', None),
|
|
489
|
+
'dtype': dtype.name,
|
|
430
490
|
}
|
|
431
491
|
|
|
432
492
|
|
|
@@ -461,11 +521,42 @@ def serialize_dataframe(obj, *, force_json_markers):
|
|
|
461
521
|
|
|
462
522
|
|
|
463
523
|
def serialize_dataframe_json(obj):
|
|
464
|
-
"""JSON fallback for DataFrame
|
|
524
|
+
"""JSON fallback for DataFrame values that JavaScript can represent safely."""
|
|
525
|
+
import pandas as pd # type: ignore
|
|
526
|
+
|
|
527
|
+
_validate_pandas_json_index(obj.index, pd, 'DataFrame')
|
|
528
|
+
json_columns = [_json_object_key(column) for column in obj.columns]
|
|
529
|
+
supported_json_columns = [column for column in json_columns if column is not None]
|
|
530
|
+
if not obj.columns.is_unique or len(set(supported_json_columns)) != len(
|
|
531
|
+
supported_json_columns
|
|
532
|
+
):
|
|
533
|
+
raise RuntimeError(
|
|
534
|
+
'JSON pandas.DataFrame encoding requires column labels to remain unique after '
|
|
535
|
+
'JSON object-key coercion; rename columns or make them distinct before applying '
|
|
536
|
+
'.columns.astype(str)'
|
|
537
|
+
)
|
|
538
|
+
for column, dtype in obj.dtypes.items():
|
|
539
|
+
if isinstance(dtype, pd.CategoricalDtype):
|
|
540
|
+
raise RuntimeError(
|
|
541
|
+
f'JSON pandas.DataFrame encoding does not support categorical dtype in '
|
|
542
|
+
f'column {column!r}; use Arrow encoding or convert explicitly '
|
|
543
|
+
"(e.g. .astype(str))"
|
|
544
|
+
)
|
|
465
545
|
try:
|
|
466
|
-
data =
|
|
546
|
+
data = (
|
|
547
|
+
[{} for _ in range(len(obj.index))]
|
|
548
|
+
if len(obj.columns) == 0
|
|
549
|
+
else obj.to_dict(orient='records')
|
|
550
|
+
)
|
|
467
551
|
except Exception as exc:
|
|
468
552
|
raise RuntimeError('JSON fallback failed for pandas.DataFrame') from exc
|
|
553
|
+
for row_number, row in enumerate(data):
|
|
554
|
+
for column, value in row.items():
|
|
555
|
+
row[column] = _normalize_pandas_json_scalar(
|
|
556
|
+
value,
|
|
557
|
+
f'DataFrame cell at row {row_number}, column {column!r}',
|
|
558
|
+
pd,
|
|
559
|
+
)
|
|
469
560
|
return {
|
|
470
561
|
'__tywrap__': 'dataframe',
|
|
471
562
|
'codecVersion': CODEC_VERSION,
|
|
@@ -507,14 +598,23 @@ def serialize_series(obj, *, force_json_markers):
|
|
|
507
598
|
|
|
508
599
|
|
|
509
600
|
def serialize_series_json(obj):
|
|
510
|
-
"""JSON fallback for Series
|
|
601
|
+
"""JSON fallback for Series values that JavaScript can represent safely."""
|
|
602
|
+
import pandas as pd # type: ignore
|
|
603
|
+
|
|
604
|
+
_validate_pandas_json_index(obj.index, pd, 'Series')
|
|
605
|
+
if isinstance(obj.dtype, pd.CategoricalDtype):
|
|
606
|
+
raise RuntimeError(
|
|
607
|
+
'JSON pandas.Series encoding does not support categorical dtype; use Arrow '
|
|
608
|
+
"encoding or convert explicitly (e.g. .astype(str))"
|
|
609
|
+
)
|
|
511
610
|
try:
|
|
512
611
|
data = obj.to_list() # type: ignore
|
|
513
|
-
except Exception:
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
612
|
+
except Exception as exc:
|
|
613
|
+
raise RuntimeError('JSON fallback failed for pandas.Series') from exc
|
|
614
|
+
data = [
|
|
615
|
+
_normalize_pandas_json_scalar(value, f'Series value at position {position}', pd)
|
|
616
|
+
for position, value in enumerate(data)
|
|
617
|
+
]
|
|
518
618
|
return {
|
|
519
619
|
'__tywrap__': 'series',
|
|
520
620
|
'codecVersion': CODEC_VERSION,
|
|
@@ -524,6 +624,70 @@ def serialize_series_json(obj):
|
|
|
524
624
|
}
|
|
525
625
|
|
|
526
626
|
|
|
627
|
+
def _validate_pandas_json_index(index, pd, container_name):
|
|
628
|
+
"""Reject index metadata that records/list-oriented JSON would discard."""
|
|
629
|
+
if isinstance(index, pd.MultiIndex):
|
|
630
|
+
raise RuntimeError(
|
|
631
|
+
f'JSON pandas.{container_name} encoding does not support MultiIndex; use Arrow '
|
|
632
|
+
'encoding or flatten the index explicitly with .reset_index()'
|
|
633
|
+
)
|
|
634
|
+
if not (
|
|
635
|
+
isinstance(index, pd.RangeIndex)
|
|
636
|
+
and index.start == 0
|
|
637
|
+
and index.step == 1
|
|
638
|
+
and index.stop == len(index)
|
|
639
|
+
and index.name is None
|
|
640
|
+
):
|
|
641
|
+
raise RuntimeError(
|
|
642
|
+
f'JSON pandas.{container_name} encoding requires an unnamed RangeIndex starting '
|
|
643
|
+
'at 0 with step 1; use Arrow encoding or normalize explicitly with '
|
|
644
|
+
'.reset_index(drop=True)'
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _json_object_key(value):
|
|
649
|
+
"""Return json.dumps' object-key spelling, or None for unsupported keys."""
|
|
650
|
+
try:
|
|
651
|
+
encoded = json.dumps({value: None})
|
|
652
|
+
except (TypeError, ValueError):
|
|
653
|
+
return None
|
|
654
|
+
return next(iter(json.loads(encoded)))
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _normalize_pandas_json_scalar(value, location, pd):
|
|
658
|
+
"""Normalize pandas nulls and reject values outside the plain JSON domain."""
|
|
659
|
+
np = sys.modules.get('numpy')
|
|
660
|
+
if np is not None and isinstance(value, np.generic):
|
|
661
|
+
value = value.item()
|
|
662
|
+
if value is None or value is pd.NA or value is pd.NaT:
|
|
663
|
+
return None
|
|
664
|
+
if type(value) is bool:
|
|
665
|
+
return value
|
|
666
|
+
if type(value) is int:
|
|
667
|
+
if value < -JS_SAFE_INTEGER_MAX or value > JS_SAFE_INTEGER_MAX:
|
|
668
|
+
raise RuntimeError(
|
|
669
|
+
f'JSON pandas encoding cannot safely represent {location} integer values '
|
|
670
|
+
'outside the JavaScript safe integer range; use Arrow encoding or '
|
|
671
|
+
"cast/encode explicitly (e.g. .astype('float64') or str)"
|
|
672
|
+
)
|
|
673
|
+
return value
|
|
674
|
+
if type(value) is float:
|
|
675
|
+
if not math.isfinite(value):
|
|
676
|
+
raise RuntimeError(
|
|
677
|
+
f'JSON pandas encoding cannot represent non-finite {location} float values '
|
|
678
|
+
'(NaN or Infinity); use .fillna(...) for intentional missing values or '
|
|
679
|
+
'Arrow encoding'
|
|
680
|
+
)
|
|
681
|
+
return value
|
|
682
|
+
if type(value) is str:
|
|
683
|
+
return value
|
|
684
|
+
raise RuntimeError(
|
|
685
|
+
f'JSON pandas encoding does not support {location} value of type '
|
|
686
|
+
f'{type(value).__name__}; use Arrow encoding or convert explicitly '
|
|
687
|
+
'(e.g. .astype(str))'
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
|
|
527
691
|
def serialize_sparse_matrix(obj):
|
|
528
692
|
"""
|
|
529
693
|
Serialize scipy sparse matrices into structured JSON envelopes (json-only;
|
|
@@ -594,12 +758,14 @@ def serialize_torch_tensor(obj, *, force_json_markers, torch_allow_copy=False):
|
|
|
594
758
|
Rejection order is significant: the categorical rejections (sparse / quantized
|
|
595
759
|
/ meta / complex) are checked BEFORE the device/contiguous opt-in branch so
|
|
596
760
|
they fail with a clear, specific message and are NOT bypassable by
|
|
597
|
-
TYWRAP_TORCH_ALLOW_COPY. The opt-in only governs the
|
|
598
|
-
|
|
761
|
+
TYWRAP_TORCH_ALLOW_COPY. The opt-in only governs the device transfer and
|
|
762
|
+
contiguous copy, never an unrepresentable layout/dtype.
|
|
599
763
|
"""
|
|
600
764
|
import torch # already importable: is_torch_tensor() gated the dispatch
|
|
601
765
|
|
|
602
766
|
tensor = obj.detach()
|
|
767
|
+
source_device = None
|
|
768
|
+
source_dtype = None
|
|
603
769
|
|
|
604
770
|
# Sparse tensors (COO/CSR/CSC/BSR/BSC -> any non-strided layout) have no dense
|
|
605
771
|
# numpy representation without a densify step, which is not the round-trip this
|
|
@@ -647,6 +813,7 @@ def serialize_torch_tensor(obj, *, force_json_markers, torch_allow_copy=False):
|
|
|
647
813
|
raise RuntimeError(
|
|
648
814
|
'Torch tensor is on a non-CPU device; set TYWRAP_TORCH_ALLOW_COPY=1 to allow CPU transfer'
|
|
649
815
|
)
|
|
816
|
+
source_device = str(tensor.device)
|
|
650
817
|
tensor = tensor.to('cpu')
|
|
651
818
|
if hasattr(tensor, 'is_contiguous') and not tensor.is_contiguous():
|
|
652
819
|
if not torch_allow_copy:
|
|
@@ -654,12 +821,15 @@ def serialize_torch_tensor(obj, *, force_json_markers, torch_allow_copy=False):
|
|
|
654
821
|
'Torch tensor is not contiguous; set TYWRAP_TORCH_ALLOW_COPY=1 to allow contiguous copy'
|
|
655
822
|
)
|
|
656
823
|
tensor = tensor.contiguous()
|
|
824
|
+
if tensor.dtype == torch.bfloat16:
|
|
825
|
+
source_dtype = str(tensor.dtype)
|
|
826
|
+
tensor = tensor.float()
|
|
657
827
|
try:
|
|
658
828
|
arr = tensor.numpy()
|
|
659
829
|
except Exception as exc:
|
|
660
830
|
raise RuntimeError('Failed to convert torch.Tensor to numpy') from exc
|
|
661
831
|
|
|
662
|
-
|
|
832
|
+
envelope = {
|
|
663
833
|
'__tywrap__': 'torch.tensor',
|
|
664
834
|
'codecVersion': CODEC_VERSION,
|
|
665
835
|
'encoding': 'ndarray',
|
|
@@ -668,6 +838,11 @@ def serialize_torch_tensor(obj, *, force_json_markers, torch_allow_copy=False):
|
|
|
668
838
|
'dtype': str(tensor.dtype),
|
|
669
839
|
'device': str(tensor.device),
|
|
670
840
|
}
|
|
841
|
+
if source_dtype is not None:
|
|
842
|
+
envelope['sourceDtype'] = source_dtype
|
|
843
|
+
if source_device is not None:
|
|
844
|
+
envelope['sourceDevice'] = source_device
|
|
845
|
+
return envelope
|
|
671
846
|
|
|
672
847
|
|
|
673
848
|
def serialize_sklearn_estimator(obj):
|
|
@@ -746,46 +921,227 @@ def serialize_stdlib(obj):
|
|
|
746
921
|
return None
|
|
747
922
|
|
|
748
923
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
if
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
if is_torch_tensor(obj):
|
|
766
|
-
return serialize_torch_tensor(
|
|
767
|
-
obj, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy
|
|
924
|
+
_NO_SCIENTIFIC = object()
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _check_serialize_depth(depth, path):
|
|
928
|
+
if depth > MAX_SERIALIZE_DEPTH:
|
|
929
|
+
raise RuntimeError(
|
|
930
|
+
f'Scientific envelope serialization maximum depth '
|
|
931
|
+
f'{MAX_SERIALIZE_DEPTH} exceeded at {path}'
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _check_serialize_nodes(nodes, path):
|
|
936
|
+
if nodes > MAX_SERIALIZE_NODES:
|
|
937
|
+
raise RuntimeError(
|
|
938
|
+
f'Scientific envelope serialization maximum visited nodes '
|
|
939
|
+
f'{MAX_SERIALIZE_NODES} exceeded at {path}'
|
|
768
940
|
)
|
|
769
|
-
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _serialize_scientific(obj, *, force_json_markers, torch_allow_copy, depth, path):
|
|
944
|
+
"""Serialize a supported scientific value, or return _NO_SCIENTIFIC."""
|
|
945
|
+
package = type(obj).__module__.split('.', 1)[0]
|
|
946
|
+
|
|
947
|
+
if package == 'numpy' and 'numpy' in sys.modules:
|
|
948
|
+
if is_numpy_array(obj):
|
|
949
|
+
_check_serialize_depth(depth, path)
|
|
950
|
+
return serialize_ndarray(obj, force_json_markers=force_json_markers)
|
|
951
|
+
elif package == 'pandas' and 'pandas' in sys.modules:
|
|
952
|
+
if is_pandas_dataframe(obj):
|
|
953
|
+
_check_serialize_depth(depth, path)
|
|
954
|
+
return serialize_dataframe(obj, force_json_markers=force_json_markers)
|
|
955
|
+
if is_pandas_series(obj):
|
|
956
|
+
_check_serialize_depth(depth, path)
|
|
957
|
+
return serialize_series(obj, force_json_markers=force_json_markers)
|
|
958
|
+
elif package == 'scipy' and 'scipy.sparse' in sys.modules:
|
|
959
|
+
if is_scipy_sparse(obj):
|
|
960
|
+
_check_serialize_depth(depth, path)
|
|
961
|
+
return serialize_sparse_matrix(obj)
|
|
962
|
+
elif package == 'torch' and 'torch' in sys.modules:
|
|
963
|
+
if is_torch_tensor(obj):
|
|
964
|
+
_check_serialize_depth(depth, path)
|
|
965
|
+
_check_serialize_depth(depth + 1, _serialize_path(path, 'value'))
|
|
966
|
+
return serialize_torch_tensor(
|
|
967
|
+
obj, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy
|
|
968
|
+
)
|
|
969
|
+
elif 'sklearn.base' in sys.modules and is_sklearn_estimator(obj):
|
|
970
|
+
# No package gate here, unlike the branches above: subclassing
|
|
971
|
+
# BaseEstimator is sklearn's documented extension point, so user-defined
|
|
972
|
+
# estimators live outside the 'sklearn' package and must still get the
|
|
973
|
+
# estimator serializer (and its param-naming errors).
|
|
974
|
+
_check_serialize_depth(depth, path)
|
|
770
975
|
return serialize_sklearn_estimator(obj)
|
|
771
|
-
|
|
976
|
+
|
|
977
|
+
return _NO_SCIENTIFIC
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _serialize_path(base, key):
|
|
981
|
+
"""Build a decoder-compatible JSONPath-like result path."""
|
|
982
|
+
if isinstance(key, int):
|
|
983
|
+
return f'{base}[{key}]'
|
|
984
|
+
if _SERIALIZE_PATH_IDENTIFIER.fullmatch(key):
|
|
985
|
+
return f'{base}.{key}'
|
|
986
|
+
return f'{base}[{json.dumps(key, ensure_ascii=False)}]'
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _invalid_key_path(base, key):
|
|
990
|
+
"""Name a dict key that cannot be represented by JSON."""
|
|
991
|
+
return f'{base}[{key!r}]'
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _needs_serialize_visit(value):
|
|
995
|
+
"""Return whether value needs container or scientific traversal work."""
|
|
996
|
+
if type(value) in (type(None), bool, int, float, str):
|
|
997
|
+
return False
|
|
998
|
+
if type(value) in (dict, list, tuple):
|
|
999
|
+
return True
|
|
1000
|
+
package = type(value).__module__.split('.', 1)[0]
|
|
1001
|
+
if package in ('numpy', 'pandas', 'scipy', 'torch'):
|
|
1002
|
+
return True
|
|
1003
|
+
return 'sklearn.base' in sys.modules and is_sklearn_estimator(value)
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def _serialize_leaf(value):
|
|
1007
|
+
"""Apply non-container conversions without allocating a traversal frame."""
|
|
1008
|
+
if type(value) in (type(None), bool, int, float, str):
|
|
1009
|
+
return value
|
|
1010
|
+
pydantic_value = serialize_pydantic(value)
|
|
772
1011
|
if pydantic_value is not _NO_PYDANTIC:
|
|
773
1012
|
return pydantic_value
|
|
774
|
-
stdlib_value = serialize_stdlib(
|
|
1013
|
+
stdlib_value = serialize_stdlib(value)
|
|
775
1014
|
if stdlib_value is not None:
|
|
776
1015
|
return stdlib_value
|
|
777
|
-
return
|
|
1016
|
+
return value
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def serialize(obj, *, force_json_markers, torch_allow_copy=False):
|
|
1020
|
+
"""
|
|
1021
|
+
Top-level result serializer.
|
|
1022
|
+
|
|
1023
|
+
Scientific codecs are type-first and only inspect packages that the value can
|
|
1024
|
+
belong to. A value from an optional package implies that package is already in
|
|
1025
|
+
sys.modules, so these checks never cold-import the scientific stack. The
|
|
1026
|
+
package dispatch deliberately precedes the JSON-native fast path: e.g. a
|
|
1027
|
+
package-defined subclass of dict still receives its relevant codec check.
|
|
1028
|
+
Every other value is left untouched so the shared JSON encoder applies the
|
|
1029
|
+
exact same default conversion at the root and at any nested depth.
|
|
1030
|
+
"""
|
|
1031
|
+
root = [None]
|
|
1032
|
+
active_ids = set()
|
|
1033
|
+
stack = [('visit', obj, 0, 'result', root, 0)]
|
|
1034
|
+
visited_nodes = 0
|
|
1035
|
+
|
|
1036
|
+
# Repeated aliases have value semantics and are intentionally serialized twice.
|
|
1037
|
+
while stack:
|
|
1038
|
+
frame = stack.pop()
|
|
1039
|
+
action = frame[0]
|
|
1040
|
+
if action == 'dict':
|
|
1041
|
+
_, current, depth, path, parent, key, output, iterator = frame
|
|
1042
|
+
try:
|
|
1043
|
+
item_key, item = next(iterator)
|
|
1044
|
+
except StopIteration:
|
|
1045
|
+
active_ids.remove(id(current))
|
|
1046
|
+
parent[key] = output
|
|
1047
|
+
continue
|
|
1048
|
+
stack.append(frame)
|
|
1049
|
+
if not (isinstance(item_key, (str, int, float, bool)) or item_key is None):
|
|
1050
|
+
invalid_path = _invalid_key_path(path, item_key)
|
|
1051
|
+
raise TypeError(
|
|
1052
|
+
f'keys must be str, int, float, bool or None, not '
|
|
1053
|
+
f'{type(item_key).__name__} at {invalid_path}'
|
|
1054
|
+
)
|
|
1055
|
+
child_key = _json_object_key(item_key)
|
|
1056
|
+
child_path = _serialize_path(path, child_key)
|
|
1057
|
+
if _needs_serialize_visit(item):
|
|
1058
|
+
stack.append(('visit', item, depth + 1, child_path, output, item_key))
|
|
1059
|
+
else:
|
|
1060
|
+
output[item_key] = _serialize_leaf(item)
|
|
1061
|
+
continue
|
|
1062
|
+
if action == 'sequence':
|
|
1063
|
+
_, current, depth, path, parent, key, output, index = frame
|
|
1064
|
+
if index == len(output):
|
|
1065
|
+
active_ids.remove(id(current))
|
|
1066
|
+
parent[key] = output if type(current) is list else tuple(output)
|
|
1067
|
+
continue
|
|
1068
|
+
stack.append(('sequence', current, depth, path, parent, key, output, index + 1))
|
|
1069
|
+
item = current[index]
|
|
1070
|
+
if _needs_serialize_visit(item):
|
|
1071
|
+
stack.append(
|
|
1072
|
+
('visit', item, depth + 1, _serialize_path(path, index), output, index)
|
|
1073
|
+
)
|
|
1074
|
+
else:
|
|
1075
|
+
output[index] = _serialize_leaf(item)
|
|
1076
|
+
continue
|
|
1077
|
+
|
|
1078
|
+
_, current, depth, path, parent, key = frame
|
|
1079
|
+
try:
|
|
1080
|
+
scientific = _serialize_scientific(
|
|
1081
|
+
current,
|
|
1082
|
+
force_json_markers=force_json_markers,
|
|
1083
|
+
torch_allow_copy=torch_allow_copy,
|
|
1084
|
+
depth=depth,
|
|
1085
|
+
path=path,
|
|
1086
|
+
)
|
|
1087
|
+
except Exception as exc:
|
|
1088
|
+
if path == 'result':
|
|
1089
|
+
raise
|
|
1090
|
+
raise RuntimeError(f'Scientific value serialization failed at {path}: {exc}') from exc
|
|
1091
|
+
if scientific is not _NO_SCIENTIFIC:
|
|
1092
|
+
# Recognized envelopes are terminal containers to the JS decoder.
|
|
1093
|
+
visited_nodes += 1
|
|
1094
|
+
_check_serialize_nodes(visited_nodes, path)
|
|
1095
|
+
if scientific.get('__tywrap__') == 'torch.tensor':
|
|
1096
|
+
nested_path = _serialize_path(path, 'value')
|
|
1097
|
+
try:
|
|
1098
|
+
visited_nodes += 1
|
|
1099
|
+
_check_serialize_nodes(visited_nodes, nested_path)
|
|
1100
|
+
except Exception as exc:
|
|
1101
|
+
if path == 'result':
|
|
1102
|
+
raise
|
|
1103
|
+
raise RuntimeError(
|
|
1104
|
+
f'Scientific value serialization failed at {path}: {exc}'
|
|
1105
|
+
) from exc
|
|
1106
|
+
parent[key] = scientific
|
|
1107
|
+
continue
|
|
1108
|
+
|
|
1109
|
+
container_type = type(current)
|
|
1110
|
+
if container_type in (dict, list, tuple):
|
|
1111
|
+
_check_serialize_depth(depth, path)
|
|
1112
|
+
visited_nodes += 1
|
|
1113
|
+
_check_serialize_nodes(visited_nodes, path)
|
|
1114
|
+
current_id = id(current)
|
|
1115
|
+
if current_id in active_ids:
|
|
1116
|
+
raise RuntimeError(f'Circular reference detected at {path}')
|
|
1117
|
+
active_ids.add(current_id)
|
|
1118
|
+
|
|
1119
|
+
if container_type is dict:
|
|
1120
|
+
output = {}
|
|
1121
|
+
parent[key] = output
|
|
1122
|
+
stack.append(
|
|
1123
|
+
('dict', current, depth, path, parent, key, output, iter(current.items()))
|
|
1124
|
+
)
|
|
1125
|
+
continue
|
|
1126
|
+
|
|
1127
|
+
output = [None] * len(current)
|
|
1128
|
+
if container_type is list:
|
|
1129
|
+
parent[key] = output
|
|
1130
|
+
stack.append(('sequence', current, depth, path, parent, key, output, 0))
|
|
1131
|
+
continue
|
|
1132
|
+
|
|
1133
|
+
parent[key] = _serialize_leaf(current)
|
|
1134
|
+
|
|
1135
|
+
return root[0]
|
|
778
1136
|
|
|
779
1137
|
|
|
780
1138
|
# =============================================================================
|
|
781
|
-
# JSON
|
|
1139
|
+
# JSON CODEC: value handling and size-limited encode/decode
|
|
782
1140
|
# =============================================================================
|
|
783
1141
|
#
|
|
784
|
-
# This
|
|
785
|
-
#
|
|
786
|
-
#
|
|
787
|
-
# core encoder exists so the Pyodide server gets identical value handling without
|
|
788
|
-
# depending on safe_codec.py. The conformance suite asserts these behaviors match.
|
|
1142
|
+
# This is the single Python implementation used by both the subprocess bridge and
|
|
1143
|
+
# the embedded Pyodide core. BridgeCodec adds payload-size enforcement around the
|
|
1144
|
+
# shared encoder; encode_value is the unbounded form used by the in-memory bridge.
|
|
789
1145
|
|
|
790
1146
|
def _is_nan_or_inf(value):
|
|
791
1147
|
if not isinstance(value, (int, float)):
|
|
@@ -841,20 +1197,9 @@ def make_default_encoder(*, allow_nan):
|
|
|
841
1197
|
if isinstance(obj, pd.Timedelta):
|
|
842
1198
|
return obj.total_seconds()
|
|
843
1199
|
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
return obj.isoformat()
|
|
848
|
-
if isinstance(obj, dt.time):
|
|
849
|
-
return obj.isoformat()
|
|
850
|
-
if isinstance(obj, dt.timedelta):
|
|
851
|
-
return obj.total_seconds()
|
|
852
|
-
if isinstance(obj, decimal.Decimal):
|
|
853
|
-
return str(obj)
|
|
854
|
-
if isinstance(obj, uuid.UUID):
|
|
855
|
-
return str(obj)
|
|
856
|
-
if isinstance(obj, (Path, PurePath)):
|
|
857
|
-
return str(obj)
|
|
1200
|
+
stdlib_value = serialize_stdlib(obj)
|
|
1201
|
+
if stdlib_value is not None:
|
|
1202
|
+
return stdlib_value
|
|
858
1203
|
|
|
859
1204
|
if isinstance(obj, (bytes, bytearray)):
|
|
860
1205
|
return {
|
|
@@ -898,18 +1243,66 @@ def encode_value(value, *, allow_nan):
|
|
|
898
1243
|
# ("...not JSON compliant: nan"), but 3.10/3.11 emit only the canonical
|
|
899
1244
|
# "Out of range float values are not JSON compliant". Match that phrase too
|
|
900
1245
|
# so the typed error message is stable across versions.
|
|
1246
|
+
nonfinite_token = re.search(r'(^|[^a-z])(nan|inf|infinity)([^a-z]|$)', error_msg)
|
|
901
1247
|
if (
|
|
902
|
-
'
|
|
903
|
-
|
|
904
|
-
or 'inf' in error_msg
|
|
905
|
-
or 'out of range float' in error_msg
|
|
906
|
-
):
|
|
1248
|
+
not allow_nan and 'out of range float values are not json compliant' in error_msg
|
|
1249
|
+
) or nonfinite_token:
|
|
907
1250
|
raise CodecError('Cannot serialize NaN - NaN/Infinity not allowed in JSON') from exc
|
|
908
1251
|
raise CodecError(f'JSON encoding failed: {exc}') from exc
|
|
909
1252
|
except TypeError as exc:
|
|
910
1253
|
raise CodecError(f'JSON encoding failed: {exc}') from exc
|
|
911
1254
|
|
|
912
1255
|
|
|
1256
|
+
class BridgeCodec:
|
|
1257
|
+
"""Safe JSON codec with explicit value handling and payload-size limits."""
|
|
1258
|
+
|
|
1259
|
+
def __init__(self, allow_nan=False, max_payload_bytes=10 * 1024 * 1024):
|
|
1260
|
+
self.allow_nan = allow_nan
|
|
1261
|
+
self.max_payload_bytes = max_payload_bytes
|
|
1262
|
+
self._encoder = make_default_encoder(allow_nan=allow_nan)
|
|
1263
|
+
|
|
1264
|
+
def encode(self, value):
|
|
1265
|
+
result = encode_value(value, allow_nan=self.allow_nan)
|
|
1266
|
+
if len(result.encode('utf-8')) > self.max_payload_bytes:
|
|
1267
|
+
raise CodecError(f'Payload exceeds {self.max_payload_bytes} bytes')
|
|
1268
|
+
return result
|
|
1269
|
+
|
|
1270
|
+
def decode(self, payload):
|
|
1271
|
+
if len(payload.encode('utf-8')) > self.max_payload_bytes:
|
|
1272
|
+
raise CodecError(f'Payload exceeds {self.max_payload_bytes} bytes')
|
|
1273
|
+
try:
|
|
1274
|
+
return json.loads(payload)
|
|
1275
|
+
except json.JSONDecodeError as exc:
|
|
1276
|
+
raise CodecError(f'JSON decoding failed: {exc}') from exc
|
|
1277
|
+
|
|
1278
|
+
def _default_encoder(self, obj):
|
|
1279
|
+
"""Compatibility hook for callers that used the codec's JSON encoder."""
|
|
1280
|
+
return self._encoder(obj)
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
_default_codec = None
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
def get_default_codec():
|
|
1287
|
+
"""Return the lazily-created default BridgeCodec instance."""
|
|
1288
|
+
global _default_codec
|
|
1289
|
+
if _default_codec is None:
|
|
1290
|
+
_default_codec = BridgeCodec()
|
|
1291
|
+
return _default_codec
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
def encode(value, *, allow_nan=False):
|
|
1295
|
+
"""Encode a value with the default codec settings."""
|
|
1296
|
+
if allow_nan:
|
|
1297
|
+
return BridgeCodec(allow_nan=True).encode(value)
|
|
1298
|
+
return get_default_codec().encode(value)
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
def decode(payload):
|
|
1302
|
+
"""Decode a JSON payload with the default codec settings."""
|
|
1303
|
+
return get_default_codec().decode(payload)
|
|
1304
|
+
|
|
1305
|
+
|
|
913
1306
|
# =============================================================================
|
|
914
1307
|
# REQUEST VALIDATION + HANDLERS + DISPATCH
|
|
915
1308
|
# =============================================================================
|
|
@@ -949,11 +1342,19 @@ def coerce_dict(value, key):
|
|
|
949
1342
|
return value
|
|
950
1343
|
|
|
951
1344
|
|
|
952
|
-
def handle_call(
|
|
1345
|
+
def handle_call(
|
|
1346
|
+
params,
|
|
1347
|
+
*,
|
|
1348
|
+
force_json_markers,
|
|
1349
|
+
torch_allow_copy,
|
|
1350
|
+
allowed_modules,
|
|
1351
|
+
allow_private_attrs,
|
|
1352
|
+
has_envelope_markers,
|
|
1353
|
+
):
|
|
953
1354
|
module_name = require_str(params, 'module')
|
|
954
1355
|
function_name = require_str(params, 'functionName')
|
|
955
|
-
args = deserialize(coerce_list(params.get('args'), 'args'))
|
|
956
|
-
kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))
|
|
1356
|
+
args = deserialize(coerce_list(params.get('args'), 'args'), has_envelope_markers=has_envelope_markers)
|
|
1357
|
+
kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'), has_envelope_markers=has_envelope_markers)
|
|
957
1358
|
mod = import_allowed_module(module_name, allowed_modules)
|
|
958
1359
|
# function_name may be dotted ('Class.method') for @classmethod/@staticmethod
|
|
959
1360
|
# calls, which the generated wrapper routes through call() rather than an
|
|
@@ -963,61 +1364,13 @@ def handle_call(params, *, force_json_markers, torch_allow_copy, allowed_modules
|
|
|
963
1364
|
return serialize(res, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy)
|
|
964
1365
|
|
|
965
1366
|
|
|
966
|
-
def handle_instantiate(params, instances, *, allowed_modules, allow_private_attrs):
|
|
967
|
-
module_name = require_str(params, 'module')
|
|
968
|
-
class_name = require_str(params, 'className')
|
|
969
|
-
args = deserialize(coerce_list(params.get('args'), 'args'))
|
|
970
|
-
kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))
|
|
971
|
-
mod = import_allowed_module(module_name, allowed_modules)
|
|
972
|
-
cls = get_allowed_attr(mod, class_name, allow_private_attrs=allow_private_attrs)
|
|
973
|
-
obj = cls(*args, **kwargs)
|
|
974
|
-
handle_id = str(id(obj))
|
|
975
|
-
instances[handle_id] = obj
|
|
976
|
-
return handle_id
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
def handle_call_method(params, instances, *, force_json_markers, torch_allow_copy, allow_private_attrs):
|
|
980
|
-
handle_id = require_str(params, 'handle')
|
|
981
|
-
method_name = require_str(params, 'methodName')
|
|
982
|
-
args = deserialize(coerce_list(params.get('args'), 'args'))
|
|
983
|
-
kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))
|
|
984
|
-
if handle_id not in instances:
|
|
985
|
-
raise InstanceHandleError(f'Unknown instance handle: {handle_id}')
|
|
986
|
-
obj = instances[handle_id]
|
|
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)
|
|
1001
|
-
return serialize(res, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy)
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
def handle_dispose_instance(params, instances):
|
|
1005
|
-
handle_id = require_str(params, 'handle')
|
|
1006
|
-
if handle_id not in instances:
|
|
1007
|
-
return False
|
|
1008
|
-
del instances[handle_id]
|
|
1009
|
-
return True
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
1367
|
def build_meta(
|
|
1013
|
-
instances,
|
|
1014
1368
|
*,
|
|
1015
1369
|
bridge,
|
|
1016
1370
|
pid,
|
|
1017
1371
|
python_version,
|
|
1018
1372
|
codec_fallback,
|
|
1019
1373
|
arrow_available_override=None,
|
|
1020
|
-
transport_info=None,
|
|
1021
1374
|
):
|
|
1022
1375
|
"""
|
|
1023
1376
|
Build the bridge metadata payload.
|
|
@@ -1031,13 +1384,6 @@ def build_meta(
|
|
|
1031
1384
|
instead of probing pyarrow. The Pyodide server forces markers to JSON
|
|
1032
1385
|
unconditionally, so it advertises arrowAvailable=False regardless of whether
|
|
1033
1386
|
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).
|
|
1041
1387
|
"""
|
|
1042
1388
|
arrow = arrow_available() if arrow_available_override is None else arrow_available_override
|
|
1043
1389
|
meta = {
|
|
@@ -1051,16 +1397,13 @@ def build_meta(
|
|
|
1051
1397
|
'scipyAvailable': module_available('scipy'),
|
|
1052
1398
|
'torchAvailable': module_available('torch'),
|
|
1053
1399
|
'sklearnAvailable': module_available('sklearn'),
|
|
1054
|
-
'instances':
|
|
1400
|
+
'instances': 0,
|
|
1055
1401
|
}
|
|
1056
|
-
if transport_info is not None:
|
|
1057
|
-
meta['transport'] = transport_info
|
|
1058
1402
|
return meta
|
|
1059
1403
|
|
|
1060
1404
|
|
|
1061
1405
|
def dispatch_request(
|
|
1062
1406
|
msg,
|
|
1063
|
-
instances,
|
|
1064
1407
|
*,
|
|
1065
1408
|
bridge,
|
|
1066
1409
|
pid,
|
|
@@ -1071,7 +1414,7 @@ def dispatch_request(
|
|
|
1071
1414
|
arrow_available_override=None,
|
|
1072
1415
|
allowed_modules=None,
|
|
1073
1416
|
allow_private_attrs=False,
|
|
1074
|
-
|
|
1417
|
+
has_envelope_markers=True,
|
|
1075
1418
|
):
|
|
1076
1419
|
"""
|
|
1077
1420
|
Validate and route a request, returning the fully-serialized response dict
|
|
@@ -1083,7 +1426,7 @@ def dispatch_request(
|
|
|
1083
1426
|
the final encode_value() call, which the caller performs.
|
|
1084
1427
|
|
|
1085
1428
|
allowed_modules: None (default) disables the import allowlist so existing
|
|
1086
|
-
behavior is preserved. Supplying a set restricts call
|
|
1429
|
+
behavior is preserved. Supplying a set restricts call imports to
|
|
1087
1430
|
those modules (plus the stdlib the bridge itself needs) and raises
|
|
1088
1431
|
ImportNotAllowedError otherwise. allow_private_attrs=False (default) blocks
|
|
1089
1432
|
getattr of underscore-prefixed names; True restores unrestricted access. See
|
|
@@ -1101,34 +1444,19 @@ def dispatch_request(
|
|
|
1101
1444
|
torch_allow_copy=torch_allow_copy,
|
|
1102
1445
|
allowed_modules=allowed_modules,
|
|
1103
1446
|
allow_private_attrs=allow_private_attrs,
|
|
1447
|
+
has_envelope_markers=has_envelope_markers,
|
|
1104
1448
|
)
|
|
1105
|
-
elif method == 'instantiate':
|
|
1106
|
-
result = handle_instantiate(
|
|
1107
|
-
params, instances, allowed_modules=allowed_modules, allow_private_attrs=allow_private_attrs
|
|
1108
|
-
)
|
|
1109
|
-
elif method == 'call_method':
|
|
1110
|
-
result = handle_call_method(
|
|
1111
|
-
params,
|
|
1112
|
-
instances,
|
|
1113
|
-
force_json_markers=force_json_markers,
|
|
1114
|
-
torch_allow_copy=torch_allow_copy,
|
|
1115
|
-
allow_private_attrs=allow_private_attrs,
|
|
1116
|
-
)
|
|
1117
|
-
elif method == 'dispose_instance':
|
|
1118
|
-
result = handle_dispose_instance(params, instances)
|
|
1119
1449
|
elif method == 'meta':
|
|
1120
1450
|
if python_version is None:
|
|
1121
1451
|
import sys
|
|
1122
1452
|
python_version = sys.version.split()[0]
|
|
1123
1453
|
codec_fallback = 'json' if force_json_markers else 'none'
|
|
1124
1454
|
result = build_meta(
|
|
1125
|
-
instances,
|
|
1126
1455
|
bridge=bridge,
|
|
1127
1456
|
pid=pid,
|
|
1128
1457
|
python_version=python_version,
|
|
1129
1458
|
codec_fallback=codec_fallback,
|
|
1130
1459
|
arrow_available_override=arrow_available_override,
|
|
1131
|
-
transport_info=transport_info,
|
|
1132
1460
|
)
|
|
1133
1461
|
else:
|
|
1134
1462
|
raise ProtocolError(f'Unknown method: {method}')
|