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.
Files changed (159) hide show
  1. package/README.md +42 -22
  2. package/SECURITY.md +38 -0
  3. package/dist/config/index.d.ts.map +1 -1
  4. package/dist/config/index.js +8 -0
  5. package/dist/config/index.js.map +1 -1
  6. package/dist/core/annotation-parser.d.ts +2 -1
  7. package/dist/core/annotation-parser.d.ts.map +1 -1
  8. package/dist/core/annotation-parser.js +6 -3
  9. package/dist/core/annotation-parser.js.map +1 -1
  10. package/dist/core/generator.d.ts +23 -4
  11. package/dist/core/generator.d.ts.map +1 -1
  12. package/dist/core/generator.js +254 -170
  13. package/dist/core/generator.js.map +1 -1
  14. package/dist/core/mapper.d.ts +3 -2
  15. package/dist/core/mapper.d.ts.map +1 -1
  16. package/dist/core/mapper.js +5 -5
  17. package/dist/core/mapper.js.map +1 -1
  18. package/dist/index.d.ts +7 -5
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +6 -4
  21. package/dist/index.js.map +1 -1
  22. package/dist/runtime/base-bridge.d.ts +3 -7
  23. package/dist/runtime/base-bridge.d.ts.map +1 -1
  24. package/dist/runtime/base-bridge.js +4 -17
  25. package/dist/runtime/base-bridge.js.map +1 -1
  26. package/dist/runtime/bounded-context.d.ts +3 -22
  27. package/dist/runtime/bounded-context.d.ts.map +1 -1
  28. package/dist/runtime/bounded-context.js +13 -53
  29. package/dist/runtime/bounded-context.js.map +1 -1
  30. package/dist/runtime/bridge-codec.d.ts +3 -3
  31. package/dist/runtime/bridge-codec.d.ts.map +1 -1
  32. package/dist/runtime/bridge-codec.js +129 -62
  33. package/dist/runtime/bridge-codec.js.map +1 -1
  34. package/dist/runtime/errors.d.ts +16 -0
  35. package/dist/runtime/errors.d.ts.map +1 -1
  36. package/dist/runtime/errors.js +17 -0
  37. package/dist/runtime/errors.js.map +1 -1
  38. package/dist/runtime/http-transport.d.ts +1 -1
  39. package/dist/runtime/http-transport.d.ts.map +1 -1
  40. package/dist/runtime/http-transport.js +1 -1
  41. package/dist/runtime/http-transport.js.map +1 -1
  42. package/dist/runtime/index.d.ts +1 -0
  43. package/dist/runtime/index.d.ts.map +1 -1
  44. package/dist/runtime/index.js +1 -0
  45. package/dist/runtime/index.js.map +1 -1
  46. package/dist/runtime/node.d.ts +7 -36
  47. package/dist/runtime/node.d.ts.map +1 -1
  48. package/dist/runtime/node.js +2 -80
  49. package/dist/runtime/node.js.map +1 -1
  50. package/dist/runtime/pooled-transport.d.ts +120 -59
  51. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  52. package/dist/runtime/pooled-transport.js +345 -78
  53. package/dist/runtime/pooled-transport.js.map +1 -1
  54. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  55. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  56. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  57. package/dist/runtime/pyodide-transport.d.ts +1 -1
  58. package/dist/runtime/pyodide-transport.d.ts.map +1 -1
  59. package/dist/runtime/pyodide-transport.js +2 -3
  60. package/dist/runtime/pyodide-transport.js.map +1 -1
  61. package/dist/runtime/rpc-client.d.ts +7 -36
  62. package/dist/runtime/rpc-client.d.ts.map +1 -1
  63. package/dist/runtime/rpc-client.js +20 -102
  64. package/dist/runtime/rpc-client.js.map +1 -1
  65. package/dist/runtime/subprocess-transport.d.ts +30 -69
  66. package/dist/runtime/subprocess-transport.d.ts.map +1 -1
  67. package/dist/runtime/subprocess-transport.js +179 -236
  68. package/dist/runtime/subprocess-transport.js.map +1 -1
  69. package/dist/runtime/timed-out-request-tracker.d.ts +2 -1
  70. package/dist/runtime/timed-out-request-tracker.d.ts.map +1 -1
  71. package/dist/runtime/transport.d.ts +9 -19
  72. package/dist/runtime/transport.d.ts.map +1 -1
  73. package/dist/runtime/transport.js +1 -1
  74. package/dist/runtime/transport.js.map +1 -1
  75. package/dist/runtime/validators.d.ts +50 -0
  76. package/dist/runtime/validators.d.ts.map +1 -1
  77. package/dist/runtime/validators.js +154 -0
  78. package/dist/runtime/validators.js.map +1 -1
  79. package/dist/types/index.d.ts +14 -39
  80. package/dist/types/index.d.ts.map +1 -1
  81. package/dist/tywrap.d.ts +3 -2
  82. package/dist/tywrap.d.ts.map +1 -1
  83. package/dist/tywrap.js +140 -13
  84. package/dist/tywrap.js.map +1 -1
  85. package/dist/utils/cache.d.ts +3 -16
  86. package/dist/utils/cache.d.ts.map +1 -1
  87. package/dist/utils/codec.d.ts +17 -0
  88. package/dist/utils/codec.d.ts.map +1 -1
  89. package/dist/utils/codec.js +500 -85
  90. package/dist/utils/codec.js.map +1 -1
  91. package/dist/utils/ir-cache.d.ts +2 -1
  92. package/dist/utils/ir-cache.d.ts.map +1 -1
  93. package/dist/utils/runtime.d.ts +0 -29
  94. package/dist/utils/runtime.d.ts.map +1 -1
  95. package/dist/utils/runtime.js +16 -107
  96. package/dist/utils/runtime.js.map +1 -1
  97. package/dist/version.d.ts.map +1 -1
  98. package/dist/version.js +1 -1
  99. package/dist/version.js.map +1 -1
  100. package/package.json +7 -6
  101. package/runtime/frame_codec.py +7 -1
  102. package/runtime/python_bridge.py +85 -125
  103. package/runtime/tywrap_bridge_core.py +515 -187
  104. package/src/config/index.ts +11 -0
  105. package/src/core/annotation-parser.ts +7 -4
  106. package/src/core/generator.ts +330 -205
  107. package/src/core/mapper.ts +8 -8
  108. package/src/index.ts +7 -4
  109. package/src/runtime/base-bridge.ts +5 -30
  110. package/src/runtime/bounded-context.ts +12 -67
  111. package/src/runtime/bridge-codec.ts +158 -74
  112. package/src/runtime/errors.ts +21 -0
  113. package/src/runtime/http-transport.ts +6 -1
  114. package/src/runtime/index.ts +6 -0
  115. package/src/runtime/node.ts +9 -120
  116. package/src/runtime/pooled-transport.ts +424 -90
  117. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  118. package/src/runtime/pyodide-transport.ts +7 -3
  119. package/src/runtime/rpc-client.ts +37 -139
  120. package/src/runtime/subprocess-transport.ts +210 -285
  121. package/src/runtime/timed-out-request-tracker.ts +1 -1
  122. package/src/runtime/transport.ts +15 -23
  123. package/src/runtime/validators.ts +212 -0
  124. package/src/types/index.ts +21 -60
  125. package/src/tywrap.ts +157 -22
  126. package/src/utils/cache.ts +3 -3
  127. package/src/utils/codec.ts +749 -112
  128. package/src/utils/ir-cache.ts +1 -1
  129. package/src/utils/runtime.ts +17 -128
  130. package/src/version.ts +1 -1
  131. package/dist/core/discovery.d.ts +0 -103
  132. package/dist/core/discovery.d.ts.map +0 -1
  133. package/dist/core/discovery.js +0 -380
  134. package/dist/core/discovery.js.map +0 -1
  135. package/dist/core/validation.d.ts +0 -102
  136. package/dist/core/validation.d.ts.map +0 -1
  137. package/dist/core/validation.js +0 -490
  138. package/dist/core/validation.js.map +0 -1
  139. package/dist/runtime/base.d.ts +0 -22
  140. package/dist/runtime/base.d.ts.map +0 -1
  141. package/dist/runtime/base.js +0 -23
  142. package/dist/runtime/base.js.map +0 -1
  143. package/dist/runtime/transport-pool.d.ts +0 -196
  144. package/dist/runtime/transport-pool.d.ts.map +0 -1
  145. package/dist/runtime/transport-pool.js +0 -418
  146. package/dist/runtime/transport-pool.js.map +0 -1
  147. package/runtime/__pycache__/_tywrap_conformance_chunking_fixtures.cpython-311.pyc +0 -0
  148. package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
  149. package/runtime/__pycache__/_tywrap_w4_chunking_fixture.cpython-311.pyc +0 -0
  150. package/runtime/__pycache__/_tywrap_w5_request_chunking_fixture.cpython-311.pyc +0 -0
  151. package/runtime/__pycache__/_tywrap_w6_pool_chunking_fixture.cpython-311.pyc +0 -0
  152. package/runtime/__pycache__/frame_codec.cpython-311.pyc +0 -0
  153. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  154. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  155. package/runtime/safe_codec.py +0 -352
  156. package/src/core/discovery.ts +0 -477
  157. package/src/core/validation.ts +0 -729
  158. package/src/runtime/base.ts +0 -24
  159. 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/instantiate/call_method by importing the requested
100
- # module and getattr-ing the requested function/class/method. That is an
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
- try:
318
- import numpy as np # noqa: F401
319
- except Exception:
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
- try:
326
- import pandas as pd # noqa: F401
327
- except Exception:
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
- try:
334
- import pandas as pd # noqa: F401
335
- except Exception:
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
- try:
342
- import scipy.sparse as sp # noqa: F401
343
- except Exception:
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
- try:
353
- import torch # noqa: F401
354
- except Exception:
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
- try:
364
- from sklearn.base import BaseEstimator # noqa: F401
365
- except Exception:
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 > 1 else obj
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 (larger payloads, potential dtype loss)."""
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: records orientation."""
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 = obj.to_dict(orient='records')
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 (potentially lossy dtype/NA representation)."""
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
- try:
515
- data = obj.to_dict() # type: ignore
516
- except Exception as exc:
517
- raise RuntimeError('JSON fallback failed for pandas.Series') from exc
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 lossy-but-lossless device
598
- transfer and contiguous copy, never an unrepresentable layout/dtype.
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
- return {
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
- def serialize(obj, *, force_json_markers, torch_allow_copy=False):
750
- """
751
- Top-level result serializer. Dispatch order is significant: numpy ndarray ->
752
- dataframe -> series -> scipy.sparse -> torch -> sklearn -> Pydantic -> stdlib
753
- -> passthrough. The remaining BridgeCodec value behaviors (numpy/pandas scalars,
754
- bytes, sets, complex rejection, NaN/Infinity) are applied later during JSON
755
- encoding by default_encoder.
756
- """
757
- if is_numpy_array(obj):
758
- return serialize_ndarray(obj, force_json_markers=force_json_markers)
759
- if is_pandas_dataframe(obj):
760
- return serialize_dataframe(obj, force_json_markers=force_json_markers)
761
- if is_pandas_series(obj):
762
- return serialize_series(obj, force_json_markers=force_json_markers)
763
- if is_scipy_sparse(obj):
764
- return serialize_sparse_matrix(obj)
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
- if is_sklearn_estimator(obj):
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
- pydantic_value = serialize_pydantic(obj)
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(obj)
1013
+ stdlib_value = serialize_stdlib(value)
775
1014
  if stdlib_value is not None:
776
1015
  return stdlib_value
777
- return obj
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 ENCODE: BridgeCodec-equivalent value handling (NaN reject, scalars, bytes)
1139
+ # JSON CODEC: value handling and size-limited encode/decode
782
1140
  # =============================================================================
783
1141
  #
784
- # This mirrors BridgeCodec._default_encoder (runtime/safe_codec.py) for the VALUE
785
- # behaviors that are part of the wire contract. The subprocess server still uses
786
- # the real BridgeCodec for its final encode (it also enforces size limits); this
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
- if isinstance(obj, dt.datetime):
845
- return obj.isoformat()
846
- if isinstance(obj, dt.date):
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
- 'nan' in error_msg
903
- or 'infinity' in error_msg
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(params, *, force_json_markers, torch_allow_copy, allowed_modules, allow_private_attrs):
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': len(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
- transport_info=None,
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/instantiate imports to
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}')