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
@@ -1,18 +1,62 @@
1
1
  #!/usr/bin/env python3
2
+ """
3
+ Reference tywrap Python bridge server (subprocess + HTTP transports).
4
+
5
+ This module owns the I/O concerns and process identity for the Node/Bun/Deno
6
+ subprocess transport and the HTTP transport:
7
+ - the stdin/stdout JSONL request/response loop (main())
8
+ - env-var size guards (TYWRAP_CODEC_MAX_BYTES / TYWRAP_REQUEST_MAX_BYTES)
9
+ - TYWRAP_CODEC_FALLBACK=json marker mode and TYWRAP_TORCH_ALLOW_COPY
10
+ - the real OS pid and bridge='python-subprocess' identity
11
+ - the final SafeCodec.encode wrapper (NaN rejection + numpy scalar coercion +
12
+ the explicit byte-size limit error message)
13
+
14
+ The protocol dispatch, request deserialization, and the 6 __tywrap__ marker
15
+ serializers live in the shared, pure module tywrap_bridge_core (so the in-WASM
16
+ Pyodide server can run the SAME code). Those names are re-exported below for
17
+ backward compatibility, since this package ships runtime/ and external importers
18
+ may reference e.g. serialize() or dispatch_request().
19
+ """
2
20
  import sys
3
21
  import json
4
- import importlib
5
- import importlib.util
6
22
  import os
7
- import traceback
8
- import base64
9
- import datetime as dt
10
- import decimal
11
- import uuid
12
- from pathlib import Path, PurePath
23
+ import importlib # noqa: F401 (re-exported for compat / used by handlers via core)
13
24
 
14
25
  from safe_codec import SafeCodec, CodecError
15
26
 
27
+ import tywrap_bridge_core as core
28
+
29
+ # Re-export the shared protocol/serialization surface so existing importers of
30
+ # python_bridge keep working after the extraction (codex-flagged: runtime/ ships).
31
+ from tywrap_bridge_core import ( # noqa: F401
32
+ PROTOCOL,
33
+ PROTOCOL_VERSION,
34
+ CODEC_VERSION,
35
+ ProtocolError,
36
+ InstanceHandleError,
37
+ deserialize,
38
+ arrow_available,
39
+ module_available,
40
+ is_numpy_array,
41
+ is_pandas_dataframe,
42
+ is_pandas_series,
43
+ is_scipy_sparse,
44
+ is_torch_tensor,
45
+ is_sklearn_estimator,
46
+ serialize_ndarray_json,
47
+ serialize_dataframe_json,
48
+ serialize_series_json,
49
+ serialize_sparse_matrix,
50
+ serialize_sklearn_estimator,
51
+ serialize_pydantic,
52
+ serialize_stdlib,
53
+ require_protocol,
54
+ require_str,
55
+ coerce_list,
56
+ coerce_dict,
57
+ build_error_payload,
58
+ )
59
+
16
60
  # Ensure the working directory is importable so local modules can be resolved when
17
61
  # the bridge is launched as a script from a different directory.
18
62
  try:
@@ -29,11 +73,8 @@ except (OSError, ValueError, TypeError, AttributeError) as exc:
29
73
  instances = {}
30
74
 
31
75
  FALLBACK_JSON = os.environ.get('TYWRAP_CODEC_FALLBACK', '').lower() == 'json'
32
- PROTOCOL = 'tywrap/1'
33
- PROTOCOL_VERSION = 1
76
+ TORCH_ALLOW_COPY = os.environ.get('TYWRAP_TORCH_ALLOW_COPY', '').lower() in ('1', 'true', 'yes')
34
77
  BRIDGE_NAME = 'python-subprocess'
35
- # Why: include a stable version in envelopes so decoders can reject incompatible changes.
36
- CODEC_VERSION = 1
37
78
 
38
79
 
39
80
  class CodecConfigError(ValueError):
@@ -134,71 +175,15 @@ def get_request_max_bytes():
134
175
  REQUEST_MAX_BYTES = get_request_max_bytes()
135
176
 
136
177
 
137
- class ProtocolError(Exception):
138
- pass
139
-
140
-
141
- class InstanceHandleError(ValueError):
142
- """Raised when an instance handle is unknown or no longer valid."""
143
-
144
- _NO_DESERIALIZE = object()
145
- _ERR_BYTES_MISSING_B64 = 'Invalid bytes envelope: missing b64'
146
- _ERR_BYTES_MISSING_DATA = 'Invalid bytes envelope: missing data'
147
- _ERR_BYTES_INVALID_BASE64 = 'Invalid bytes envelope: invalid base64'
148
-
149
-
150
- def _deserialize_bytes_envelope(value) -> object:
151
- """
152
- Decode base64-encoded bytes envelopes from JS into Python bytes.
153
-
154
- Supported shapes:
155
- - { "__tywrap_bytes__": true, "b64": "..." } (JS SafeCodec.encodeRequest)
156
- - { "__type__": "bytes", "encoding": "base64", "data": "..." } (legacy/compat)
157
-
158
- Why: TS SafeCodec encodes Uint8Array/ArrayBuffer as base64 objects, but
159
- Python handlers expect real bytes/bytearray to preserve behavior (e.g., len()).
160
- """
161
- if not isinstance(value, dict):
162
- return _NO_DESERIALIZE
163
-
164
- if value.get('__tywrap_bytes__') is True:
165
- b64 = value.get('b64')
166
- if not isinstance(b64, str):
167
- raise ProtocolError(_ERR_BYTES_MISSING_B64)
168
- try:
169
- return base64.b64decode(b64, validate=True)
170
- except Exception as exc:
171
- raise ProtocolError(_ERR_BYTES_INVALID_BASE64) from exc
172
-
173
- if value.get('__type__') == 'bytes' and value.get('encoding') == 'base64':
174
- data = value.get('data')
175
- if not isinstance(data, str):
176
- raise ProtocolError(_ERR_BYTES_MISSING_DATA)
177
- try:
178
- return base64.b64decode(data, validate=True)
179
- except Exception as exc:
180
- raise ProtocolError(_ERR_BYTES_INVALID_BASE64) from exc
181
-
182
- return _NO_DESERIALIZE
183
-
184
-
185
- def deserialize(value):
178
+ def serialize(obj):
186
179
  """
187
- Recursively deserialize request values into Python-native types.
180
+ Backward-compatible result serializer (subprocess identity).
188
181
 
189
- Why: requests are JSON-only; we need a small set of explicit decoders
190
- (currently bytes) to restore Python semantics at the boundary.
182
+ Threads the env-derived TYWRAP_CODEC_FALLBACK / TYWRAP_TORCH_ALLOW_COPY flags
183
+ into the shared core serializer. Kept as a module-level function so existing
184
+ importers of python_bridge.serialize keep working.
191
185
  """
192
- decoded = _deserialize_bytes_envelope(value)
193
- if decoded is not _NO_DESERIALIZE:
194
- return decoded
195
-
196
- if isinstance(value, list):
197
- return [deserialize(item) for item in value]
198
- if isinstance(value, dict):
199
- # Preserve dict shape while decoding nested values.
200
- return {k: deserialize(v) for k, v in value.items()}
201
- return value
186
+ return core.serialize(obj, force_json_markers=FALLBACK_JSON, torch_allow_copy=TORCH_ALLOW_COPY)
202
187
 
203
188
 
204
189
  _PROTOCOL_DIAGNOSTIC_MAX = 2048
@@ -222,644 +207,42 @@ def emit_protocol_diagnostic(message: str) -> None:
222
207
  pass
223
208
 
224
209
 
225
- def arrow_available():
226
- """
227
- Return True when pyarrow can be imported.
228
-
229
- Why: advertise Arrow capability to the TS side without crashing startup when
230
- pyarrow is optional or missing.
231
- """
232
- try:
233
- import pyarrow
234
- except (ImportError, OSError):
235
- return False
236
- return True
237
-
238
-
239
- def module_available(module_name: str) -> bool:
240
- """
241
- Lightweight feature detection for optional codec dependencies.
242
-
243
- Why: exposes availability in bridge metadata without importing heavy modules or triggering
244
- side effects, so the TS side can decide when to rely on optional codecs. These flags are
245
- best-effort hints; serialization still performs its own import checks for correctness.
246
- """
247
- try:
248
- return importlib.util.find_spec(module_name) is not None
249
- except (ImportError, AttributeError, TypeError, ValueError):
250
- # Why: guard against unusual importlib edge cases without masking other failures.
251
- return False
252
-
253
-
254
- def is_numpy_array(obj):
255
- """
256
- Detect numpy arrays when NumPy is installed.
257
-
258
- Why: keep NumPy optional while enabling ndarray serialization.
259
- """
260
- try:
261
- import numpy as np # noqa: F401
262
- except Exception:
263
- return False
264
- return isinstance(obj, np.ndarray)
265
-
266
-
267
- def is_pandas_dataframe(obj):
268
- """
269
- Detect pandas DataFrame instances when pandas is installed.
270
-
271
- Why: avoid hard pandas dependency while enabling dataframe encoding.
272
- """
273
- try:
274
- import pandas as pd # noqa: F401
275
- except Exception:
276
- return False
277
- return isinstance(obj, pd.DataFrame)
278
-
279
-
280
- def is_pandas_series(obj):
281
- """
282
- Detect pandas Series instances when pandas is installed.
283
-
284
- Why: avoid hard pandas dependency while enabling series encoding.
285
- """
286
- try:
287
- import pandas as pd # noqa: F401
288
- except Exception:
289
- return False
290
- return isinstance(obj, pd.Series)
291
-
292
-
293
- def is_scipy_sparse(obj):
294
- """
295
- Detect scipy sparse matrices when scipy is installed.
296
-
297
- Why: allow sparse matrix encoding without importing scipy in all environments.
298
- """
299
- try:
300
- import scipy.sparse as sp # noqa: F401
301
- except Exception:
302
- return False
303
- try:
304
- return sp.issparse(obj)
305
- except Exception:
306
- return False
307
-
308
-
309
- def is_torch_tensor(obj):
310
- """
311
- Detect torch tensors when torch is installed.
312
-
313
- Why: allow tensor encoding without a hard torch dependency.
314
- """
315
- try:
316
- import torch # noqa: F401
317
- except Exception:
318
- return False
319
- try:
320
- return torch.is_tensor(obj)
321
- except Exception:
322
- return False
323
-
324
-
325
- def is_sklearn_estimator(obj):
326
- """
327
- Detect sklearn estimators for metadata-only serialization.
328
-
329
- Why: allow feature-gated estimator metadata without importing sklearn by default.
330
- """
331
- try:
332
- from sklearn.base import BaseEstimator # noqa: F401
333
- except Exception:
334
- return False
335
- return isinstance(obj, BaseEstimator)
336
-
337
-
338
- def serialize_ndarray(obj):
339
- """
340
- Encode a NumPy ndarray for transport over the JSONL bridge.
341
-
342
- Why: Arrow IPC gives a compact, lossless binary payload that the JS side can decode as a
343
- Table. If JSON fallback is explicitly requested, honor it even when pyarrow is installed so
344
- callers don't unexpectedly need an Arrow decoder on the TypeScript side.
345
-
346
- Note: PyArrow's pa.array() only handles 1D arrays. For multi-dimensional arrays, we flatten
347
- before encoding and include shape metadata for reconstruction on the JS side. This maintains
348
- Arrow's binary efficiency while working with the current arrow-js implementation (which
349
- doesn't yet support FixedShapeTensorArray). See: https://github.com/apache/arrow-js/issues/115
350
- """
351
- if FALLBACK_JSON:
352
- return serialize_ndarray_json(obj)
353
- try:
354
- import pyarrow as pa # type: ignore
355
- except Exception as exc:
356
- raise RuntimeError(
357
- 'Arrow encoding unavailable for ndarray; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'
358
- ) from exc
359
- try:
360
- # Flatten multi-dimensional arrays for Arrow compatibility
361
- # pa.array() only handles 1D arrays; we preserve shape for JS-side reconstruction
362
- original_shape = list(obj.shape) if hasattr(obj, 'shape') else None
363
- flat = obj.flatten() if hasattr(obj, 'ndim') and obj.ndim > 1 else obj
364
- arr = pa.array(flat)
365
- table = pa.Table.from_arrays([arr], names=['value'])
366
- sink = pa.BufferOutputStream()
367
- with pa.ipc.new_stream(sink, table.schema) as writer:
368
- writer.write_table(table)
369
- buf = sink.getvalue()
370
- b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')
371
- return {
372
- '__tywrap__': 'ndarray',
373
- 'codecVersion': CODEC_VERSION,
374
- 'encoding': 'arrow',
375
- 'b64': b64,
376
- 'shape': original_shape,
377
- 'dtype': str(obj.dtype) if hasattr(obj, 'dtype') else None,
378
- }
379
- except Exception as exc:
380
- if FALLBACK_JSON:
381
- return serialize_ndarray_json(obj)
382
- raise RuntimeError('Arrow encoding failed for ndarray') from exc
383
-
384
-
385
- def serialize_ndarray_json(obj):
386
- """
387
- JSON fallback for ndarray encoding.
388
-
389
- Why: this keeps the bridge usable in environments without pyarrow/Arrow decoding, at the
390
- cost of larger payloads and potential dtype loss.
391
- """
392
- try:
393
- data = obj.tolist()
394
- except Exception as exc:
395
- raise RuntimeError('JSON fallback failed for ndarray') from exc
396
- return {
397
- '__tywrap__': 'ndarray',
398
- 'codecVersion': CODEC_VERSION,
399
- 'encoding': 'json',
400
- 'data': data,
401
- 'shape': getattr(obj, 'shape', None),
402
- }
403
-
404
-
405
- def serialize_dataframe(obj):
406
- """
407
- Encode a pandas DataFrame for transport.
408
-
409
- Why: we emit Feather (Arrow IPC file) as *uncompressed* because the JS apache-arrow reader
410
- does not implement record batch compression. Keeping this uncompressed makes Arrow mode
411
- work out-of-the-box for Node decoders.
412
- """
413
- if FALLBACK_JSON:
414
- return serialize_dataframe_json(obj)
415
- try:
416
- import pyarrow as pa # type: ignore
417
- import pyarrow.feather as feather # type: ignore
418
- except Exception as exc:
419
- raise RuntimeError(
420
- 'Arrow encoding unavailable for pandas.DataFrame; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'
421
- ) from exc
422
- try:
423
- table = pa.Table.from_pandas(obj) # type: ignore
424
- sink = pa.BufferOutputStream()
425
- # Use explicit uncompressed payloads so JS decoders (apache-arrow) can read them
426
- # without optional compression dependencies.
427
- feather.write_feather(table, sink, compression='uncompressed')
428
- buf = sink.getvalue()
429
- b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')
430
- return {
431
- '__tywrap__': 'dataframe',
432
- 'codecVersion': CODEC_VERSION,
433
- 'encoding': 'arrow',
434
- 'b64': b64,
435
- }
436
- except Exception as exc:
437
- if FALLBACK_JSON:
438
- return serialize_dataframe_json(obj)
439
- raise RuntimeError('Arrow encoding failed for pandas.DataFrame') from exc
440
-
441
-
442
- def serialize_dataframe_json(obj):
443
- """
444
- JSON fallback for DataFrame encoding.
445
-
446
- Why: this keeps the example/runtime working without Arrow; it is easy to inspect but larger
447
- than Arrow and may not preserve all dtypes exactly.
448
- """
449
- try:
450
- data = obj.to_dict(orient='records')
451
- except Exception as exc:
452
- raise RuntimeError('JSON fallback failed for pandas.DataFrame') from exc
453
- return {
454
- '__tywrap__': 'dataframe',
455
- 'codecVersion': CODEC_VERSION,
456
- 'encoding': 'json',
457
- 'data': data,
458
- }
459
-
460
-
461
- def serialize_series(obj):
462
- """
463
- Encode a pandas Series for transport.
464
-
465
- Why: encode as a single-column Arrow Table stream (not a raw Array schema) because the JS
466
- decoder contract is "table-like" and pyarrow's IPC writer expects a Schema, not a DataType.
467
- """
468
- if FALLBACK_JSON:
469
- return serialize_series_json(obj)
470
- try:
471
- import pyarrow as pa # type: ignore
472
- except Exception as exc:
473
- raise RuntimeError(
474
- 'Arrow encoding unavailable for pandas.Series; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'
475
- ) from exc
476
- try:
477
- arr = pa.Array.from_pandas(obj) # type: ignore
478
- table = pa.Table.from_arrays([arr], names=['value'])
479
- sink = pa.BufferOutputStream()
480
- with pa.ipc.new_stream(sink, table.schema) as writer:
481
- writer.write_table(table)
482
- buf = sink.getvalue()
483
- b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')
484
- return {
485
- '__tywrap__': 'series',
486
- 'codecVersion': CODEC_VERSION,
487
- 'encoding': 'arrow',
488
- 'b64': b64,
489
- 'name': getattr(obj, 'name', None),
490
- }
491
- except Exception as exc:
492
- if FALLBACK_JSON:
493
- return serialize_series_json(obj)
494
- raise RuntimeError('Arrow encoding failed for pandas.Series') from exc
495
-
496
-
497
- def serialize_series_json(obj):
498
- """
499
- JSON fallback for Series encoding.
500
-
501
- Why: avoids requiring Arrow decoding support, at the cost of potentially lossy dtype/NA
502
- representation compared to Arrow.
503
- """
504
- try:
505
- data = obj.to_list() # type: ignore
506
- except Exception:
507
- try:
508
- data = obj.to_dict() # type: ignore
509
- except Exception as exc:
510
- raise RuntimeError('JSON fallback failed for pandas.Series') from exc
511
- return {
512
- '__tywrap__': 'series',
513
- 'codecVersion': CODEC_VERSION,
514
- 'encoding': 'json',
515
- 'data': data,
516
- 'name': getattr(obj, 'name', None),
517
- }
518
-
519
-
520
- def serialize_sparse_matrix(obj):
521
- """
522
- Serialize scipy sparse matrices into structured JSON envelopes.
523
-
524
- Why: preserve sparsity and matrix shape without implicit dense conversion, keeping
525
- failures explicit when unsupported formats or dtypes are encountered.
526
- """
527
- try:
528
- fmt = obj.getformat()
529
- except Exception as exc:
530
- raise RuntimeError('Failed to inspect scipy sparse matrix format') from exc
531
-
532
- if fmt not in ('csr', 'csc', 'coo'):
533
- raise RuntimeError(f'Unsupported scipy sparse format: {fmt}')
534
-
535
- dtype = None
536
- try:
537
- dtype = str(obj.dtype)
538
- except Exception:
539
- dtype = None
540
- if getattr(obj.dtype, 'kind', None) == 'c':
541
- raise RuntimeError('Complex sparse matrices are not supported by JSON codec')
542
-
543
- if fmt in ('csr', 'csc'):
544
- data = obj.data.tolist()
545
- indices = obj.indices.tolist()
546
- indptr = obj.indptr.tolist()
547
- return {
548
- '__tywrap__': 'scipy.sparse',
549
- 'codecVersion': CODEC_VERSION,
550
- 'encoding': 'json',
551
- 'format': fmt,
552
- 'shape': list(obj.shape),
553
- 'data': data,
554
- 'indices': indices,
555
- 'indptr': indptr,
556
- 'dtype': dtype,
557
- }
558
-
559
- # coo
560
- data = obj.data.tolist()
561
- row = obj.row.tolist()
562
- col = obj.col.tolist()
563
- return {
564
- '__tywrap__': 'scipy.sparse',
565
- 'codecVersion': CODEC_VERSION,
566
- 'encoding': 'json',
567
- 'format': fmt,
568
- 'shape': list(obj.shape),
569
- 'data': data,
570
- 'row': row,
571
- 'col': col,
572
- 'dtype': dtype,
573
- }
574
-
575
-
576
- def serialize_torch_tensor(obj):
577
- """
578
- Serialize torch.Tensor values via the ndarray envelope.
579
-
580
- Why: ensure CPU-only transport by default and make device/copy behavior explicit to callers.
581
- """
582
- allow_copy = os.environ.get('TYWRAP_TORCH_ALLOW_COPY', '').lower() in ('1', 'true', 'yes')
583
- tensor = obj.detach()
584
- if getattr(tensor, 'device', None) is not None and tensor.device.type != 'cpu':
585
- if not allow_copy:
586
- raise RuntimeError(
587
- 'Torch tensor is on a non-CPU device; set TYWRAP_TORCH_ALLOW_COPY=1 to allow CPU transfer'
588
- )
589
- tensor = tensor.to('cpu')
590
- if hasattr(tensor, 'is_contiguous') and not tensor.is_contiguous():
591
- if not allow_copy:
592
- raise RuntimeError(
593
- 'Torch tensor is not contiguous; set TYWRAP_TORCH_ALLOW_COPY=1 to allow contiguous copy'
594
- )
595
- tensor = tensor.contiguous()
596
- try:
597
- arr = tensor.numpy()
598
- except Exception as exc:
599
- raise RuntimeError('Failed to convert torch.Tensor to numpy') from exc
600
-
601
- return {
602
- '__tywrap__': 'torch.tensor',
603
- 'codecVersion': CODEC_VERSION,
604
- 'encoding': 'ndarray',
605
- 'value': serialize_ndarray(arr),
606
- 'shape': list(tensor.shape),
607
- 'dtype': str(tensor.dtype),
608
- 'device': str(tensor.device),
609
- }
610
-
611
-
612
- def serialize_sklearn_estimator(obj):
613
- """
614
- Serialize sklearn estimators as metadata only.
615
-
616
- Why: avoid unsafe pickling while still exposing model identity and params to TypeScript.
617
- """
618
- try:
619
- import sklearn # noqa: F401
620
- except Exception as exc:
621
- raise RuntimeError('scikit-learn is not available') from exc
622
-
623
- params = obj.get_params(deep=False)
624
- try:
625
- json.dumps(params)
626
- except Exception as exc:
627
- raise RuntimeError(
628
- 'scikit-learn estimator params are not JSON-serializable; avoid returning estimators or sanitize params'
629
- ) from exc
630
-
631
- return {
632
- '__tywrap__': 'sklearn.estimator',
633
- 'codecVersion': CODEC_VERSION,
634
- 'encoding': 'json',
635
- 'className': obj.__class__.__name__,
636
- 'module': obj.__class__.__module__,
637
- 'version': getattr(sklearn, '__version__', None),
638
- 'params': params,
639
- }
640
-
641
-
642
- _NO_PYDANTIC = object()
643
-
644
-
645
- def serialize_pydantic(obj):
646
- """
647
- Serialize Pydantic v2 models without importing Pydantic.
648
-
649
- Why: returning BaseModel instances is common in typed Python APIs. Converting via
650
- `model_dump` keeps Python type hints accurate (return the model), while the bridge still
651
- emits a JSON-serializable payload. We default to `by_alias=True` so alias_generator-based
652
- camelCase schemas round-trip cleanly to TypeScript.
653
- """
654
-
655
- model_dump = getattr(obj, 'model_dump', None)
656
- if not callable(model_dump):
657
- return _NO_PYDANTIC
658
- try:
659
- try:
660
- return model_dump(by_alias=True, mode='json')
661
- except TypeError:
662
- # Older Pydantic versions may not support `mode=...`.
663
- return model_dump(by_alias=True)
664
- except Exception as exc:
665
- raise RuntimeError(f'model_dump failed: {exc}') from exc
666
-
667
- def serialize(obj):
668
- if is_numpy_array(obj):
669
- return serialize_ndarray(obj)
670
- if is_pandas_dataframe(obj):
671
- return serialize_dataframe(obj)
672
- if is_pandas_series(obj):
673
- return serialize_series(obj)
674
- if is_scipy_sparse(obj):
675
- return serialize_sparse_matrix(obj)
676
- if is_torch_tensor(obj):
677
- return serialize_torch_tensor(obj)
678
- if is_sklearn_estimator(obj):
679
- return serialize_sklearn_estimator(obj)
680
- pydantic_value = serialize_pydantic(obj)
681
- if pydantic_value is not _NO_PYDANTIC:
682
- return pydantic_value
683
- stdlib_value = serialize_stdlib(obj)
684
- if stdlib_value is not None:
685
- return stdlib_value
686
- return obj
687
-
688
-
689
- def serialize_stdlib(obj):
690
- if isinstance(obj, dt.datetime):
691
- return obj.isoformat()
692
- if isinstance(obj, dt.date):
693
- return obj.isoformat()
694
- if isinstance(obj, dt.time):
695
- return obj.isoformat()
696
- if isinstance(obj, dt.timedelta):
697
- return obj.total_seconds()
698
- if isinstance(obj, decimal.Decimal):
699
- return str(obj)
700
- if isinstance(obj, uuid.UUID):
701
- return str(obj)
702
- if isinstance(obj, (Path, PurePath)):
703
- return str(obj)
704
- return None
705
-
706
-
707
- def handle_call(params):
708
- module_name = require_str(params, 'module')
709
- function_name = require_str(params, 'functionName')
710
- args = deserialize(coerce_list(params.get('args'), 'args'))
711
- kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))
712
- mod = importlib.import_module(module_name)
713
- func = getattr(mod, function_name)
714
- res = func(*args, **kwargs)
715
- return serialize(res)
716
-
717
-
718
- def handle_instantiate(params):
719
- module_name = require_str(params, 'module')
720
- class_name = require_str(params, 'className')
721
- args = deserialize(coerce_list(params.get('args'), 'args'))
722
- kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))
723
- mod = importlib.import_module(module_name)
724
- cls = getattr(mod, class_name)
725
- obj = cls(*args, **kwargs)
726
- handle_id = str(id(obj))
727
- instances[handle_id] = obj
728
- return handle_id
729
-
730
-
731
- def handle_call_method(params):
732
- handle_id = require_str(params, 'handle')
733
- method_name = require_str(params, 'methodName')
734
- args = deserialize(coerce_list(params.get('args'), 'args'))
735
- kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))
736
- if handle_id not in instances:
737
- raise InstanceHandleError(f'Unknown instance handle: {handle_id}')
738
- obj = instances[handle_id]
739
- func = getattr(obj, method_name)
740
- res = func(*args, **kwargs)
741
- return serialize(res)
742
-
743
-
744
- def handle_dispose_instance(params):
745
- handle_id = require_str(params, 'handle')
746
- if handle_id not in instances:
747
- return False
748
- del instances[handle_id]
749
- return True
750
-
751
-
752
210
  def handle_meta():
753
211
  """
754
- Return bridge metadata for capability detection.
212
+ Return bridge metadata for capability detection (subprocess identity).
755
213
 
756
214
  Why: the Node side uses this to decide whether optional codecs can be used.
215
+ Kept for backward compatibility; delegates to the shared core builder with the
216
+ real pid and bridge='python-subprocess'.
757
217
  """
758
- return {
759
- 'protocol': PROTOCOL,
760
- 'protocolVersion': PROTOCOL_VERSION,
761
- 'bridge': BRIDGE_NAME,
762
- 'pythonVersion': sys.version.split()[0],
763
- 'pid': os.getpid(),
764
- 'codecFallback': 'json' if FALLBACK_JSON else 'none',
765
- 'arrowAvailable': arrow_available(),
766
- 'scipyAvailable': module_available('scipy'),
767
- 'torchAvailable': module_available('torch'),
768
- 'sklearnAvailable': module_available('sklearn'),
769
- 'instances': len(instances),
770
- }
771
-
772
-
773
- def require_protocol(msg):
774
- if not isinstance(msg, dict):
775
- raise ProtocolError('Invalid request payload')
776
- proto = msg.get('protocol')
777
- if proto != PROTOCOL:
778
- raise ProtocolError(f'Invalid protocol: {proto}')
779
- mid = msg.get('id')
780
- if not isinstance(mid, int):
781
- raise ProtocolError(f'Invalid request id: {mid}')
782
- return mid
783
-
784
-
785
- def require_str(params, key):
786
- """
787
- Return a required string parameter from a request.
788
-
789
- Why: keep request schema validation centralized and explicit for clearer errors.
790
- """
791
- value = params.get(key)
792
- if not isinstance(value, str) or not value:
793
- raise ProtocolError(f'Missing {key}')
794
- return value
795
-
796
-
797
- def coerce_list(value, key):
798
- """
799
- Coerce an optional list parameter into a list.
800
-
801
- Why: normalize args inputs while rejecting invalid shapes early.
802
- """
803
- if value is None:
804
- return []
805
- if not isinstance(value, list):
806
- raise ProtocolError(f'Invalid {key}')
807
- return value
808
-
809
-
810
- def coerce_dict(value, key):
811
- """
812
- Coerce an optional dict parameter into a dict.
813
-
814
- Why: normalize kwargs inputs while rejecting invalid shapes early.
815
- """
816
- if value is None:
817
- return {}
818
- if not isinstance(value, dict):
819
- raise ProtocolError(f'Invalid {key}')
820
- return value
218
+ return core.build_meta(
219
+ instances,
220
+ bridge=BRIDGE_NAME,
221
+ pid=os.getpid(),
222
+ python_version=sys.version.split()[0],
223
+ codec_fallback='json' if FALLBACK_JSON else 'none',
224
+ )
821
225
 
822
226
 
823
227
  def dispatch_request(msg):
824
228
  """
825
- Dispatch a validated request to the correct handler.
826
-
827
- Why: keep the main loop focused on I/O while this function handles validation and routing.
828
- """
829
- mid = require_protocol(msg)
830
- method = msg.get('method')
831
- if not isinstance(method, str):
832
- raise ProtocolError('Missing method')
833
- params = coerce_dict(msg.get('params'), 'params')
834
- if method == 'call':
835
- result = handle_call(params)
836
- elif method == 'instantiate':
837
- result = handle_instantiate(params)
838
- elif method == 'call_method':
839
- result = handle_call_method(params)
840
- elif method == 'dispose_instance':
841
- result = handle_dispose_instance(params)
842
- elif method == 'meta':
843
- result = handle_meta()
844
- else:
845
- raise ProtocolError(f'Unknown method: {method}')
846
- return mid, result
847
-
848
-
849
- def build_error_payload(mid, exc, *, include_traceback):
850
- """
851
- Build a protocol error response.
229
+ Dispatch a validated request to the correct handler (subprocess identity).
852
230
 
853
- Why: ensure error formatting stays consistent while keeping exception handling centralized.
231
+ Why: keep main() focused on I/O. Delegates routing/validation/serialization to
232
+ the shared core, supplying the subprocess pid/bridge and the env-derived flags.
233
+ Returns (mid, result) to preserve the historical signature for any importer.
854
234
  """
855
- error = { 'type': type(exc).__name__, 'message': str(exc) }
856
- if include_traceback:
857
- error['traceback'] = traceback.format_exc()
858
- return {
859
- 'id': mid if mid is not None else -1,
860
- 'protocol': PROTOCOL,
861
- 'error': error,
862
- }
235
+ out = core.dispatch_request(
236
+ msg,
237
+ instances,
238
+ bridge=BRIDGE_NAME,
239
+ pid=os.getpid(),
240
+ force_json_markers=FALLBACK_JSON,
241
+ allow_nan=False,
242
+ python_version=sys.version.split()[0],
243
+ torch_allow_copy=TORCH_ALLOW_COPY,
244
+ )
245
+ return out['id'], out['result']
863
246
 
864
247
 
865
248
  def encode_response(out):
@@ -915,7 +298,7 @@ def main():
915
298
  mid = req_id
916
299
  try:
917
300
  mid, result = dispatch_request(msg)
918
- out = { 'id': mid, 'protocol': PROTOCOL, 'result': result }
301
+ out = {'id': mid, 'protocol': PROTOCOL, 'result': result}
919
302
  except ProtocolError as e:
920
303
  emit_protocol_diagnostic(str(e))
921
304
  out = build_error_payload(mid, e, include_traceback=False)