tywrap 0.3.0 → 0.5.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 (101) hide show
  1. package/README.md +40 -5
  2. package/dist/cli.js +24 -6
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config/index.d.ts.map +1 -1
  5. package/dist/config/index.js +19 -13
  6. package/dist/config/index.js.map +1 -1
  7. package/dist/core/analyzer.d.ts.map +1 -1
  8. package/dist/core/analyzer.js +0 -1
  9. package/dist/core/analyzer.js.map +1 -1
  10. package/dist/dev.d.ts +57 -0
  11. package/dist/dev.d.ts.map +1 -0
  12. package/dist/dev.js +743 -0
  13. package/dist/dev.js.map +1 -0
  14. package/dist/index.d.ts +5 -4
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +6 -6
  17. package/dist/index.js.map +1 -1
  18. package/dist/runtime/base.d.ts +7 -7
  19. package/dist/runtime/base.js +8 -8
  20. package/dist/runtime/bounded-context.d.ts +15 -31
  21. package/dist/runtime/bounded-context.d.ts.map +1 -1
  22. package/dist/runtime/bounded-context.js +16 -15
  23. package/dist/runtime/bounded-context.js.map +1 -1
  24. package/dist/runtime/http.d.ts +24 -5
  25. package/dist/runtime/http.d.ts.map +1 -1
  26. package/dist/runtime/http.js +57 -12
  27. package/dist/runtime/http.js.map +1 -1
  28. package/dist/runtime/node.d.ts +33 -7
  29. package/dist/runtime/node.d.ts.map +1 -1
  30. package/dist/runtime/node.js +144 -120
  31. package/dist/runtime/node.js.map +1 -1
  32. package/dist/runtime/optimized-node.d.ts +5 -4
  33. package/dist/runtime/optimized-node.d.ts.map +1 -1
  34. package/dist/runtime/optimized-node.js +5 -4
  35. package/dist/runtime/optimized-node.js.map +1 -1
  36. package/dist/runtime/pooled-transport.d.ts +2 -18
  37. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  38. package/dist/runtime/pooled-transport.js +2 -29
  39. package/dist/runtime/pooled-transport.js.map +1 -1
  40. package/dist/runtime/process-io.d.ts +2 -18
  41. package/dist/runtime/process-io.d.ts.map +1 -1
  42. package/dist/runtime/process-io.js +3 -30
  43. package/dist/runtime/process-io.js.map +1 -1
  44. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts +12 -0
  45. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -0
  46. package/dist/runtime/pyodide-bootstrap-core.generated.js +12 -0
  47. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -0
  48. package/dist/runtime/pyodide-io.d.ts +7 -37
  49. package/dist/runtime/pyodide-io.d.ts.map +1 -1
  50. package/dist/runtime/pyodide-io.js +90 -214
  51. package/dist/runtime/pyodide-io.js.map +1 -1
  52. package/dist/runtime/pyodide.d.ts +25 -5
  53. package/dist/runtime/pyodide.d.ts.map +1 -1
  54. package/dist/runtime/pyodide.js +59 -10
  55. package/dist/runtime/pyodide.js.map +1 -1
  56. package/dist/runtime/{bridge-protocol.d.ts → rpc-client.d.ts} +59 -46
  57. package/dist/runtime/rpc-client.d.ts.map +1 -0
  58. package/dist/runtime/{bridge-protocol.js → rpc-client.js} +60 -42
  59. package/dist/runtime/rpc-client.js.map +1 -0
  60. package/dist/runtime/worker-pool.d.ts +8 -20
  61. package/dist/runtime/worker-pool.d.ts.map +1 -1
  62. package/dist/runtime/worker-pool.js +58 -59
  63. package/dist/runtime/worker-pool.js.map +1 -1
  64. package/dist/types/index.d.ts +8 -10
  65. package/dist/types/index.d.ts.map +1 -1
  66. package/dist/tywrap.d.ts +6 -0
  67. package/dist/tywrap.d.ts.map +1 -1
  68. package/dist/tywrap.js +10 -3
  69. package/dist/tywrap.js.map +1 -1
  70. package/package.json +22 -18
  71. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  72. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  73. package/runtime/python_bridge.py +85 -702
  74. package/runtime/safe_codec.py +10 -2
  75. package/runtime/tywrap_bridge_core.py +875 -0
  76. package/src/cli.ts +27 -6
  77. package/src/config/index.ts +28 -15
  78. package/src/core/analyzer.ts +1 -2
  79. package/src/dev.ts +983 -0
  80. package/src/index.ts +7 -7
  81. package/src/runtime/base.ts +8 -8
  82. package/src/runtime/bounded-context.ts +17 -56
  83. package/src/runtime/http.ts +85 -13
  84. package/src/runtime/node.ts +172 -178
  85. package/src/runtime/optimized-node.ts +5 -4
  86. package/src/runtime/pooled-transport.ts +2 -57
  87. package/src/runtime/process-io.ts +3 -55
  88. package/src/runtime/pyodide-bootstrap-core.generated.ts +12 -0
  89. package/src/runtime/pyodide-io.ts +92 -243
  90. package/src/runtime/pyodide.ts +87 -10
  91. package/src/runtime/{bridge-protocol.ts → rpc-client.ts} +76 -49
  92. package/src/runtime/worker-pool.ts +67 -88
  93. package/src/types/index.ts +20 -12
  94. package/src/tywrap.ts +17 -3
  95. package/dist/runtime/bridge-core.d.ts +0 -65
  96. package/dist/runtime/bridge-core.d.ts.map +0 -1
  97. package/dist/runtime/bridge-core.js +0 -379
  98. package/dist/runtime/bridge-core.js.map +0 -1
  99. package/dist/runtime/bridge-protocol.d.ts.map +0 -1
  100. package/dist/runtime/bridge-protocol.js.map +0 -1
  101. package/src/runtime/bridge-core.ts +0 -494
@@ -0,0 +1,875 @@
1
+ """
2
+ Shared tywrap bridge core: protocol dispatch + value (de)serialization.
3
+
4
+ This module is the SINGLE source of truth for the "tywrap/1" server-side
5
+ protocol. It is imported by:
6
+
7
+ - runtime/python_bridge.py (the Node/Bun/Deno subprocess server and the HTTP
8
+ server), which owns I/O concerns: the stdin/stdout JSONL loop, env-var size
9
+ guards, the real OS pid, bridge='python-subprocess', and the final SafeCodec
10
+ encode wrapper.
11
+
12
+ - the in-WASM Pyodide server (src/runtime/pyodide-io.ts). Pyodide cannot read
13
+ this file from disk, so it is shipped as a build-time-generated TypeScript
14
+ string constant (src/runtime/pyodide-bootstrap-core.generated.ts) produced by
15
+ scripts/generate-pyodide-bootstrap.mjs and exec'd into a module registered in
16
+ sys.modules. A conformance drift guard (test/runtime_conformance.test.ts)
17
+ asserts the generated constant stays byte-identical to this file.
18
+
19
+ CROSS-LANGUAGE CONTRACT (Python <-> the TypeScript decoder in src/utils/codec.ts
20
+ and the request encoder in src/runtime/safe-codec.ts):
21
+
22
+ * Every value-type "marker" envelope carries {'__tywrap__': <type>,
23
+ 'codecVersion': 1, 'encoding': ...}. The 6 markers are: ndarray, dataframe,
24
+ series, scipy.sparse, torch.tensor, sklearn.estimator.
25
+ * bytes round-trip both ways via base64 envelopes (see _deserialize_bytes_*
26
+ and the bytes branch of default_encoder).
27
+ * NaN/Infinity are rejected (the JS client cannot parse the non-standard tokens
28
+ that allow_nan=True would emit).
29
+
30
+ PURITY: This module depends only on the standard library plus LAZY optional
31
+ imports (numpy/pandas/scipy/torch/sklearn/pyarrow are each imported inside the
32
+ function that needs them). It performs no stdin/stdout I/O and reads no env vars,
33
+ so it runs unchanged under CPython-in-WASM (Pyodide).
34
+
35
+ force_json_markers: a *parameter* threaded through every serializer (including
36
+ the nested torch.tensor -> ndarray call). When True, ndarray/dataframe/series are
37
+ forced down their JSON path regardless of pyarrow availability. Pyodide passes
38
+ True (Arrow is unavailable in WASM); the subprocess server passes the boolean
39
+ derived from TYWRAP_CODEC_FALLBACK=json so that "Node in json-fallback mode" and
40
+ "Pyodide" produce byte-identical marker envelopes.
41
+ """
42
+
43
+ import base64
44
+ import datetime as dt
45
+ import decimal
46
+ import importlib
47
+ import importlib.util
48
+ import json
49
+ import math
50
+ import traceback
51
+ import uuid
52
+ from pathlib import Path, PurePath
53
+
54
+ # Protocol constants. These MUST match src/runtime/protocol.ts (PROTOCOL_ID,
55
+ # TYWRAP_PROTOCOL_VERSION) and the codec version baked into marker envelopes.
56
+ PROTOCOL = 'tywrap/1'
57
+ PROTOCOL_VERSION = 1
58
+ CODEC_VERSION = 1
59
+
60
+
61
+ class ProtocolError(Exception):
62
+ """Raised for malformed requests (bad protocol/id/method/params)."""
63
+
64
+
65
+ class InstanceHandleError(ValueError):
66
+ """Raised when an instance handle is unknown or no longer valid."""
67
+
68
+
69
+ class CodecError(Exception):
70
+ """Raised when value encoding fails (e.g. NaN/Infinity not allowed)."""
71
+
72
+
73
+ # =============================================================================
74
+ # REQUEST-SIDE DESERIALIZATION (bytes envelopes -> Python bytes)
75
+ # =============================================================================
76
+
77
+ _NO_DESERIALIZE = object()
78
+ _ERR_BYTES_MISSING_B64 = 'Invalid bytes envelope: missing b64'
79
+ _ERR_BYTES_MISSING_DATA = 'Invalid bytes envelope: missing data'
80
+ _ERR_BYTES_INVALID_BASE64 = 'Invalid bytes envelope: invalid base64'
81
+
82
+
83
+ def _deserialize_bytes_envelope(value):
84
+ """
85
+ Decode base64-encoded bytes envelopes from JS into Python bytes.
86
+
87
+ Supported shapes:
88
+ - { "__tywrap_bytes__": true, "b64": "..." } (JS SafeCodec.encodeRequest)
89
+ - { "__type__": "bytes", "encoding": "base64", "data": "..." } (legacy/compat)
90
+
91
+ Why: TS SafeCodec encodes Uint8Array/ArrayBuffer as base64 objects, but
92
+ Python handlers expect real bytes/bytearray to preserve behavior (e.g., len()).
93
+ """
94
+ if not isinstance(value, dict):
95
+ return _NO_DESERIALIZE
96
+
97
+ if value.get('__tywrap_bytes__') is True:
98
+ b64 = value.get('b64')
99
+ if not isinstance(b64, str):
100
+ raise ProtocolError(_ERR_BYTES_MISSING_B64)
101
+ try:
102
+ return base64.b64decode(b64, validate=True)
103
+ except Exception as exc:
104
+ raise ProtocolError(_ERR_BYTES_INVALID_BASE64) from exc
105
+
106
+ if value.get('__type__') == 'bytes' and value.get('encoding') == 'base64':
107
+ data = value.get('data')
108
+ if not isinstance(data, str):
109
+ raise ProtocolError(_ERR_BYTES_MISSING_DATA)
110
+ try:
111
+ return base64.b64decode(data, validate=True)
112
+ except Exception as exc:
113
+ raise ProtocolError(_ERR_BYTES_INVALID_BASE64) from exc
114
+
115
+ return _NO_DESERIALIZE
116
+
117
+
118
+ def deserialize(value):
119
+ """
120
+ Recursively deserialize request values into Python-native types.
121
+
122
+ Why: requests are JSON-only; we need a small set of explicit decoders
123
+ (currently bytes) to restore Python semantics at the boundary.
124
+ """
125
+ decoded = _deserialize_bytes_envelope(value)
126
+ if decoded is not _NO_DESERIALIZE:
127
+ return decoded
128
+
129
+ if isinstance(value, list):
130
+ return [deserialize(item) for item in value]
131
+ if isinstance(value, dict):
132
+ # Preserve dict shape while decoding nested values.
133
+ return {k: deserialize(v) for k, v in value.items()}
134
+ return value
135
+
136
+
137
+ # =============================================================================
138
+ # CAPABILITY DETECTION (lazy, best-effort)
139
+ # =============================================================================
140
+
141
+ def arrow_available():
142
+ """Return True when pyarrow can be imported."""
143
+ try:
144
+ import pyarrow # noqa: F401
145
+ except (ImportError, OSError):
146
+ return False
147
+ return True
148
+
149
+
150
+ def module_available(module_name):
151
+ """
152
+ Lightweight feature detection for optional codec dependencies via find_spec.
153
+
154
+ Why: exposes availability in bridge metadata without importing heavy modules.
155
+ """
156
+ try:
157
+ return importlib.util.find_spec(module_name) is not None
158
+ except (ImportError, AttributeError, TypeError, ValueError):
159
+ return False
160
+
161
+
162
+ def is_numpy_array(obj):
163
+ try:
164
+ import numpy as np # noqa: F401
165
+ except Exception:
166
+ return False
167
+ return isinstance(obj, np.ndarray)
168
+
169
+
170
+ def is_pandas_dataframe(obj):
171
+ try:
172
+ import pandas as pd # noqa: F401
173
+ except Exception:
174
+ return False
175
+ return isinstance(obj, pd.DataFrame)
176
+
177
+
178
+ def is_pandas_series(obj):
179
+ try:
180
+ import pandas as pd # noqa: F401
181
+ except Exception:
182
+ return False
183
+ return isinstance(obj, pd.Series)
184
+
185
+
186
+ def is_scipy_sparse(obj):
187
+ try:
188
+ import scipy.sparse as sp # noqa: F401
189
+ except Exception:
190
+ return False
191
+ try:
192
+ return sp.issparse(obj)
193
+ except Exception:
194
+ return False
195
+
196
+
197
+ def is_torch_tensor(obj):
198
+ try:
199
+ import torch # noqa: F401
200
+ except Exception:
201
+ return False
202
+ try:
203
+ return torch.is_tensor(obj)
204
+ except Exception:
205
+ return False
206
+
207
+
208
+ def is_sklearn_estimator(obj):
209
+ try:
210
+ from sklearn.base import BaseEstimator # noqa: F401
211
+ except Exception:
212
+ return False
213
+ return isinstance(obj, BaseEstimator)
214
+
215
+
216
+ # =============================================================================
217
+ # MARKER SERIALIZERS (6 __tywrap__ value types)
218
+ # =============================================================================
219
+ #
220
+ # Each serializer accepts force_json_markers. When True, the Arrow path is never
221
+ # taken (used by Pyodide and by the subprocess server in TYWRAP_CODEC_FALLBACK=json
222
+ # mode). The JSON fallback envelopes are byte-identical across both callers, which
223
+ # is what the conformance suite asserts.
224
+
225
+ def serialize_ndarray(obj, *, force_json_markers):
226
+ """
227
+ Encode a NumPy ndarray. Arrow IPC (compact, lossless) by default; JSON when
228
+ force_json_markers is set or pyarrow is unavailable in fallback mode.
229
+
230
+ Note: pa.array() only handles 1D arrays; multi-dimensional arrays are
231
+ flattened with shape metadata for JS-side reconstruction. See
232
+ https://github.com/apache/arrow-js/issues/115
233
+ """
234
+ if force_json_markers:
235
+ return serialize_ndarray_json(obj)
236
+ try:
237
+ import pyarrow as pa # type: ignore
238
+ except Exception as exc:
239
+ raise RuntimeError(
240
+ 'Arrow encoding unavailable for ndarray; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'
241
+ ) from exc
242
+ try:
243
+ original_shape = list(obj.shape) if hasattr(obj, 'shape') else None
244
+ flat = obj.flatten() if hasattr(obj, 'ndim') and obj.ndim > 1 else obj
245
+ arr = pa.array(flat)
246
+ table = pa.Table.from_arrays([arr], names=['value'])
247
+ sink = pa.BufferOutputStream()
248
+ with pa.ipc.new_stream(sink, table.schema) as writer:
249
+ writer.write_table(table)
250
+ buf = sink.getvalue()
251
+ b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')
252
+ return {
253
+ '__tywrap__': 'ndarray',
254
+ 'codecVersion': CODEC_VERSION,
255
+ 'encoding': 'arrow',
256
+ 'b64': b64,
257
+ 'shape': original_shape,
258
+ 'dtype': str(obj.dtype) if hasattr(obj, 'dtype') else None,
259
+ }
260
+ except Exception as exc:
261
+ raise RuntimeError('Arrow encoding failed for ndarray') from exc
262
+
263
+
264
+ def serialize_ndarray_json(obj):
265
+ """JSON fallback for ndarray (larger payloads, potential dtype loss)."""
266
+ try:
267
+ data = obj.tolist()
268
+ except Exception as exc:
269
+ raise RuntimeError('JSON fallback failed for ndarray') from exc
270
+ return {
271
+ '__tywrap__': 'ndarray',
272
+ 'codecVersion': CODEC_VERSION,
273
+ 'encoding': 'json',
274
+ 'data': data,
275
+ 'shape': getattr(obj, 'shape', None),
276
+ }
277
+
278
+
279
+ def serialize_dataframe(obj, *, force_json_markers):
280
+ """
281
+ Encode a pandas DataFrame. Feather/Arrow-IPC (uncompressed, so apache-arrow
282
+ in JS can read it) by default; JSON when force_json_markers is set.
283
+ """
284
+ if force_json_markers:
285
+ return serialize_dataframe_json(obj)
286
+ try:
287
+ import pyarrow as pa # type: ignore
288
+ import pyarrow.feather as feather # type: ignore
289
+ except Exception as exc:
290
+ raise RuntimeError(
291
+ 'Arrow encoding unavailable for pandas.DataFrame; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'
292
+ ) from exc
293
+ try:
294
+ table = pa.Table.from_pandas(obj) # type: ignore
295
+ sink = pa.BufferOutputStream()
296
+ feather.write_feather(table, sink, compression='uncompressed')
297
+ buf = sink.getvalue()
298
+ b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')
299
+ return {
300
+ '__tywrap__': 'dataframe',
301
+ 'codecVersion': CODEC_VERSION,
302
+ 'encoding': 'arrow',
303
+ 'b64': b64,
304
+ }
305
+ except Exception as exc:
306
+ raise RuntimeError('Arrow encoding failed for pandas.DataFrame') from exc
307
+
308
+
309
+ def serialize_dataframe_json(obj):
310
+ """JSON fallback for DataFrame: records orientation."""
311
+ try:
312
+ data = obj.to_dict(orient='records')
313
+ except Exception as exc:
314
+ raise RuntimeError('JSON fallback failed for pandas.DataFrame') from exc
315
+ return {
316
+ '__tywrap__': 'dataframe',
317
+ 'codecVersion': CODEC_VERSION,
318
+ 'encoding': 'json',
319
+ 'data': data,
320
+ }
321
+
322
+
323
+ def serialize_series(obj, *, force_json_markers):
324
+ """
325
+ Encode a pandas Series as a single-column Arrow Table stream (the JS decoder
326
+ contract is "table-like"); JSON when force_json_markers is set.
327
+ """
328
+ if force_json_markers:
329
+ return serialize_series_json(obj)
330
+ try:
331
+ import pyarrow as pa # type: ignore
332
+ except Exception as exc:
333
+ raise RuntimeError(
334
+ 'Arrow encoding unavailable for pandas.Series; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'
335
+ ) from exc
336
+ try:
337
+ arr = pa.Array.from_pandas(obj) # type: ignore
338
+ table = pa.Table.from_arrays([arr], names=['value'])
339
+ sink = pa.BufferOutputStream()
340
+ with pa.ipc.new_stream(sink, table.schema) as writer:
341
+ writer.write_table(table)
342
+ buf = sink.getvalue()
343
+ b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')
344
+ return {
345
+ '__tywrap__': 'series',
346
+ 'codecVersion': CODEC_VERSION,
347
+ 'encoding': 'arrow',
348
+ 'b64': b64,
349
+ 'name': getattr(obj, 'name', None),
350
+ }
351
+ except Exception as exc:
352
+ raise RuntimeError('Arrow encoding failed for pandas.Series') from exc
353
+
354
+
355
+ def serialize_series_json(obj):
356
+ """JSON fallback for Series (potentially lossy dtype/NA representation)."""
357
+ try:
358
+ data = obj.to_list() # type: ignore
359
+ except Exception:
360
+ try:
361
+ data = obj.to_dict() # type: ignore
362
+ except Exception as exc:
363
+ raise RuntimeError('JSON fallback failed for pandas.Series') from exc
364
+ return {
365
+ '__tywrap__': 'series',
366
+ 'codecVersion': CODEC_VERSION,
367
+ 'encoding': 'json',
368
+ 'data': data,
369
+ 'name': getattr(obj, 'name', None),
370
+ }
371
+
372
+
373
+ def serialize_sparse_matrix(obj):
374
+ """
375
+ Serialize scipy sparse matrices into structured JSON envelopes (json-only;
376
+ there is no Arrow path). Preserves sparsity; rejects unsupported formats and
377
+ complex dtypes explicitly.
378
+ """
379
+ try:
380
+ fmt = obj.getformat()
381
+ except Exception as exc:
382
+ raise RuntimeError('Failed to inspect scipy sparse matrix format') from exc
383
+
384
+ if fmt not in ('csr', 'csc', 'coo'):
385
+ raise RuntimeError(f'Unsupported scipy sparse format: {fmt}')
386
+
387
+ dtype = None
388
+ try:
389
+ dtype = str(obj.dtype)
390
+ except Exception:
391
+ dtype = None
392
+ if getattr(obj.dtype, 'kind', None) == 'c':
393
+ raise RuntimeError('Complex sparse matrices are not supported by JSON codec')
394
+
395
+ if fmt in ('csr', 'csc'):
396
+ data = obj.data.tolist()
397
+ indices = obj.indices.tolist()
398
+ indptr = obj.indptr.tolist()
399
+ return {
400
+ '__tywrap__': 'scipy.sparse',
401
+ 'codecVersion': CODEC_VERSION,
402
+ 'encoding': 'json',
403
+ 'format': fmt,
404
+ 'shape': list(obj.shape),
405
+ 'data': data,
406
+ 'indices': indices,
407
+ 'indptr': indptr,
408
+ 'dtype': dtype,
409
+ }
410
+
411
+ # coo
412
+ data = obj.data.tolist()
413
+ row = obj.row.tolist()
414
+ col = obj.col.tolist()
415
+ return {
416
+ '__tywrap__': 'scipy.sparse',
417
+ 'codecVersion': CODEC_VERSION,
418
+ 'encoding': 'json',
419
+ 'format': fmt,
420
+ 'shape': list(obj.shape),
421
+ 'data': data,
422
+ 'row': row,
423
+ 'col': col,
424
+ 'dtype': dtype,
425
+ }
426
+
427
+
428
+ def serialize_torch_tensor(obj, *, force_json_markers, torch_allow_copy=False):
429
+ """
430
+ Serialize torch.Tensor values via the nested ndarray envelope. CPU-only by
431
+ default; device/copy behavior is explicit. force_json_markers is threaded
432
+ into the nested ndarray serialization so Pyodide gets a JSON ndarray value.
433
+ """
434
+ tensor = obj.detach()
435
+ if getattr(tensor, 'device', None) is not None and tensor.device.type != 'cpu':
436
+ if not torch_allow_copy:
437
+ raise RuntimeError(
438
+ 'Torch tensor is on a non-CPU device; set TYWRAP_TORCH_ALLOW_COPY=1 to allow CPU transfer'
439
+ )
440
+ tensor = tensor.to('cpu')
441
+ if hasattr(tensor, 'is_contiguous') and not tensor.is_contiguous():
442
+ if not torch_allow_copy:
443
+ raise RuntimeError(
444
+ 'Torch tensor is not contiguous; set TYWRAP_TORCH_ALLOW_COPY=1 to allow contiguous copy'
445
+ )
446
+ tensor = tensor.contiguous()
447
+ try:
448
+ arr = tensor.numpy()
449
+ except Exception as exc:
450
+ raise RuntimeError('Failed to convert torch.Tensor to numpy') from exc
451
+
452
+ return {
453
+ '__tywrap__': 'torch.tensor',
454
+ 'codecVersion': CODEC_VERSION,
455
+ 'encoding': 'ndarray',
456
+ 'value': serialize_ndarray(arr, force_json_markers=force_json_markers),
457
+ 'shape': list(tensor.shape),
458
+ 'dtype': str(tensor.dtype),
459
+ 'device': str(tensor.device),
460
+ }
461
+
462
+
463
+ def serialize_sklearn_estimator(obj):
464
+ """Serialize sklearn estimators as metadata only (json-only); no pickling."""
465
+ try:
466
+ import sklearn # noqa: F401
467
+ except Exception as exc:
468
+ raise RuntimeError('scikit-learn is not available') from exc
469
+
470
+ params = obj.get_params(deep=False)
471
+ try:
472
+ json.dumps(params)
473
+ except Exception as exc:
474
+ raise RuntimeError(
475
+ 'scikit-learn estimator params are not JSON-serializable; avoid returning estimators or sanitize params'
476
+ ) from exc
477
+
478
+ return {
479
+ '__tywrap__': 'sklearn.estimator',
480
+ 'codecVersion': CODEC_VERSION,
481
+ 'encoding': 'json',
482
+ 'className': obj.__class__.__name__,
483
+ 'module': obj.__class__.__module__,
484
+ 'version': getattr(sklearn, '__version__', None),
485
+ 'params': params,
486
+ }
487
+
488
+
489
+ _NO_PYDANTIC = object()
490
+
491
+
492
+ def serialize_pydantic(obj):
493
+ """
494
+ Serialize Pydantic v2 models via model_dump(by_alias=True, mode='json')
495
+ without importing Pydantic. Returns _NO_PYDANTIC when obj is not a model.
496
+ """
497
+ model_dump = getattr(obj, 'model_dump', None)
498
+ if not callable(model_dump):
499
+ return _NO_PYDANTIC
500
+ try:
501
+ try:
502
+ return model_dump(by_alias=True, mode='json')
503
+ except TypeError:
504
+ # Older Pydantic versions may not support `mode=...`.
505
+ return model_dump(by_alias=True)
506
+ except Exception as exc:
507
+ raise RuntimeError(f'model_dump failed: {exc}') from exc
508
+
509
+
510
+ def serialize_stdlib(obj):
511
+ """Coerce common stdlib scalar types to JSON-safe forms; None otherwise."""
512
+ if isinstance(obj, dt.datetime):
513
+ return obj.isoformat()
514
+ if isinstance(obj, dt.date):
515
+ return obj.isoformat()
516
+ if isinstance(obj, dt.time):
517
+ return obj.isoformat()
518
+ if isinstance(obj, dt.timedelta):
519
+ return obj.total_seconds()
520
+ if isinstance(obj, decimal.Decimal):
521
+ return str(obj)
522
+ if isinstance(obj, uuid.UUID):
523
+ return str(obj)
524
+ if isinstance(obj, (Path, PurePath)):
525
+ return str(obj)
526
+ return None
527
+
528
+
529
+ def serialize(obj, *, force_json_markers, torch_allow_copy=False):
530
+ """
531
+ Top-level result serializer. Dispatch order is significant: numpy ndarray ->
532
+ dataframe -> series -> scipy.sparse -> torch -> sklearn -> Pydantic -> stdlib
533
+ -> passthrough. The remaining SafeCodec value behaviors (numpy/pandas scalars,
534
+ bytes, sets, complex rejection, NaN/Infinity) are applied later during JSON
535
+ encoding by default_encoder.
536
+ """
537
+ if is_numpy_array(obj):
538
+ return serialize_ndarray(obj, force_json_markers=force_json_markers)
539
+ if is_pandas_dataframe(obj):
540
+ return serialize_dataframe(obj, force_json_markers=force_json_markers)
541
+ if is_pandas_series(obj):
542
+ return serialize_series(obj, force_json_markers=force_json_markers)
543
+ if is_scipy_sparse(obj):
544
+ return serialize_sparse_matrix(obj)
545
+ if is_torch_tensor(obj):
546
+ return serialize_torch_tensor(
547
+ obj, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy
548
+ )
549
+ if is_sklearn_estimator(obj):
550
+ return serialize_sklearn_estimator(obj)
551
+ pydantic_value = serialize_pydantic(obj)
552
+ if pydantic_value is not _NO_PYDANTIC:
553
+ return pydantic_value
554
+ stdlib_value = serialize_stdlib(obj)
555
+ if stdlib_value is not None:
556
+ return stdlib_value
557
+ return obj
558
+
559
+
560
+ # =============================================================================
561
+ # JSON ENCODE: SafeCodec-equivalent value handling (NaN reject, scalars, bytes)
562
+ # =============================================================================
563
+ #
564
+ # This mirrors SafeCodec._default_encoder (runtime/safe_codec.py) for the VALUE
565
+ # behaviors that are part of the wire contract. The subprocess server still uses
566
+ # the real SafeCodec for its final encode (it also enforces size limits); this
567
+ # core encoder exists so the Pyodide server gets identical value handling without
568
+ # depending on safe_codec.py. The conformance suite asserts these behaviors match.
569
+
570
+ def _is_nan_or_inf(value):
571
+ if not isinstance(value, (int, float)):
572
+ return False
573
+ try:
574
+ return math.isnan(value) or math.isinf(value)
575
+ except (TypeError, ValueError):
576
+ return False
577
+
578
+
579
+ def _is_numpy_scalar(obj):
580
+ try:
581
+ import numpy as np
582
+ except ImportError:
583
+ return False
584
+ return isinstance(obj, (np.generic, np.ndarray)) and obj.ndim == 0
585
+
586
+
587
+ def _is_pandas_scalar(obj):
588
+ try:
589
+ import pandas as pd
590
+ except ImportError:
591
+ return False
592
+ return isinstance(obj, (pd.Timestamp, pd.Timedelta, type(pd.NaT)))
593
+
594
+
595
+ def make_default_encoder(*, allow_nan):
596
+ """
597
+ Build a json.dumps default= encoder matching SafeCodec's value handling.
598
+
599
+ Raises CodecError for NaN/Infinity extracted from numpy scalars (json.dumps
600
+ itself rejects top-level/nested NaN/Infinity floats when allow_nan=False).
601
+ """
602
+
603
+ def default_encoder(obj):
604
+ # numpy/pandas scalars first (need .item() extraction).
605
+ if _is_numpy_scalar(obj):
606
+ extracted = obj.item()
607
+ if not allow_nan and _is_nan_or_inf(extracted):
608
+ raise CodecError('Cannot serialize NaN - NaN/Infinity not allowed in JSON')
609
+ return extracted
610
+
611
+ if _is_pandas_scalar(obj):
612
+ try:
613
+ import pandas as pd
614
+ except ImportError:
615
+ pass
616
+ else:
617
+ if obj is pd.NaT or (hasattr(pd, 'isna') and pd.isna(obj)):
618
+ return None
619
+ if isinstance(obj, pd.Timestamp):
620
+ return obj.isoformat()
621
+ if isinstance(obj, pd.Timedelta):
622
+ return obj.total_seconds()
623
+
624
+ if isinstance(obj, dt.datetime):
625
+ return obj.isoformat()
626
+ if isinstance(obj, dt.date):
627
+ return obj.isoformat()
628
+ if isinstance(obj, dt.time):
629
+ return obj.isoformat()
630
+ if isinstance(obj, dt.timedelta):
631
+ return obj.total_seconds()
632
+ if isinstance(obj, decimal.Decimal):
633
+ return str(obj)
634
+ if isinstance(obj, uuid.UUID):
635
+ return str(obj)
636
+ if isinstance(obj, (Path, PurePath)):
637
+ return str(obj)
638
+
639
+ if isinstance(obj, (bytes, bytearray)):
640
+ return {
641
+ '__type__': 'bytes',
642
+ 'encoding': 'base64',
643
+ 'data': base64.b64encode(obj).decode('ascii'),
644
+ }
645
+
646
+ model_dump = getattr(obj, 'model_dump', None)
647
+ if callable(model_dump):
648
+ try:
649
+ return model_dump(by_alias=True, mode='json')
650
+ except TypeError:
651
+ return model_dump(by_alias=True)
652
+
653
+ if isinstance(obj, (set, frozenset)):
654
+ return list(obj)
655
+
656
+ if isinstance(obj, complex):
657
+ raise TypeError(f'Object of type {type(obj).__name__} is not JSON serializable')
658
+
659
+ raise TypeError(f'Object of type {type(obj).__name__} is not JSON serializable')
660
+
661
+ return default_encoder
662
+
663
+
664
+ def encode_value(value, *, allow_nan):
665
+ """
666
+ JSON-encode a fully-serialized response value, applying the SafeCodec-equivalent
667
+ default encoder and rejecting NaN/Infinity when allow_nan is False.
668
+
669
+ Raises CodecError (wrapping the json.dumps ValueError) on NaN/Infinity, matching
670
+ SafeCodec's "Cannot serialize NaN..." wording so error parity holds.
671
+ """
672
+ try:
673
+ return json.dumps(value, default=make_default_encoder(allow_nan=allow_nan), allow_nan=allow_nan)
674
+ except ValueError as exc:
675
+ error_msg = str(exc).lower()
676
+ # json.dumps(allow_nan=False) rejects NaN/Infinity with a ValueError whose
677
+ # wording is Python-version dependent: 3.12+ appends the offending value
678
+ # ("...not JSON compliant: nan"), but 3.10/3.11 emit only the canonical
679
+ # "Out of range float values are not JSON compliant". Match that phrase too
680
+ # so the typed error message is stable across versions.
681
+ if (
682
+ 'nan' in error_msg
683
+ or 'infinity' in error_msg
684
+ or 'inf' in error_msg
685
+ or 'out of range float' in error_msg
686
+ ):
687
+ raise CodecError('Cannot serialize NaN - NaN/Infinity not allowed in JSON') from exc
688
+ raise CodecError(f'JSON encoding failed: {exc}') from exc
689
+ except TypeError as exc:
690
+ raise CodecError(f'JSON encoding failed: {exc}') from exc
691
+
692
+
693
+ # =============================================================================
694
+ # REQUEST VALIDATION + HANDLERS + DISPATCH
695
+ # =============================================================================
696
+
697
+ def require_protocol(msg):
698
+ if not isinstance(msg, dict):
699
+ raise ProtocolError('Invalid request payload')
700
+ proto = msg.get('protocol')
701
+ if proto != PROTOCOL:
702
+ raise ProtocolError(f'Invalid protocol: {proto}')
703
+ mid = msg.get('id')
704
+ if not isinstance(mid, int):
705
+ raise ProtocolError(f'Invalid request id: {mid}')
706
+ return mid
707
+
708
+
709
+ def require_str(params, key):
710
+ value = params.get(key)
711
+ if not isinstance(value, str) or not value:
712
+ raise ProtocolError(f'Missing {key}')
713
+ return value
714
+
715
+
716
+ def coerce_list(value, key):
717
+ if value is None:
718
+ return []
719
+ if not isinstance(value, list):
720
+ raise ProtocolError(f'Invalid {key}')
721
+ return value
722
+
723
+
724
+ def coerce_dict(value, key):
725
+ if value is None:
726
+ return {}
727
+ if not isinstance(value, dict):
728
+ raise ProtocolError(f'Invalid {key}')
729
+ return value
730
+
731
+
732
+ def handle_call(params, *, force_json_markers, torch_allow_copy):
733
+ module_name = require_str(params, 'module')
734
+ function_name = require_str(params, 'functionName')
735
+ args = deserialize(coerce_list(params.get('args'), 'args'))
736
+ kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))
737
+ mod = importlib.import_module(module_name)
738
+ func = getattr(mod, function_name)
739
+ res = func(*args, **kwargs)
740
+ return serialize(res, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy)
741
+
742
+
743
+ def handle_instantiate(params, instances):
744
+ module_name = require_str(params, 'module')
745
+ class_name = require_str(params, 'className')
746
+ args = deserialize(coerce_list(params.get('args'), 'args'))
747
+ kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))
748
+ mod = importlib.import_module(module_name)
749
+ cls = getattr(mod, class_name)
750
+ obj = cls(*args, **kwargs)
751
+ handle_id = str(id(obj))
752
+ instances[handle_id] = obj
753
+ return handle_id
754
+
755
+
756
+ def handle_call_method(params, instances, *, force_json_markers, torch_allow_copy):
757
+ handle_id = require_str(params, 'handle')
758
+ method_name = require_str(params, 'methodName')
759
+ args = deserialize(coerce_list(params.get('args'), 'args'))
760
+ kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))
761
+ if handle_id not in instances:
762
+ raise InstanceHandleError(f'Unknown instance handle: {handle_id}')
763
+ obj = instances[handle_id]
764
+ func = getattr(obj, method_name)
765
+ res = func(*args, **kwargs)
766
+ return serialize(res, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy)
767
+
768
+
769
+ def handle_dispose_instance(params, instances):
770
+ handle_id = require_str(params, 'handle')
771
+ if handle_id not in instances:
772
+ return False
773
+ del instances[handle_id]
774
+ return True
775
+
776
+
777
+ def build_meta(instances, *, bridge, pid, python_version, codec_fallback, arrow_available_override=None):
778
+ """
779
+ Build the bridge metadata payload.
780
+
781
+ Field order here is part of the wire contract (the JS validator and the
782
+ documented BridgeInfo shape). Callers supply the backend-specific identity:
783
+ the subprocess server passes bridge='python-subprocess' and a real pid; the
784
+ Pyodide server passes bridge='pyodide' and pid=None.
785
+
786
+ arrow_available_override: when not None, report this value for arrowAvailable
787
+ instead of probing pyarrow. The Pyodide server forces markers to JSON
788
+ unconditionally, so it advertises arrowAvailable=False regardless of whether
789
+ pyarrow happens to be importable in the WASM environment.
790
+ """
791
+ arrow = arrow_available() if arrow_available_override is None else arrow_available_override
792
+ return {
793
+ 'protocol': PROTOCOL,
794
+ 'protocolVersion': PROTOCOL_VERSION,
795
+ 'bridge': bridge,
796
+ 'pythonVersion': python_version,
797
+ 'pid': pid,
798
+ 'codecFallback': codec_fallback,
799
+ 'arrowAvailable': arrow,
800
+ 'scipyAvailable': module_available('scipy'),
801
+ 'torchAvailable': module_available('torch'),
802
+ 'sklearnAvailable': module_available('sklearn'),
803
+ 'instances': len(instances),
804
+ }
805
+
806
+
807
+ def dispatch_request(
808
+ msg,
809
+ instances,
810
+ *,
811
+ bridge,
812
+ pid,
813
+ force_json_markers,
814
+ allow_nan=False,
815
+ python_version=None,
816
+ torch_allow_copy=False,
817
+ arrow_available_override=None,
818
+ ):
819
+ """
820
+ Validate and route a request, returning the fully-serialized response dict
821
+ ({'id', 'protocol', 'result'}). Raises ProtocolError for malformed requests
822
+ and propagates handler exceptions to the caller, which is responsible for
823
+ building the error envelope (so it controls traceback inclusion).
824
+
825
+ allow_nan is accepted for signature symmetry; NaN rejection happens during
826
+ the final encode_value() call, which the caller performs.
827
+ """
828
+ mid = require_protocol(msg)
829
+ method = msg.get('method')
830
+ if not isinstance(method, str):
831
+ raise ProtocolError('Missing method')
832
+ params = coerce_dict(msg.get('params'), 'params')
833
+ if method == 'call':
834
+ result = handle_call(
835
+ params, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy
836
+ )
837
+ elif method == 'instantiate':
838
+ result = handle_instantiate(params, instances)
839
+ elif method == 'call_method':
840
+ result = handle_call_method(
841
+ params, instances, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy
842
+ )
843
+ elif method == 'dispose_instance':
844
+ result = handle_dispose_instance(params, instances)
845
+ elif method == 'meta':
846
+ if python_version is None:
847
+ import sys
848
+ python_version = sys.version.split()[0]
849
+ codec_fallback = 'json' if force_json_markers else 'none'
850
+ result = build_meta(
851
+ instances,
852
+ bridge=bridge,
853
+ pid=pid,
854
+ python_version=python_version,
855
+ codec_fallback=codec_fallback,
856
+ arrow_available_override=arrow_available_override,
857
+ )
858
+ else:
859
+ raise ProtocolError(f'Unknown method: {method}')
860
+ return {'id': mid, 'protocol': PROTOCOL, 'result': result}
861
+
862
+
863
+ def build_error_payload(mid, exc, *, include_traceback):
864
+ """
865
+ Build a protocol error response. Protocol/validation errors omit traceback;
866
+ handler errors include it. Field order matches the reference server.
867
+ """
868
+ error = {'type': type(exc).__name__, 'message': str(exc)}
869
+ if include_traceback:
870
+ error['traceback'] = traceback.format_exc()
871
+ return {
872
+ 'id': mid if mid is not None else -1,
873
+ 'protocol': PROTOCOL,
874
+ 'error': error,
875
+ }