tywrap 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -221
- package/dist/cli.js +70 -3
- package/dist/cli.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +56 -12
- package/dist/config/index.js.map +1 -1
- package/dist/core/generator.d.ts.map +1 -1
- package/dist/core/generator.js +6 -2
- package/dist/core/generator.js.map +1 -1
- package/dist/core/mapper.d.ts.map +1 -1
- package/dist/core/mapper.js +20 -5
- package/dist/core/mapper.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/runtime/bridge-core.d.ts +64 -0
- package/dist/runtime/bridge-core.d.ts.map +1 -0
- package/dist/runtime/bridge-core.js +344 -0
- package/dist/runtime/bridge-core.js.map +1 -0
- package/dist/runtime/node.d.ts +5 -6
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +95 -193
- package/dist/runtime/node.js.map +1 -1
- package/dist/runtime/optimized-node.d.ts +4 -7
- package/dist/runtime/optimized-node.d.ts.map +1 -1
- package/dist/runtime/optimized-node.js +117 -186
- package/dist/runtime/optimized-node.js.map +1 -1
- package/dist/runtime/timed-out-request-tracker.d.ts +20 -0
- package/dist/runtime/timed-out-request-tracker.d.ts.map +1 -0
- package/dist/runtime/timed-out-request-tracker.js +48 -0
- package/dist/runtime/timed-out-request-tracker.js.map +1 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/tywrap.d.ts +17 -4
- package/dist/tywrap.d.ts.map +1 -1
- package/dist/tywrap.js +68 -35
- package/dist/tywrap.js.map +1 -1
- package/dist/utils/codec.d.ts +22 -0
- package/dist/utils/codec.d.ts.map +1 -1
- package/dist/utils/codec.js +245 -55
- package/dist/utils/codec.js.map +1 -1
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +7 -4
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/python.d.ts.map +1 -1
- package/dist/utils/python.js.map +1 -1
- package/package.json +11 -2
- package/runtime/python_bridge.py +450 -69
- package/src/cli.ts +81 -5
- package/src/config/index.ts +67 -16
- package/src/core/generator.ts +6 -2
- package/src/core/mapper.ts +21 -8
- package/src/index.ts +2 -1
- package/src/runtime/bridge-core.ts +454 -0
- package/src/runtime/node.ts +117 -224
- package/src/runtime/optimized-node.ts +160 -235
- package/src/runtime/timed-out-request-tracker.ts +58 -0
- package/src/types/index.ts +3 -0
- package/src/tywrap.ts +91 -36
- package/src/utils/codec.ts +306 -83
- package/src/utils/logger.ts +11 -7
- package/src/utils/python.ts +0 -1
package/runtime/python_bridge.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import sys
|
|
3
3
|
import json
|
|
4
4
|
import importlib
|
|
5
|
+
import importlib.util
|
|
5
6
|
import os
|
|
6
7
|
import traceback
|
|
7
8
|
import base64
|
|
@@ -10,27 +11,177 @@ import decimal
|
|
|
10
11
|
import uuid
|
|
11
12
|
from pathlib import Path, PurePath
|
|
12
13
|
|
|
14
|
+
# Ensure the working directory is importable so local modules can be resolved when
|
|
15
|
+
# the bridge is launched as a script from a different directory.
|
|
16
|
+
try:
|
|
17
|
+
cwd = os.getcwd()
|
|
18
|
+
if cwd and cwd not in sys.path:
|
|
19
|
+
sys.path.insert(0, cwd)
|
|
20
|
+
except (OSError, ValueError, TypeError, AttributeError) as exc:
|
|
21
|
+
# Non-fatal: continue without cwd in path.
|
|
22
|
+
try:
|
|
23
|
+
sys.stderr.write(f'[tywrap] Warning: could not add cwd to sys.path: {exc}\n')
|
|
24
|
+
except (OSError, ValueError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
13
27
|
instances = {}
|
|
14
28
|
|
|
15
29
|
FALLBACK_JSON = os.environ.get('TYWRAP_CODEC_FALLBACK', '').lower() == 'json'
|
|
16
30
|
PROTOCOL = 'tywrap/1'
|
|
17
31
|
PROTOCOL_VERSION = 1
|
|
18
32
|
BRIDGE_NAME = 'python-subprocess'
|
|
33
|
+
# Why: include a stable version in envelopes so decoders can reject incompatible changes.
|
|
34
|
+
CODEC_VERSION = 1
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CodecConfigError(ValueError):
|
|
38
|
+
"""Codec configuration error."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CodecMaxBytesParseError(CodecConfigError):
|
|
42
|
+
"""Invalid TYWRAP_CODEC_MAX_BYTES value."""
|
|
43
|
+
|
|
44
|
+
def __init__(self) -> None:
|
|
45
|
+
super().__init__('TYWRAP_CODEC_MAX_BYTES must be an integer byte count')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PayloadTooLargeError(ValueError):
|
|
49
|
+
"""Response payload exceeds configured size limit."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, payload_bytes: int, max_bytes: int) -> None:
|
|
52
|
+
super().__init__(
|
|
53
|
+
f'Response payload is {payload_bytes} bytes which exceeds TYWRAP_CODEC_MAX_BYTES={max_bytes}'
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RequestMaxBytesParseError(CodecConfigError):
|
|
58
|
+
"""Invalid TYWRAP_REQUEST_MAX_BYTES value."""
|
|
59
|
+
|
|
60
|
+
def __init__(self) -> None:
|
|
61
|
+
super().__init__('TYWRAP_REQUEST_MAX_BYTES must be an integer byte count')
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RequestTooLargeError(ValueError):
|
|
65
|
+
"""Request payload exceeds configured size limit."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, payload_bytes: int, max_bytes: int) -> None:
|
|
68
|
+
super().__init__(
|
|
69
|
+
f'Request payload is {payload_bytes} bytes which exceeds TYWRAP_REQUEST_MAX_BYTES={max_bytes}'
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_codec_max_bytes():
|
|
74
|
+
"""
|
|
75
|
+
Return the optional max payload size (bytes) for JSONL responses.
|
|
76
|
+
|
|
77
|
+
Why: the subprocess transport writes a single JSON line per response; limiting size avoids
|
|
78
|
+
accidental large payloads that can spike memory or clog IPC, and keeps failures explicit.
|
|
79
|
+
"""
|
|
80
|
+
raw = os.environ.get('TYWRAP_CODEC_MAX_BYTES')
|
|
81
|
+
if raw is None:
|
|
82
|
+
return None
|
|
83
|
+
raw = str(raw).strip()
|
|
84
|
+
if not raw:
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
value = int(raw)
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
raise CodecMaxBytesParseError() from exc
|
|
90
|
+
if value <= 0:
|
|
91
|
+
return None
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Why: parse once at startup to avoid per-response env lookups.
|
|
96
|
+
CODEC_MAX_BYTES = get_codec_max_bytes()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_request_max_bytes():
|
|
100
|
+
"""
|
|
101
|
+
Return the optional max payload size (bytes) for JSONL requests.
|
|
102
|
+
|
|
103
|
+
Why: cap request sizes to avoid oversized JSON payloads that can exhaust memory or hang
|
|
104
|
+
downstream parsers. This keeps the bridge failure mode explicit.
|
|
105
|
+
"""
|
|
106
|
+
raw = os.environ.get('TYWRAP_REQUEST_MAX_BYTES')
|
|
107
|
+
if raw is None:
|
|
108
|
+
return None
|
|
109
|
+
raw = str(raw).strip()
|
|
110
|
+
if not raw:
|
|
111
|
+
return None
|
|
112
|
+
try:
|
|
113
|
+
value = int(raw)
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
raise RequestMaxBytesParseError() from exc
|
|
116
|
+
if value <= 0:
|
|
117
|
+
return None
|
|
118
|
+
return value
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Why: parse once at startup to avoid per-request env lookups.
|
|
122
|
+
REQUEST_MAX_BYTES = get_request_max_bytes()
|
|
19
123
|
|
|
20
124
|
|
|
21
125
|
class ProtocolError(Exception):
|
|
22
126
|
pass
|
|
23
127
|
|
|
24
128
|
|
|
25
|
-
|
|
129
|
+
_PROTOCOL_DIAGNOSTIC_MAX = 2048
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def emit_protocol_diagnostic(message: str) -> None:
|
|
133
|
+
"""
|
|
134
|
+
Write bounded protocol diagnostics to stderr.
|
|
135
|
+
|
|
136
|
+
Why: provide context for malformed requests without flooding stderr or breaking the JSONL
|
|
137
|
+
stream expected by the JS side.
|
|
138
|
+
"""
|
|
26
139
|
try:
|
|
27
|
-
|
|
140
|
+
msg = str(message)
|
|
141
|
+
if len(msg) > _PROTOCOL_DIAGNOSTIC_MAX:
|
|
142
|
+
msg = msg[:_PROTOCOL_DIAGNOSTIC_MAX] + '...'
|
|
143
|
+
sys.stderr.write(f'[tywrap] Protocol error: {msg}\n')
|
|
144
|
+
sys.stderr.flush()
|
|
28
145
|
except Exception:
|
|
146
|
+
# Avoid raising from diagnostics
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def arrow_available():
|
|
151
|
+
"""
|
|
152
|
+
Return True when pyarrow can be imported.
|
|
153
|
+
|
|
154
|
+
Why: advertise Arrow capability to the TS side without crashing startup when
|
|
155
|
+
pyarrow is optional or missing.
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
import pyarrow
|
|
159
|
+
except (ImportError, OSError):
|
|
29
160
|
return False
|
|
30
161
|
return True
|
|
31
162
|
|
|
32
163
|
|
|
164
|
+
def module_available(module_name: str) -> bool:
|
|
165
|
+
"""
|
|
166
|
+
Lightweight feature detection for optional codec dependencies.
|
|
167
|
+
|
|
168
|
+
Why: exposes availability in bridge metadata without importing heavy modules or triggering
|
|
169
|
+
side effects, so the TS side can decide when to rely on optional codecs. These flags are
|
|
170
|
+
best-effort hints; serialization still performs its own import checks for correctness.
|
|
171
|
+
"""
|
|
172
|
+
try:
|
|
173
|
+
return importlib.util.find_spec(module_name) is not None
|
|
174
|
+
except (ImportError, AttributeError, TypeError, ValueError):
|
|
175
|
+
# Why: guard against unusual importlib edge cases without masking other failures.
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
|
|
33
179
|
def is_numpy_array(obj):
|
|
180
|
+
"""
|
|
181
|
+
Detect numpy arrays when NumPy is installed.
|
|
182
|
+
|
|
183
|
+
Why: keep NumPy optional while enabling ndarray serialization.
|
|
184
|
+
"""
|
|
34
185
|
try:
|
|
35
186
|
import numpy as np # noqa: F401
|
|
36
187
|
except Exception:
|
|
@@ -39,6 +190,11 @@ def is_numpy_array(obj):
|
|
|
39
190
|
|
|
40
191
|
|
|
41
192
|
def is_pandas_dataframe(obj):
|
|
193
|
+
"""
|
|
194
|
+
Detect pandas DataFrame instances when pandas is installed.
|
|
195
|
+
|
|
196
|
+
Why: avoid hard pandas dependency while enabling dataframe encoding.
|
|
197
|
+
"""
|
|
42
198
|
try:
|
|
43
199
|
import pandas as pd # noqa: F401
|
|
44
200
|
except Exception:
|
|
@@ -47,6 +203,11 @@ def is_pandas_dataframe(obj):
|
|
|
47
203
|
|
|
48
204
|
|
|
49
205
|
def is_pandas_series(obj):
|
|
206
|
+
"""
|
|
207
|
+
Detect pandas Series instances when pandas is installed.
|
|
208
|
+
|
|
209
|
+
Why: avoid hard pandas dependency while enabling series encoding.
|
|
210
|
+
"""
|
|
50
211
|
try:
|
|
51
212
|
import pandas as pd # noqa: F401
|
|
52
213
|
except Exception:
|
|
@@ -55,6 +216,11 @@ def is_pandas_series(obj):
|
|
|
55
216
|
|
|
56
217
|
|
|
57
218
|
def is_scipy_sparse(obj):
|
|
219
|
+
"""
|
|
220
|
+
Detect scipy sparse matrices when scipy is installed.
|
|
221
|
+
|
|
222
|
+
Why: allow sparse matrix encoding without importing scipy in all environments.
|
|
223
|
+
"""
|
|
58
224
|
try:
|
|
59
225
|
import scipy.sparse as sp # noqa: F401
|
|
60
226
|
except Exception:
|
|
@@ -66,6 +232,11 @@ def is_scipy_sparse(obj):
|
|
|
66
232
|
|
|
67
233
|
|
|
68
234
|
def is_torch_tensor(obj):
|
|
235
|
+
"""
|
|
236
|
+
Detect torch tensors when torch is installed.
|
|
237
|
+
|
|
238
|
+
Why: allow tensor encoding without a hard torch dependency.
|
|
239
|
+
"""
|
|
69
240
|
try:
|
|
70
241
|
import torch # noqa: F401
|
|
71
242
|
except Exception:
|
|
@@ -77,6 +248,11 @@ def is_torch_tensor(obj):
|
|
|
77
248
|
|
|
78
249
|
|
|
79
250
|
def is_sklearn_estimator(obj):
|
|
251
|
+
"""
|
|
252
|
+
Detect sklearn estimators for metadata-only serialization.
|
|
253
|
+
|
|
254
|
+
Why: allow feature-gated estimator metadata without importing sklearn by default.
|
|
255
|
+
"""
|
|
80
256
|
try:
|
|
81
257
|
from sklearn.base import BaseEstimator # noqa: F401
|
|
82
258
|
except Exception:
|
|
@@ -85,23 +261,32 @@ def is_sklearn_estimator(obj):
|
|
|
85
261
|
|
|
86
262
|
|
|
87
263
|
def serialize_ndarray(obj):
|
|
264
|
+
"""
|
|
265
|
+
Encode a NumPy ndarray for transport over the JSONL bridge.
|
|
266
|
+
|
|
267
|
+
Why: Arrow IPC gives a compact, lossless binary payload that the JS side can decode as a
|
|
268
|
+
Table. If JSON fallback is explicitly requested, honor it even when pyarrow is installed so
|
|
269
|
+
callers don't unexpectedly need an Arrow decoder on the TypeScript side.
|
|
270
|
+
"""
|
|
271
|
+
if FALLBACK_JSON:
|
|
272
|
+
return serialize_ndarray_json(obj)
|
|
88
273
|
try:
|
|
89
274
|
import pyarrow as pa # type: ignore
|
|
90
275
|
except Exception as exc:
|
|
91
|
-
if FALLBACK_JSON:
|
|
92
|
-
return serialize_ndarray_json(obj)
|
|
93
276
|
raise RuntimeError(
|
|
94
277
|
'Arrow encoding unavailable for ndarray; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'
|
|
95
278
|
) from exc
|
|
96
279
|
try:
|
|
97
280
|
arr = pa.array(obj)
|
|
281
|
+
table = pa.Table.from_arrays([arr], names=['value'])
|
|
98
282
|
sink = pa.BufferOutputStream()
|
|
99
|
-
with pa.ipc.new_stream(sink,
|
|
100
|
-
writer.
|
|
283
|
+
with pa.ipc.new_stream(sink, table.schema) as writer:
|
|
284
|
+
writer.write_table(table)
|
|
101
285
|
buf = sink.getvalue()
|
|
102
286
|
b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')
|
|
103
287
|
return {
|
|
104
288
|
'__tywrap__': 'ndarray',
|
|
289
|
+
'codecVersion': CODEC_VERSION,
|
|
105
290
|
'encoding': 'arrow',
|
|
106
291
|
'b64': b64,
|
|
107
292
|
'shape': getattr(obj, 'shape', None),
|
|
@@ -113,12 +298,19 @@ def serialize_ndarray(obj):
|
|
|
113
298
|
|
|
114
299
|
|
|
115
300
|
def serialize_ndarray_json(obj):
|
|
301
|
+
"""
|
|
302
|
+
JSON fallback for ndarray encoding.
|
|
303
|
+
|
|
304
|
+
Why: this keeps the bridge usable in environments without pyarrow/Arrow decoding, at the
|
|
305
|
+
cost of larger payloads and potential dtype loss.
|
|
306
|
+
"""
|
|
116
307
|
try:
|
|
117
308
|
data = obj.tolist()
|
|
118
309
|
except Exception as exc:
|
|
119
310
|
raise RuntimeError('JSON fallback failed for ndarray') from exc
|
|
120
311
|
return {
|
|
121
312
|
'__tywrap__': 'ndarray',
|
|
313
|
+
'codecVersion': CODEC_VERSION,
|
|
122
314
|
'encoding': 'json',
|
|
123
315
|
'data': data,
|
|
124
316
|
'shape': getattr(obj, 'shape', None),
|
|
@@ -126,23 +318,33 @@ def serialize_ndarray_json(obj):
|
|
|
126
318
|
|
|
127
319
|
|
|
128
320
|
def serialize_dataframe(obj):
|
|
321
|
+
"""
|
|
322
|
+
Encode a pandas DataFrame for transport.
|
|
323
|
+
|
|
324
|
+
Why: we emit Feather (Arrow IPC file) as *uncompressed* because the JS apache-arrow reader
|
|
325
|
+
does not implement record batch compression. Keeping this uncompressed makes Arrow mode
|
|
326
|
+
work out-of-the-box for Node decoders.
|
|
327
|
+
"""
|
|
328
|
+
if FALLBACK_JSON:
|
|
329
|
+
return serialize_dataframe_json(obj)
|
|
129
330
|
try:
|
|
130
331
|
import pyarrow as pa # type: ignore
|
|
131
332
|
import pyarrow.feather as feather # type: ignore
|
|
132
333
|
except Exception as exc:
|
|
133
|
-
if FALLBACK_JSON:
|
|
134
|
-
return serialize_dataframe_json(obj)
|
|
135
334
|
raise RuntimeError(
|
|
136
335
|
'Arrow encoding unavailable for pandas.DataFrame; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'
|
|
137
336
|
) from exc
|
|
138
337
|
try:
|
|
139
338
|
table = pa.Table.from_pandas(obj) # type: ignore
|
|
140
339
|
sink = pa.BufferOutputStream()
|
|
141
|
-
|
|
340
|
+
# Use explicit uncompressed payloads so JS decoders (apache-arrow) can read them
|
|
341
|
+
# without optional compression dependencies.
|
|
342
|
+
feather.write_feather(table, sink, compression='uncompressed')
|
|
142
343
|
buf = sink.getvalue()
|
|
143
344
|
b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')
|
|
144
345
|
return {
|
|
145
346
|
'__tywrap__': 'dataframe',
|
|
347
|
+
'codecVersion': CODEC_VERSION,
|
|
146
348
|
'encoding': 'arrow',
|
|
147
349
|
'b64': b64,
|
|
148
350
|
}
|
|
@@ -153,35 +355,50 @@ def serialize_dataframe(obj):
|
|
|
153
355
|
|
|
154
356
|
|
|
155
357
|
def serialize_dataframe_json(obj):
|
|
358
|
+
"""
|
|
359
|
+
JSON fallback for DataFrame encoding.
|
|
360
|
+
|
|
361
|
+
Why: this keeps the example/runtime working without Arrow; it is easy to inspect but larger
|
|
362
|
+
than Arrow and may not preserve all dtypes exactly.
|
|
363
|
+
"""
|
|
156
364
|
try:
|
|
157
365
|
data = obj.to_dict(orient='records')
|
|
158
366
|
except Exception as exc:
|
|
159
367
|
raise RuntimeError('JSON fallback failed for pandas.DataFrame') from exc
|
|
160
368
|
return {
|
|
161
369
|
'__tywrap__': 'dataframe',
|
|
370
|
+
'codecVersion': CODEC_VERSION,
|
|
162
371
|
'encoding': 'json',
|
|
163
372
|
'data': data,
|
|
164
373
|
}
|
|
165
374
|
|
|
166
375
|
|
|
167
376
|
def serialize_series(obj):
|
|
377
|
+
"""
|
|
378
|
+
Encode a pandas Series for transport.
|
|
379
|
+
|
|
380
|
+
Why: encode as a single-column Arrow Table stream (not a raw Array schema) because the JS
|
|
381
|
+
decoder contract is "table-like" and pyarrow's IPC writer expects a Schema, not a DataType.
|
|
382
|
+
"""
|
|
383
|
+
if FALLBACK_JSON:
|
|
384
|
+
return serialize_series_json(obj)
|
|
168
385
|
try:
|
|
169
386
|
import pyarrow as pa # type: ignore
|
|
170
387
|
except Exception as exc:
|
|
171
|
-
if FALLBACK_JSON:
|
|
172
|
-
return serialize_series_json(obj)
|
|
173
388
|
raise RuntimeError(
|
|
174
389
|
'Arrow encoding unavailable for pandas.Series; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'
|
|
175
390
|
) from exc
|
|
176
391
|
try:
|
|
177
392
|
arr = pa.Array.from_pandas(obj) # type: ignore
|
|
393
|
+
table = pa.Table.from_arrays([arr], names=['value'])
|
|
178
394
|
sink = pa.BufferOutputStream()
|
|
179
|
-
with pa.ipc.new_stream(sink,
|
|
180
|
-
writer.
|
|
395
|
+
with pa.ipc.new_stream(sink, table.schema) as writer:
|
|
396
|
+
writer.write_table(table)
|
|
181
397
|
buf = sink.getvalue()
|
|
182
398
|
b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')
|
|
183
399
|
return {
|
|
184
400
|
'__tywrap__': 'series',
|
|
401
|
+
'codecVersion': CODEC_VERSION,
|
|
185
402
|
'encoding': 'arrow',
|
|
186
403
|
'b64': b64,
|
|
187
404
|
'name': getattr(obj, 'name', None),
|
|
@@ -193,6 +410,12 @@ def serialize_series(obj):
|
|
|
193
410
|
|
|
194
411
|
|
|
195
412
|
def serialize_series_json(obj):
|
|
413
|
+
"""
|
|
414
|
+
JSON fallback for Series encoding.
|
|
415
|
+
|
|
416
|
+
Why: avoids requiring Arrow decoding support, at the cost of potentially lossy dtype/NA
|
|
417
|
+
representation compared to Arrow.
|
|
418
|
+
"""
|
|
196
419
|
try:
|
|
197
420
|
data = obj.to_list() # type: ignore
|
|
198
421
|
except Exception:
|
|
@@ -202,6 +425,7 @@ def serialize_series_json(obj):
|
|
|
202
425
|
raise RuntimeError('JSON fallback failed for pandas.Series') from exc
|
|
203
426
|
return {
|
|
204
427
|
'__tywrap__': 'series',
|
|
428
|
+
'codecVersion': CODEC_VERSION,
|
|
205
429
|
'encoding': 'json',
|
|
206
430
|
'data': data,
|
|
207
431
|
'name': getattr(obj, 'name', None),
|
|
@@ -209,6 +433,12 @@ def serialize_series_json(obj):
|
|
|
209
433
|
|
|
210
434
|
|
|
211
435
|
def serialize_sparse_matrix(obj):
|
|
436
|
+
"""
|
|
437
|
+
Serialize scipy sparse matrices into structured JSON envelopes.
|
|
438
|
+
|
|
439
|
+
Why: preserve sparsity and matrix shape without implicit dense conversion, keeping
|
|
440
|
+
failures explicit when unsupported formats or dtypes are encountered.
|
|
441
|
+
"""
|
|
212
442
|
try:
|
|
213
443
|
fmt = obj.getformat()
|
|
214
444
|
except Exception as exc:
|
|
@@ -231,6 +461,7 @@ def serialize_sparse_matrix(obj):
|
|
|
231
461
|
indptr = obj.indptr.tolist()
|
|
232
462
|
return {
|
|
233
463
|
'__tywrap__': 'scipy.sparse',
|
|
464
|
+
'codecVersion': CODEC_VERSION,
|
|
234
465
|
'encoding': 'json',
|
|
235
466
|
'format': fmt,
|
|
236
467
|
'shape': list(obj.shape),
|
|
@@ -246,6 +477,7 @@ def serialize_sparse_matrix(obj):
|
|
|
246
477
|
col = obj.col.tolist()
|
|
247
478
|
return {
|
|
248
479
|
'__tywrap__': 'scipy.sparse',
|
|
480
|
+
'codecVersion': CODEC_VERSION,
|
|
249
481
|
'encoding': 'json',
|
|
250
482
|
'format': fmt,
|
|
251
483
|
'shape': list(obj.shape),
|
|
@@ -257,6 +489,11 @@ def serialize_sparse_matrix(obj):
|
|
|
257
489
|
|
|
258
490
|
|
|
259
491
|
def serialize_torch_tensor(obj):
|
|
492
|
+
"""
|
|
493
|
+
Serialize torch.Tensor values via the ndarray envelope.
|
|
494
|
+
|
|
495
|
+
Why: ensure CPU-only transport by default and make device/copy behavior explicit to callers.
|
|
496
|
+
"""
|
|
260
497
|
allow_copy = os.environ.get('TYWRAP_TORCH_ALLOW_COPY', '').lower() in ('1', 'true', 'yes')
|
|
261
498
|
tensor = obj.detach()
|
|
262
499
|
if getattr(tensor, 'device', None) is not None and tensor.device.type != 'cpu':
|
|
@@ -278,6 +515,7 @@ def serialize_torch_tensor(obj):
|
|
|
278
515
|
|
|
279
516
|
return {
|
|
280
517
|
'__tywrap__': 'torch.tensor',
|
|
518
|
+
'codecVersion': CODEC_VERSION,
|
|
281
519
|
'encoding': 'ndarray',
|
|
282
520
|
'value': serialize_ndarray(arr),
|
|
283
521
|
'shape': list(tensor.shape),
|
|
@@ -287,6 +525,11 @@ def serialize_torch_tensor(obj):
|
|
|
287
525
|
|
|
288
526
|
|
|
289
527
|
def serialize_sklearn_estimator(obj):
|
|
528
|
+
"""
|
|
529
|
+
Serialize sklearn estimators as metadata only.
|
|
530
|
+
|
|
531
|
+
Why: avoid unsafe pickling while still exposing model identity and params to TypeScript.
|
|
532
|
+
"""
|
|
290
533
|
try:
|
|
291
534
|
import sklearn # noqa: F401
|
|
292
535
|
except Exception as exc:
|
|
@@ -302,6 +545,7 @@ def serialize_sklearn_estimator(obj):
|
|
|
302
545
|
|
|
303
546
|
return {
|
|
304
547
|
'__tywrap__': 'sklearn.estimator',
|
|
548
|
+
'codecVersion': CODEC_VERSION,
|
|
305
549
|
'encoding': 'json',
|
|
306
550
|
'className': obj.__class__.__name__,
|
|
307
551
|
'module': obj.__class__.__module__,
|
|
@@ -309,6 +553,29 @@ def serialize_sklearn_estimator(obj):
|
|
|
309
553
|
'params': params,
|
|
310
554
|
}
|
|
311
555
|
|
|
556
|
+
|
|
557
|
+
_NO_PYDANTIC = object()
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def serialize_pydantic(obj):
|
|
561
|
+
"""
|
|
562
|
+
Serialize Pydantic v2 models without importing Pydantic.
|
|
563
|
+
|
|
564
|
+
Why: returning BaseModel instances is common in typed Python APIs. Converting via
|
|
565
|
+
`model_dump` keeps Python type hints accurate (return the model), while the bridge still
|
|
566
|
+
emits a JSON-serializable payload. We default to `by_alias=True` so alias_generator-based
|
|
567
|
+
camelCase schemas round-trip cleanly to TypeScript.
|
|
568
|
+
"""
|
|
569
|
+
|
|
570
|
+
model_dump = getattr(obj, 'model_dump', None)
|
|
571
|
+
if not callable(model_dump):
|
|
572
|
+
return _NO_PYDANTIC
|
|
573
|
+
try:
|
|
574
|
+
return model_dump(by_alias=True, mode='json')
|
|
575
|
+
except TypeError:
|
|
576
|
+
# Older Pydantic versions may not support `mode=...`.
|
|
577
|
+
return model_dump(by_alias=True)
|
|
578
|
+
|
|
312
579
|
def serialize(obj):
|
|
313
580
|
if is_numpy_array(obj):
|
|
314
581
|
return serialize_ndarray(obj)
|
|
@@ -322,6 +589,9 @@ def serialize(obj):
|
|
|
322
589
|
return serialize_torch_tensor(obj)
|
|
323
590
|
if is_sklearn_estimator(obj):
|
|
324
591
|
return serialize_sklearn_estimator(obj)
|
|
592
|
+
pydantic_value = serialize_pydantic(obj)
|
|
593
|
+
if pydantic_value is not _NO_PYDANTIC:
|
|
594
|
+
return pydantic_value
|
|
325
595
|
stdlib_value = serialize_stdlib(obj)
|
|
326
596
|
if stdlib_value is not None:
|
|
327
597
|
return stdlib_value
|
|
@@ -347,10 +617,10 @@ def serialize_stdlib(obj):
|
|
|
347
617
|
|
|
348
618
|
|
|
349
619
|
def handle_call(params):
|
|
350
|
-
module_name = params
|
|
351
|
-
function_name = params
|
|
352
|
-
args = params.get('args')
|
|
353
|
-
kwargs = params.get('kwargs')
|
|
620
|
+
module_name = require_str(params, 'module')
|
|
621
|
+
function_name = require_str(params, 'functionName')
|
|
622
|
+
args = coerce_list(params.get('args'), 'args')
|
|
623
|
+
kwargs = coerce_dict(params.get('kwargs'), 'kwargs')
|
|
354
624
|
mod = importlib.import_module(module_name)
|
|
355
625
|
func = getattr(mod, function_name)
|
|
356
626
|
res = func(*args, **kwargs)
|
|
@@ -358,10 +628,10 @@ def handle_call(params):
|
|
|
358
628
|
|
|
359
629
|
|
|
360
630
|
def handle_instantiate(params):
|
|
361
|
-
module_name = params
|
|
362
|
-
class_name = params
|
|
363
|
-
args = params.get('args')
|
|
364
|
-
kwargs = params.get('kwargs')
|
|
631
|
+
module_name = require_str(params, 'module')
|
|
632
|
+
class_name = require_str(params, 'className')
|
|
633
|
+
args = coerce_list(params.get('args'), 'args')
|
|
634
|
+
kwargs = coerce_dict(params.get('kwargs'), 'kwargs')
|
|
365
635
|
mod = importlib.import_module(module_name)
|
|
366
636
|
cls = getattr(mod, class_name)
|
|
367
637
|
obj = cls(*args, **kwargs)
|
|
@@ -371,10 +641,10 @@ def handle_instantiate(params):
|
|
|
371
641
|
|
|
372
642
|
|
|
373
643
|
def handle_call_method(params):
|
|
374
|
-
handle_id = params
|
|
375
|
-
method_name = params
|
|
376
|
-
args = params.get('args')
|
|
377
|
-
kwargs = params.get('kwargs')
|
|
644
|
+
handle_id = require_str(params, 'handle')
|
|
645
|
+
method_name = require_str(params, 'methodName')
|
|
646
|
+
args = coerce_list(params.get('args'), 'args')
|
|
647
|
+
kwargs = coerce_dict(params.get('kwargs'), 'kwargs')
|
|
378
648
|
if handle_id not in instances:
|
|
379
649
|
raise KeyError(f'Unknown handle: {handle_id}')
|
|
380
650
|
obj = instances[handle_id]
|
|
@@ -384,7 +654,7 @@ def handle_call_method(params):
|
|
|
384
654
|
|
|
385
655
|
|
|
386
656
|
def handle_dispose_instance(params):
|
|
387
|
-
handle_id = params
|
|
657
|
+
handle_id = require_str(params, 'handle')
|
|
388
658
|
if handle_id not in instances:
|
|
389
659
|
raise KeyError(f'Unknown handle: {handle_id}')
|
|
390
660
|
del instances[handle_id]
|
|
@@ -392,6 +662,11 @@ def handle_dispose_instance(params):
|
|
|
392
662
|
|
|
393
663
|
|
|
394
664
|
def handle_meta():
|
|
665
|
+
"""
|
|
666
|
+
Return bridge metadata for capability detection.
|
|
667
|
+
|
|
668
|
+
Why: the Node side uses this to decide whether optional codecs can be used.
|
|
669
|
+
"""
|
|
395
670
|
return {
|
|
396
671
|
'protocol': PROTOCOL,
|
|
397
672
|
'protocolVersion': PROTOCOL_VERSION,
|
|
@@ -400,11 +675,16 @@ def handle_meta():
|
|
|
400
675
|
'pid': os.getpid(),
|
|
401
676
|
'codecFallback': 'json' if FALLBACK_JSON else 'none',
|
|
402
677
|
'arrowAvailable': arrow_available(),
|
|
678
|
+
'scipyAvailable': module_available('scipy'),
|
|
679
|
+
'torchAvailable': module_available('torch'),
|
|
680
|
+
'sklearnAvailable': module_available('sklearn'),
|
|
403
681
|
'instances': len(instances),
|
|
404
682
|
}
|
|
405
683
|
|
|
406
684
|
|
|
407
685
|
def require_protocol(msg):
|
|
686
|
+
if not isinstance(msg, dict):
|
|
687
|
+
raise ProtocolError('Invalid request payload')
|
|
408
688
|
proto = msg.get('protocol')
|
|
409
689
|
if proto != PROTOCOL:
|
|
410
690
|
raise ProtocolError(f'Invalid protocol: {proto}')
|
|
@@ -414,6 +694,114 @@ def require_protocol(msg):
|
|
|
414
694
|
return mid
|
|
415
695
|
|
|
416
696
|
|
|
697
|
+
def require_str(params, key):
|
|
698
|
+
"""
|
|
699
|
+
Return a required string parameter from a request.
|
|
700
|
+
|
|
701
|
+
Why: keep request schema validation centralized and explicit for clearer errors.
|
|
702
|
+
"""
|
|
703
|
+
value = params.get(key)
|
|
704
|
+
if not isinstance(value, str) or not value:
|
|
705
|
+
raise ProtocolError(f'Missing {key}')
|
|
706
|
+
return value
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def coerce_list(value, key):
|
|
710
|
+
"""
|
|
711
|
+
Coerce an optional list parameter into a list.
|
|
712
|
+
|
|
713
|
+
Why: normalize args inputs while rejecting invalid shapes early.
|
|
714
|
+
"""
|
|
715
|
+
if value is None:
|
|
716
|
+
return []
|
|
717
|
+
if not isinstance(value, list):
|
|
718
|
+
raise ProtocolError(f'Invalid {key}')
|
|
719
|
+
return value
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def coerce_dict(value, key):
|
|
723
|
+
"""
|
|
724
|
+
Coerce an optional dict parameter into a dict.
|
|
725
|
+
|
|
726
|
+
Why: normalize kwargs inputs while rejecting invalid shapes early.
|
|
727
|
+
"""
|
|
728
|
+
if value is None:
|
|
729
|
+
return {}
|
|
730
|
+
if not isinstance(value, dict):
|
|
731
|
+
raise ProtocolError(f'Invalid {key}')
|
|
732
|
+
return value
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def dispatch_request(msg):
|
|
736
|
+
"""
|
|
737
|
+
Dispatch a validated request to the correct handler.
|
|
738
|
+
|
|
739
|
+
Why: keep the main loop focused on I/O while this function handles validation and routing.
|
|
740
|
+
"""
|
|
741
|
+
mid = require_protocol(msg)
|
|
742
|
+
method = msg.get('method')
|
|
743
|
+
if not isinstance(method, str):
|
|
744
|
+
raise ProtocolError('Missing method')
|
|
745
|
+
params = coerce_dict(msg.get('params'), 'params')
|
|
746
|
+
if method == 'call':
|
|
747
|
+
result = handle_call(params)
|
|
748
|
+
elif method == 'instantiate':
|
|
749
|
+
result = handle_instantiate(params)
|
|
750
|
+
elif method == 'call_method':
|
|
751
|
+
result = handle_call_method(params)
|
|
752
|
+
elif method == 'dispose_instance':
|
|
753
|
+
result = handle_dispose_instance(params)
|
|
754
|
+
elif method == 'meta':
|
|
755
|
+
result = handle_meta()
|
|
756
|
+
else:
|
|
757
|
+
raise ProtocolError(f'Unknown method: {method}')
|
|
758
|
+
return mid, result
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def build_error_payload(mid, exc, *, include_traceback):
|
|
762
|
+
"""
|
|
763
|
+
Build a protocol error response.
|
|
764
|
+
|
|
765
|
+
Why: ensure error formatting stays consistent while keeping exception handling centralized.
|
|
766
|
+
"""
|
|
767
|
+
error = { 'type': type(exc).__name__, 'message': str(exc) }
|
|
768
|
+
if include_traceback:
|
|
769
|
+
error['traceback'] = traceback.format_exc()
|
|
770
|
+
return {
|
|
771
|
+
'id': mid if mid is not None else -1,
|
|
772
|
+
'protocol': PROTOCOL,
|
|
773
|
+
'error': error,
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def encode_response(out):
|
|
778
|
+
"""
|
|
779
|
+
Serialize the response and enforce size limits.
|
|
780
|
+
|
|
781
|
+
Why: keep payload size checks outside the main loop for clarity and lint compliance.
|
|
782
|
+
"""
|
|
783
|
+
payload = json.dumps(out)
|
|
784
|
+
payload_bytes = len(payload.encode('utf-8'))
|
|
785
|
+
if CODEC_MAX_BYTES is not None and payload_bytes > CODEC_MAX_BYTES:
|
|
786
|
+
raise PayloadTooLargeError(payload_bytes, CODEC_MAX_BYTES)
|
|
787
|
+
return payload
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def write_payload(payload: str) -> bool:
|
|
791
|
+
"""
|
|
792
|
+
Write a JSONL payload to stdout and flush.
|
|
793
|
+
|
|
794
|
+
Why: centralize BrokenPipe handling so the main loop can exit cleanly when the
|
|
795
|
+
parent process goes away.
|
|
796
|
+
"""
|
|
797
|
+
try:
|
|
798
|
+
sys.stdout.write(payload + '\n')
|
|
799
|
+
sys.stdout.flush()
|
|
800
|
+
return True
|
|
801
|
+
except BrokenPipeError:
|
|
802
|
+
return False
|
|
803
|
+
|
|
804
|
+
|
|
417
805
|
def main():
|
|
418
806
|
for line in sys.stdin:
|
|
419
807
|
line = line.strip()
|
|
@@ -422,54 +810,47 @@ def main():
|
|
|
422
810
|
mid = None
|
|
423
811
|
out = None
|
|
424
812
|
try:
|
|
813
|
+
if REQUEST_MAX_BYTES is not None:
|
|
814
|
+
payload_bytes = len(line.encode('utf-8'))
|
|
815
|
+
if payload_bytes > REQUEST_MAX_BYTES:
|
|
816
|
+
raise RequestTooLargeError(payload_bytes, REQUEST_MAX_BYTES)
|
|
425
817
|
msg = json.loads(line)
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
if not isinstance(params, dict):
|
|
432
|
-
raise ProtocolError('Invalid params')
|
|
818
|
+
if isinstance(msg, dict):
|
|
819
|
+
req_id = msg.get('id')
|
|
820
|
+
if isinstance(req_id, int):
|
|
821
|
+
# Why: preserve request ids even when handlers raise.
|
|
822
|
+
mid = req_id
|
|
433
823
|
try:
|
|
434
|
-
|
|
435
|
-
result = handle_call(params)
|
|
436
|
-
elif method == 'instantiate':
|
|
437
|
-
result = handle_instantiate(params)
|
|
438
|
-
elif method == 'call_method':
|
|
439
|
-
result = handle_call_method(params)
|
|
440
|
-
elif method == 'dispose_instance':
|
|
441
|
-
result = handle_dispose_instance(params)
|
|
442
|
-
elif method == 'meta':
|
|
443
|
-
result = handle_meta()
|
|
444
|
-
else:
|
|
445
|
-
raise ValueError('Unknown method')
|
|
824
|
+
mid, result = dispatch_request(msg)
|
|
446
825
|
out = { 'id': mid, 'protocol': PROTOCOL, 'result': result }
|
|
447
|
-
except
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
except
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
826
|
+
except ProtocolError as e:
|
|
827
|
+
emit_protocol_diagnostic(str(e))
|
|
828
|
+
out = build_error_payload(mid, e, include_traceback=False)
|
|
829
|
+
except Exception as e: # noqa: BLE001
|
|
830
|
+
# Why: ensure any handler error becomes a protocol-compliant response.
|
|
831
|
+
out = build_error_payload(mid, e, include_traceback=True)
|
|
832
|
+
except RequestTooLargeError as e:
|
|
833
|
+
emit_protocol_diagnostic(str(e))
|
|
834
|
+
out = build_error_payload(mid, e, include_traceback=False)
|
|
835
|
+
except json.JSONDecodeError as e:
|
|
836
|
+
emit_protocol_diagnostic(f'Invalid JSON: {e}')
|
|
837
|
+
out = build_error_payload(mid, e, include_traceback=False)
|
|
838
|
+
except Exception as e: # noqa: BLE001
|
|
839
|
+
# Why: catch malformed input without breaking the JSONL protocol.
|
|
840
|
+
out = build_error_payload(mid, e, include_traceback=False)
|
|
459
841
|
|
|
460
842
|
try:
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
sys.stdout.flush()
|
|
843
|
+
payload = encode_response(out)
|
|
844
|
+
if not write_payload(payload):
|
|
845
|
+
return
|
|
846
|
+
except Exception as e: # noqa: BLE001
|
|
847
|
+
# Why: fallback error keeps responses well-formed even if serialization fails.
|
|
848
|
+
err_out = build_error_payload(mid, e, include_traceback=False)
|
|
849
|
+
try:
|
|
850
|
+
if not write_payload(json.dumps(err_out)):
|
|
851
|
+
return
|
|
852
|
+
except Exception:
|
|
853
|
+
return
|
|
473
854
|
|
|
474
855
|
|
|
475
856
|
if __name__ == '__main__':
|