tywrap 0.6.1 → 0.7.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/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/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/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 +1 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/node.d.ts +12 -19
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +14 -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 +15 -0
- package/dist/runtime/rpc-client.js.map +1 -1
- package/dist/runtime/subprocess-transport.d.ts +10 -1
- package/dist/runtime/subprocess-transport.d.ts.map +1 -1
- package/dist/runtime/subprocess-transport.js +18 -0
- package/dist/runtime/subprocess-transport.js.map +1 -1
- package/dist/runtime/transport.d.ts +59 -0
- package/dist/runtime/transport.d.ts.map +1 -1
- package/dist/runtime/transport.js +1 -0
- package/dist/runtime/transport.js.map +1 -1
- package/dist/types/index.d.ts +35 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/tywrap.d.ts.map +1 -1
- package/dist/tywrap.js +212 -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 +53 -2
- package/dist/utils/codec.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +7 -1
- package/runtime/__pycache__/_tywrap_member_fixtures.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/tywrap_bridge_core.py +55 -3
- package/src/core/generator.ts +50 -11
- package/src/runtime/base-bridge.ts +106 -0
- package/src/runtime/http-transport.ts +21 -1
- package/src/runtime/http.ts +7 -51
- package/src/runtime/index.ts +1 -0
- package/src/runtime/node.ts +17 -52
- 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 +17 -0
- package/src/runtime/subprocess-transport.ts +20 -1
- package/src/runtime/transport.ts +71 -0
- package/src/types/index.ts +37 -0
- package/src/tywrap.ts +274 -163
- package/src/utils/codec.ts +61 -4
- package/src/version.ts +1 -1
package/src/runtime/node.ts
CHANGED
|
@@ -13,14 +13,13 @@ import { delimiter, isAbsolute, join, resolve } from 'node:path';
|
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { createRequire } from 'node:module';
|
|
15
15
|
|
|
16
|
-
import type { PythonRuntime, BridgeInfo } from '../types/index.js';
|
|
17
16
|
import { autoRegisterArrowDecoder } from '../utils/codec.js';
|
|
18
17
|
import { getDefaultPythonPath } from '../utils/python.js';
|
|
19
18
|
import { getVenvBinDir, getVenvPythonExe } from '../utils/runtime.js';
|
|
20
19
|
import { globalCache } from '../utils/cache.js';
|
|
21
20
|
|
|
22
|
-
import {
|
|
23
|
-
import { RpcClient
|
|
21
|
+
import { BasePythonBridge } from './base-bridge.js';
|
|
22
|
+
import { RpcClient } from './rpc-client.js';
|
|
24
23
|
import { BridgeCodecError, BridgeExecutionError, BridgeProtocolError } from './errors.js';
|
|
25
24
|
import { SubprocessTransport } from './subprocess-transport.js';
|
|
26
25
|
import { PooledTransport } from './pooled-transport.js';
|
|
@@ -305,7 +304,7 @@ function normalizeWarmupCommands(commands: NodeBridgeOptions['warmupCommands']):
|
|
|
305
304
|
* await pooledBridge.init();
|
|
306
305
|
* ```
|
|
307
306
|
*/
|
|
308
|
-
export class NodeBridge extends
|
|
307
|
+
export class NodeBridge extends BasePythonBridge {
|
|
309
308
|
private readonly resolvedOptions: ResolvedOptions;
|
|
310
309
|
private readonly pooledTransport: PooledTransport;
|
|
311
310
|
private readonly rpc: RpcClient;
|
|
@@ -433,16 +432,26 @@ export class NodeBridge extends DisposableBase implements PythonRuntime {
|
|
|
433
432
|
}
|
|
434
433
|
|
|
435
434
|
// ===========================================================================
|
|
436
|
-
// RPC
|
|
435
|
+
// RPC DELEGATION (the held RpcClient)
|
|
437
436
|
// ===========================================================================
|
|
438
437
|
|
|
438
|
+
/**
|
|
439
|
+
* Expose the held RpcClient to BasePythonBridge's shared delegating methods
|
|
440
|
+
* (instantiate/callMethod/disposeInstance/getBridgeInfo). call() is
|
|
441
|
+
* overridden below to layer caching on top.
|
|
442
|
+
*/
|
|
443
|
+
protected getRpcClient(): RpcClient {
|
|
444
|
+
return this.rpc;
|
|
445
|
+
}
|
|
446
|
+
|
|
439
447
|
/**
|
|
440
448
|
* Call a Python function, with optional result caching.
|
|
441
449
|
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
450
|
+
* Overrides BasePythonBridge.call() to layer the cache lookup/writeback on
|
|
451
|
+
* top of the shared delegation. Cache lookup stays FIRST so cache hits return
|
|
452
|
+
* without forcing init, preserving the pre-composition behavior.
|
|
444
453
|
*/
|
|
445
|
-
async call<T = unknown>(
|
|
454
|
+
override async call<T = unknown>(
|
|
446
455
|
module: string,
|
|
447
456
|
functionName: string,
|
|
448
457
|
args: unknown[],
|
|
@@ -479,50 +488,6 @@ export class NodeBridge extends DisposableBase implements PythonRuntime {
|
|
|
479
488
|
return this.rpc.call<T>(module, functionName, args, kwargs);
|
|
480
489
|
}
|
|
481
490
|
|
|
482
|
-
async instantiate<T = unknown>(
|
|
483
|
-
module: string,
|
|
484
|
-
className: string,
|
|
485
|
-
args: unknown[],
|
|
486
|
-
kwargs?: Record<string, unknown>
|
|
487
|
-
): Promise<T> {
|
|
488
|
-
await this.ensureReady();
|
|
489
|
-
return this.rpc.instantiate<T>(module, className, args, kwargs);
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
async callMethod<T = unknown>(
|
|
493
|
-
handle: string,
|
|
494
|
-
methodName: string,
|
|
495
|
-
args: unknown[],
|
|
496
|
-
kwargs?: Record<string, unknown>
|
|
497
|
-
): Promise<T> {
|
|
498
|
-
await this.ensureReady();
|
|
499
|
-
return this.rpc.callMethod<T>(handle, methodName, args, kwargs);
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
async disposeInstance(handle: string): Promise<void> {
|
|
503
|
-
await this.ensureReady();
|
|
504
|
-
return this.rpc.disposeInstance(handle);
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/**
|
|
508
|
-
* Fetch bridge diagnostics and feature availability.
|
|
509
|
-
*/
|
|
510
|
-
async getBridgeInfo(options?: GetBridgeInfoOptions): Promise<BridgeInfo> {
|
|
511
|
-
await this.ensureReady();
|
|
512
|
-
return this.rpc.getBridgeInfo(options);
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
/**
|
|
516
|
-
* Ensure the facade is initialized before delegating an RPC. Replicates the
|
|
517
|
-
* auto-init that BoundedContext.execute() gave for free, so the facade's
|
|
518
|
-
* own doInit pre-work (script check, Arrow decoder) runs before any RPC.
|
|
519
|
-
*/
|
|
520
|
-
private async ensureReady(): Promise<void> {
|
|
521
|
-
if (!this.isReady) {
|
|
522
|
-
await this.init();
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
491
|
// ===========================================================================
|
|
527
492
|
// POOL STATISTICS
|
|
528
493
|
// ===========================================================================
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { DisposableBase } from './bounded-context.js';
|
|
12
12
|
import { BridgeDisposedError, BridgeExecutionError } from './errors.js';
|
|
13
|
-
import type { Transport } from './transport.js';
|
|
13
|
+
import type { Transport, TransportCapabilities } from './transport.js';
|
|
14
14
|
import { TransportPool, type TransportLease } from './transport-pool.js';
|
|
15
15
|
|
|
16
16
|
// =============================================================================
|
|
@@ -21,7 +21,13 @@ import { TransportPool, type TransportLease } from './transport-pool.js';
|
|
|
21
21
|
* Options for creating a PooledTransport.
|
|
22
22
|
*/
|
|
23
23
|
export interface PooledTransportOptions {
|
|
24
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Factory function to create transports for each worker.
|
|
26
|
+
*
|
|
27
|
+
* Construction MUST be side-effect-free — spawn processes/open connections in
|
|
28
|
+
* `init()`/`send()`, never in the constructor. The pool may build a probe
|
|
29
|
+
* instance solely to read its {@link Transport.capabilities} descriptor.
|
|
30
|
+
*/
|
|
25
31
|
createTransport: () => Transport;
|
|
26
32
|
|
|
27
33
|
/** Maximum number of workers in the pool. Default: 1 */
|
|
@@ -91,6 +97,8 @@ export class PooledTransport extends DisposableBase implements Transport {
|
|
|
91
97
|
onReplacementWorkerReady?: (worker: TransportLease) => Promise<void>;
|
|
92
98
|
};
|
|
93
99
|
private pool?: TransportPool;
|
|
100
|
+
/** Memoized capability descriptor — built at most once (see {@link capabilities}). */
|
|
101
|
+
private cachedCapabilities?: TransportCapabilities;
|
|
94
102
|
|
|
95
103
|
/**
|
|
96
104
|
* Create a new PooledTransport.
|
|
@@ -181,6 +189,21 @@ export class PooledTransport extends DisposableBase implements Transport {
|
|
|
181
189
|
});
|
|
182
190
|
}
|
|
183
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Static capability descriptor for the pool.
|
|
194
|
+
*
|
|
195
|
+
* A pool's wire behavior is exactly that of the workers it distributes across,
|
|
196
|
+
* so this reads the descriptor from one probe transport built by the same
|
|
197
|
+
* factory. `createTransport` MUST be construction-side-effect-free — the
|
|
198
|
+
* built-in transports spawn nothing until `init()`/`send()`. The result is
|
|
199
|
+
* memoized so at most one probe is ever built regardless of call count, and
|
|
200
|
+
* this stays safe to call at any lifecycle point.
|
|
201
|
+
*/
|
|
202
|
+
capabilities(): TransportCapabilities {
|
|
203
|
+
this.cachedCapabilities ??= this.poolOptions.createTransport().capabilities();
|
|
204
|
+
return this.cachedCapabilities;
|
|
205
|
+
}
|
|
206
|
+
|
|
184
207
|
// ===========================================================================
|
|
185
208
|
// POOL STATISTICS
|
|
186
209
|
// ===========================================================================
|
|
@@ -9,4 +9,4 @@
|
|
|
9
9
|
* Regenerate with: node scripts/generate-pyodide-bootstrap.mjs
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
export const PYODIDE_BRIDGE_CORE_SOURCE: string = "\"\"\"\nShared tywrap bridge core: protocol dispatch + value (de)serialization.\n\nThis module is the SINGLE source of truth for the \"tywrap/1\" server-side\nprotocol. It is imported by:\n\n - runtime/python_bridge.py (the Node/Bun/Deno subprocess server and the HTTP\n server), which owns I/O concerns: the stdin/stdout JSONL loop, env-var size\n guards, the real OS pid, bridge='python-subprocess', and the final BridgeCodec\n encode wrapper.\n\n - the in-WASM Pyodide server (src/runtime/pyodide-transport.ts). Pyodide cannot read\n this file from disk, so it is shipped as a build-time-generated TypeScript\n string constant (src/runtime/pyodide-bootstrap-core.generated.ts) produced by\n scripts/generate-pyodide-bootstrap.mjs and exec'd into a module registered in\n sys.modules. A conformance drift guard (test/runtime_conformance.test.ts)\n asserts the generated constant stays byte-identical to this file.\n\nCROSS-LANGUAGE CONTRACT (Python <-> the TypeScript decoder in src/utils/codec.ts\nand the request encoder in src/runtime/bridge-codec.ts):\n\n * Every value-type \"marker\" envelope carries {'__tywrap__': <type>,\n 'codecVersion': 1, 'encoding': ...}. The 6 markers are: ndarray, dataframe,\n series, scipy.sparse, torch.tensor, sklearn.estimator.\n * bytes round-trip both ways via base64 envelopes (see _deserialize_bytes_*\n and the bytes branch of default_encoder).\n * NaN/Infinity are rejected (the JS client cannot parse the non-standard tokens\n that allow_nan=True would emit).\n\nPURITY: This module depends only on the standard library plus LAZY optional\nimports (numpy/pandas/scipy/torch/sklearn/pyarrow are each imported inside the\nfunction that needs them). It performs no stdin/stdout I/O and reads no env vars,\nso it runs unchanged under CPython-in-WASM (Pyodide).\n\nforce_json_markers: a *parameter* threaded through every serializer (including\nthe nested torch.tensor -> ndarray call). When True, ndarray/dataframe/series are\nforced down their JSON path regardless of pyarrow availability. Pyodide passes\nTrue (Arrow is unavailable in WASM); the subprocess server passes the boolean\nderived from TYWRAP_CODEC_FALLBACK=json so that \"Node in json-fallback mode\" and\n\"Pyodide\" produce byte-identical marker envelopes.\n\"\"\"\n\nimport base64\nimport datetime as dt\nimport decimal\nimport importlib\nimport importlib.util\nimport json\nimport math\nimport traceback\nimport uuid\nfrom pathlib import Path, PurePath\n\n# Protocol constants. These MUST match src/runtime/protocol.ts (PROTOCOL_ID,\n# TYWRAP_PROTOCOL_VERSION) and the codec version baked into marker envelopes.\nPROTOCOL = 'tywrap/1'\nPROTOCOL_VERSION = 1\nCODEC_VERSION = 1\n\n\nclass ProtocolError(Exception):\n \"\"\"Raised for malformed requests (bad protocol/id/method/params).\"\"\"\n\n\nclass InstanceHandleError(ValueError):\n \"\"\"Raised when an instance handle is unknown or no longer valid.\"\"\"\n\n\nclass ImportNotAllowedError(PermissionError):\n \"\"\"Raised when a requested module import is not on the active allowlist.\"\"\"\n\n def __init__(self, module_name):\n super().__init__(\n f'Import of module {module_name!r} is not permitted by the tywrap bridge '\n 'allowlist; add it to TYWRAP_ALLOWED_MODULES (subprocess) or the '\n 'allowed_modules parameter to enable it'\n )\n\n\nclass AttributeNotAllowedError(PermissionError):\n \"\"\"Raised when access to a private/dunder attribute is denied by policy.\"\"\"\n\n def __init__(self, attr_name):\n super().__init__(\n f'Access to attribute {attr_name!r} is not permitted by the tywrap bridge: '\n 'underscore-prefixed (private/dunder) attributes are blocked to prevent '\n 'sandbox-escape via attributes like __globals__/__subclasses__/__builtins__; '\n 'set TYWRAP_ALLOW_PRIVATE_ATTRS=1 (subprocess) or pass allow_private_attrs=True '\n 'to override'\n )\n\n\n# =============================================================================\n# IMPORT / ATTRIBUTE ALLOWLIST (trust boundary enforcement)\n# =============================================================================\n#\n# The bridge dispatches call/instantiate/call_method by importing the requested\n# module and getattr-ing the requested function/class/method. That is an\n# arbitrary import+getattr+call surface, so two complementary guards live here.\n# Both are PURE (no env reads) so the rules behave identically under the\n# subprocess server and the in-WASM Pyodide server; the subprocess server derives\n# the parameters from env vars (TYWRAP_ALLOWED_MODULES / TYWRAP_ALLOW_PRIVATE_ATTRS)\n# and threads them in, exactly like force_json_markers / torch_allow_copy.\n#\n# 1. MODULE ALLOWLIST (opt-in, default = allow all):\n# allowed_modules=None means \"no restriction\" so existing configurations keep\n# working unchanged. When a caller supplies a set, only those modules (plus the\n# stdlib the bridge itself needs to serialize results, see _BRIDGE_REQUIRED_MODULES)\n# may be imported; submodules of an allowed module are permitted (e.g. allowing\n# 'scipy' also allows 'scipy.sparse'). A non-allowlisted import fails LOUDLY with\n# ImportNotAllowedError rather than silently importing.\n#\n# 2. PRIVATE-ATTRIBUTE BLOCK (default ON):\n# getattr of any name starting with '_' (single-underscore private OR dunder) is\n# rejected. This blocks the classic escape chain (obj.__class__.__subclasses__()\n# /__globals__/__builtins__/__import__) without depending on the module allowlist.\n# tywrap-generated wrappers never reference underscore-prefixed names (the IR\n# analyzer skips them), so this does not regress generated code. Set\n# allow_private_attrs=True to restore unrestricted getattr for trusted callers.\n\n# Stdlib modules the bridge's own serialization/handlers may need to import even\n# when a caller-supplied allowlist is active. Optional codec deps (numpy, pandas,\n# scipy, torch, sklearn, pyarrow) are intentionally NOT here: if a caller restricts\n# modules, they must opt those in explicitly. These names cover only what the\n# bridge core itself imports.\n_BRIDGE_REQUIRED_MODULES = frozenset(\n {\n 'base64',\n 'datetime',\n 'decimal',\n 'importlib',\n 'json',\n 'math',\n 'sys',\n 'traceback',\n 'uuid',\n 'pathlib',\n }\n)\n\n\ndef _top_level_package(module_name):\n \"\"\"Return the top-level package of a dotted module name ('a.b.c' -> 'a').\"\"\"\n return module_name.split('.', 1)[0]\n\n\ndef _is_module_allowed(module_name, allowed_modules):\n \"\"\"\n Return True when module_name may be imported under the active policy.\n\n allowed_modules=None disables enforcement (allow all). Otherwise a module is\n allowed when it (or its top-level package) is explicitly listed, or it is one\n of the stdlib modules the bridge itself requires.\n \"\"\"\n if allowed_modules is None:\n return True\n if module_name in allowed_modules or module_name in _BRIDGE_REQUIRED_MODULES:\n return True\n top = _top_level_package(module_name)\n return top in allowed_modules or top in _BRIDGE_REQUIRED_MODULES\n\n\ndef import_allowed_module(module_name, allowed_modules):\n \"\"\"\n Import module_name only if permitted by the allowlist, else raise loudly.\n\n This is the single chokepoint every handler routes module imports through.\n \"\"\"\n if not _is_module_allowed(module_name, allowed_modules):\n raise ImportNotAllowedError(module_name)\n return importlib.import_module(module_name)\n\n\ndef get_allowed_attr(obj, attr_name, *, allow_private_attrs):\n \"\"\"\n getattr(obj, attr_name) with the private/dunder block applied.\n\n Rejects any underscore-prefixed name unless allow_private_attrs is True. This\n is the single chokepoint every handler routes attribute access through.\n \"\"\"\n if not allow_private_attrs and attr_name.startswith('_'):\n raise AttributeNotAllowedError(attr_name)\n return getattr(obj, attr_name)\n\n\nclass CodecError(Exception):\n \"\"\"Raised when value encoding fails (e.g. NaN/Infinity not allowed).\"\"\"\n\n\n# =============================================================================\n# REQUEST-SIDE DESERIALIZATION (bytes envelopes -> Python bytes)\n# =============================================================================\n\n_NO_DESERIALIZE = object()\n_ERR_BYTES_MISSING_B64 = 'Invalid bytes envelope: missing b64'\n_ERR_BYTES_MISSING_DATA = 'Invalid bytes envelope: missing data'\n_ERR_BYTES_INVALID_BASE64 = 'Invalid bytes envelope: invalid base64'\n\n\ndef _deserialize_bytes_envelope(value):\n \"\"\"\n Decode base64-encoded bytes envelopes from JS into Python bytes.\n\n Supported shapes:\n - { \"__tywrap_bytes__\": true, \"b64\": \"...\" } (JS BridgeCodec.encodeRequest)\n - { \"__type__\": \"bytes\", \"encoding\": \"base64\", \"data\": \"...\" } (legacy/compat)\n\n Why: TS BridgeCodec encodes Uint8Array/ArrayBuffer as base64 objects, but\n Python handlers expect real bytes/bytearray to preserve behavior (e.g., len()).\n \"\"\"\n if not isinstance(value, dict):\n return _NO_DESERIALIZE\n\n if value.get('__tywrap_bytes__') is True:\n b64 = value.get('b64')\n if not isinstance(b64, str):\n raise ProtocolError(_ERR_BYTES_MISSING_B64)\n try:\n return base64.b64decode(b64, validate=True)\n except Exception as exc:\n raise ProtocolError(_ERR_BYTES_INVALID_BASE64) from exc\n\n if value.get('__type__') == 'bytes' and value.get('encoding') == 'base64':\n data = value.get('data')\n if not isinstance(data, str):\n raise ProtocolError(_ERR_BYTES_MISSING_DATA)\n try:\n return base64.b64decode(data, validate=True)\n except Exception as exc:\n raise ProtocolError(_ERR_BYTES_INVALID_BASE64) from exc\n\n return _NO_DESERIALIZE\n\n\ndef deserialize(value):\n \"\"\"\n Recursively deserialize request values into Python-native types.\n\n Why: requests are JSON-only; we need a small set of explicit decoders\n (currently bytes) to restore Python semantics at the boundary.\n \"\"\"\n decoded = _deserialize_bytes_envelope(value)\n if decoded is not _NO_DESERIALIZE:\n return decoded\n\n if isinstance(value, list):\n return [deserialize(item) for item in value]\n if isinstance(value, dict):\n # Preserve dict shape while decoding nested values.\n return {k: deserialize(v) for k, v in value.items()}\n return value\n\n\n# =============================================================================\n# CAPABILITY DETECTION (lazy, best-effort)\n# =============================================================================\n\ndef arrow_available():\n \"\"\"Return True when pyarrow can be imported.\"\"\"\n try:\n import pyarrow # noqa: F401\n except (ImportError, OSError):\n return False\n return True\n\n\ndef module_available(module_name):\n \"\"\"\n Lightweight feature detection for optional codec dependencies via find_spec.\n\n Why: exposes availability in bridge metadata without importing heavy modules.\n \"\"\"\n try:\n return importlib.util.find_spec(module_name) is not None\n except (ImportError, AttributeError, TypeError, ValueError):\n return False\n\n\ndef is_numpy_array(obj):\n try:\n import numpy as np # noqa: F401\n except Exception:\n return False\n return isinstance(obj, np.ndarray)\n\n\ndef is_pandas_dataframe(obj):\n try:\n import pandas as pd # noqa: F401\n except Exception:\n return False\n return isinstance(obj, pd.DataFrame)\n\n\ndef is_pandas_series(obj):\n try:\n import pandas as pd # noqa: F401\n except Exception:\n return False\n return isinstance(obj, pd.Series)\n\n\ndef is_scipy_sparse(obj):\n try:\n import scipy.sparse as sp # noqa: F401\n except Exception:\n return False\n try:\n return sp.issparse(obj)\n except Exception:\n return False\n\n\ndef is_torch_tensor(obj):\n try:\n import torch # noqa: F401\n except Exception:\n return False\n try:\n return torch.is_tensor(obj)\n except Exception:\n return False\n\n\ndef is_sklearn_estimator(obj):\n try:\n from sklearn.base import BaseEstimator # noqa: F401\n except Exception:\n return False\n return isinstance(obj, BaseEstimator)\n\n\n# =============================================================================\n# MARKER SERIALIZERS (6 __tywrap__ value types)\n# =============================================================================\n#\n# Each serializer accepts force_json_markers. When True, the Arrow path is never\n# taken (used by Pyodide and by the subprocess server in TYWRAP_CODEC_FALLBACK=json\n# mode). The JSON fallback envelopes are byte-identical across both callers, which\n# is what the conformance suite asserts.\n\ndef serialize_ndarray(obj, *, force_json_markers):\n \"\"\"\n Encode a NumPy ndarray. Arrow IPC (compact, lossless) by default; JSON when\n force_json_markers is set or pyarrow is unavailable in fallback mode.\n\n Note: pa.array() only handles 1D arrays; multi-dimensional arrays are\n flattened with shape metadata for JS-side reconstruction. See\n https://github.com/apache/arrow-js/issues/115\n \"\"\"\n if force_json_markers:\n return serialize_ndarray_json(obj)\n try:\n import pyarrow as pa # type: ignore\n except Exception as exc:\n raise RuntimeError(\n 'Arrow encoding unavailable for ndarray; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'\n ) from exc\n try:\n original_shape = list(obj.shape) if hasattr(obj, 'shape') else None\n flat = obj.flatten() if hasattr(obj, 'ndim') and obj.ndim > 1 else obj\n arr = pa.array(flat)\n table = pa.Table.from_arrays([arr], names=['value'])\n sink = pa.BufferOutputStream()\n with pa.ipc.new_stream(sink, table.schema) as writer:\n writer.write_table(table)\n buf = sink.getvalue()\n b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')\n return {\n '__tywrap__': 'ndarray',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'arrow',\n 'b64': b64,\n 'shape': original_shape,\n 'dtype': str(obj.dtype) if hasattr(obj, 'dtype') else None,\n }\n except Exception as exc:\n raise RuntimeError('Arrow encoding failed for ndarray') from exc\n\n\ndef serialize_ndarray_json(obj):\n \"\"\"JSON fallback for ndarray (larger payloads, potential dtype loss).\"\"\"\n try:\n data = obj.tolist()\n except Exception as exc:\n raise RuntimeError('JSON fallback failed for ndarray') from exc\n return {\n '__tywrap__': 'ndarray',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'data': data,\n 'shape': getattr(obj, 'shape', None),\n }\n\n\ndef serialize_dataframe(obj, *, force_json_markers):\n \"\"\"\n Encode a pandas DataFrame. Feather/Arrow-IPC (uncompressed, so apache-arrow\n in JS can read it) by default; JSON when force_json_markers is set.\n \"\"\"\n if force_json_markers:\n return serialize_dataframe_json(obj)\n try:\n import pyarrow as pa # type: ignore\n import pyarrow.feather as feather # type: ignore\n except Exception as exc:\n raise RuntimeError(\n 'Arrow encoding unavailable for pandas.DataFrame; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'\n ) from exc\n try:\n table = pa.Table.from_pandas(obj) # type: ignore\n sink = pa.BufferOutputStream()\n feather.write_feather(table, sink, compression='uncompressed')\n buf = sink.getvalue()\n b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')\n return {\n '__tywrap__': 'dataframe',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'arrow',\n 'b64': b64,\n }\n except Exception as exc:\n raise RuntimeError('Arrow encoding failed for pandas.DataFrame') from exc\n\n\ndef serialize_dataframe_json(obj):\n \"\"\"JSON fallback for DataFrame: records orientation.\"\"\"\n try:\n data = obj.to_dict(orient='records')\n except Exception as exc:\n raise RuntimeError('JSON fallback failed for pandas.DataFrame') from exc\n return {\n '__tywrap__': 'dataframe',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'data': data,\n }\n\n\ndef serialize_series(obj, *, force_json_markers):\n \"\"\"\n Encode a pandas Series as a single-column Arrow Table stream (the JS decoder\n contract is \"table-like\"); JSON when force_json_markers is set.\n \"\"\"\n if force_json_markers:\n return serialize_series_json(obj)\n try:\n import pyarrow as pa # type: ignore\n except Exception as exc:\n raise RuntimeError(\n 'Arrow encoding unavailable for pandas.Series; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'\n ) from exc\n try:\n arr = pa.Array.from_pandas(obj) # type: ignore\n table = pa.Table.from_arrays([arr], names=['value'])\n sink = pa.BufferOutputStream()\n with pa.ipc.new_stream(sink, table.schema) as writer:\n writer.write_table(table)\n buf = sink.getvalue()\n b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')\n return {\n '__tywrap__': 'series',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'arrow',\n 'b64': b64,\n 'name': getattr(obj, 'name', None),\n }\n except Exception as exc:\n raise RuntimeError('Arrow encoding failed for pandas.Series') from exc\n\n\ndef serialize_series_json(obj):\n \"\"\"JSON fallback for Series (potentially lossy dtype/NA representation).\"\"\"\n try:\n data = obj.to_list() # type: ignore\n except Exception:\n try:\n data = obj.to_dict() # type: ignore\n except Exception as exc:\n raise RuntimeError('JSON fallback failed for pandas.Series') from exc\n return {\n '__tywrap__': 'series',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'data': data,\n 'name': getattr(obj, 'name', None),\n }\n\n\ndef serialize_sparse_matrix(obj):\n \"\"\"\n Serialize scipy sparse matrices into structured JSON envelopes (json-only;\n there is no Arrow path). Preserves sparsity; rejects unsupported formats and\n complex dtypes explicitly.\n \"\"\"\n try:\n fmt = obj.getformat()\n except Exception as exc:\n raise RuntimeError('Failed to inspect scipy sparse matrix format') from exc\n\n if fmt not in ('csr', 'csc', 'coo'):\n raise RuntimeError(f'Unsupported scipy sparse format: {fmt}')\n\n dtype = None\n try:\n dtype = str(obj.dtype)\n except Exception:\n dtype = None\n if getattr(obj.dtype, 'kind', None) == 'c':\n raise RuntimeError('Complex sparse matrices are not supported by JSON codec')\n\n if fmt in ('csr', 'csc'):\n data = obj.data.tolist()\n indices = obj.indices.tolist()\n indptr = obj.indptr.tolist()\n return {\n '__tywrap__': 'scipy.sparse',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'format': fmt,\n 'shape': list(obj.shape),\n 'data': data,\n 'indices': indices,\n 'indptr': indptr,\n 'dtype': dtype,\n }\n\n # coo\n data = obj.data.tolist()\n row = obj.row.tolist()\n col = obj.col.tolist()\n return {\n '__tywrap__': 'scipy.sparse',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'format': fmt,\n 'shape': list(obj.shape),\n 'data': data,\n 'row': row,\n 'col': col,\n 'dtype': dtype,\n }\n\n\ndef serialize_torch_tensor(obj, *, force_json_markers, torch_allow_copy=False):\n \"\"\"\n Serialize torch.Tensor values via the nested ndarray envelope. CPU-only by\n default; device/copy behavior is explicit. force_json_markers is threaded\n into the nested ndarray serialization so Pyodide gets a JSON ndarray value.\n \"\"\"\n tensor = obj.detach()\n if getattr(tensor, 'device', None) is not None and tensor.device.type != 'cpu':\n if not torch_allow_copy:\n raise RuntimeError(\n 'Torch tensor is on a non-CPU device; set TYWRAP_TORCH_ALLOW_COPY=1 to allow CPU transfer'\n )\n tensor = tensor.to('cpu')\n if hasattr(tensor, 'is_contiguous') and not tensor.is_contiguous():\n if not torch_allow_copy:\n raise RuntimeError(\n 'Torch tensor is not contiguous; set TYWRAP_TORCH_ALLOW_COPY=1 to allow contiguous copy'\n )\n tensor = tensor.contiguous()\n try:\n arr = tensor.numpy()\n except Exception as exc:\n raise RuntimeError('Failed to convert torch.Tensor to numpy') from exc\n\n return {\n '__tywrap__': 'torch.tensor',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'ndarray',\n 'value': serialize_ndarray(arr, force_json_markers=force_json_markers),\n 'shape': list(tensor.shape),\n 'dtype': str(tensor.dtype),\n 'device': str(tensor.device),\n }\n\n\ndef serialize_sklearn_estimator(obj):\n \"\"\"Serialize sklearn estimators as metadata only (json-only); no pickling.\"\"\"\n try:\n import sklearn # noqa: F401\n except Exception as exc:\n raise RuntimeError('scikit-learn is not available') from exc\n\n params = obj.get_params(deep=False)\n try:\n json.dumps(params)\n except Exception as exc:\n raise RuntimeError(\n 'scikit-learn estimator params are not JSON-serializable; avoid returning estimators or sanitize params'\n ) from exc\n\n return {\n '__tywrap__': 'sklearn.estimator',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'className': obj.__class__.__name__,\n 'module': obj.__class__.__module__,\n 'version': getattr(sklearn, '__version__', None),\n 'params': params,\n }\n\n\n_NO_PYDANTIC = object()\n\n\ndef serialize_pydantic(obj):\n \"\"\"\n Serialize Pydantic v2 models via model_dump(by_alias=True, mode='json')\n without importing Pydantic. Returns _NO_PYDANTIC when obj is not a model.\n \"\"\"\n model_dump = getattr(obj, 'model_dump', None)\n if not callable(model_dump):\n return _NO_PYDANTIC\n try:\n try:\n return model_dump(by_alias=True, mode='json')\n except TypeError:\n # Older Pydantic versions may not support `mode=...`.\n return model_dump(by_alias=True)\n except Exception as exc:\n raise RuntimeError(f'model_dump failed: {exc}') from exc\n\n\ndef serialize_stdlib(obj):\n \"\"\"Coerce common stdlib scalar types to JSON-safe forms; None otherwise.\"\"\"\n if isinstance(obj, dt.datetime):\n return obj.isoformat()\n if isinstance(obj, dt.date):\n return obj.isoformat()\n if isinstance(obj, dt.time):\n return obj.isoformat()\n if isinstance(obj, dt.timedelta):\n return obj.total_seconds()\n if isinstance(obj, decimal.Decimal):\n return str(obj)\n if isinstance(obj, uuid.UUID):\n return str(obj)\n if isinstance(obj, (Path, PurePath)):\n return str(obj)\n return None\n\n\ndef serialize(obj, *, force_json_markers, torch_allow_copy=False):\n \"\"\"\n Top-level result serializer. Dispatch order is significant: numpy ndarray ->\n dataframe -> series -> scipy.sparse -> torch -> sklearn -> Pydantic -> stdlib\n -> passthrough. The remaining BridgeCodec value behaviors (numpy/pandas scalars,\n bytes, sets, complex rejection, NaN/Infinity) are applied later during JSON\n encoding by default_encoder.\n \"\"\"\n if is_numpy_array(obj):\n return serialize_ndarray(obj, force_json_markers=force_json_markers)\n if is_pandas_dataframe(obj):\n return serialize_dataframe(obj, force_json_markers=force_json_markers)\n if is_pandas_series(obj):\n return serialize_series(obj, force_json_markers=force_json_markers)\n if is_scipy_sparse(obj):\n return serialize_sparse_matrix(obj)\n if is_torch_tensor(obj):\n return serialize_torch_tensor(\n obj, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy\n )\n if is_sklearn_estimator(obj):\n return serialize_sklearn_estimator(obj)\n pydantic_value = serialize_pydantic(obj)\n if pydantic_value is not _NO_PYDANTIC:\n return pydantic_value\n stdlib_value = serialize_stdlib(obj)\n if stdlib_value is not None:\n return stdlib_value\n return obj\n\n\n# =============================================================================\n# JSON ENCODE: BridgeCodec-equivalent value handling (NaN reject, scalars, bytes)\n# =============================================================================\n#\n# This mirrors BridgeCodec._default_encoder (runtime/safe_codec.py) for the VALUE\n# behaviors that are part of the wire contract. The subprocess server still uses\n# the real BridgeCodec for its final encode (it also enforces size limits); this\n# core encoder exists so the Pyodide server gets identical value handling without\n# depending on safe_codec.py. The conformance suite asserts these behaviors match.\n\ndef _is_nan_or_inf(value):\n if not isinstance(value, (int, float)):\n return False\n try:\n return math.isnan(value) or math.isinf(value)\n except (TypeError, ValueError):\n return False\n\n\ndef _is_numpy_scalar(obj):\n try:\n import numpy as np\n except ImportError:\n return False\n return isinstance(obj, (np.generic, np.ndarray)) and obj.ndim == 0\n\n\ndef _is_pandas_scalar(obj):\n try:\n import pandas as pd\n except ImportError:\n return False\n return isinstance(obj, (pd.Timestamp, pd.Timedelta, type(pd.NaT)))\n\n\ndef make_default_encoder(*, allow_nan):\n \"\"\"\n Build a json.dumps default= encoder matching BridgeCodec's value handling.\n\n Raises CodecError for NaN/Infinity extracted from numpy scalars (json.dumps\n itself rejects top-level/nested NaN/Infinity floats when allow_nan=False).\n \"\"\"\n\n def default_encoder(obj):\n # numpy/pandas scalars first (need .item() extraction).\n if _is_numpy_scalar(obj):\n extracted = obj.item()\n if not allow_nan and _is_nan_or_inf(extracted):\n raise CodecError('Cannot serialize NaN - NaN/Infinity not allowed in JSON')\n return extracted\n\n if _is_pandas_scalar(obj):\n try:\n import pandas as pd\n except ImportError:\n pass\n else:\n if obj is pd.NaT or (hasattr(pd, 'isna') and pd.isna(obj)):\n return None\n if isinstance(obj, pd.Timestamp):\n return obj.isoformat()\n if isinstance(obj, pd.Timedelta):\n return obj.total_seconds()\n\n if isinstance(obj, dt.datetime):\n return obj.isoformat()\n if isinstance(obj, dt.date):\n return obj.isoformat()\n if isinstance(obj, dt.time):\n return obj.isoformat()\n if isinstance(obj, dt.timedelta):\n return obj.total_seconds()\n if isinstance(obj, decimal.Decimal):\n return str(obj)\n if isinstance(obj, uuid.UUID):\n return str(obj)\n if isinstance(obj, (Path, PurePath)):\n return str(obj)\n\n if isinstance(obj, (bytes, bytearray)):\n return {\n '__type__': 'bytes',\n 'encoding': 'base64',\n 'data': base64.b64encode(obj).decode('ascii'),\n }\n\n model_dump = getattr(obj, 'model_dump', None)\n if callable(model_dump):\n try:\n return model_dump(by_alias=True, mode='json')\n except TypeError:\n return model_dump(by_alias=True)\n\n if isinstance(obj, (set, frozenset)):\n return list(obj)\n\n if isinstance(obj, complex):\n raise TypeError(f'Object of type {type(obj).__name__} is not JSON serializable')\n\n raise TypeError(f'Object of type {type(obj).__name__} is not JSON serializable')\n\n return default_encoder\n\n\ndef encode_value(value, *, allow_nan):\n \"\"\"\n JSON-encode a fully-serialized response value, applying the BridgeCodec-equivalent\n default encoder and rejecting NaN/Infinity when allow_nan is False.\n\n Raises CodecError (wrapping the json.dumps ValueError) on NaN/Infinity, matching\n BridgeCodec's \"Cannot serialize NaN...\" wording so error parity holds.\n \"\"\"\n try:\n return json.dumps(value, default=make_default_encoder(allow_nan=allow_nan), allow_nan=allow_nan)\n except ValueError as exc:\n error_msg = str(exc).lower()\n # json.dumps(allow_nan=False) rejects NaN/Infinity with a ValueError whose\n # wording is Python-version dependent: 3.12+ appends the offending value\n # (\"...not JSON compliant: nan\"), but 3.10/3.11 emit only the canonical\n # \"Out of range float values are not JSON compliant\". Match that phrase too\n # so the typed error message is stable across versions.\n if (\n 'nan' in error_msg\n or 'infinity' in error_msg\n or 'inf' in error_msg\n or 'out of range float' in error_msg\n ):\n raise CodecError('Cannot serialize NaN - NaN/Infinity not allowed in JSON') from exc\n raise CodecError(f'JSON encoding failed: {exc}') from exc\n except TypeError as exc:\n raise CodecError(f'JSON encoding failed: {exc}') from exc\n\n\n# =============================================================================\n# REQUEST VALIDATION + HANDLERS + DISPATCH\n# =============================================================================\n\ndef require_protocol(msg):\n if not isinstance(msg, dict):\n raise ProtocolError('Invalid request payload')\n proto = msg.get('protocol')\n if proto != PROTOCOL:\n raise ProtocolError(f'Invalid protocol: {proto}')\n mid = msg.get('id')\n if not isinstance(mid, int):\n raise ProtocolError(f'Invalid request id: {mid}')\n return mid\n\n\ndef require_str(params, key):\n value = params.get(key)\n if not isinstance(value, str) or not value:\n raise ProtocolError(f'Missing {key}')\n return value\n\n\ndef coerce_list(value, key):\n if value is None:\n return []\n if not isinstance(value, list):\n raise ProtocolError(f'Invalid {key}')\n return value\n\n\ndef coerce_dict(value, key):\n if value is None:\n return {}\n if not isinstance(value, dict):\n raise ProtocolError(f'Invalid {key}')\n return value\n\n\ndef handle_call(params, *, force_json_markers, torch_allow_copy, allowed_modules, allow_private_attrs):\n module_name = require_str(params, 'module')\n function_name = require_str(params, 'functionName')\n args = deserialize(coerce_list(params.get('args'), 'args'))\n kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))\n mod = import_allowed_module(module_name, allowed_modules)\n func = get_allowed_attr(mod, function_name, allow_private_attrs=allow_private_attrs)\n res = func(*args, **kwargs)\n return serialize(res, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy)\n\n\ndef handle_instantiate(params, instances, *, allowed_modules, allow_private_attrs):\n module_name = require_str(params, 'module')\n class_name = require_str(params, 'className')\n args = deserialize(coerce_list(params.get('args'), 'args'))\n kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))\n mod = import_allowed_module(module_name, allowed_modules)\n cls = get_allowed_attr(mod, class_name, allow_private_attrs=allow_private_attrs)\n obj = cls(*args, **kwargs)\n handle_id = str(id(obj))\n instances[handle_id] = obj\n return handle_id\n\n\ndef handle_call_method(params, instances, *, force_json_markers, torch_allow_copy, allow_private_attrs):\n handle_id = require_str(params, 'handle')\n method_name = require_str(params, 'methodName')\n args = deserialize(coerce_list(params.get('args'), 'args'))\n kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))\n if handle_id not in instances:\n raise InstanceHandleError(f'Unknown instance handle: {handle_id}')\n obj = instances[handle_id]\n func = get_allowed_attr(obj, method_name, allow_private_attrs=allow_private_attrs)\n res = func(*args, **kwargs)\n return serialize(res, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy)\n\n\ndef handle_dispose_instance(params, instances):\n handle_id = require_str(params, 'handle')\n if handle_id not in instances:\n return False\n del instances[handle_id]\n return True\n\n\ndef build_meta(instances, *, bridge, pid, python_version, codec_fallback, arrow_available_override=None):\n \"\"\"\n Build the bridge metadata payload.\n\n Field order here is part of the wire contract (the JS validator and the\n documented BridgeInfo shape). Callers supply the backend-specific identity:\n the subprocess server passes bridge='python-subprocess' and a real pid; the\n Pyodide server passes bridge='pyodide' and pid=None.\n\n arrow_available_override: when not None, report this value for arrowAvailable\n instead of probing pyarrow. The Pyodide server forces markers to JSON\n unconditionally, so it advertises arrowAvailable=False regardless of whether\n pyarrow happens to be importable in the WASM environment.\n \"\"\"\n arrow = arrow_available() if arrow_available_override is None else arrow_available_override\n return {\n 'protocol': PROTOCOL,\n 'protocolVersion': PROTOCOL_VERSION,\n 'bridge': bridge,\n 'pythonVersion': python_version,\n 'pid': pid,\n 'codecFallback': codec_fallback,\n 'arrowAvailable': arrow,\n 'scipyAvailable': module_available('scipy'),\n 'torchAvailable': module_available('torch'),\n 'sklearnAvailable': module_available('sklearn'),\n 'instances': len(instances),\n }\n\n\ndef dispatch_request(\n msg,\n instances,\n *,\n bridge,\n pid,\n force_json_markers,\n allow_nan=False,\n python_version=None,\n torch_allow_copy=False,\n arrow_available_override=None,\n allowed_modules=None,\n allow_private_attrs=False,\n):\n \"\"\"\n Validate and route a request, returning the fully-serialized response dict\n ({'id', 'protocol', 'result'}). Raises ProtocolError for malformed requests\n and propagates handler exceptions to the caller, which is responsible for\n building the error envelope (so it controls traceback inclusion).\n\n allow_nan is accepted for signature symmetry; NaN rejection happens during\n the final encode_value() call, which the caller performs.\n\n allowed_modules: None (default) disables the import allowlist so existing\n behavior is preserved. Supplying a set restricts call/instantiate imports to\n those modules (plus the stdlib the bridge itself needs) and raises\n ImportNotAllowedError otherwise. allow_private_attrs=False (default) blocks\n getattr of underscore-prefixed names; True restores unrestricted access. See\n the IMPORT / ATTRIBUTE ALLOWLIST section above for the full trust model.\n \"\"\"\n mid = require_protocol(msg)\n method = msg.get('method')\n if not isinstance(method, str):\n raise ProtocolError('Missing method')\n params = coerce_dict(msg.get('params'), 'params')\n if method == 'call':\n result = handle_call(\n params,\n force_json_markers=force_json_markers,\n torch_allow_copy=torch_allow_copy,\n allowed_modules=allowed_modules,\n allow_private_attrs=allow_private_attrs,\n )\n elif method == 'instantiate':\n result = handle_instantiate(\n params, instances, allowed_modules=allowed_modules, allow_private_attrs=allow_private_attrs\n )\n elif method == 'call_method':\n result = handle_call_method(\n params,\n instances,\n force_json_markers=force_json_markers,\n torch_allow_copy=torch_allow_copy,\n allow_private_attrs=allow_private_attrs,\n )\n elif method == 'dispose_instance':\n result = handle_dispose_instance(params, instances)\n elif method == 'meta':\n if python_version is None:\n import sys\n python_version = sys.version.split()[0]\n codec_fallback = 'json' if force_json_markers else 'none'\n result = build_meta(\n instances,\n bridge=bridge,\n pid=pid,\n python_version=python_version,\n codec_fallback=codec_fallback,\n arrow_available_override=arrow_available_override,\n )\n else:\n raise ProtocolError(f'Unknown method: {method}')\n return {'id': mid, 'protocol': PROTOCOL, 'result': result}\n\n\ndef build_error_payload(mid, exc, *, include_traceback):\n \"\"\"\n Build a protocol error response. Protocol/validation errors omit traceback;\n handler errors include it. Field order matches the reference server.\n \"\"\"\n error = {'type': type(exc).__name__, 'message': str(exc)}\n if include_traceback:\n error['traceback'] = traceback.format_exc()\n return {\n 'id': mid if mid is not None else -1,\n 'protocol': PROTOCOL,\n 'error': error,\n }\n";
|
|
12
|
+
export const PYODIDE_BRIDGE_CORE_SOURCE: string = "\"\"\"\nShared tywrap bridge core: protocol dispatch + value (de)serialization.\n\nThis module is the SINGLE source of truth for the \"tywrap/1\" server-side\nprotocol. It is imported by:\n\n - runtime/python_bridge.py (the Node/Bun/Deno subprocess server and the HTTP\n server), which owns I/O concerns: the stdin/stdout JSONL loop, env-var size\n guards, the real OS pid, bridge='python-subprocess', and the final BridgeCodec\n encode wrapper.\n\n - the in-WASM Pyodide server (src/runtime/pyodide-transport.ts). Pyodide cannot read\n this file from disk, so it is shipped as a build-time-generated TypeScript\n string constant (src/runtime/pyodide-bootstrap-core.generated.ts) produced by\n scripts/generate-pyodide-bootstrap.mjs and exec'd into a module registered in\n sys.modules. A conformance drift guard (test/runtime_conformance.test.ts)\n asserts the generated constant stays byte-identical to this file.\n\nCROSS-LANGUAGE CONTRACT (Python <-> the TypeScript decoder in src/utils/codec.ts\nand the request encoder in src/runtime/bridge-codec.ts):\n\n * Every value-type \"marker\" envelope carries {'__tywrap__': <type>,\n 'codecVersion': 1, 'encoding': ...}. The 6 markers are: ndarray, dataframe,\n series, scipy.sparse, torch.tensor, sklearn.estimator.\n * bytes round-trip both ways via base64 envelopes (see _deserialize_bytes_*\n and the bytes branch of default_encoder).\n * NaN/Infinity are rejected (the JS client cannot parse the non-standard tokens\n that allow_nan=True would emit).\n\nPURITY: This module depends only on the standard library plus LAZY optional\nimports (numpy/pandas/scipy/torch/sklearn/pyarrow are each imported inside the\nfunction that needs them). It performs no stdin/stdout I/O and reads no env vars,\nso it runs unchanged under CPython-in-WASM (Pyodide).\n\nforce_json_markers: a *parameter* threaded through every serializer (including\nthe nested torch.tensor -> ndarray call). When True, ndarray/dataframe/series are\nforced down their JSON path regardless of pyarrow availability. Pyodide passes\nTrue (Arrow is unavailable in WASM); the subprocess server passes the boolean\nderived from TYWRAP_CODEC_FALLBACK=json so that \"Node in json-fallback mode\" and\n\"Pyodide\" produce byte-identical marker envelopes.\n\"\"\"\n\nimport base64\nimport datetime as dt\nimport decimal\nimport functools\nimport importlib\nimport importlib.util\nimport inspect\nimport json\nimport math\nimport traceback\nimport uuid\nfrom pathlib import Path, PurePath\n\n# Protocol constants. These MUST match src/runtime/protocol.ts (PROTOCOL_ID,\n# TYWRAP_PROTOCOL_VERSION) and the codec version baked into marker envelopes.\nPROTOCOL = 'tywrap/1'\nPROTOCOL_VERSION = 1\nCODEC_VERSION = 1\n\n\nclass ProtocolError(Exception):\n \"\"\"Raised for malformed requests (bad protocol/id/method/params).\"\"\"\n\n\nclass InstanceHandleError(ValueError):\n \"\"\"Raised when an instance handle is unknown or no longer valid.\"\"\"\n\n\nclass ImportNotAllowedError(PermissionError):\n \"\"\"Raised when a requested module import is not on the active allowlist.\"\"\"\n\n def __init__(self, module_name):\n super().__init__(\n f'Import of module {module_name!r} is not permitted by the tywrap bridge '\n 'allowlist; add it to TYWRAP_ALLOWED_MODULES (subprocess) or the '\n 'allowed_modules parameter to enable it'\n )\n\n\nclass AttributeNotAllowedError(PermissionError):\n \"\"\"Raised when access to a private/dunder attribute is denied by policy.\"\"\"\n\n def __init__(self, attr_name):\n super().__init__(\n f'Access to attribute {attr_name!r} is not permitted by the tywrap bridge: '\n 'underscore-prefixed (private/dunder) attributes are blocked to prevent '\n 'sandbox-escape via attributes like __globals__/__subclasses__/__builtins__; '\n 'set TYWRAP_ALLOW_PRIVATE_ATTRS=1 (subprocess) or pass allow_private_attrs=True '\n 'to override'\n )\n\n\n# =============================================================================\n# IMPORT / ATTRIBUTE ALLOWLIST (trust boundary enforcement)\n# =============================================================================\n#\n# The bridge dispatches call/instantiate/call_method by importing the requested\n# module and getattr-ing the requested function/class/method. That is an\n# arbitrary import+getattr+call surface, so two complementary guards live here.\n# Both are PURE (no env reads) so the rules behave identically under the\n# subprocess server and the in-WASM Pyodide server; the subprocess server derives\n# the parameters from env vars (TYWRAP_ALLOWED_MODULES / TYWRAP_ALLOW_PRIVATE_ATTRS)\n# and threads them in, exactly like force_json_markers / torch_allow_copy.\n#\n# 1. MODULE ALLOWLIST (opt-in, default = allow all):\n# allowed_modules=None means \"no restriction\" so existing configurations keep\n# working unchanged. When a caller supplies a set, only those modules (plus the\n# stdlib the bridge itself needs to serialize results, see _BRIDGE_REQUIRED_MODULES)\n# may be imported; submodules of an allowed module are permitted (e.g. allowing\n# 'scipy' also allows 'scipy.sparse'). A non-allowlisted import fails LOUDLY with\n# ImportNotAllowedError rather than silently importing.\n#\n# 2. PRIVATE-ATTRIBUTE BLOCK (default ON):\n# getattr of any name starting with '_' (single-underscore private OR dunder) is\n# rejected. This blocks the classic escape chain (obj.__class__.__subclasses__()\n# /__globals__/__builtins__/__import__) without depending on the module allowlist.\n# tywrap-generated wrappers never reference underscore-prefixed names (the IR\n# analyzer skips them), so this does not regress generated code. Set\n# allow_private_attrs=True to restore unrestricted getattr for trusted callers.\n\n# Stdlib modules the bridge's own serialization/handlers may need to import even\n# when a caller-supplied allowlist is active. Optional codec deps (numpy, pandas,\n# scipy, torch, sklearn, pyarrow) are intentionally NOT here: if a caller restricts\n# modules, they must opt those in explicitly. These names cover only what the\n# bridge core itself imports.\n_BRIDGE_REQUIRED_MODULES = frozenset(\n {\n 'base64',\n 'datetime',\n 'decimal',\n 'importlib',\n 'json',\n 'math',\n 'sys',\n 'traceback',\n 'uuid',\n 'pathlib',\n }\n)\n\n\ndef _top_level_package(module_name):\n \"\"\"Return the top-level package of a dotted module name ('a.b.c' -> 'a').\"\"\"\n return module_name.split('.', 1)[0]\n\n\ndef _is_module_allowed(module_name, allowed_modules):\n \"\"\"\n Return True when module_name may be imported under the active policy.\n\n allowed_modules=None disables enforcement (allow all). Otherwise a module is\n allowed when it (or its top-level package) is explicitly listed, or it is one\n of the stdlib modules the bridge itself requires.\n \"\"\"\n if allowed_modules is None:\n return True\n if module_name in allowed_modules or module_name in _BRIDGE_REQUIRED_MODULES:\n return True\n top = _top_level_package(module_name)\n return top in allowed_modules or top in _BRIDGE_REQUIRED_MODULES\n\n\ndef import_allowed_module(module_name, allowed_modules):\n \"\"\"\n Import module_name only if permitted by the allowlist, else raise loudly.\n\n This is the single chokepoint every handler routes module imports through.\n \"\"\"\n if not _is_module_allowed(module_name, allowed_modules):\n raise ImportNotAllowedError(module_name)\n return importlib.import_module(module_name)\n\n\ndef get_allowed_attr(obj, attr_name, *, allow_private_attrs):\n \"\"\"\n getattr(obj, attr_name) with the private/dunder block applied.\n\n Rejects any underscore-prefixed name unless allow_private_attrs is True. This\n is the single chokepoint every handler routes attribute access through.\n \"\"\"\n if not allow_private_attrs and attr_name.startswith('_'):\n raise AttributeNotAllowedError(attr_name)\n return getattr(obj, attr_name)\n\n\ndef resolve_allowed_attr_path(root, dotted_name, *, allow_private_attrs):\n \"\"\"\n Resolve a possibly-dotted attribute path from root, applying the\n private/dunder getattr guard to EVERY segment.\n\n A single segment (the common case, e.g. a module-level function) behaves\n exactly like get_allowed_attr. Dotted names exist because @classmethod and\n @staticmethod are invoked through their owning class: the generated wrapper\n emits call(module, 'Class.method', ...), so the bridge must walk\n module -> Class -> method. Guarding each segment means 'Class._secret' or\n '_Hidden.method' are rejected exactly as a direct private getattr would be —\n the dotted path opens no access the single-getattr path did not already.\n \"\"\"\n obj = root\n for segment in dotted_name.split('.'):\n obj = get_allowed_attr(obj, segment, allow_private_attrs=allow_private_attrs)\n return obj\n\n\ndef is_accessor_attr(obj, attr_name):\n \"\"\"\n True when attr_name resolves to a @property or functools.cached_property on\n obj's type — i.e. it is read by attribute access, not called.\n\n Inspects type(obj)'s MRO via getattr_static (which never triggers the\n descriptor protocol), NOT the instance dict. That matters for\n cached_property: after the first read it stores its value in the instance\n __dict__, so an instance-level static lookup would return the cached value\n rather than the descriptor and misclassify it as a method on the next read.\n Reading from the type keeps the classification stable across repeated reads.\n \"\"\"\n descriptor = inspect.getattr_static(type(obj), attr_name, None)\n return isinstance(descriptor, (property, functools.cached_property))\n\n\nclass CodecError(Exception):\n \"\"\"Raised when value encoding fails (e.g. NaN/Infinity not allowed).\"\"\"\n\n\n# =============================================================================\n# REQUEST-SIDE DESERIALIZATION (bytes envelopes -> Python bytes)\n# =============================================================================\n\n_NO_DESERIALIZE = object()\n_ERR_BYTES_MISSING_B64 = 'Invalid bytes envelope: missing b64'\n_ERR_BYTES_MISSING_DATA = 'Invalid bytes envelope: missing data'\n_ERR_BYTES_INVALID_BASE64 = 'Invalid bytes envelope: invalid base64'\n\n\ndef _deserialize_bytes_envelope(value):\n \"\"\"\n Decode base64-encoded bytes envelopes from JS into Python bytes.\n\n Supported shapes:\n - { \"__tywrap_bytes__\": true, \"b64\": \"...\" } (JS BridgeCodec.encodeRequest)\n - { \"__type__\": \"bytes\", \"encoding\": \"base64\", \"data\": \"...\" } (legacy/compat)\n\n Why: TS BridgeCodec encodes Uint8Array/ArrayBuffer as base64 objects, but\n Python handlers expect real bytes/bytearray to preserve behavior (e.g., len()).\n \"\"\"\n if not isinstance(value, dict):\n return _NO_DESERIALIZE\n\n if value.get('__tywrap_bytes__') is True:\n b64 = value.get('b64')\n if not isinstance(b64, str):\n raise ProtocolError(_ERR_BYTES_MISSING_B64)\n try:\n return base64.b64decode(b64, validate=True)\n except Exception as exc:\n raise ProtocolError(_ERR_BYTES_INVALID_BASE64) from exc\n\n if value.get('__type__') == 'bytes' and value.get('encoding') == 'base64':\n data = value.get('data')\n if not isinstance(data, str):\n raise ProtocolError(_ERR_BYTES_MISSING_DATA)\n try:\n return base64.b64decode(data, validate=True)\n except Exception as exc:\n raise ProtocolError(_ERR_BYTES_INVALID_BASE64) from exc\n\n return _NO_DESERIALIZE\n\n\ndef deserialize(value):\n \"\"\"\n Recursively deserialize request values into Python-native types.\n\n Why: requests are JSON-only; we need a small set of explicit decoders\n (currently bytes) to restore Python semantics at the boundary.\n \"\"\"\n decoded = _deserialize_bytes_envelope(value)\n if decoded is not _NO_DESERIALIZE:\n return decoded\n\n if isinstance(value, list):\n return [deserialize(item) for item in value]\n if isinstance(value, dict):\n # Preserve dict shape while decoding nested values.\n return {k: deserialize(v) for k, v in value.items()}\n return value\n\n\n# =============================================================================\n# CAPABILITY DETECTION (lazy, best-effort)\n# =============================================================================\n\ndef arrow_available():\n \"\"\"Return True when pyarrow can be imported.\"\"\"\n try:\n import pyarrow # noqa: F401\n except (ImportError, OSError):\n return False\n return True\n\n\ndef module_available(module_name):\n \"\"\"\n Lightweight feature detection for optional codec dependencies via find_spec.\n\n Why: exposes availability in bridge metadata without importing heavy modules.\n \"\"\"\n try:\n return importlib.util.find_spec(module_name) is not None\n except (ImportError, AttributeError, TypeError, ValueError):\n return False\n\n\ndef is_numpy_array(obj):\n try:\n import numpy as np # noqa: F401\n except Exception:\n return False\n return isinstance(obj, np.ndarray)\n\n\ndef is_pandas_dataframe(obj):\n try:\n import pandas as pd # noqa: F401\n except Exception:\n return False\n return isinstance(obj, pd.DataFrame)\n\n\ndef is_pandas_series(obj):\n try:\n import pandas as pd # noqa: F401\n except Exception:\n return False\n return isinstance(obj, pd.Series)\n\n\ndef is_scipy_sparse(obj):\n try:\n import scipy.sparse as sp # noqa: F401\n except Exception:\n return False\n try:\n return sp.issparse(obj)\n except Exception:\n return False\n\n\ndef is_torch_tensor(obj):\n try:\n import torch # noqa: F401\n except Exception:\n return False\n try:\n return torch.is_tensor(obj)\n except Exception:\n return False\n\n\ndef is_sklearn_estimator(obj):\n try:\n from sklearn.base import BaseEstimator # noqa: F401\n except Exception:\n return False\n return isinstance(obj, BaseEstimator)\n\n\n# =============================================================================\n# MARKER SERIALIZERS (6 __tywrap__ value types)\n# =============================================================================\n#\n# Each serializer accepts force_json_markers. When True, the Arrow path is never\n# taken (used by Pyodide and by the subprocess server in TYWRAP_CODEC_FALLBACK=json\n# mode). The JSON fallback envelopes are byte-identical across both callers, which\n# is what the conformance suite asserts.\n\ndef serialize_ndarray(obj, *, force_json_markers):\n \"\"\"\n Encode a NumPy ndarray. Arrow IPC (compact, lossless) by default; JSON when\n force_json_markers is set or pyarrow is unavailable in fallback mode.\n\n Note: pa.array() only handles 1D arrays; multi-dimensional arrays are\n flattened with shape metadata for JS-side reconstruction. See\n https://github.com/apache/arrow-js/issues/115\n \"\"\"\n if force_json_markers:\n return serialize_ndarray_json(obj)\n try:\n import pyarrow as pa # type: ignore\n except Exception as exc:\n raise RuntimeError(\n 'Arrow encoding unavailable for ndarray; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'\n ) from exc\n try:\n original_shape = list(obj.shape) if hasattr(obj, 'shape') else None\n flat = obj.flatten() if hasattr(obj, 'ndim') and obj.ndim > 1 else obj\n arr = pa.array(flat)\n table = pa.Table.from_arrays([arr], names=['value'])\n sink = pa.BufferOutputStream()\n with pa.ipc.new_stream(sink, table.schema) as writer:\n writer.write_table(table)\n buf = sink.getvalue()\n b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')\n return {\n '__tywrap__': 'ndarray',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'arrow',\n 'b64': b64,\n 'shape': original_shape,\n 'dtype': str(obj.dtype) if hasattr(obj, 'dtype') else None,\n }\n except Exception as exc:\n raise RuntimeError('Arrow encoding failed for ndarray') from exc\n\n\ndef serialize_ndarray_json(obj):\n \"\"\"JSON fallback for ndarray (larger payloads, potential dtype loss).\"\"\"\n try:\n data = obj.tolist()\n except Exception as exc:\n raise RuntimeError('JSON fallback failed for ndarray') from exc\n return {\n '__tywrap__': 'ndarray',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'data': data,\n 'shape': getattr(obj, 'shape', None),\n }\n\n\ndef serialize_dataframe(obj, *, force_json_markers):\n \"\"\"\n Encode a pandas DataFrame. Feather/Arrow-IPC (uncompressed, so apache-arrow\n in JS can read it) by default; JSON when force_json_markers is set.\n \"\"\"\n if force_json_markers:\n return serialize_dataframe_json(obj)\n try:\n import pyarrow as pa # type: ignore\n import pyarrow.feather as feather # type: ignore\n except Exception as exc:\n raise RuntimeError(\n 'Arrow encoding unavailable for pandas.DataFrame; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'\n ) from exc\n try:\n table = pa.Table.from_pandas(obj) # type: ignore\n sink = pa.BufferOutputStream()\n feather.write_feather(table, sink, compression='uncompressed')\n buf = sink.getvalue()\n b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')\n return {\n '__tywrap__': 'dataframe',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'arrow',\n 'b64': b64,\n }\n except Exception as exc:\n raise RuntimeError('Arrow encoding failed for pandas.DataFrame') from exc\n\n\ndef serialize_dataframe_json(obj):\n \"\"\"JSON fallback for DataFrame: records orientation.\"\"\"\n try:\n data = obj.to_dict(orient='records')\n except Exception as exc:\n raise RuntimeError('JSON fallback failed for pandas.DataFrame') from exc\n return {\n '__tywrap__': 'dataframe',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'data': data,\n }\n\n\ndef serialize_series(obj, *, force_json_markers):\n \"\"\"\n Encode a pandas Series as a single-column Arrow Table stream (the JS decoder\n contract is \"table-like\"); JSON when force_json_markers is set.\n \"\"\"\n if force_json_markers:\n return serialize_series_json(obj)\n try:\n import pyarrow as pa # type: ignore\n except Exception as exc:\n raise RuntimeError(\n 'Arrow encoding unavailable for pandas.Series; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'\n ) from exc\n try:\n arr = pa.Array.from_pandas(obj) # type: ignore\n table = pa.Table.from_arrays([arr], names=['value'])\n sink = pa.BufferOutputStream()\n with pa.ipc.new_stream(sink, table.schema) as writer:\n writer.write_table(table)\n buf = sink.getvalue()\n b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')\n return {\n '__tywrap__': 'series',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'arrow',\n 'b64': b64,\n 'name': getattr(obj, 'name', None),\n }\n except Exception as exc:\n raise RuntimeError('Arrow encoding failed for pandas.Series') from exc\n\n\ndef serialize_series_json(obj):\n \"\"\"JSON fallback for Series (potentially lossy dtype/NA representation).\"\"\"\n try:\n data = obj.to_list() # type: ignore\n except Exception:\n try:\n data = obj.to_dict() # type: ignore\n except Exception as exc:\n raise RuntimeError('JSON fallback failed for pandas.Series') from exc\n return {\n '__tywrap__': 'series',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'data': data,\n 'name': getattr(obj, 'name', None),\n }\n\n\ndef serialize_sparse_matrix(obj):\n \"\"\"\n Serialize scipy sparse matrices into structured JSON envelopes (json-only;\n there is no Arrow path). Preserves sparsity; rejects unsupported formats and\n complex dtypes explicitly.\n \"\"\"\n try:\n fmt = obj.getformat()\n except Exception as exc:\n raise RuntimeError('Failed to inspect scipy sparse matrix format') from exc\n\n if fmt not in ('csr', 'csc', 'coo'):\n raise RuntimeError(f'Unsupported scipy sparse format: {fmt}')\n\n dtype = None\n try:\n dtype = str(obj.dtype)\n except Exception:\n dtype = None\n if getattr(obj.dtype, 'kind', None) == 'c':\n raise RuntimeError('Complex sparse matrices are not supported by JSON codec')\n\n if fmt in ('csr', 'csc'):\n data = obj.data.tolist()\n indices = obj.indices.tolist()\n indptr = obj.indptr.tolist()\n return {\n '__tywrap__': 'scipy.sparse',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'format': fmt,\n 'shape': list(obj.shape),\n 'data': data,\n 'indices': indices,\n 'indptr': indptr,\n 'dtype': dtype,\n }\n\n # coo\n data = obj.data.tolist()\n row = obj.row.tolist()\n col = obj.col.tolist()\n return {\n '__tywrap__': 'scipy.sparse',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'format': fmt,\n 'shape': list(obj.shape),\n 'data': data,\n 'row': row,\n 'col': col,\n 'dtype': dtype,\n }\n\n\ndef serialize_torch_tensor(obj, *, force_json_markers, torch_allow_copy=False):\n \"\"\"\n Serialize torch.Tensor values via the nested ndarray envelope. CPU-only by\n default; device/copy behavior is explicit. force_json_markers is threaded\n into the nested ndarray serialization so Pyodide gets a JSON ndarray value.\n \"\"\"\n tensor = obj.detach()\n if getattr(tensor, 'device', None) is not None and tensor.device.type != 'cpu':\n if not torch_allow_copy:\n raise RuntimeError(\n 'Torch tensor is on a non-CPU device; set TYWRAP_TORCH_ALLOW_COPY=1 to allow CPU transfer'\n )\n tensor = tensor.to('cpu')\n if hasattr(tensor, 'is_contiguous') and not tensor.is_contiguous():\n if not torch_allow_copy:\n raise RuntimeError(\n 'Torch tensor is not contiguous; set TYWRAP_TORCH_ALLOW_COPY=1 to allow contiguous copy'\n )\n tensor = tensor.contiguous()\n try:\n arr = tensor.numpy()\n except Exception as exc:\n raise RuntimeError('Failed to convert torch.Tensor to numpy') from exc\n\n return {\n '__tywrap__': 'torch.tensor',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'ndarray',\n 'value': serialize_ndarray(arr, force_json_markers=force_json_markers),\n 'shape': list(tensor.shape),\n 'dtype': str(tensor.dtype),\n 'device': str(tensor.device),\n }\n\n\ndef serialize_sklearn_estimator(obj):\n \"\"\"Serialize sklearn estimators as metadata only (json-only); no pickling.\"\"\"\n try:\n import sklearn # noqa: F401\n except Exception as exc:\n raise RuntimeError('scikit-learn is not available') from exc\n\n params = obj.get_params(deep=False)\n try:\n json.dumps(params)\n except Exception as exc:\n raise RuntimeError(\n 'scikit-learn estimator params are not JSON-serializable; avoid returning estimators or sanitize params'\n ) from exc\n\n return {\n '__tywrap__': 'sklearn.estimator',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'className': obj.__class__.__name__,\n 'module': obj.__class__.__module__,\n 'version': getattr(sklearn, '__version__', None),\n 'params': params,\n }\n\n\n_NO_PYDANTIC = object()\n\n\ndef serialize_pydantic(obj):\n \"\"\"\n Serialize Pydantic v2 models via model_dump(by_alias=True, mode='json')\n without importing Pydantic. Returns _NO_PYDANTIC when obj is not a model.\n \"\"\"\n model_dump = getattr(obj, 'model_dump', None)\n if not callable(model_dump):\n return _NO_PYDANTIC\n try:\n try:\n return model_dump(by_alias=True, mode='json')\n except TypeError:\n # Older Pydantic versions may not support `mode=...`.\n return model_dump(by_alias=True)\n except Exception as exc:\n raise RuntimeError(f'model_dump failed: {exc}') from exc\n\n\ndef serialize_stdlib(obj):\n \"\"\"Coerce common stdlib scalar types to JSON-safe forms; None otherwise.\"\"\"\n if isinstance(obj, dt.datetime):\n return obj.isoformat()\n if isinstance(obj, dt.date):\n return obj.isoformat()\n if isinstance(obj, dt.time):\n return obj.isoformat()\n if isinstance(obj, dt.timedelta):\n return obj.total_seconds()\n if isinstance(obj, decimal.Decimal):\n return str(obj)\n if isinstance(obj, uuid.UUID):\n return str(obj)\n if isinstance(obj, (Path, PurePath)):\n return str(obj)\n return None\n\n\ndef serialize(obj, *, force_json_markers, torch_allow_copy=False):\n \"\"\"\n Top-level result serializer. Dispatch order is significant: numpy ndarray ->\n dataframe -> series -> scipy.sparse -> torch -> sklearn -> Pydantic -> stdlib\n -> passthrough. The remaining BridgeCodec value behaviors (numpy/pandas scalars,\n bytes, sets, complex rejection, NaN/Infinity) are applied later during JSON\n encoding by default_encoder.\n \"\"\"\n if is_numpy_array(obj):\n return serialize_ndarray(obj, force_json_markers=force_json_markers)\n if is_pandas_dataframe(obj):\n return serialize_dataframe(obj, force_json_markers=force_json_markers)\n if is_pandas_series(obj):\n return serialize_series(obj, force_json_markers=force_json_markers)\n if is_scipy_sparse(obj):\n return serialize_sparse_matrix(obj)\n if is_torch_tensor(obj):\n return serialize_torch_tensor(\n obj, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy\n )\n if is_sklearn_estimator(obj):\n return serialize_sklearn_estimator(obj)\n pydantic_value = serialize_pydantic(obj)\n if pydantic_value is not _NO_PYDANTIC:\n return pydantic_value\n stdlib_value = serialize_stdlib(obj)\n if stdlib_value is not None:\n return stdlib_value\n return obj\n\n\n# =============================================================================\n# JSON ENCODE: BridgeCodec-equivalent value handling (NaN reject, scalars, bytes)\n# =============================================================================\n#\n# This mirrors BridgeCodec._default_encoder (runtime/safe_codec.py) for the VALUE\n# behaviors that are part of the wire contract. The subprocess server still uses\n# the real BridgeCodec for its final encode (it also enforces size limits); this\n# core encoder exists so the Pyodide server gets identical value handling without\n# depending on safe_codec.py. The conformance suite asserts these behaviors match.\n\ndef _is_nan_or_inf(value):\n if not isinstance(value, (int, float)):\n return False\n try:\n return math.isnan(value) or math.isinf(value)\n except (TypeError, ValueError):\n return False\n\n\ndef _is_numpy_scalar(obj):\n try:\n import numpy as np\n except ImportError:\n return False\n return isinstance(obj, (np.generic, np.ndarray)) and obj.ndim == 0\n\n\ndef _is_pandas_scalar(obj):\n try:\n import pandas as pd\n except ImportError:\n return False\n return isinstance(obj, (pd.Timestamp, pd.Timedelta, type(pd.NaT)))\n\n\ndef make_default_encoder(*, allow_nan):\n \"\"\"\n Build a json.dumps default= encoder matching BridgeCodec's value handling.\n\n Raises CodecError for NaN/Infinity extracted from numpy scalars (json.dumps\n itself rejects top-level/nested NaN/Infinity floats when allow_nan=False).\n \"\"\"\n\n def default_encoder(obj):\n # numpy/pandas scalars first (need .item() extraction).\n if _is_numpy_scalar(obj):\n extracted = obj.item()\n if not allow_nan and _is_nan_or_inf(extracted):\n raise CodecError('Cannot serialize NaN - NaN/Infinity not allowed in JSON')\n return extracted\n\n if _is_pandas_scalar(obj):\n try:\n import pandas as pd\n except ImportError:\n pass\n else:\n if obj is pd.NaT or (hasattr(pd, 'isna') and pd.isna(obj)):\n return None\n if isinstance(obj, pd.Timestamp):\n return obj.isoformat()\n if isinstance(obj, pd.Timedelta):\n return obj.total_seconds()\n\n if isinstance(obj, dt.datetime):\n return obj.isoformat()\n if isinstance(obj, dt.date):\n return obj.isoformat()\n if isinstance(obj, dt.time):\n return obj.isoformat()\n if isinstance(obj, dt.timedelta):\n return obj.total_seconds()\n if isinstance(obj, decimal.Decimal):\n return str(obj)\n if isinstance(obj, uuid.UUID):\n return str(obj)\n if isinstance(obj, (Path, PurePath)):\n return str(obj)\n\n if isinstance(obj, (bytes, bytearray)):\n return {\n '__type__': 'bytes',\n 'encoding': 'base64',\n 'data': base64.b64encode(obj).decode('ascii'),\n }\n\n model_dump = getattr(obj, 'model_dump', None)\n if callable(model_dump):\n try:\n return model_dump(by_alias=True, mode='json')\n except TypeError:\n return model_dump(by_alias=True)\n\n if isinstance(obj, (set, frozenset)):\n return list(obj)\n\n if isinstance(obj, complex):\n raise TypeError(f'Object of type {type(obj).__name__} is not JSON serializable')\n\n raise TypeError(f'Object of type {type(obj).__name__} is not JSON serializable')\n\n return default_encoder\n\n\ndef encode_value(value, *, allow_nan):\n \"\"\"\n JSON-encode a fully-serialized response value, applying the BridgeCodec-equivalent\n default encoder and rejecting NaN/Infinity when allow_nan is False.\n\n Raises CodecError (wrapping the json.dumps ValueError) on NaN/Infinity, matching\n BridgeCodec's \"Cannot serialize NaN...\" wording so error parity holds.\n \"\"\"\n try:\n return json.dumps(value, default=make_default_encoder(allow_nan=allow_nan), allow_nan=allow_nan)\n except ValueError as exc:\n error_msg = str(exc).lower()\n # json.dumps(allow_nan=False) rejects NaN/Infinity with a ValueError whose\n # wording is Python-version dependent: 3.12+ appends the offending value\n # (\"...not JSON compliant: nan\"), but 3.10/3.11 emit only the canonical\n # \"Out of range float values are not JSON compliant\". Match that phrase too\n # so the typed error message is stable across versions.\n if (\n 'nan' in error_msg\n or 'infinity' in error_msg\n or 'inf' in error_msg\n or 'out of range float' in error_msg\n ):\n raise CodecError('Cannot serialize NaN - NaN/Infinity not allowed in JSON') from exc\n raise CodecError(f'JSON encoding failed: {exc}') from exc\n except TypeError as exc:\n raise CodecError(f'JSON encoding failed: {exc}') from exc\n\n\n# =============================================================================\n# REQUEST VALIDATION + HANDLERS + DISPATCH\n# =============================================================================\n\ndef require_protocol(msg):\n if not isinstance(msg, dict):\n raise ProtocolError('Invalid request payload')\n proto = msg.get('protocol')\n if proto != PROTOCOL:\n raise ProtocolError(f'Invalid protocol: {proto}')\n mid = msg.get('id')\n if not isinstance(mid, int):\n raise ProtocolError(f'Invalid request id: {mid}')\n return mid\n\n\ndef require_str(params, key):\n value = params.get(key)\n if not isinstance(value, str) or not value:\n raise ProtocolError(f'Missing {key}')\n return value\n\n\ndef coerce_list(value, key):\n if value is None:\n return []\n if not isinstance(value, list):\n raise ProtocolError(f'Invalid {key}')\n return value\n\n\ndef coerce_dict(value, key):\n if value is None:\n return {}\n if not isinstance(value, dict):\n raise ProtocolError(f'Invalid {key}')\n return value\n\n\ndef handle_call(params, *, force_json_markers, torch_allow_copy, allowed_modules, allow_private_attrs):\n module_name = require_str(params, 'module')\n function_name = require_str(params, 'functionName')\n args = deserialize(coerce_list(params.get('args'), 'args'))\n kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))\n mod = import_allowed_module(module_name, allowed_modules)\n # function_name may be dotted ('Class.method') for @classmethod/@staticmethod\n # calls, which the generated wrapper routes through call() rather than an\n # instance handle. resolve_allowed_attr_path guards each segment.\n func = resolve_allowed_attr_path(mod, function_name, allow_private_attrs=allow_private_attrs)\n res = func(*args, **kwargs)\n return serialize(res, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy)\n\n\ndef handle_instantiate(params, instances, *, allowed_modules, allow_private_attrs):\n module_name = require_str(params, 'module')\n class_name = require_str(params, 'className')\n args = deserialize(coerce_list(params.get('args'), 'args'))\n kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))\n mod = import_allowed_module(module_name, allowed_modules)\n cls = get_allowed_attr(mod, class_name, allow_private_attrs=allow_private_attrs)\n obj = cls(*args, **kwargs)\n handle_id = str(id(obj))\n instances[handle_id] = obj\n return handle_id\n\n\ndef handle_call_method(params, instances, *, force_json_markers, torch_allow_copy, allow_private_attrs):\n handle_id = require_str(params, 'handle')\n method_name = require_str(params, 'methodName')\n args = deserialize(coerce_list(params.get('args'), 'args'))\n kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))\n if handle_id not in instances:\n raise InstanceHandleError(f'Unknown instance handle: {handle_id}')\n obj = instances[handle_id]\n # A @property / functools.cached_property is read, not called: the generated\n # `get prop()` accessor emits callMethod(handle, name, []). Classify before\n # touching the value (so cached_property is detected on its first read) and\n # return the attribute directly; everything else is a bound method to call.\n if is_accessor_attr(obj, method_name):\n # An accessor is read, never called: a generated `get prop()` always\n # sends empty args. Reject a malformed request that supplies any so it\n # fails loudly instead of silently dropping the arguments.\n if args or kwargs:\n raise ProtocolError(f'Accessor {method_name!r} does not accept arguments')\n res = get_allowed_attr(obj, method_name, allow_private_attrs=allow_private_attrs)\n else:\n func = get_allowed_attr(obj, method_name, allow_private_attrs=allow_private_attrs)\n res = func(*args, **kwargs)\n return serialize(res, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy)\n\n\ndef handle_dispose_instance(params, instances):\n handle_id = require_str(params, 'handle')\n if handle_id not in instances:\n return False\n del instances[handle_id]\n return True\n\n\ndef build_meta(instances, *, bridge, pid, python_version, codec_fallback, arrow_available_override=None):\n \"\"\"\n Build the bridge metadata payload.\n\n Field order here is part of the wire contract (the JS validator and the\n documented BridgeInfo shape). Callers supply the backend-specific identity:\n the subprocess server passes bridge='python-subprocess' and a real pid; the\n Pyodide server passes bridge='pyodide' and pid=None.\n\n arrow_available_override: when not None, report this value for arrowAvailable\n instead of probing pyarrow. The Pyodide server forces markers to JSON\n unconditionally, so it advertises arrowAvailable=False regardless of whether\n pyarrow happens to be importable in the WASM environment.\n \"\"\"\n arrow = arrow_available() if arrow_available_override is None else arrow_available_override\n return {\n 'protocol': PROTOCOL,\n 'protocolVersion': PROTOCOL_VERSION,\n 'bridge': bridge,\n 'pythonVersion': python_version,\n 'pid': pid,\n 'codecFallback': codec_fallback,\n 'arrowAvailable': arrow,\n 'scipyAvailable': module_available('scipy'),\n 'torchAvailable': module_available('torch'),\n 'sklearnAvailable': module_available('sklearn'),\n 'instances': len(instances),\n }\n\n\ndef dispatch_request(\n msg,\n instances,\n *,\n bridge,\n pid,\n force_json_markers,\n allow_nan=False,\n python_version=None,\n torch_allow_copy=False,\n arrow_available_override=None,\n allowed_modules=None,\n allow_private_attrs=False,\n):\n \"\"\"\n Validate and route a request, returning the fully-serialized response dict\n ({'id', 'protocol', 'result'}). Raises ProtocolError for malformed requests\n and propagates handler exceptions to the caller, which is responsible for\n building the error envelope (so it controls traceback inclusion).\n\n allow_nan is accepted for signature symmetry; NaN rejection happens during\n the final encode_value() call, which the caller performs.\n\n allowed_modules: None (default) disables the import allowlist so existing\n behavior is preserved. Supplying a set restricts call/instantiate imports to\n those modules (plus the stdlib the bridge itself needs) and raises\n ImportNotAllowedError otherwise. allow_private_attrs=False (default) blocks\n getattr of underscore-prefixed names; True restores unrestricted access. See\n the IMPORT / ATTRIBUTE ALLOWLIST section above for the full trust model.\n \"\"\"\n mid = require_protocol(msg)\n method = msg.get('method')\n if not isinstance(method, str):\n raise ProtocolError('Missing method')\n params = coerce_dict(msg.get('params'), 'params')\n if method == 'call':\n result = handle_call(\n params,\n force_json_markers=force_json_markers,\n torch_allow_copy=torch_allow_copy,\n allowed_modules=allowed_modules,\n allow_private_attrs=allow_private_attrs,\n )\n elif method == 'instantiate':\n result = handle_instantiate(\n params, instances, allowed_modules=allowed_modules, allow_private_attrs=allow_private_attrs\n )\n elif method == 'call_method':\n result = handle_call_method(\n params,\n instances,\n force_json_markers=force_json_markers,\n torch_allow_copy=torch_allow_copy,\n allow_private_attrs=allow_private_attrs,\n )\n elif method == 'dispose_instance':\n result = handle_dispose_instance(params, instances)\n elif method == 'meta':\n if python_version is None:\n import sys\n python_version = sys.version.split()[0]\n codec_fallback = 'json' if force_json_markers else 'none'\n result = build_meta(\n instances,\n bridge=bridge,\n pid=pid,\n python_version=python_version,\n codec_fallback=codec_fallback,\n arrow_available_override=arrow_available_override,\n )\n else:\n raise ProtocolError(f'Unknown method: {method}')\n return {'id': mid, 'protocol': PROTOCOL, 'result': result}\n\n\ndef build_error_payload(mid, exc, *, include_traceback):\n \"\"\"\n Build a protocol error response. Protocol/validation errors omit traceback;\n handler errors include it. Field order matches the reference server.\n \"\"\"\n error = {'type': type(exc).__name__, 'message': str(exc)}\n if include_traceback:\n error['traceback'] = traceback.format_exc()\n return {\n 'id': mid if mid is not None else -1,\n 'protocol': PROTOCOL,\n 'error': error,\n }\n";
|
|
@@ -20,6 +20,7 @@ import { PYODIDE_BRIDGE_CORE_SOURCE } from './pyodide-bootstrap-core.generated.j
|
|
|
20
20
|
import {
|
|
21
21
|
PROTOCOL_ID,
|
|
22
22
|
type Transport,
|
|
23
|
+
type TransportCapabilities,
|
|
23
24
|
type ProtocolMessage,
|
|
24
25
|
type ProtocolResponse,
|
|
25
26
|
} from './transport.js';
|
|
@@ -341,6 +342,27 @@ export class PyodideTransport extends DisposableBase implements Transport {
|
|
|
341
342
|
);
|
|
342
343
|
}
|
|
343
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Static capability descriptor for the Pyodide backend.
|
|
347
|
+
*
|
|
348
|
+
* The in-WASM server is JSON-only — pyarrow is unavailable in WASM, so the
|
|
349
|
+
* bootstrap forces JSON markers and reports `arrowAvailable: false`; hence
|
|
350
|
+
* `supportsArrow: false`. Binary still rides through base64 bytes envelopes.
|
|
351
|
+
* Chunking/streaming are not implemented (0.8.0). Calls are in-memory string
|
|
352
|
+
* passing with no framing, so there is no transport-level frame ceiling
|
|
353
|
+
* (`maxFrameBytes: Number.POSITIVE_INFINITY`).
|
|
354
|
+
*/
|
|
355
|
+
capabilities(): TransportCapabilities {
|
|
356
|
+
return {
|
|
357
|
+
backend: 'pyodide',
|
|
358
|
+
supportsArrow: false,
|
|
359
|
+
supportsBinary: true,
|
|
360
|
+
supportsChunking: false,
|
|
361
|
+
supportsStreaming: false,
|
|
362
|
+
maxFrameBytes: Number.POSITIVE_INFINITY,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
344
366
|
// ===========================================================================
|
|
345
367
|
// PRIVATE HELPERS
|
|
346
368
|
// ===========================================================================
|
package/src/runtime/pyodide.ts
CHANGED
|
@@ -8,10 +8,8 @@
|
|
|
8
8
|
* @see https://github.com/bbopen/tywrap/issues/149
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
import { DisposableBase } from './bounded-context.js';
|
|
14
|
-
import { RpcClient, type GetBridgeInfoOptions } from './rpc-client.js';
|
|
11
|
+
import { BasePythonBridge } from './base-bridge.js';
|
|
12
|
+
import { RpcClient } from './rpc-client.js';
|
|
15
13
|
import { PyodideTransport } from './pyodide-transport.js';
|
|
16
14
|
import type { CodecOptions } from './bridge-codec.js';
|
|
17
15
|
|
|
@@ -67,7 +65,7 @@ export interface PyodideBridgeOptions {
|
|
|
67
65
|
* await bridge.dispose();
|
|
68
66
|
* ```
|
|
69
67
|
*/
|
|
70
|
-
export class PyodideBridge extends
|
|
68
|
+
export class PyodideBridge extends BasePythonBridge {
|
|
71
69
|
private readonly rpc: RpcClient;
|
|
72
70
|
|
|
73
71
|
/**
|
|
@@ -110,56 +108,13 @@ export class PyodideBridge extends DisposableBase implements PythonRuntime {
|
|
|
110
108
|
}
|
|
111
109
|
|
|
112
110
|
// ===========================================================================
|
|
113
|
-
// RPC
|
|
111
|
+
// RPC DELEGATION (the held RpcClient; never PyodideTransport directly)
|
|
114
112
|
// ===========================================================================
|
|
115
113
|
|
|
116
|
-
async call<T = unknown>(
|
|
117
|
-
module: string,
|
|
118
|
-
functionName: string,
|
|
119
|
-
args: unknown[],
|
|
120
|
-
kwargs?: Record<string, unknown>
|
|
121
|
-
): Promise<T> {
|
|
122
|
-
await this.ensureReady();
|
|
123
|
-
return this.rpc.call<T>(module, functionName, args, kwargs);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async instantiate<T = unknown>(
|
|
127
|
-
module: string,
|
|
128
|
-
className: string,
|
|
129
|
-
args: unknown[],
|
|
130
|
-
kwargs?: Record<string, unknown>
|
|
131
|
-
): Promise<T> {
|
|
132
|
-
await this.ensureReady();
|
|
133
|
-
return this.rpc.instantiate<T>(module, className, args, kwargs);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async callMethod<T = unknown>(
|
|
137
|
-
handle: string,
|
|
138
|
-
methodName: string,
|
|
139
|
-
args: unknown[],
|
|
140
|
-
kwargs?: Record<string, unknown>
|
|
141
|
-
): Promise<T> {
|
|
142
|
-
await this.ensureReady();
|
|
143
|
-
return this.rpc.callMethod<T>(handle, methodName, args, kwargs);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async disposeInstance(handle: string): Promise<void> {
|
|
147
|
-
await this.ensureReady();
|
|
148
|
-
return this.rpc.disposeInstance(handle);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
async getBridgeInfo(options?: GetBridgeInfoOptions): Promise<BridgeInfo> {
|
|
152
|
-
await this.ensureReady();
|
|
153
|
-
return this.rpc.getBridgeInfo(options);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
114
|
/**
|
|
157
|
-
*
|
|
158
|
-
* auto-init that the bounded execute path provided pre-composition.
|
|
115
|
+
* Expose the held RpcClient to BasePythonBridge's shared delegating methods.
|
|
159
116
|
*/
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
await this.init();
|
|
163
|
-
}
|
|
117
|
+
protected getRpcClient(): RpcClient {
|
|
118
|
+
return this.rpc;
|
|
164
119
|
}
|
|
165
120
|
}
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
PROTOCOL_ID,
|
|
28
28
|
TYWRAP_PROTOCOL_VERSION,
|
|
29
29
|
type Transport,
|
|
30
|
+
type TransportCapabilities,
|
|
30
31
|
type ProtocolMessage,
|
|
31
32
|
} from './transport.js';
|
|
32
33
|
|
|
@@ -518,4 +519,20 @@ export class RpcClient extends DisposableBase {
|
|
|
518
519
|
this.bridgeInfoCache = info;
|
|
519
520
|
return info;
|
|
520
521
|
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Report the underlying transport's static capability descriptor.
|
|
525
|
+
*
|
|
526
|
+
* This returns the transport-level flags (Arrow/binary carriage, framing,
|
|
527
|
+
* chunking/streaming, max frame size) WITHOUT any network round-trip — the
|
|
528
|
+
* descriptor is authoritative for what the wire channel can do. It is
|
|
529
|
+
* deliberately distinct from {@link getBridgeInfo}, which reports the *Python
|
|
530
|
+
* environment* (which optional libraries are importable). Callers that need
|
|
531
|
+
* both — "can this transport carry Arrow AND does this Python have pyarrow?" —
|
|
532
|
+
* should consult both: the transport descriptor for the channel, the bridge
|
|
533
|
+
* info for library availability.
|
|
534
|
+
*/
|
|
535
|
+
capabilities(): TransportCapabilities {
|
|
536
|
+
return this.transport.capabilities();
|
|
537
|
+
}
|
|
521
538
|
}
|
|
@@ -18,7 +18,7 @@ import type { Writable } from 'stream';
|
|
|
18
18
|
import { DisposableBase } from './bounded-context.js';
|
|
19
19
|
import { BridgeDisposedError, BridgeProtocolError, BridgeTimeoutError } from './errors.js';
|
|
20
20
|
import { TimedOutRequestTracker } from './timed-out-request-tracker.js';
|
|
21
|
-
import type { Transport } from './transport.js';
|
|
21
|
+
import type { Transport, TransportCapabilities } from './transport.js';
|
|
22
22
|
|
|
23
23
|
// =============================================================================
|
|
24
24
|
// CONSTANTS
|
|
@@ -323,6 +323,25 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
323
323
|
});
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
+
/**
|
|
327
|
+
* Static capability descriptor for the subprocess backend.
|
|
328
|
+
*
|
|
329
|
+
* Subprocess carries Arrow IPC and arbitrary binary (bytes envelopes) over the
|
|
330
|
+
* JSONL stream. Chunking/streaming are not implemented (0.8.0). `maxFrameBytes`
|
|
331
|
+
* is the configured JSONL line-length limit — the largest single response line
|
|
332
|
+
* this transport will accept before raising a protocol error.
|
|
333
|
+
*/
|
|
334
|
+
capabilities(): TransportCapabilities {
|
|
335
|
+
return {
|
|
336
|
+
backend: 'subprocess',
|
|
337
|
+
supportsArrow: true,
|
|
338
|
+
supportsBinary: true,
|
|
339
|
+
supportsChunking: false,
|
|
340
|
+
supportsStreaming: false,
|
|
341
|
+
maxFrameBytes: this.maxLineLength,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
326
345
|
// ===========================================================================
|
|
327
346
|
// BOUNDED CONTEXT LIFECYCLE
|
|
328
347
|
// ===========================================================================
|
package/src/runtime/transport.ts
CHANGED
|
@@ -102,6 +102,66 @@ export interface ProtocolResponse {
|
|
|
102
102
|
};
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// =============================================================================
|
|
106
|
+
// TRANSPORT CAPABILITIES
|
|
107
|
+
// =============================================================================
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Static, transport-level capability descriptor.
|
|
111
|
+
*
|
|
112
|
+
* Each backend exposes one of these via {@link Transport.capabilities} so callers
|
|
113
|
+
* can reason about what the wire channel can carry WITHOUT round-tripping to
|
|
114
|
+
* Python. These flags describe the transport itself (what bytes it can move and
|
|
115
|
+
* how it frames them) — they are deliberately separate from the bridge's runtime
|
|
116
|
+
* `meta` report ({@link BridgeInfo}), which describes the *Python environment*
|
|
117
|
+
* (which optional libraries happen to be importable). The transport descriptor is
|
|
118
|
+
* authoritative for transport-level flags; the meta report is authoritative for
|
|
119
|
+
* library availability.
|
|
120
|
+
*
|
|
121
|
+
* Honest for TODAY's behavior: `supportsChunking` and `supportsStreaming` are
|
|
122
|
+
* `false` on every backend — both are planned for 0.8.0 and no backend implements
|
|
123
|
+
* them yet. See docs/transport-capabilities.md for the full matrix.
|
|
124
|
+
*/
|
|
125
|
+
export interface TransportCapabilities {
|
|
126
|
+
/** Which backend this transport drives. */
|
|
127
|
+
readonly backend: 'subprocess' | 'http' | 'pyodide';
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Whether the transport can carry Arrow-encoded payloads (binary IPC frames)
|
|
131
|
+
* on the wire. Pyodide is JSON-only (pyarrow is unavailable in WASM), so it is
|
|
132
|
+
* `false` there; subprocess and HTTP can move Arrow bytes.
|
|
133
|
+
*/
|
|
134
|
+
readonly supportsArrow: boolean;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Whether the transport can carry arbitrary binary data (e.g. Python `bytes`).
|
|
138
|
+
* All current backends carry binary via base64 envelopes, so this is `true`
|
|
139
|
+
* everywhere.
|
|
140
|
+
*/
|
|
141
|
+
readonly supportsBinary: boolean;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Whether the transport splits a single logical message across multiple wire
|
|
145
|
+
* frames. Not implemented on any backend yet (planned for 0.8.0) — always
|
|
146
|
+
* `false`.
|
|
147
|
+
*/
|
|
148
|
+
readonly supportsChunking: boolean;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether the transport can stream incremental results for a single request.
|
|
152
|
+
* Not implemented on any backend yet (planned for 0.8.0) — always `false`.
|
|
153
|
+
*/
|
|
154
|
+
readonly supportsStreaming: boolean;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Maximum size, in bytes, of a single wire frame the transport will accept.
|
|
158
|
+
* `Number.POSITIVE_INFINITY` means the transport imposes no frame ceiling of
|
|
159
|
+
* its own (a higher layer — e.g. the codec's payload limit — may still cap the
|
|
160
|
+
* size). For the subprocess backend this is the JSONL line-length limit.
|
|
161
|
+
*/
|
|
162
|
+
readonly maxFrameBytes: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
105
165
|
// =============================================================================
|
|
106
166
|
// TRANSPORT INTERFACE
|
|
107
167
|
// =============================================================================
|
|
@@ -177,6 +237,16 @@ export interface Transport extends Disposable {
|
|
|
177
237
|
* Returns `false` in all other states.
|
|
178
238
|
*/
|
|
179
239
|
readonly isReady: boolean;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Static, transport-level capability descriptor.
|
|
243
|
+
*
|
|
244
|
+
* Returns what this transport can carry and how it frames messages, with
|
|
245
|
+
* honest values for the transport's current behavior (see
|
|
246
|
+
* {@link TransportCapabilities}). It does NOT depend on lifecycle state — it is
|
|
247
|
+
* safe to call before `init()` and after `dispose()`.
|
|
248
|
+
*/
|
|
249
|
+
capabilities(): TransportCapabilities;
|
|
180
250
|
}
|
|
181
251
|
|
|
182
252
|
// =============================================================================
|
|
@@ -228,6 +298,7 @@ export function isTransport(value: unknown): value is Transport {
|
|
|
228
298
|
typeof (value as Transport).init === 'function' &&
|
|
229
299
|
typeof (value as Transport).send === 'function' &&
|
|
230
300
|
typeof (value as Transport).dispose === 'function' &&
|
|
301
|
+
typeof (value as Transport).capabilities === 'function' &&
|
|
231
302
|
'isReady' in value
|
|
232
303
|
);
|
|
233
304
|
}
|