tywrap 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -5
- package/dist/core/annotation-parser.d.ts.map +1 -1
- package/dist/core/annotation-parser.js.map +1 -1
- package/dist/core/emit-call.d.ts.map +1 -1
- package/dist/core/emit-call.js +1 -1
- package/dist/core/emit-call.js.map +1 -1
- package/dist/core/generator.d.ts.map +1 -1
- package/dist/core/generator.js +40 -9
- package/dist/core/generator.js.map +1 -1
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +1 -3
- package/dist/dev.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/runtime/base-bridge.d.ts +57 -0
- package/dist/runtime/base-bridge.d.ts.map +1 -0
- package/dist/runtime/base-bridge.js +72 -0
- package/dist/runtime/base-bridge.js.map +1 -0
- package/dist/runtime/frame-codec.d.ts +111 -0
- package/dist/runtime/frame-codec.d.ts.map +1 -0
- package/dist/runtime/frame-codec.js +352 -0
- package/dist/runtime/frame-codec.js.map +1 -0
- package/dist/runtime/http-transport.d.ts +11 -1
- package/dist/runtime/http-transport.d.ts.map +1 -1
- package/dist/runtime/http-transport.js +19 -0
- package/dist/runtime/http-transport.js.map +1 -1
- package/dist/runtime/http.d.ts +5 -12
- package/dist/runtime/http.d.ts.map +1 -1
- package/dist/runtime/http.js +6 -29
- package/dist/runtime/http.js.map +1 -1
- package/dist/runtime/index.d.ts +2 -2
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/node.d.ts +25 -19
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +19 -34
- package/dist/runtime/node.js.map +1 -1
- package/dist/runtime/pooled-transport.d.ts +21 -2
- package/dist/runtime/pooled-transport.d.ts.map +1 -1
- package/dist/runtime/pooled-transport.js +16 -0
- package/dist/runtime/pooled-transport.js.map +1 -1
- package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
- package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
- package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
- package/dist/runtime/pyodide-transport.d.ts +12 -1
- package/dist/runtime/pyodide-transport.d.ts.map +1 -1
- package/dist/runtime/pyodide-transport.js +20 -0
- package/dist/runtime/pyodide-transport.js.map +1 -1
- package/dist/runtime/pyodide.d.ts +5 -12
- package/dist/runtime/pyodide.d.ts.map +1 -1
- package/dist/runtime/pyodide.js +6 -29
- package/dist/runtime/pyodide.js.map +1 -1
- package/dist/runtime/rpc-client.d.ts +14 -1
- package/dist/runtime/rpc-client.d.ts.map +1 -1
- package/dist/runtime/rpc-client.js +68 -6
- package/dist/runtime/rpc-client.js.map +1 -1
- package/dist/runtime/subprocess-transport.d.ts +177 -3
- package/dist/runtime/subprocess-transport.d.ts.map +1 -1
- package/dist/runtime/subprocess-transport.js +526 -26
- package/dist/runtime/subprocess-transport.js.map +1 -1
- package/dist/runtime/transport.d.ts +141 -0
- package/dist/runtime/transport.d.ts.map +1 -1
- package/dist/runtime/transport.js +21 -0
- package/dist/runtime/transport.js.map +1 -1
- package/dist/types/index.d.ts +59 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/tywrap.d.ts.map +1 -1
- package/dist/tywrap.js +204 -149
- package/dist/tywrap.js.map +1 -1
- package/dist/utils/codec.d.ts +2 -0
- package/dist/utils/codec.d.ts.map +1 -1
- package/dist/utils/codec.js +205 -6
- package/dist/utils/codec.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +7 -1
- package/runtime/__pycache__/_tywrap_conformance_chunking_fixtures.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_w4_chunking_fixture.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_w5_request_chunking_fixture.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_w6_pool_chunking_fixture.cpython-311.pyc +0 -0
- package/runtime/__pycache__/frame_codec.cpython-311.pyc +0 -0
- package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
- package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
- package/runtime/frame_codec.py +424 -0
- package/runtime/python_bridge.py +241 -42
- package/runtime/tywrap_bridge_core.py +152 -13
- package/src/core/annotation-parser.ts +2 -1
- package/src/core/emit-call.ts +1 -7
- package/src/core/generator.ts +50 -11
- package/src/dev.ts +1 -3
- package/src/index.ts +1 -0
- package/src/runtime/base-bridge.ts +106 -0
- package/src/runtime/frame-codec.ts +469 -0
- package/src/runtime/http-transport.ts +21 -1
- package/src/runtime/http.ts +7 -51
- package/src/runtime/index.ts +2 -6
- package/src/runtime/node.ts +42 -53
- package/src/runtime/pooled-transport.ts +25 -2
- package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
- package/src/runtime/pyodide-transport.ts +22 -0
- package/src/runtime/pyodide.ts +7 -52
- package/src/runtime/rpc-client.ts +91 -7
- package/src/runtime/subprocess-transport.ts +629 -30
- package/src/runtime/transport.ts +169 -0
- package/src/types/index.ts +62 -0
- package/src/tywrap.ts +265 -162
- package/src/utils/codec.ts +245 -7
- package/src/version.ts +1 -1
package/src/utils/codec.ts
CHANGED
|
@@ -111,6 +111,24 @@ export type DecodedValue =
|
|
|
111
111
|
|
|
112
112
|
let arrowTableFrom: ((bytes: Uint8Array) => ArrowTable | Uint8Array) | undefined;
|
|
113
113
|
|
|
114
|
+
// Why: lazy auto-registration (on first Arrow decode) imports apache-arrow at most
|
|
115
|
+
// once per process. We cache the in-flight/settled attempt so concurrent decodes
|
|
116
|
+
// share a single dynamic import, and a missing module is not re-probed on every call.
|
|
117
|
+
let lazyRegistration: Promise<boolean> | undefined;
|
|
118
|
+
|
|
119
|
+
// Why: the lazy decode path imports apache-arrow through the default Node loader, which
|
|
120
|
+
// is hard to simulate-as-absent in a test env where the dependency IS installed. This
|
|
121
|
+
// internal seam lets the unit suite exercise the "apache-arrow missing" clear-failure
|
|
122
|
+
// branch deterministically. It is intentionally NOT re-exported from the package root
|
|
123
|
+
// (see test/api_surface.test.ts) and is reset by clearArrowDecoder().
|
|
124
|
+
let lazyArrowLoaderOverride: ArrowModuleLoader | undefined;
|
|
125
|
+
|
|
126
|
+
/** @internal Test-only: override the loader used by lazy auto-registration. */
|
|
127
|
+
export const _setLazyArrowLoaderForTesting = (loader: ArrowModuleLoader | undefined): void => {
|
|
128
|
+
lazyArrowLoaderOverride = loader;
|
|
129
|
+
lazyRegistration = undefined;
|
|
130
|
+
};
|
|
131
|
+
|
|
114
132
|
export function registerArrowDecoder(
|
|
115
133
|
decoder: (bytes: Uint8Array) => ArrowTable | Uint8Array
|
|
116
134
|
): void {
|
|
@@ -119,6 +137,10 @@ export function registerArrowDecoder(
|
|
|
119
137
|
|
|
120
138
|
export function clearArrowDecoder(): void {
|
|
121
139
|
arrowTableFrom = undefined;
|
|
140
|
+
// Why: reset the cached import attempt so tests (and reload helpers) can exercise
|
|
141
|
+
// the auto-registration path again from a clean slate.
|
|
142
|
+
lazyRegistration = undefined;
|
|
143
|
+
lazyArrowLoaderOverride = undefined;
|
|
122
144
|
}
|
|
123
145
|
|
|
124
146
|
/**
|
|
@@ -186,6 +208,12 @@ export async function autoRegisterArrowDecoder(
|
|
|
186
208
|
}
|
|
187
209
|
try {
|
|
188
210
|
const arrowModule = await loader();
|
|
211
|
+
// Another path may have registered a decoder while the import was in flight
|
|
212
|
+
// (e.g. an explicit registerArrowDecoder() during concurrent startup/reload).
|
|
213
|
+
// Don't clobber it — the explicit registration wins.
|
|
214
|
+
if (hasArrowDecoder()) {
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
189
217
|
registerArrowDecoderFromModule(arrowModule as { tableFromIPC?: unknown });
|
|
190
218
|
return true;
|
|
191
219
|
} catch {
|
|
@@ -214,17 +242,46 @@ function fromBase64(b64: string): Uint8Array {
|
|
|
214
242
|
throw new Error('Base64 decoding is not available in this runtime');
|
|
215
243
|
}
|
|
216
244
|
|
|
245
|
+
// Why: a single, actionable message for both decode paths so users always know the two
|
|
246
|
+
// supported remedies — install the optional dependency, or opt into the lossy JSON fallback.
|
|
247
|
+
const ARROW_MISSING_MESSAGE =
|
|
248
|
+
'Received an Arrow-encoded payload but no Arrow decoder is available. ' +
|
|
249
|
+
'Install the optional dependency with `npm install apache-arrow`, or set ' +
|
|
250
|
+
'TYWRAP_CODEC_FALLBACK=json on the Python side to receive JSON instead ' +
|
|
251
|
+
'(lossy for dtype/NA fidelity). tywrap never silently downgrades Arrow payloads.';
|
|
252
|
+
|
|
217
253
|
function requireArrowDecoder(): (bytes: Uint8Array) => ArrowTable | Uint8Array {
|
|
218
254
|
if (!arrowTableFrom) {
|
|
219
|
-
throw new Error(
|
|
220
|
-
|
|
221
|
-
|
|
255
|
+
throw new Error(ARROW_MISSING_MESSAGE);
|
|
256
|
+
}
|
|
257
|
+
return arrowTableFrom;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Ensure an Arrow decoder is registered, lazily importing apache-arrow on first use.
|
|
262
|
+
*
|
|
263
|
+
* Why: keep apache-arrow optional and zero-config. The first Arrow-encoded payload
|
|
264
|
+
* triggers a single best-effort dynamic import; if it succeeds the decoder is cached
|
|
265
|
+
* for the rest of the process. If apache-arrow is absent we throw a clear, actionable
|
|
266
|
+
* error rather than silently producing wrong data.
|
|
267
|
+
*/
|
|
268
|
+
async function ensureArrowDecoder(): Promise<(bytes: Uint8Array) => ArrowTable | Uint8Array> {
|
|
269
|
+
if (arrowTableFrom) {
|
|
270
|
+
return arrowTableFrom;
|
|
271
|
+
}
|
|
272
|
+
// Reuse a single import attempt across concurrent decodes.
|
|
273
|
+
lazyRegistration ??= autoRegisterArrowDecoder(
|
|
274
|
+
lazyArrowLoaderOverride ? { loader: lazyArrowLoaderOverride } : {}
|
|
275
|
+
);
|
|
276
|
+
await lazyRegistration;
|
|
277
|
+
if (!arrowTableFrom) {
|
|
278
|
+
throw new Error(ARROW_MISSING_MESSAGE);
|
|
222
279
|
}
|
|
223
280
|
return arrowTableFrom;
|
|
224
281
|
}
|
|
225
282
|
|
|
226
283
|
async function tryDecodeArrowTable(bytes: Uint8Array): Promise<ArrowTable | Uint8Array> {
|
|
227
|
-
const decoder =
|
|
284
|
+
const decoder = await ensureArrowDecoder();
|
|
228
285
|
try {
|
|
229
286
|
return decoder(bytes);
|
|
230
287
|
} catch (err) {
|
|
@@ -451,6 +508,31 @@ const decodeNdarrayEnvelope: EnvelopeHandler = (value, decodeArrow) => {
|
|
|
451
508
|
throw new Error(`Invalid ndarray envelope: unsupported encoding ${String(encoding)}`);
|
|
452
509
|
};
|
|
453
510
|
|
|
511
|
+
/**
|
|
512
|
+
* Assert an array holds only integer indices within [0, bound). Used to re-validate
|
|
513
|
+
* scipy.sparse index arrays on the JS side so a corrupt/oversized index can never
|
|
514
|
+
* silently address out of the declared shape.
|
|
515
|
+
*
|
|
516
|
+
* Why: the JS decoder never reconstructs a Python object, but it IS the boundary a
|
|
517
|
+
* downstream consumer trusts — validating index ranges here turns a corrupt payload
|
|
518
|
+
* into a clear, early failure instead of a confusing downstream error.
|
|
519
|
+
*/
|
|
520
|
+
function assertIndexArrayInRange(arr: readonly unknown[], bound: number, label: string): void {
|
|
521
|
+
for (let i = 0; i < arr.length; i += 1) {
|
|
522
|
+
const idx = arr[i];
|
|
523
|
+
if (typeof idx !== 'number' || !Number.isInteger(idx)) {
|
|
524
|
+
throw new Error(
|
|
525
|
+
`Invalid scipy.sparse envelope: ${label}[${i}] must be an integer, got ${String(idx)}`
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
if (idx < 0 || idx >= bound) {
|
|
529
|
+
throw new Error(
|
|
530
|
+
`Invalid scipy.sparse envelope: ${label}[${i}]=${idx} is out of range [0, ${bound})`
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
454
536
|
const decodeScipySparseEnvelope: EnvelopeHandler = value => {
|
|
455
537
|
const encoding = value.encoding;
|
|
456
538
|
if (encoding !== 'json') {
|
|
@@ -465,10 +547,16 @@ const decodeScipySparseEnvelope: EnvelopeHandler = value => {
|
|
|
465
547
|
!Array.isArray(shape) ||
|
|
466
548
|
shape.length !== 2 ||
|
|
467
549
|
typeof shape[0] !== 'number' ||
|
|
468
|
-
|
|
550
|
+
!Number.isInteger(shape[0]) ||
|
|
551
|
+
shape[0] < 0 ||
|
|
552
|
+
typeof shape[1] !== 'number' ||
|
|
553
|
+
!Number.isInteger(shape[1]) ||
|
|
554
|
+
shape[1] < 0
|
|
469
555
|
) {
|
|
470
|
-
throw new Error('Invalid scipy.sparse envelope: shape must be a 2-item
|
|
556
|
+
throw new Error('Invalid scipy.sparse envelope: shape must be a 2-item non-negative integer[]');
|
|
471
557
|
}
|
|
558
|
+
const rows = shape[0];
|
|
559
|
+
const cols = shape[1];
|
|
472
560
|
const data = value.data;
|
|
473
561
|
if (!Array.isArray(data)) {
|
|
474
562
|
throw new Error('Invalid scipy.sparse envelope: data must be an array');
|
|
@@ -482,6 +570,16 @@ const decodeScipySparseEnvelope: EnvelopeHandler = value => {
|
|
|
482
570
|
if (!Array.isArray(row) || !Array.isArray(col)) {
|
|
483
571
|
throw new Error('Invalid scipy.sparse envelope: coo requires row and col arrays');
|
|
484
572
|
}
|
|
573
|
+
// COO: one (row, col, value) triple per stored entry, so all three arrays
|
|
574
|
+
// share a length; row/col index into [0, rows)/[0, cols) respectively.
|
|
575
|
+
if (row.length !== data.length || col.length !== data.length) {
|
|
576
|
+
throw new Error(
|
|
577
|
+
`Invalid scipy.sparse envelope: coo row/col/data lengths must match ` +
|
|
578
|
+
`(data=${data.length}, row=${row.length}, col=${col.length})`
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
assertIndexArrayInRange(row, rows, 'row');
|
|
582
|
+
assertIndexArrayInRange(col, cols, 'col');
|
|
485
583
|
return {
|
|
486
584
|
format,
|
|
487
585
|
shape,
|
|
@@ -497,6 +595,52 @@ const decodeScipySparseEnvelope: EnvelopeHandler = value => {
|
|
|
497
595
|
if (!Array.isArray(indices) || !Array.isArray(indptr)) {
|
|
498
596
|
throw new Error('Invalid scipy.sparse envelope: csr/csc requires indices and indptr arrays');
|
|
499
597
|
}
|
|
598
|
+
// CSR/CSC: one column-index (CSR) or row-index (CSC) per stored value, so
|
|
599
|
+
// indices and data share a length. indptr partitions indices into one segment
|
|
600
|
+
// per major axis (rows for CSR, cols for CSC), so indptr has majorAxis + 1
|
|
601
|
+
// entries; the inner indices address the minor axis.
|
|
602
|
+
if (indices.length !== data.length) {
|
|
603
|
+
throw new Error(
|
|
604
|
+
`Invalid scipy.sparse envelope: ${format} indices/data lengths must match ` +
|
|
605
|
+
`(data=${data.length}, indices=${indices.length})`
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
const majorAxis = format === 'csr' ? rows : cols;
|
|
609
|
+
const minorAxis = format === 'csr' ? cols : rows;
|
|
610
|
+
if (indptr.length !== majorAxis + 1) {
|
|
611
|
+
throw new Error(
|
|
612
|
+
`Invalid scipy.sparse envelope: ${format} indptr length must be ${majorAxis + 1} ` +
|
|
613
|
+
`(${format === 'csr' ? 'rows' : 'cols'}+1), got ${indptr.length}`
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
// indptr must be a valid CSR/CSC pointer array: integers that start at 0, end
|
|
617
|
+
// at data.length, and never decrease — otherwise a structurally impossible
|
|
618
|
+
// matrix (e.g. [0, 99, 1]) would slip through the length check above.
|
|
619
|
+
for (let i = 0; i < indptr.length; i += 1) {
|
|
620
|
+
const ptr = indptr[i];
|
|
621
|
+
if (typeof ptr !== 'number' || !Number.isInteger(ptr)) {
|
|
622
|
+
throw new Error(
|
|
623
|
+
`Invalid scipy.sparse envelope: indptr[${i}] must be an integer, got ${String(ptr)}`
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
if (ptr < 0 || ptr > data.length) {
|
|
627
|
+
throw new Error(
|
|
628
|
+
`Invalid scipy.sparse envelope: indptr[${i}]=${ptr} is out of range [0, ${data.length}]`
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
if (i > 0 && ptr < (indptr[i - 1] as number)) {
|
|
632
|
+
throw new Error('Invalid scipy.sparse envelope: indptr must be non-decreasing');
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
if (indptr[0] !== 0) {
|
|
636
|
+
throw new Error('Invalid scipy.sparse envelope: indptr must start at 0');
|
|
637
|
+
}
|
|
638
|
+
if (indptr[indptr.length - 1] !== data.length) {
|
|
639
|
+
throw new Error(
|
|
640
|
+
`Invalid scipy.sparse envelope: indptr must end at data.length (${data.length})`
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
assertIndexArrayInRange(indices, minorAxis, 'indices');
|
|
500
644
|
return {
|
|
501
645
|
format,
|
|
502
646
|
shape,
|
|
@@ -507,6 +651,11 @@ const decodeScipySparseEnvelope: EnvelopeHandler = value => {
|
|
|
507
651
|
} satisfies SparseMatrix;
|
|
508
652
|
};
|
|
509
653
|
|
|
654
|
+
/** Product of a shape's dimensions (the element count). [] (scalar) -> 1. */
|
|
655
|
+
function shapeProduct(shape: readonly number[]): number {
|
|
656
|
+
return shape.reduce((acc, dim) => acc * dim, 1);
|
|
657
|
+
}
|
|
658
|
+
|
|
510
659
|
const decodeTorchTensorEnvelope: EnvelopeHandler = <T>(
|
|
511
660
|
value: { [k: string]: unknown },
|
|
512
661
|
_decodeArrow: (bytes: Uint8Array) => MaybePromise<T>,
|
|
@@ -523,20 +672,96 @@ const decodeTorchTensorEnvelope: EnvelopeHandler = <T>(
|
|
|
523
672
|
if (!isObject(nested) || (nested as { __tywrap__?: unknown }).__tywrap__ !== 'ndarray') {
|
|
524
673
|
throw new Error('Invalid torch.tensor envelope: value must be an ndarray envelope');
|
|
525
674
|
}
|
|
526
|
-
const decoded = recurse(nested);
|
|
527
675
|
const shapeValue = value.shape;
|
|
528
676
|
const shape = isNumberArray(shapeValue) ? shapeValue : undefined;
|
|
677
|
+
// The tensor shape must be a non-negative-integer dimension list. A negative or
|
|
678
|
+
// non-integer dim is a corrupt envelope, not a valid tensor.
|
|
679
|
+
if (shape) {
|
|
680
|
+
for (let i = 0; i < shape.length; i += 1) {
|
|
681
|
+
const dim = shape[i] as number;
|
|
682
|
+
if (!Number.isInteger(dim) || dim < 0) {
|
|
683
|
+
throw new Error(
|
|
684
|
+
`Invalid torch.tensor envelope: shape[${i}]=${dim} must be a non-negative integer`
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
// Cross-check the tensor shape's element count against the nested ndarray's
|
|
690
|
+
// declared shape (metadata only — no decode needed). A mismatch means the two
|
|
691
|
+
// shapes disagree about how many elements the payload holds.
|
|
692
|
+
const nestedShapeValue = (nested as { shape?: unknown }).shape;
|
|
693
|
+
const nestedShape = isNumberArray(nestedShapeValue) ? nestedShapeValue : undefined;
|
|
694
|
+
if (shape && nestedShape && shapeProduct(shape) !== shapeProduct(nestedShape)) {
|
|
695
|
+
throw new Error(
|
|
696
|
+
`Invalid torch.tensor envelope: shape ${JSON.stringify(shape)} ` +
|
|
697
|
+
`(product ${shapeProduct(shape)}) disagrees with nested ndarray shape ` +
|
|
698
|
+
`${JSON.stringify(nestedShape)} (product ${shapeProduct(nestedShape)})`
|
|
699
|
+
);
|
|
700
|
+
}
|
|
529
701
|
const dtypeValue = value.dtype;
|
|
530
702
|
const dtype = typeof dtypeValue === 'string' ? dtypeValue : undefined;
|
|
531
703
|
const deviceValue = value.device;
|
|
704
|
+
if (deviceValue !== undefined && (typeof deviceValue !== 'string' || deviceValue.length === 0)) {
|
|
705
|
+
throw new Error(
|
|
706
|
+
'Invalid torch.tensor envelope: device must be a non-empty string when provided'
|
|
707
|
+
);
|
|
708
|
+
}
|
|
532
709
|
const device = typeof deviceValue === 'string' ? deviceValue : undefined;
|
|
533
710
|
|
|
711
|
+
const decoded = recurse(nested);
|
|
534
712
|
if (isPromiseLike(decoded)) {
|
|
535
713
|
return decoded.then(data => ({ data, shape, dtype, device })) as Promise<T | unknown>;
|
|
536
714
|
}
|
|
537
715
|
return { data: decoded, shape, dtype, device } satisfies TorchTensor;
|
|
538
716
|
};
|
|
539
717
|
|
|
718
|
+
/**
|
|
719
|
+
* Recursively assert a value is plain JSON (null | boolean | number | string |
|
|
720
|
+
* JSON array | plain object of JSON). Rejects functions, symbols, bigints, class
|
|
721
|
+
* instances, and any non-finite number — the things a metadata-only sklearn
|
|
722
|
+
* envelope must never carry. This validates; it never reconstructs.
|
|
723
|
+
*/
|
|
724
|
+
function assertPlainJson(value: unknown, path: string): void {
|
|
725
|
+
if (value === null) {
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
const t = typeof value;
|
|
729
|
+
if (t === 'string' || t === 'boolean') {
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (t === 'number') {
|
|
733
|
+
if (!Number.isFinite(value as number)) {
|
|
734
|
+
throw new Error(
|
|
735
|
+
`Invalid sklearn.estimator envelope: ${path} must be a finite JSON number, got ${String(value)}`
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (Array.isArray(value)) {
|
|
741
|
+
value.forEach((item, i) => assertPlainJson(item, `${path}[${i}]`));
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (t === 'object') {
|
|
745
|
+
// Reject exotic objects (class instances, Map/Set, etc.): a JSON object is a
|
|
746
|
+
// plain object whose prototype is Object.prototype or null.
|
|
747
|
+
const proto = Object.getPrototypeOf(value as object);
|
|
748
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
749
|
+
throw new Error(
|
|
750
|
+
`Invalid sklearn.estimator envelope: ${path} must be a plain JSON object, ` +
|
|
751
|
+
`got ${(value as object).constructor?.name ?? 'object'}`
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
755
|
+
assertPlainJson(v, `${path}.${k}`);
|
|
756
|
+
}
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
// function | symbol | bigint | undefined
|
|
760
|
+
throw new Error(
|
|
761
|
+
`Invalid sklearn.estimator envelope: ${path} is not JSON-serializable (type ${t})`
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
|
|
540
765
|
const decodeSklearnEstimatorEnvelope: EnvelopeHandler = value => {
|
|
541
766
|
const encoding = value.encoding;
|
|
542
767
|
if (encoding !== 'json') {
|
|
@@ -550,6 +775,19 @@ const decodeSklearnEstimatorEnvelope: EnvelopeHandler = value => {
|
|
|
550
775
|
'Invalid sklearn.estimator envelope: expected className/module strings + params object'
|
|
551
776
|
);
|
|
552
777
|
}
|
|
778
|
+
// params must be a PLAIN JSON object end to end — metadata-only estimators never
|
|
779
|
+
// carry callables, class instances, or nested non-JSON values. Validate (do not
|
|
780
|
+
// reconstruct) so a corrupt envelope fails clearly instead of leaking a function
|
|
781
|
+
// or exotic object to a downstream consumer.
|
|
782
|
+
if (
|
|
783
|
+
Object.getPrototypeOf(params) !== Object.prototype &&
|
|
784
|
+
Object.getPrototypeOf(params) !== null
|
|
785
|
+
) {
|
|
786
|
+
throw new Error('Invalid sklearn.estimator envelope: params must be a plain JSON object');
|
|
787
|
+
}
|
|
788
|
+
for (const [k, v] of Object.entries(params)) {
|
|
789
|
+
assertPlainJson(v, `params.${k}`);
|
|
790
|
+
}
|
|
553
791
|
const versionValue = value.version;
|
|
554
792
|
if (versionValue !== undefined && typeof versionValue !== 'string') {
|
|
555
793
|
throw new Error('Invalid sklearn.estimator envelope: version must be a string when provided');
|
package/src/version.ts
CHANGED