tywrap 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +1 -0
  2. package/dist/index.d.ts +3 -3
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +6 -6
  5. package/dist/index.js.map +1 -1
  6. package/dist/runtime/base.d.ts +7 -7
  7. package/dist/runtime/base.js +8 -8
  8. package/dist/runtime/bounded-context.d.ts +15 -31
  9. package/dist/runtime/bounded-context.d.ts.map +1 -1
  10. package/dist/runtime/bounded-context.js +16 -15
  11. package/dist/runtime/bounded-context.js.map +1 -1
  12. package/dist/runtime/http.d.ts +24 -5
  13. package/dist/runtime/http.d.ts.map +1 -1
  14. package/dist/runtime/http.js +57 -12
  15. package/dist/runtime/http.js.map +1 -1
  16. package/dist/runtime/node.d.ts +33 -7
  17. package/dist/runtime/node.d.ts.map +1 -1
  18. package/dist/runtime/node.js +139 -151
  19. package/dist/runtime/node.js.map +1 -1
  20. package/dist/runtime/pooled-transport.d.ts +2 -18
  21. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  22. package/dist/runtime/pooled-transport.js +2 -29
  23. package/dist/runtime/pooled-transport.js.map +1 -1
  24. package/dist/runtime/process-io.d.ts +2 -18
  25. package/dist/runtime/process-io.d.ts.map +1 -1
  26. package/dist/runtime/process-io.js +3 -30
  27. package/dist/runtime/process-io.js.map +1 -1
  28. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts +12 -0
  29. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -0
  30. package/dist/runtime/pyodide-bootstrap-core.generated.js +12 -0
  31. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -0
  32. package/dist/runtime/pyodide-io.d.ts +7 -37
  33. package/dist/runtime/pyodide-io.d.ts.map +1 -1
  34. package/dist/runtime/pyodide-io.js +90 -214
  35. package/dist/runtime/pyodide-io.js.map +1 -1
  36. package/dist/runtime/pyodide.d.ts +25 -5
  37. package/dist/runtime/pyodide.d.ts.map +1 -1
  38. package/dist/runtime/pyodide.js +59 -10
  39. package/dist/runtime/pyodide.js.map +1 -1
  40. package/dist/runtime/{bridge-protocol.d.ts → rpc-client.d.ts} +59 -46
  41. package/dist/runtime/rpc-client.d.ts.map +1 -0
  42. package/dist/runtime/{bridge-protocol.js → rpc-client.js} +60 -42
  43. package/dist/runtime/rpc-client.js.map +1 -0
  44. package/dist/runtime/worker-pool.d.ts +2 -19
  45. package/dist/runtime/worker-pool.d.ts.map +1 -1
  46. package/dist/runtime/worker-pool.js +2 -30
  47. package/dist/runtime/worker-pool.js.map +1 -1
  48. package/dist/types/index.d.ts +8 -3
  49. package/dist/types/index.d.ts.map +1 -1
  50. package/dist/tywrap.d.ts.map +1 -1
  51. package/dist/tywrap.js +1 -3
  52. package/dist/tywrap.js.map +1 -1
  53. package/package.json +9 -12
  54. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  55. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  56. package/runtime/python_bridge.py +85 -702
  57. package/runtime/safe_codec.py +10 -2
  58. package/runtime/tywrap_bridge_core.py +875 -0
  59. package/src/index.ts +6 -6
  60. package/src/runtime/base.ts +8 -8
  61. package/src/runtime/bounded-context.ts +17 -56
  62. package/src/runtime/http.ts +85 -13
  63. package/src/runtime/node.ts +165 -217
  64. package/src/runtime/pooled-transport.ts +2 -57
  65. package/src/runtime/process-io.ts +3 -55
  66. package/src/runtime/pyodide-bootstrap-core.generated.ts +12 -0
  67. package/src/runtime/pyodide-io.ts +92 -243
  68. package/src/runtime/pyodide.ts +87 -10
  69. package/src/runtime/{bridge-protocol.ts → rpc-client.ts} +76 -49
  70. package/src/runtime/worker-pool.ts +2 -58
  71. package/src/types/index.ts +20 -4
  72. package/src/tywrap.ts +1 -3
  73. package/dist/core/analyzer.d.ts +0 -64
  74. package/dist/core/analyzer.d.ts.map +0 -1
  75. package/dist/core/analyzer.js +0 -710
  76. package/dist/core/analyzer.js.map +0 -1
  77. package/dist/runtime/bridge-core.d.ts +0 -65
  78. package/dist/runtime/bridge-core.d.ts.map +0 -1
  79. package/dist/runtime/bridge-core.js +0 -379
  80. package/dist/runtime/bridge-core.js.map +0 -1
  81. package/dist/runtime/bridge-protocol.d.ts.map +0 -1
  82. package/dist/runtime/bridge-protocol.js.map +0 -1
  83. package/dist/utils/parallel-processor.d.ts +0 -146
  84. package/dist/utils/parallel-processor.d.ts.map +0 -1
  85. package/dist/utils/parallel-processor.js +0 -707
  86. package/dist/utils/parallel-processor.js.map +0 -1
  87. package/src/core/analyzer.ts +0 -824
  88. package/src/runtime/bridge-core.ts +0 -494
  89. package/src/utils/parallel-processor.ts +0 -955
@@ -0,0 +1,12 @@
1
+ /**
2
+ * AUTO-GENERATED — DO NOT EDIT.
3
+ *
4
+ * Source: runtime/tywrap_bridge_core.py
5
+ * Generator: scripts/generate-pyodide-bootstrap.mjs (runs in `npm run build`)
6
+ *
7
+ * This is the shared Python bridge core embedded as a string so the Pyodide
8
+ * (in-WASM) server can exec the identical code the subprocess server imports.
9
+ * Regenerate with: node scripts/generate-pyodide-bootstrap.mjs
10
+ */
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 SafeCodec\n encode wrapper.\n\n - the in-WASM Pyodide server (src/runtime/pyodide-io.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/safe-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 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 SafeCodec.encodeRequest)\n - { \"__type__\": \"bytes\", \"encoding\": \"base64\", \"data\": \"...\" } (legacy/compat)\n\n Why: TS SafeCodec 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 SafeCodec 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: SafeCodec-equivalent value handling (NaN reject, scalars, bytes)\n# =============================================================================\n#\n# This mirrors SafeCodec._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 SafeCodec 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 SafeCodec'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 SafeCodec-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 SafeCodec'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):\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 = importlib.import_module(module_name)\n func = getattr(mod, function_name)\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):\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 = importlib.import_module(module_name)\n cls = getattr(mod, class_name)\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):\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 = getattr(obj, method_name)\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):\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 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, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy\n )\n elif method == 'instantiate':\n result = handle_instantiate(params, instances)\n elif method == 'call_method':\n result = handle_call_method(\n params, instances, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy\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";
@@ -14,8 +14,9 @@
14
14
  * @see https://github.com/bbopen/tywrap/issues/149
15
15
  */
16
16
 
17
- import { BoundedContext } from './bounded-context.js';
18
- import { BridgeExecutionError, BridgeProtocolError } from './errors.js';
17
+ import { DisposableBase } from './bounded-context.js';
18
+ import { BridgeProtocolError } from './errors.js';
19
+ import { PYODIDE_BRIDGE_CORE_SOURCE } from './pyodide-bootstrap-core.generated.js';
19
20
  import {
20
21
  PROTOCOL_ID,
21
22
  type Transport,
@@ -65,125 +66,100 @@ const DEFAULT_INDEX_URL = 'https://cdn.jsdelivr.net/pyodide/v0.28.0/full/';
65
66
  /**
66
67
  * Bootstrap Python code that sets up the dispatch function.
67
68
  *
68
- * This code is injected into Pyodide during initialization and provides
69
- * a single entry point for all protocol messages. The dispatch function
70
- * parses JSON messages, routes to appropriate handlers, and returns
71
- * JSON responses.
69
+ * This is injected into Pyodide once at init. It does two things:
70
+ *
71
+ * 1. Loads the SHARED bridge core (the exact same code runtime/python_bridge.py
72
+ * imports) by exec'ing the build-time-embedded source into a fresh module and
73
+ * registering it in sys.modules as 'tywrap_bridge_core'. Pyodide has no
74
+ * filesystem to import from, so the source is shipped as a string constant
75
+ * (PYODIDE_BRIDGE_CORE_SOURCE) kept in sync with the .py by a drift-guard test.
76
+ *
77
+ * 2. Defines the single dispatch entry point __tywrap_dispatch(message_json) the
78
+ * JS transport calls. It routes through core.dispatch_request and encodes the
79
+ * response through core.encode_value, mirroring the reference server's error
80
+ * ladder so the on-the-wire envelopes are byte-identical.
81
+ *
82
+ * CONTRACT (Pyodide side): markers are forced to JSON (force_json_markers=True)
83
+ * because pyarrow is not available in WASM, NaN/Infinity are rejected
84
+ * (allow_nan=False), the bridge identifies itself as 'pyodide' with pid=None, and
85
+ * arrowAvailable is reported False. These choices match the JS-side decoder and
86
+ * the relaxed BridgeInfo validator (bridge in {python-subprocess, pyodide, http},
87
+ * pid number|null).
88
+ *
89
+ * The PYODIDE_CORE_MODULE_NAME must match the import name used inside the core
90
+ * module's own docstring/contract and the conformance harness.
72
91
  */
73
- const BOOTSTRAP_PYTHON = `
74
- import json
75
- import importlib
92
+ const PYODIDE_CORE_MODULE_NAME = 'tywrap_bridge_core';
93
+
94
+ // Exported so the cross-backend conformance suite can run the EXACT bootstrap
95
+ // source (this string, with the generated core constant inlined and the real
96
+ // __tywrap_dispatch error ladder) under CPython — proving the glue, not just the
97
+ // shared core module. It is not part of the public package surface.
98
+ export const BOOTSTRAP_PYTHON = `
99
+ import sys as __tywrap_sys
100
+ import json as __tywrap_json
101
+ import types as __tywrap_types
102
+
103
+ # Load the shared bridge core from the embedded source into a real module so the
104
+ # Pyodide server runs the IDENTICAL protocol/serialization code as the reference
105
+ # subprocess server. Registering in sys.modules lets the core's own internal
106
+ # 'import sys' (used lazily in dispatch_request for meta) resolve normally.
107
+ __tywrap_core_source = ${JSON.stringify(PYODIDE_BRIDGE_CORE_SOURCE)}
108
+ __tywrap_core = __tywrap_types.ModuleType(${JSON.stringify(PYODIDE_CORE_MODULE_NAME)})
109
+ __tywrap_sys.modules[${JSON.stringify(PYODIDE_CORE_MODULE_NAME)}] = __tywrap_core
110
+ exec(compile(__tywrap_core_source, '<tywrap_bridge_core>', 'exec'), __tywrap_core.__dict__)
76
111
 
77
112
  __tywrap_instances = {}
78
- __tywrap_protocol = 'tywrap/1'
79
-
80
- def __tywrap_require_str(params, key):
81
- value = params.get(key)
82
- if not isinstance(value, str) or not value:
83
- raise ValueError(f'Missing {key}')
84
- return value
85
-
86
- def __tywrap_coerce_list(value, key):
87
- if value is None:
88
- return []
89
- if not isinstance(value, list):
90
- raise ValueError(f'Invalid {key}')
91
- return value
92
-
93
- def __tywrap_coerce_dict(value, key):
94
- if value is None:
95
- return {}
96
- if not isinstance(value, dict):
97
- raise ValueError(f'Invalid {key}')
98
- return value
113
+ __tywrap_protocol = __tywrap_core.PROTOCOL
114
+
99
115
 
100
116
  def __tywrap_dispatch(message_json):
101
117
  """
102
- Dispatch a protocol message and return a JSON response.
103
-
104
- Args:
105
- message_json: JSON-encoded ProtocolMessage string
118
+ Dispatch a protocol message and return a JSON response string.
106
119
 
107
- Returns:
108
- JSON-encoded ProtocolResponse string
120
+ Mirrors runtime/python_bridge.py main()'s error ladder: ProtocolError ->
121
+ error envelope WITHOUT traceback; any other handler error -> error envelope
122
+ WITH traceback. The final encode goes through core.encode_value(allow_nan=False)
123
+ so NaN/Infinity is rejected with the same wording the subprocess server uses.
109
124
  """
110
- msg = None
111
- msg_id = -1
125
+ core = __tywrap_core
126
+ mid = None
112
127
  try:
113
- msg = json.loads(message_json)
114
- if not isinstance(msg, dict):
115
- raise ValueError('Invalid request payload')
116
-
117
- msg_id = msg.get('id')
118
- if not isinstance(msg_id, int):
119
- raise ValueError(f'Invalid request id: {msg_id}')
120
-
121
- protocol = msg.get('protocol')
122
- if protocol != __tywrap_protocol:
123
- raise ValueError(f'Invalid protocol: {protocol}')
124
-
125
- method = msg.get('method')
126
- if not isinstance(method, str):
127
- raise ValueError('Missing method')
128
-
129
- params = __tywrap_coerce_dict(msg.get('params'), 'params')
130
-
131
- if method == 'call':
132
- module_name = __tywrap_require_str(params, 'module')
133
- function_name = __tywrap_require_str(params, 'functionName')
134
- args = __tywrap_coerce_list(params.get('args'), 'args')
135
- kwargs = __tywrap_coerce_dict(params.get('kwargs'), 'kwargs')
136
- mod = importlib.import_module(module_name)
137
- fn = getattr(mod, function_name)
138
- result = fn(*args, **kwargs)
139
-
140
- elif method == 'instantiate':
141
- module_name = __tywrap_require_str(params, 'module')
142
- class_name = __tywrap_require_str(params, 'className')
143
- args = __tywrap_coerce_list(params.get('args'), 'args')
144
- kwargs = __tywrap_coerce_dict(params.get('kwargs'), 'kwargs')
145
- mod = importlib.import_module(module_name)
146
- cls = getattr(mod, class_name)
147
- obj = cls(*args, **kwargs)
148
- handle = str(id(obj))
149
- __tywrap_instances[handle] = obj
150
- result = handle
151
-
152
- elif method == 'call_method':
153
- handle = __tywrap_require_str(params, 'handle')
154
- method_name = __tywrap_require_str(params, 'methodName')
155
- args = __tywrap_coerce_list(params.get('args'), 'args')
156
- kwargs = __tywrap_coerce_dict(params.get('kwargs'), 'kwargs')
157
- if handle not in __tywrap_instances:
158
- raise ValueError(f'Unknown instance handle: {handle}')
159
- obj = __tywrap_instances[handle]
160
- bound_method = getattr(obj, method_name)
161
- result = bound_method(*args, **kwargs)
162
-
163
- elif method == 'dispose_instance':
164
- handle = __tywrap_require_str(params, 'handle')
165
- result = __tywrap_instances.pop(handle, None) is not None
166
-
167
- else:
168
- raise ValueError(f'Unknown method: {method}')
169
-
170
- return json.dumps({
171
- 'id': msg_id,
172
- 'protocol': __tywrap_protocol,
173
- 'result': result
174
- })
128
+ msg = __tywrap_json.loads(message_json)
129
+ if isinstance(msg, dict) and isinstance(msg.get('id'), int):
130
+ mid = msg.get('id')
131
+ try:
132
+ out = core.dispatch_request(
133
+ msg,
134
+ __tywrap_instances,
135
+ bridge='pyodide',
136
+ pid=None,
137
+ force_json_markers=True,
138
+ allow_nan=False,
139
+ arrow_available_override=False,
140
+ )
141
+ except core.ProtocolError as e:
142
+ out = core.build_error_payload(mid, e, include_traceback=False)
143
+ except Exception as e:
144
+ out = core.build_error_payload(mid, e, include_traceback=True)
145
+ except Exception as e:
146
+ # Malformed JSON / unexpected pre-dispatch failure: well-formed error,
147
+ # no traceback (matches the reference's outer handler).
148
+ out = core.build_error_payload(mid, e, include_traceback=False)
175
149
 
150
+ try:
151
+ return core.encode_value(out, allow_nan=False)
152
+ except core.CodecError as e:
153
+ # The subprocess server's encode_response() converts CodecError -> ValueError
154
+ # so the NaN/Infinity rejection surfaces with type 'ValueError'. Match that
155
+ # so the error envelope is byte-identical across backends.
156
+ err_out = core.build_error_payload(mid, ValueError(str(e)), include_traceback=False)
157
+ return __tywrap_json.dumps(err_out)
176
158
  except Exception as e:
177
- import traceback
178
- return json.dumps({
179
- 'id': msg_id,
180
- 'protocol': __tywrap_protocol,
181
- 'error': {
182
- 'type': type(e).__name__,
183
- 'message': str(e),
184
- 'traceback': traceback.format_exc()
185
- }
186
- })
159
+ # Any other encoding failure: well-formed error envelope, no traceback,
160
+ # exactly as the subprocess server's fallback does.
161
+ err_out = core.build_error_payload(mid, e, include_traceback=False)
162
+ return __tywrap_json.dumps(err_out)
187
163
  `;
188
164
 
189
165
  // =============================================================================
@@ -193,8 +169,10 @@ def __tywrap_dispatch(message_json):
193
169
  /**
194
170
  * Transport implementation for in-memory Pyodide communication.
195
171
  *
196
- * This transport extends BoundedContext for lifecycle management and
197
- * implements the Transport interface for message-based communication.
172
+ * This transport extends DisposableBase for lifecycle management and
173
+ * implements the Transport interface for message-based communication. It is a
174
+ * pure transport: it moves bytes via send() and carries no RPC methods (those
175
+ * live on PythonRuntime, implemented by PyodideBridge through an RpcClient).
198
176
  *
199
177
  * @example
200
178
  * ```typescript
@@ -214,10 +192,9 @@ def __tywrap_dispatch(message_json):
214
192
  * await transport.dispose();
215
193
  * ```
216
194
  */
217
- export class PyodideIO extends BoundedContext implements Transport {
195
+ export class PyodideIO extends DisposableBase implements Transport {
218
196
  private readonly indexURL: string;
219
197
  private readonly packages: readonly string[];
220
- private requestId = 0;
221
198
  private py?: PyodideInstance;
222
199
 
223
200
  /**
@@ -232,7 +209,7 @@ export class PyodideIO extends BoundedContext implements Transport {
232
209
  }
233
210
 
234
211
  // ===========================================================================
235
- // LIFECYCLE (BoundedContext implementation)
212
+ // LIFECYCLE
236
213
  // ===========================================================================
237
214
 
238
215
  /**
@@ -364,110 +341,6 @@ export class PyodideIO extends BoundedContext implements Transport {
364
341
  );
365
342
  }
366
343
 
367
- // ===========================================================================
368
- // RUNTIME EXECUTION (BoundedContext abstract methods)
369
- // ===========================================================================
370
-
371
- /**
372
- * Call a Python function.
373
- *
374
- * Convenience method that constructs a 'call' message and sends it.
375
- */
376
- async call<T = unknown>(
377
- module: string,
378
- functionName: string,
379
- args: unknown[],
380
- kwargs?: Record<string, unknown>
381
- ): Promise<T> {
382
- const message: ProtocolMessage = {
383
- id: this.generateId(),
384
- protocol: PROTOCOL_ID,
385
- method: 'call',
386
- params: {
387
- module,
388
- functionName,
389
- args: args ?? [],
390
- kwargs,
391
- },
392
- };
393
-
394
- const responseJson = await this.send(JSON.stringify(message), 30000);
395
- return this.parseResponse<T>(responseJson);
396
- }
397
-
398
- /**
399
- * Instantiate a Python class.
400
- *
401
- * Convenience method that constructs an 'instantiate' message and sends it.
402
- */
403
- async instantiate<T = unknown>(
404
- module: string,
405
- className: string,
406
- args: unknown[],
407
- kwargs?: Record<string, unknown>
408
- ): Promise<T> {
409
- const message: ProtocolMessage = {
410
- id: this.generateId(),
411
- protocol: PROTOCOL_ID,
412
- method: 'instantiate',
413
- params: {
414
- module,
415
- className,
416
- args: args ?? [],
417
- kwargs,
418
- },
419
- };
420
-
421
- const responseJson = await this.send(JSON.stringify(message), 30000);
422
- return this.parseResponse<T>(responseJson);
423
- }
424
-
425
- /**
426
- * Call a method on a Python instance.
427
- *
428
- * Convenience method that constructs a 'call_method' message and sends it.
429
- */
430
- async callMethod<T = unknown>(
431
- handle: string,
432
- methodName: string,
433
- args: unknown[],
434
- kwargs?: Record<string, unknown>
435
- ): Promise<T> {
436
- const message: ProtocolMessage = {
437
- id: this.generateId(),
438
- protocol: PROTOCOL_ID,
439
- method: 'call_method',
440
- params: {
441
- handle,
442
- methodName,
443
- args: args ?? [],
444
- kwargs,
445
- },
446
- };
447
-
448
- const responseJson = await this.send(JSON.stringify(message), 30000);
449
- return this.parseResponse<T>(responseJson);
450
- }
451
-
452
- /**
453
- * Dispose a Python instance.
454
- *
455
- * Convenience method that constructs a 'dispose_instance' message and sends it.
456
- */
457
- async disposeInstance(handle: string): Promise<void> {
458
- const message: ProtocolMessage = {
459
- id: this.generateId(),
460
- protocol: PROTOCOL_ID,
461
- method: 'dispose_instance',
462
- params: {
463
- handle,
464
- },
465
- };
466
-
467
- const responseJson = await this.send(JSON.stringify(message), 30000);
468
- this.parseResponse<void>(responseJson);
469
- }
470
-
471
344
  // ===========================================================================
472
345
  // PRIVATE HELPERS
473
346
  // ===========================================================================
@@ -509,30 +382,6 @@ export class PyodideIO extends BoundedContext implements Transport {
509
382
  await this.py.runPythonAsync(BOOTSTRAP_PYTHON);
510
383
  }
511
384
 
512
- /**
513
- * Parse a JSON response and extract the result or throw an error.
514
- */
515
- private parseResponse<T>(responseJson: string): T {
516
- const response = JSON.parse(responseJson) as ProtocolResponse;
517
-
518
- if (response.error) {
519
- const err = new BridgeExecutionError(`${response.error.type}: ${response.error.message}`, {
520
- code: response.error.type,
521
- });
522
- err.traceback = response.error.traceback;
523
- throw err;
524
- }
525
-
526
- return response.result as T;
527
- }
528
-
529
- /**
530
- * Generate a unique message ID.
531
- */
532
- private generateId(): number {
533
- return ++this.requestId;
534
- }
535
-
536
385
  /**
537
386
  * Safely destroy a Pyodide proxy object.
538
387
  *
@@ -1,13 +1,17 @@
1
1
  /**
2
- * Pyodide runtime bridge for BridgeProtocol.
2
+ * Pyodide runtime bridge.
3
3
  *
4
- * PyodideBridge extends BridgeProtocol and uses PyodideIO transport for
5
- * in-memory Python execution in browser environments via WebAssembly.
4
+ * PyodideBridge is a thin facade: it extends DisposableBase (lifecycle) and
5
+ * implements PythonRuntime by HOLDING an RpcClient over a PyodideIO transport
6
+ * for in-memory Python execution in browser environments via WebAssembly.
6
7
  *
7
8
  * @see https://github.com/bbopen/tywrap/issues/149
8
9
  */
9
10
 
10
- import { BridgeProtocol, type BridgeProtocolOptions } from './bridge-protocol.js';
11
+ import type { PythonRuntime, BridgeInfo } from '../types/index.js';
12
+
13
+ import { DisposableBase } from './bounded-context.js';
14
+ import { RpcClient, type GetBridgeInfoOptions } from './rpc-client.js';
11
15
  import { PyodideIO } from './pyodide-io.js';
12
16
  import type { CodecOptions } from './safe-codec.js';
13
17
 
@@ -63,26 +67,99 @@ export interface PyodideBridgeOptions {
63
67
  * await bridge.dispose();
64
68
  * ```
65
69
  */
66
- export class PyodideBridge extends BridgeProtocol {
70
+ export class PyodideBridge extends DisposableBase implements PythonRuntime {
71
+ private readonly rpc: RpcClient;
72
+
67
73
  /**
68
74
  * Create a new PyodideBridge instance.
69
75
  *
70
76
  * @param options - Configuration options for the bridge
71
77
  */
72
78
  constructor(options: PyodideBridgeOptions = {}) {
73
- // Create Pyodide transport
79
+ super();
80
+
74
81
  const transport = new PyodideIO({
75
82
  indexURL: options.indexURL,
76
83
  packages: options.packages,
77
84
  });
78
85
 
79
- // Initialize BridgeProtocol with transport and codec options
80
- const protocolOptions: BridgeProtocolOptions = {
86
+ this.rpc = new RpcClient({
81
87
  transport,
82
88
  codec: options.codec,
83
89
  defaultTimeoutMs: options.timeoutMs,
84
- };
90
+ });
91
+ // One disposal chain: facade -> rpc -> transport.
92
+ this.trackResource(this.rpc);
93
+ }
94
+
95
+ // ===========================================================================
96
+ // LIFECYCLE
97
+ // ===========================================================================
98
+
99
+ protected async doInit(): Promise<void> {
100
+ // No facade-specific pre-init; the held RpcClient drives transport.init().
101
+ await this.rpc.init();
102
+ }
103
+
104
+ /**
105
+ * No facade-specific teardown: the RpcClient (and its transport) is tracked
106
+ * as a resource and disposed automatically by DisposableBase.
107
+ */
108
+ protected async doDispose(): Promise<void> {
109
+ // Intentionally empty; tracked resources handle disposal.
110
+ }
111
+
112
+ // ===========================================================================
113
+ // RPC METHODS (delegate to the held RpcClient; never PyodideIO directly)
114
+ // ===========================================================================
85
115
 
86
- super(protocolOptions);
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
+ /**
157
+ * Ensure the facade is initialized before delegating an RPC, replicating the
158
+ * auto-init that the bounded execute path provided pre-composition.
159
+ */
160
+ private async ensureReady(): Promise<void> {
161
+ if (!this.isReady) {
162
+ await this.init();
163
+ }
87
164
  }
88
165
  }