tywrap 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +42 -22
  2. package/SECURITY.md +38 -0
  3. package/dist/config/index.d.ts.map +1 -1
  4. package/dist/config/index.js +8 -0
  5. package/dist/config/index.js.map +1 -1
  6. package/dist/core/annotation-parser.d.ts +2 -1
  7. package/dist/core/annotation-parser.d.ts.map +1 -1
  8. package/dist/core/annotation-parser.js +6 -3
  9. package/dist/core/annotation-parser.js.map +1 -1
  10. package/dist/core/generator.d.ts +23 -4
  11. package/dist/core/generator.d.ts.map +1 -1
  12. package/dist/core/generator.js +254 -170
  13. package/dist/core/generator.js.map +1 -1
  14. package/dist/core/mapper.d.ts +3 -2
  15. package/dist/core/mapper.d.ts.map +1 -1
  16. package/dist/core/mapper.js +5 -5
  17. package/dist/core/mapper.js.map +1 -1
  18. package/dist/index.d.ts +7 -5
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +6 -4
  21. package/dist/index.js.map +1 -1
  22. package/dist/runtime/base-bridge.d.ts +3 -7
  23. package/dist/runtime/base-bridge.d.ts.map +1 -1
  24. package/dist/runtime/base-bridge.js +4 -17
  25. package/dist/runtime/base-bridge.js.map +1 -1
  26. package/dist/runtime/bounded-context.d.ts +3 -22
  27. package/dist/runtime/bounded-context.d.ts.map +1 -1
  28. package/dist/runtime/bounded-context.js +13 -53
  29. package/dist/runtime/bounded-context.js.map +1 -1
  30. package/dist/runtime/bridge-codec.d.ts +3 -3
  31. package/dist/runtime/bridge-codec.d.ts.map +1 -1
  32. package/dist/runtime/bridge-codec.js +129 -62
  33. package/dist/runtime/bridge-codec.js.map +1 -1
  34. package/dist/runtime/errors.d.ts +16 -0
  35. package/dist/runtime/errors.d.ts.map +1 -1
  36. package/dist/runtime/errors.js +17 -0
  37. package/dist/runtime/errors.js.map +1 -1
  38. package/dist/runtime/http-transport.d.ts +1 -1
  39. package/dist/runtime/http-transport.d.ts.map +1 -1
  40. package/dist/runtime/http-transport.js +1 -1
  41. package/dist/runtime/http-transport.js.map +1 -1
  42. package/dist/runtime/index.d.ts +1 -0
  43. package/dist/runtime/index.d.ts.map +1 -1
  44. package/dist/runtime/index.js +1 -0
  45. package/dist/runtime/index.js.map +1 -1
  46. package/dist/runtime/node.d.ts +7 -36
  47. package/dist/runtime/node.d.ts.map +1 -1
  48. package/dist/runtime/node.js +2 -80
  49. package/dist/runtime/node.js.map +1 -1
  50. package/dist/runtime/pooled-transport.d.ts +120 -59
  51. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  52. package/dist/runtime/pooled-transport.js +345 -78
  53. package/dist/runtime/pooled-transport.js.map +1 -1
  54. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  55. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  56. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  57. package/dist/runtime/pyodide-transport.d.ts +1 -1
  58. package/dist/runtime/pyodide-transport.d.ts.map +1 -1
  59. package/dist/runtime/pyodide-transport.js +2 -3
  60. package/dist/runtime/pyodide-transport.js.map +1 -1
  61. package/dist/runtime/rpc-client.d.ts +7 -36
  62. package/dist/runtime/rpc-client.d.ts.map +1 -1
  63. package/dist/runtime/rpc-client.js +20 -102
  64. package/dist/runtime/rpc-client.js.map +1 -1
  65. package/dist/runtime/subprocess-transport.d.ts +30 -69
  66. package/dist/runtime/subprocess-transport.d.ts.map +1 -1
  67. package/dist/runtime/subprocess-transport.js +179 -236
  68. package/dist/runtime/subprocess-transport.js.map +1 -1
  69. package/dist/runtime/timed-out-request-tracker.d.ts +2 -1
  70. package/dist/runtime/timed-out-request-tracker.d.ts.map +1 -1
  71. package/dist/runtime/transport.d.ts +9 -19
  72. package/dist/runtime/transport.d.ts.map +1 -1
  73. package/dist/runtime/transport.js +1 -1
  74. package/dist/runtime/transport.js.map +1 -1
  75. package/dist/runtime/validators.d.ts +50 -0
  76. package/dist/runtime/validators.d.ts.map +1 -1
  77. package/dist/runtime/validators.js +154 -0
  78. package/dist/runtime/validators.js.map +1 -1
  79. package/dist/types/index.d.ts +14 -39
  80. package/dist/types/index.d.ts.map +1 -1
  81. package/dist/tywrap.d.ts +3 -2
  82. package/dist/tywrap.d.ts.map +1 -1
  83. package/dist/tywrap.js +140 -13
  84. package/dist/tywrap.js.map +1 -1
  85. package/dist/utils/cache.d.ts +3 -16
  86. package/dist/utils/cache.d.ts.map +1 -1
  87. package/dist/utils/codec.d.ts +17 -0
  88. package/dist/utils/codec.d.ts.map +1 -1
  89. package/dist/utils/codec.js +500 -85
  90. package/dist/utils/codec.js.map +1 -1
  91. package/dist/utils/ir-cache.d.ts +2 -1
  92. package/dist/utils/ir-cache.d.ts.map +1 -1
  93. package/dist/utils/runtime.d.ts +0 -29
  94. package/dist/utils/runtime.d.ts.map +1 -1
  95. package/dist/utils/runtime.js +16 -107
  96. package/dist/utils/runtime.js.map +1 -1
  97. package/dist/version.d.ts.map +1 -1
  98. package/dist/version.js +1 -1
  99. package/dist/version.js.map +1 -1
  100. package/package.json +7 -6
  101. package/runtime/frame_codec.py +7 -1
  102. package/runtime/python_bridge.py +85 -125
  103. package/runtime/tywrap_bridge_core.py +515 -187
  104. package/src/config/index.ts +11 -0
  105. package/src/core/annotation-parser.ts +7 -4
  106. package/src/core/generator.ts +330 -205
  107. package/src/core/mapper.ts +8 -8
  108. package/src/index.ts +7 -4
  109. package/src/runtime/base-bridge.ts +5 -30
  110. package/src/runtime/bounded-context.ts +12 -67
  111. package/src/runtime/bridge-codec.ts +158 -74
  112. package/src/runtime/errors.ts +21 -0
  113. package/src/runtime/http-transport.ts +6 -1
  114. package/src/runtime/index.ts +6 -0
  115. package/src/runtime/node.ts +9 -120
  116. package/src/runtime/pooled-transport.ts +424 -90
  117. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  118. package/src/runtime/pyodide-transport.ts +7 -3
  119. package/src/runtime/rpc-client.ts +37 -139
  120. package/src/runtime/subprocess-transport.ts +210 -285
  121. package/src/runtime/timed-out-request-tracker.ts +1 -1
  122. package/src/runtime/transport.ts +15 -23
  123. package/src/runtime/validators.ts +212 -0
  124. package/src/types/index.ts +21 -60
  125. package/src/tywrap.ts +157 -22
  126. package/src/utils/cache.ts +3 -3
  127. package/src/utils/codec.ts +749 -112
  128. package/src/utils/ir-cache.ts +1 -1
  129. package/src/utils/runtime.ts +17 -128
  130. package/src/version.ts +1 -1
  131. package/dist/core/discovery.d.ts +0 -103
  132. package/dist/core/discovery.d.ts.map +0 -1
  133. package/dist/core/discovery.js +0 -380
  134. package/dist/core/discovery.js.map +0 -1
  135. package/dist/core/validation.d.ts +0 -102
  136. package/dist/core/validation.d.ts.map +0 -1
  137. package/dist/core/validation.js +0 -490
  138. package/dist/core/validation.js.map +0 -1
  139. package/dist/runtime/base.d.ts +0 -22
  140. package/dist/runtime/base.d.ts.map +0 -1
  141. package/dist/runtime/base.js +0 -23
  142. package/dist/runtime/base.js.map +0 -1
  143. package/dist/runtime/transport-pool.d.ts +0 -196
  144. package/dist/runtime/transport-pool.d.ts.map +0 -1
  145. package/dist/runtime/transport-pool.js +0 -418
  146. package/dist/runtime/transport-pool.js.map +0 -1
  147. package/runtime/__pycache__/_tywrap_conformance_chunking_fixtures.cpython-311.pyc +0 -0
  148. package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
  149. package/runtime/__pycache__/_tywrap_w4_chunking_fixture.cpython-311.pyc +0 -0
  150. package/runtime/__pycache__/_tywrap_w5_request_chunking_fixture.cpython-311.pyc +0 -0
  151. package/runtime/__pycache__/_tywrap_w6_pool_chunking_fixture.cpython-311.pyc +0 -0
  152. package/runtime/__pycache__/frame_codec.cpython-311.pyc +0 -0
  153. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  154. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  155. package/runtime/safe_codec.py +0 -352
  156. package/src/core/discovery.ts +0 -477
  157. package/src/core/validation.ts +0 -729
  158. package/src/runtime/base.ts +0 -24
  159. package/src/runtime/transport-pool.ts +0 -538
@@ -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 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(\n f'Unsupported scipy sparse format: {fmt}; only csr/csc/coo are supported. '\n 'Convert explicitly (e.g. matrix.tocsr()) before returning'\n )\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(\n 'Complex scipy sparse matrices are not supported by the JSON codec; '\n 'split into real/imag components explicitly before returning'\n )\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 Rejection order is significant: the categorical rejections (sparse / quantized\n / meta / complex) are checked BEFORE the device/contiguous opt-in branch so\n they fail with a clear, specific message and are NOT bypassable by\n TYWRAP_TORCH_ALLOW_COPY. The opt-in only governs the lossy-but-lossless device\n transfer and contiguous copy, never an unrepresentable layout/dtype.\n \"\"\"\n import torch # already importable: is_torch_tensor() gated the dispatch\n\n tensor = obj.detach()\n\n # Sparse tensors (COO/CSR/CSC/BSR/BSC -> any non-strided layout) have no dense\n # numpy representation without a densify step, which is not the round-trip this\n # envelope promises. Reject explicitly rather than emitting a misleading\n # \"not contiguous\" error or silently densifying.\n layout = getattr(tensor, 'layout', None)\n if getattr(tensor, 'is_sparse', False) or (\n layout is not None and layout != torch.strided\n ):\n raise RuntimeError(\n f'Torch sparse tensors are not supported (layout={layout}); '\n 'convert to a dense CPU tensor explicitly (e.g. tensor.to_dense()) before returning'\n )\n\n # Quantized tensors carry a qscheme/scale/zero_point that numpy() cannot\n # represent; .numpy() raises an opaque \"unsupported ScalarType\" deep in torch.\n # Reject up front with an actionable message.\n if getattr(tensor, 'is_quantized', False):\n raise RuntimeError(\n 'Torch quantized tensors are not supported; dequantize explicitly '\n '(e.g. tensor.dequantize()) before returning'\n )\n\n # Meta tensors have shape/dtype but NO storage; copying to CPU yields garbage,\n # so this is never a lossy-but-honest transfer the opt-in could authorize.\n if getattr(tensor, 'is_meta', False) or (\n getattr(tensor, 'device', None) is not None and tensor.device.type == 'meta'\n ):\n raise RuntimeError(\n 'Torch meta tensors carry no data and cannot be serialized; '\n 'materialize the tensor on a real device before returning'\n )\n\n # Complex tensors round-trip to numpy complex arrays, which are not\n # JSON-serializable and have no codec envelope. Reject explicitly instead of\n # emitting Python complex tuples that the JS decoder cannot parse.\n if torch.is_complex(tensor):\n raise RuntimeError(\n f'Torch complex tensors are not supported (dtype={tensor.dtype}); '\n 'split into real/imag components explicitly before returning'\n )\n\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\n # Metadata-only: NEVER pickle/joblib. Every param value must be plain JSON\n # (no callables, nested estimators, numpy arrays, or other objects). Probe\n # each value individually so the error names the offending param instead of\n # failing opaquely on the whole dict. allow_nan=False also rejects NaN/Inf\n # params here for parity with the response codec.\n for key, value in params.items():\n try:\n json.dumps(value, allow_nan=False)\n except (TypeError, ValueError) as exc:\n raise RuntimeError(\n f'scikit-learn estimator param {key!r} is not JSON-serializable '\n f'(got {type(value).__name__}); estimators are serialized as metadata only '\n '(no pickle/joblib), so every param must be a plain JSON value. '\n 'Sanitize or drop the param before returning'\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(\n instances,\n *,\n bridge,\n pid,\n python_version,\n codec_fallback,\n arrow_available_override=None,\n transport_info=None,\n):\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 transport_info: optional chunked-transport negotiation block (BridgeInfo\n .transport). Core stays oblivious to framing policy -- it only echoes what\n the I/O layer tells it. The subprocess server passes a {'frameProtocol',\n 'supportsChunking', 'maxFrameBytes'} dict when chunking is negotiated; the\n Pyodide server passes None (single-frame, in-memory). When None the block is\n omitted entirely (backward compatible: old bridges never emit it).\n \"\"\"\n arrow = arrow_available() if arrow_available_override is None else arrow_available_override\n meta = {\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 if transport_info is not None:\n meta['transport'] = transport_info\n return meta\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 transport_info=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 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 transport_info=transport_info,\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 importlib\nimport importlib.util\nimport json\nimport math\nimport re\nimport sys\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\nMAX_SERIALIZE_DEPTH = 900\nMAX_SERIALIZE_NODES = 1_000_000\nJS_SAFE_INTEGER_MAX = 2**53 - 1\n_SERIALIZE_PATH_IDENTIFIER = re.compile(r'^[A-Za-z_$][\\w$]*$', re.ASCII)\n\n\nclass ProtocolError(Exception):\n \"\"\"Raised for malformed requests (bad protocol/id/method/params).\"\"\"\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 requests by importing the requested module and\n# 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\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, *, has_envelope_markers=True):\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 if not has_envelope_markers:\n return value\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, has_envelope_markers=True) for item in value]\n if isinstance(value, dict):\n # Preserve dict shape while decoding nested values.\n return {k: deserialize(v, has_envelope_markers=True) 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 \"\"\"Return whether obj is an ndarray without importing NumPy.\"\"\"\n np = sys.modules.get('numpy')\n if np is None:\n return False\n return isinstance(obj, np.ndarray)\n\n\ndef is_pandas_dataframe(obj):\n \"\"\"Return whether obj is a DataFrame without importing pandas.\"\"\"\n pd = sys.modules.get('pandas')\n if pd is None:\n return False\n return isinstance(obj, pd.DataFrame)\n\n\ndef is_pandas_series(obj):\n \"\"\"Return whether obj is a Series without importing pandas.\"\"\"\n pd = sys.modules.get('pandas')\n if pd is None:\n return False\n return isinstance(obj, pd.Series)\n\n\ndef is_scipy_sparse(obj):\n \"\"\"Return whether obj is sparse without importing SciPy.\"\"\"\n sp = sys.modules.get('scipy.sparse')\n if sp is None:\n return False\n try:\n return sp.issparse(obj)\n except Exception:\n return False\n\n\ndef is_torch_tensor(obj):\n \"\"\"Return whether obj is a Tensor without importing PyTorch.\"\"\"\n torch = sys.modules.get('torch')\n if torch is None:\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 \"\"\"Return whether obj is an estimator without importing scikit-learn.\"\"\"\n sklearn_base = sys.modules.get('sklearn.base')\n if sklearn_base is None:\n return False\n return isinstance(obj, sklearn_base.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 values that JavaScript can represent safely.\"\"\"\n dtype = obj.dtype\n dtype_label = str(dtype)\n\n if not dtype.isnative:\n raise RuntimeError(\n f'JSON ndarray encoding does not support big-endian dtype={dtype_label}; '\n \"convert to native byte order with a.byteswap().view(a.dtype.newbyteorder('='))\"\n )\n\n if dtype.kind in ('i', 'u'):\n # JSON numbers become JavaScript Number values. Scan only on this explicit\n # fallback path and reject before tolist() can silently round an integer.\n if obj.size and (\n (obj < -JS_SAFE_INTEGER_MAX).any() or (obj > JS_SAFE_INTEGER_MAX).any()\n ):\n raise RuntimeError(\n f'JSON ndarray encoding cannot safely represent dtype={dtype_label} values '\n 'outside the JavaScript safe integer range; use Arrow encoding or '\n \"cast/encode explicitly (e.g. .astype('float64') or str)\"\n )\n elif dtype.kind == 'M':\n raise RuntimeError(\n f'JSON ndarray encoding does not support dtype={dtype_label}; use Arrow encoding '\n \"or convert explicitly (e.g. .astype('datetime64[ms]').astype(str) or int with \"\n 'declared unit)'\n )\n elif dtype.kind == 'm':\n raise RuntimeError(\n f'JSON ndarray encoding does not support dtype={dtype_label}; use Arrow encoding '\n \"or convert explicitly (e.g. .astype('timedelta64[ms]').astype(str) or int with \"\n 'declared unit)'\n )\n elif dtype.kind == 'V':\n if dtype.fields is not None:\n raise RuntimeError(\n f'JSON ndarray encoding does not support structured dtype={dtype_label}; '\n 'encode each named field explicitly (e.g. as a plain JSON object)'\n )\n raise RuntimeError(\n f'JSON ndarray encoding does not support void dtype={dtype_label}; convert the '\n \"raw bytes explicitly (e.g. .view('uint8'))\"\n )\n elif dtype.kind == 'O':\n raise RuntimeError(\n f'JSON ndarray encoding does not support object dtype={dtype_label}; cast to a '\n 'concrete numeric dtype or encode elements explicitly as plain JSON'\n )\n elif dtype.kind == 'S':\n raise RuntimeError(\n f'JSON ndarray encoding does not support byte-string dtype={dtype_label}; '\n 'decode elements and return a plain JSON list explicitly'\n )\n elif dtype.kind == 'U':\n raise RuntimeError(\n f'JSON ndarray encoding does not support unicode dtype={dtype_label}; convert '\n 'explicitly to plain JSON strings with .tolist()'\n )\n elif dtype.kind == 'c':\n raise RuntimeError(\n f'JSON ndarray encoding does not support complex dtype={dtype_label}; encode '\n '.real and .imag arrays explicitly'\n )\n elif dtype.kind == 'f' and dtype.itemsize > 8:\n raise RuntimeError(\n f'JSON ndarray encoding does not support float dtype={dtype_label} wider than '\n \"64 bits; cast explicitly (e.g. .astype('float64')) or use Arrow encoding\"\n )\n elif dtype.kind not in ('b', 'f'):\n raise RuntimeError(\n f'JSON ndarray encoding does not support dtype={dtype_label}; cast to bool, '\n 'integer, or float dtype, or encode values explicitly as plain JSON'\n )\n\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 'dtype': dtype.name,\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 values that JavaScript can represent safely.\"\"\"\n import pandas as pd # type: ignore\n\n _validate_pandas_json_index(obj.index, pd, 'DataFrame')\n json_columns = [_json_object_key(column) for column in obj.columns]\n supported_json_columns = [column for column in json_columns if column is not None]\n if not obj.columns.is_unique or len(set(supported_json_columns)) != len(\n supported_json_columns\n ):\n raise RuntimeError(\n 'JSON pandas.DataFrame encoding requires column labels to remain unique after '\n 'JSON object-key coercion; rename columns or make them distinct before applying '\n '.columns.astype(str)'\n )\n for column, dtype in obj.dtypes.items():\n if isinstance(dtype, pd.CategoricalDtype):\n raise RuntimeError(\n f'JSON pandas.DataFrame encoding does not support categorical dtype in '\n f'column {column!r}; use Arrow encoding or convert explicitly '\n \"(e.g. .astype(str))\"\n )\n try:\n data = (\n [{} for _ in range(len(obj.index))]\n if len(obj.columns) == 0\n else obj.to_dict(orient='records')\n )\n except Exception as exc:\n raise RuntimeError('JSON fallback failed for pandas.DataFrame') from exc\n for row_number, row in enumerate(data):\n for column, value in row.items():\n row[column] = _normalize_pandas_json_scalar(\n value,\n f'DataFrame cell at row {row_number}, column {column!r}',\n pd,\n )\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 values that JavaScript can represent safely.\"\"\"\n import pandas as pd # type: ignore\n\n _validate_pandas_json_index(obj.index, pd, 'Series')\n if isinstance(obj.dtype, pd.CategoricalDtype):\n raise RuntimeError(\n 'JSON pandas.Series encoding does not support categorical dtype; use Arrow '\n \"encoding or convert explicitly (e.g. .astype(str))\"\n )\n try:\n data = obj.to_list() # type: ignore\n except Exception as exc:\n raise RuntimeError('JSON fallback failed for pandas.Series') from exc\n data = [\n _normalize_pandas_json_scalar(value, f'Series value at position {position}', pd)\n for position, value in enumerate(data)\n ]\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 _validate_pandas_json_index(index, pd, container_name):\n \"\"\"Reject index metadata that records/list-oriented JSON would discard.\"\"\"\n if isinstance(index, pd.MultiIndex):\n raise RuntimeError(\n f'JSON pandas.{container_name} encoding does not support MultiIndex; use Arrow '\n 'encoding or flatten the index explicitly with .reset_index()'\n )\n if not (\n isinstance(index, pd.RangeIndex)\n and index.start == 0\n and index.step == 1\n and index.stop == len(index)\n and index.name is None\n ):\n raise RuntimeError(\n f'JSON pandas.{container_name} encoding requires an unnamed RangeIndex starting '\n 'at 0 with step 1; use Arrow encoding or normalize explicitly with '\n '.reset_index(drop=True)'\n )\n\n\ndef _json_object_key(value):\n \"\"\"Return json.dumps' object-key spelling, or None for unsupported keys.\"\"\"\n try:\n encoded = json.dumps({value: None})\n except (TypeError, ValueError):\n return None\n return next(iter(json.loads(encoded)))\n\n\ndef _normalize_pandas_json_scalar(value, location, pd):\n \"\"\"Normalize pandas nulls and reject values outside the plain JSON domain.\"\"\"\n np = sys.modules.get('numpy')\n if np is not None and isinstance(value, np.generic):\n value = value.item()\n if value is None or value is pd.NA or value is pd.NaT:\n return None\n if type(value) is bool:\n return value\n if type(value) is int:\n if value < -JS_SAFE_INTEGER_MAX or value > JS_SAFE_INTEGER_MAX:\n raise RuntimeError(\n f'JSON pandas encoding cannot safely represent {location} integer values '\n 'outside the JavaScript safe integer range; use Arrow encoding or '\n \"cast/encode explicitly (e.g. .astype('float64') or str)\"\n )\n return value\n if type(value) is float:\n if not math.isfinite(value):\n raise RuntimeError(\n f'JSON pandas encoding cannot represent non-finite {location} float values '\n '(NaN or Infinity); use .fillna(...) for intentional missing values or '\n 'Arrow encoding'\n )\n return value\n if type(value) is str:\n return value\n raise RuntimeError(\n f'JSON pandas encoding does not support {location} value of type '\n f'{type(value).__name__}; use Arrow encoding or convert explicitly '\n '(e.g. .astype(str))'\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(\n f'Unsupported scipy sparse format: {fmt}; only csr/csc/coo are supported. '\n 'Convert explicitly (e.g. matrix.tocsr()) before returning'\n )\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(\n 'Complex scipy sparse matrices are not supported by the JSON codec; '\n 'split into real/imag components explicitly before returning'\n )\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 Rejection order is significant: the categorical rejections (sparse / quantized\n / meta / complex) are checked BEFORE the device/contiguous opt-in branch so\n they fail with a clear, specific message and are NOT bypassable by\n TYWRAP_TORCH_ALLOW_COPY. The opt-in only governs the device transfer and\n contiguous copy, never an unrepresentable layout/dtype.\n \"\"\"\n import torch # already importable: is_torch_tensor() gated the dispatch\n\n tensor = obj.detach()\n source_device = None\n source_dtype = None\n\n # Sparse tensors (COO/CSR/CSC/BSR/BSC -> any non-strided layout) have no dense\n # numpy representation without a densify step, which is not the round-trip this\n # envelope promises. Reject explicitly rather than emitting a misleading\n # \"not contiguous\" error or silently densifying.\n layout = getattr(tensor, 'layout', None)\n if getattr(tensor, 'is_sparse', False) or (\n layout is not None and layout != torch.strided\n ):\n raise RuntimeError(\n f'Torch sparse tensors are not supported (layout={layout}); '\n 'convert to a dense CPU tensor explicitly (e.g. tensor.to_dense()) before returning'\n )\n\n # Quantized tensors carry a qscheme/scale/zero_point that numpy() cannot\n # represent; .numpy() raises an opaque \"unsupported ScalarType\" deep in torch.\n # Reject up front with an actionable message.\n if getattr(tensor, 'is_quantized', False):\n raise RuntimeError(\n 'Torch quantized tensors are not supported; dequantize explicitly '\n '(e.g. tensor.dequantize()) before returning'\n )\n\n # Meta tensors have shape/dtype but NO storage; copying to CPU yields garbage,\n # so this is never a lossy-but-honest transfer the opt-in could authorize.\n if getattr(tensor, 'is_meta', False) or (\n getattr(tensor, 'device', None) is not None and tensor.device.type == 'meta'\n ):\n raise RuntimeError(\n 'Torch meta tensors carry no data and cannot be serialized; '\n 'materialize the tensor on a real device before returning'\n )\n\n # Complex tensors round-trip to numpy complex arrays, which are not\n # JSON-serializable and have no codec envelope. Reject explicitly instead of\n # emitting Python complex tuples that the JS decoder cannot parse.\n if torch.is_complex(tensor):\n raise RuntimeError(\n f'Torch complex tensors are not supported (dtype={tensor.dtype}); '\n 'split into real/imag components explicitly before returning'\n )\n\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 source_device = str(tensor.device)\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 if tensor.dtype == torch.bfloat16:\n source_dtype = str(tensor.dtype)\n tensor = tensor.float()\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 envelope = {\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 if source_dtype is not None:\n envelope['sourceDtype'] = source_dtype\n if source_device is not None:\n envelope['sourceDevice'] = source_device\n return envelope\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\n # Metadata-only: NEVER pickle/joblib. Every param value must be plain JSON\n # (no callables, nested estimators, numpy arrays, or other objects). Probe\n # each value individually so the error names the offending param instead of\n # failing opaquely on the whole dict. allow_nan=False also rejects NaN/Inf\n # params here for parity with the response codec.\n for key, value in params.items():\n try:\n json.dumps(value, allow_nan=False)\n except (TypeError, ValueError) as exc:\n raise RuntimeError(\n f'scikit-learn estimator param {key!r} is not JSON-serializable '\n f'(got {type(value).__name__}); estimators are serialized as metadata only '\n '(no pickle/joblib), so every param must be a plain JSON value. '\n 'Sanitize or drop the param before returning'\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\n_NO_SCIENTIFIC = object()\n\n\ndef _check_serialize_depth(depth, path):\n if depth > MAX_SERIALIZE_DEPTH:\n raise RuntimeError(\n f'Scientific envelope serialization maximum depth '\n f'{MAX_SERIALIZE_DEPTH} exceeded at {path}'\n )\n\n\ndef _check_serialize_nodes(nodes, path):\n if nodes > MAX_SERIALIZE_NODES:\n raise RuntimeError(\n f'Scientific envelope serialization maximum visited nodes '\n f'{MAX_SERIALIZE_NODES} exceeded at {path}'\n )\n\n\ndef _serialize_scientific(obj, *, force_json_markers, torch_allow_copy, depth, path):\n \"\"\"Serialize a supported scientific value, or return _NO_SCIENTIFIC.\"\"\"\n package = type(obj).__module__.split('.', 1)[0]\n\n if package == 'numpy' and 'numpy' in sys.modules:\n if is_numpy_array(obj):\n _check_serialize_depth(depth, path)\n return serialize_ndarray(obj, force_json_markers=force_json_markers)\n elif package == 'pandas' and 'pandas' in sys.modules:\n if is_pandas_dataframe(obj):\n _check_serialize_depth(depth, path)\n return serialize_dataframe(obj, force_json_markers=force_json_markers)\n if is_pandas_series(obj):\n _check_serialize_depth(depth, path)\n return serialize_series(obj, force_json_markers=force_json_markers)\n elif package == 'scipy' and 'scipy.sparse' in sys.modules:\n if is_scipy_sparse(obj):\n _check_serialize_depth(depth, path)\n return serialize_sparse_matrix(obj)\n elif package == 'torch' and 'torch' in sys.modules:\n if is_torch_tensor(obj):\n _check_serialize_depth(depth, path)\n _check_serialize_depth(depth + 1, _serialize_path(path, 'value'))\n return serialize_torch_tensor(\n obj, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy\n )\n elif 'sklearn.base' in sys.modules and is_sklearn_estimator(obj):\n # No package gate here, unlike the branches above: subclassing\n # BaseEstimator is sklearn's documented extension point, so user-defined\n # estimators live outside the 'sklearn' package and must still get the\n # estimator serializer (and its param-naming errors).\n _check_serialize_depth(depth, path)\n return serialize_sklearn_estimator(obj)\n\n return _NO_SCIENTIFIC\n\n\ndef _serialize_path(base, key):\n \"\"\"Build a decoder-compatible JSONPath-like result path.\"\"\"\n if isinstance(key, int):\n return f'{base}[{key}]'\n if _SERIALIZE_PATH_IDENTIFIER.fullmatch(key):\n return f'{base}.{key}'\n return f'{base}[{json.dumps(key, ensure_ascii=False)}]'\n\n\ndef _invalid_key_path(base, key):\n \"\"\"Name a dict key that cannot be represented by JSON.\"\"\"\n return f'{base}[{key!r}]'\n\n\ndef _needs_serialize_visit(value):\n \"\"\"Return whether value needs container or scientific traversal work.\"\"\"\n if type(value) in (type(None), bool, int, float, str):\n return False\n if type(value) in (dict, list, tuple):\n return True\n package = type(value).__module__.split('.', 1)[0]\n if package in ('numpy', 'pandas', 'scipy', 'torch'):\n return True\n return 'sklearn.base' in sys.modules and is_sklearn_estimator(value)\n\n\ndef _serialize_leaf(value):\n \"\"\"Apply non-container conversions without allocating a traversal frame.\"\"\"\n if type(value) in (type(None), bool, int, float, str):\n return value\n pydantic_value = serialize_pydantic(value)\n if pydantic_value is not _NO_PYDANTIC:\n return pydantic_value\n stdlib_value = serialize_stdlib(value)\n if stdlib_value is not None:\n return stdlib_value\n return value\n\n\ndef serialize(obj, *, force_json_markers, torch_allow_copy=False):\n \"\"\"\n Top-level result serializer.\n\n Scientific codecs are type-first and only inspect packages that the value can\n belong to. A value from an optional package implies that package is already in\n sys.modules, so these checks never cold-import the scientific stack. The\n package dispatch deliberately precedes the JSON-native fast path: e.g. a\n package-defined subclass of dict still receives its relevant codec check.\n Every other value is left untouched so the shared JSON encoder applies the\n exact same default conversion at the root and at any nested depth.\n \"\"\"\n root = [None]\n active_ids = set()\n stack = [('visit', obj, 0, 'result', root, 0)]\n visited_nodes = 0\n\n # Repeated aliases have value semantics and are intentionally serialized twice.\n while stack:\n frame = stack.pop()\n action = frame[0]\n if action == 'dict':\n _, current, depth, path, parent, key, output, iterator = frame\n try:\n item_key, item = next(iterator)\n except StopIteration:\n active_ids.remove(id(current))\n parent[key] = output\n continue\n stack.append(frame)\n if not (isinstance(item_key, (str, int, float, bool)) or item_key is None):\n invalid_path = _invalid_key_path(path, item_key)\n raise TypeError(\n f'keys must be str, int, float, bool or None, not '\n f'{type(item_key).__name__} at {invalid_path}'\n )\n child_key = _json_object_key(item_key)\n child_path = _serialize_path(path, child_key)\n if _needs_serialize_visit(item):\n stack.append(('visit', item, depth + 1, child_path, output, item_key))\n else:\n output[item_key] = _serialize_leaf(item)\n continue\n if action == 'sequence':\n _, current, depth, path, parent, key, output, index = frame\n if index == len(output):\n active_ids.remove(id(current))\n parent[key] = output if type(current) is list else tuple(output)\n continue\n stack.append(('sequence', current, depth, path, parent, key, output, index + 1))\n item = current[index]\n if _needs_serialize_visit(item):\n stack.append(\n ('visit', item, depth + 1, _serialize_path(path, index), output, index)\n )\n else:\n output[index] = _serialize_leaf(item)\n continue\n\n _, current, depth, path, parent, key = frame\n try:\n scientific = _serialize_scientific(\n current,\n force_json_markers=force_json_markers,\n torch_allow_copy=torch_allow_copy,\n depth=depth,\n path=path,\n )\n except Exception as exc:\n if path == 'result':\n raise\n raise RuntimeError(f'Scientific value serialization failed at {path}: {exc}') from exc\n if scientific is not _NO_SCIENTIFIC:\n # Recognized envelopes are terminal containers to the JS decoder.\n visited_nodes += 1\n _check_serialize_nodes(visited_nodes, path)\n if scientific.get('__tywrap__') == 'torch.tensor':\n nested_path = _serialize_path(path, 'value')\n try:\n visited_nodes += 1\n _check_serialize_nodes(visited_nodes, nested_path)\n except Exception as exc:\n if path == 'result':\n raise\n raise RuntimeError(\n f'Scientific value serialization failed at {path}: {exc}'\n ) from exc\n parent[key] = scientific\n continue\n\n container_type = type(current)\n if container_type in (dict, list, tuple):\n _check_serialize_depth(depth, path)\n visited_nodes += 1\n _check_serialize_nodes(visited_nodes, path)\n current_id = id(current)\n if current_id in active_ids:\n raise RuntimeError(f'Circular reference detected at {path}')\n active_ids.add(current_id)\n\n if container_type is dict:\n output = {}\n parent[key] = output\n stack.append(\n ('dict', current, depth, path, parent, key, output, iter(current.items()))\n )\n continue\n\n output = [None] * len(current)\n if container_type is list:\n parent[key] = output\n stack.append(('sequence', current, depth, path, parent, key, output, 0))\n continue\n\n parent[key] = _serialize_leaf(current)\n\n return root[0]\n\n\n# =============================================================================\n# JSON CODEC: value handling and size-limited encode/decode\n# =============================================================================\n#\n# This is the single Python implementation used by both the subprocess bridge and\n# the embedded Pyodide core. BridgeCodec adds payload-size enforcement around the\n# shared encoder; encode_value is the unbounded form used by the in-memory bridge.\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 stdlib_value = serialize_stdlib(obj)\n if stdlib_value is not None:\n return stdlib_value\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 nonfinite_token = re.search(r'(^|[^a-z])(nan|inf|infinity)([^a-z]|$)', error_msg)\n if (\n not allow_nan and 'out of range float values are not json compliant' in error_msg\n ) or nonfinite_token:\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\nclass BridgeCodec:\n \"\"\"Safe JSON codec with explicit value handling and payload-size limits.\"\"\"\n\n def __init__(self, allow_nan=False, max_payload_bytes=10 * 1024 * 1024):\n self.allow_nan = allow_nan\n self.max_payload_bytes = max_payload_bytes\n self._encoder = make_default_encoder(allow_nan=allow_nan)\n\n def encode(self, value):\n result = encode_value(value, allow_nan=self.allow_nan)\n if len(result.encode('utf-8')) > self.max_payload_bytes:\n raise CodecError(f'Payload exceeds {self.max_payload_bytes} bytes')\n return result\n\n def decode(self, payload):\n if len(payload.encode('utf-8')) > self.max_payload_bytes:\n raise CodecError(f'Payload exceeds {self.max_payload_bytes} bytes')\n try:\n return json.loads(payload)\n except json.JSONDecodeError as exc:\n raise CodecError(f'JSON decoding failed: {exc}') from exc\n\n def _default_encoder(self, obj):\n \"\"\"Compatibility hook for callers that used the codec's JSON encoder.\"\"\"\n return self._encoder(obj)\n\n\n_default_codec = None\n\n\ndef get_default_codec():\n \"\"\"Return the lazily-created default BridgeCodec instance.\"\"\"\n global _default_codec\n if _default_codec is None:\n _default_codec = BridgeCodec()\n return _default_codec\n\n\ndef encode(value, *, allow_nan=False):\n \"\"\"Encode a value with the default codec settings.\"\"\"\n if allow_nan:\n return BridgeCodec(allow_nan=True).encode(value)\n return get_default_codec().encode(value)\n\n\ndef decode(payload):\n \"\"\"Decode a JSON payload with the default codec settings.\"\"\"\n return get_default_codec().decode(payload)\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(\n params,\n *,\n force_json_markers,\n torch_allow_copy,\n allowed_modules,\n allow_private_attrs,\n has_envelope_markers,\n):\n module_name = require_str(params, 'module')\n function_name = require_str(params, 'functionName')\n args = deserialize(coerce_list(params.get('args'), 'args'), has_envelope_markers=has_envelope_markers)\n kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'), has_envelope_markers=has_envelope_markers)\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 build_meta(\n *,\n bridge,\n pid,\n python_version,\n codec_fallback,\n arrow_available_override=None,\n):\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 meta = {\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': 0,\n }\n return meta\n\n\ndef dispatch_request(\n msg,\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 has_envelope_markers=True,\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 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 has_envelope_markers=has_envelope_markers,\n )\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 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";
@@ -110,7 +110,6 @@ __tywrap_core = __tywrap_types.ModuleType(${JSON.stringify(PYODIDE_CORE_MODULE_N
110
110
  __tywrap_sys.modules[${JSON.stringify(PYODIDE_CORE_MODULE_NAME)}] = __tywrap_core
111
111
  exec(compile(__tywrap_core_source, '<tywrap_bridge_core>', 'exec'), __tywrap_core.__dict__)
112
112
 
113
- __tywrap_instances = {}
114
113
  __tywrap_protocol = __tywrap_core.PROTOCOL
115
114
 
116
115
 
@@ -132,12 +131,12 @@ def __tywrap_dispatch(message_json):
132
131
  try:
133
132
  out = core.dispatch_request(
134
133
  msg,
135
- __tywrap_instances,
136
134
  bridge='pyodide',
137
135
  pid=None,
138
136
  force_json_markers=True,
139
137
  allow_nan=False,
140
138
  arrow_available_override=False,
139
+ has_envelope_markers=('__tywrap' in message_json or '__type__' in message_json),
141
140
  )
142
141
  except core.ProtocolError as e:
143
142
  out = core.build_error_payload(mid, e, include_traceback=False)
@@ -277,7 +276,12 @@ export class PyodideTransport extends DisposableBase implements Transport {
277
276
  * @throws BridgeTimeoutError if operation times out or is aborted
278
277
  * @throws BridgeExecutionError for Python execution errors
279
278
  */
280
- async send(message: string, timeoutMs: number, signal?: AbortSignal): Promise<string> {
279
+ async send(
280
+ message: string,
281
+ timeoutMs: number,
282
+ signal?: AbortSignal,
283
+ _requestId?: number
284
+ ): Promise<string> {
281
285
  return this.execute(
282
286
  async () => {
283
287
  if (!this.py) {
@@ -6,7 +6,7 @@
6
6
  * NOT a base class bridges extend. It owns the one place where the wire frame
7
7
  * is built and correlated: id generation, {id, protocol} stamping, codec
8
8
  * encode/decode, and transport.send. It composes DisposableBase to obtain its
9
- * lifecycle (init/dispose) and bounded execution (timeout/retry/abort), but it
9
+ * lifecycle (init/dispose) and single-attempt bounded execution (timeout/abort), but it
10
10
  * carries no PythonRuntime contract obligation — the facade implements
11
11
  * PythonRuntime and delegates the four RPC methods to this client.
12
12
  *
@@ -18,7 +18,7 @@
18
18
  * @see https://github.com/bbopen/tywrap/issues/149
19
19
  */
20
20
 
21
- import type { BridgeBackend, BridgeInfo, BridgeTransportInfo } from '../types/index.js';
21
+ import type { BridgeBackend, BridgeInfo } from '../types/index.js';
22
22
 
23
23
  import { DisposableBase, type ExecuteOptions } from './bounded-context.js';
24
24
  import { BridgeProtocolError } from './errors.js';
@@ -179,55 +179,6 @@ function validateBridgeInfoPayload(value: unknown): BridgeInfo {
179
179
  );
180
180
  }
181
181
 
182
- // OPTIONAL chunked-transport negotiation block. Absent on old bridges and on
183
- // HTTP/Pyodide (single-frame in 0.8.0) — absence is backward compatible. When
184
- // present, validate every field and CARRY IT THROUGH so negotiation data
185
- // survives the rebuild below (the old validator silently dropped unknown
186
- // fields, which would have discarded this block).
187
- let transport: BridgeTransportInfo | undefined;
188
- const rawTransport = obj.transport;
189
- if (rawTransport !== undefined) {
190
- if (!rawTransport || typeof rawTransport !== 'object' || Array.isArray(rawTransport)) {
191
- const kind =
192
- rawTransport === null
193
- ? 'null'
194
- : Array.isArray(rawTransport)
195
- ? 'array'
196
- : typeof rawTransport;
197
- throw new BridgeProtocolError(
198
- `Invalid bridge info payload: transport expected object, got ${kind}`
199
- );
200
- }
201
- const t = rawTransport as {
202
- frameProtocol?: unknown;
203
- supportsChunking?: unknown;
204
- maxFrameBytes?: unknown;
205
- };
206
- const frameProtocol = t.frameProtocol;
207
- if (typeof frameProtocol !== 'string' || frameProtocol.length === 0) {
208
- throw new BridgeProtocolError(
209
- `Invalid bridge info payload: transport.frameProtocol expected non-empty string, got ${formatValue(frameProtocol)}`
210
- );
211
- }
212
- const supportsChunking = t.supportsChunking;
213
- if (typeof supportsChunking !== 'boolean') {
214
- throw new BridgeProtocolError(
215
- `Invalid bridge info payload: transport.supportsChunking expected boolean, got ${formatValue(supportsChunking)}`
216
- );
217
- }
218
- const maxFrameBytes = t.maxFrameBytes;
219
- if (
220
- typeof maxFrameBytes !== 'number' ||
221
- !Number.isInteger(maxFrameBytes) ||
222
- maxFrameBytes <= 0
223
- ) {
224
- throw new BridgeProtocolError(
225
- `Invalid bridge info payload: transport.maxFrameBytes expected positive integer, got ${formatValue(maxFrameBytes)}`
226
- );
227
- }
228
- transport = { frameProtocol, supportsChunking, maxFrameBytes };
229
- }
230
-
231
182
  const info: BridgeInfo = {
232
183
  protocol: PROTOCOL_ID,
233
184
  protocolVersion: TYWRAP_PROTOCOL_VERSION,
@@ -241,9 +192,6 @@ function validateBridgeInfoPayload(value: unknown): BridgeInfo {
241
192
  sklearnAvailable,
242
193
  instances,
243
194
  };
244
- if (transport !== undefined) {
245
- info.transport = transport;
246
- }
247
195
  return info;
248
196
  }
249
197
 
@@ -266,7 +214,7 @@ function validateBridgeInfoPayload(value: unknown): BridgeInfo {
266
214
  * this.rpc = new RpcClient({ transport, defaultTimeoutMs: options.timeout });
267
215
  * this.trackResource(this.rpc);
268
216
  * }
269
- * // call/instantiate/callMethod/disposeInstance delegate to this.rpc.*
217
+ * // call and getBridgeInfo delegate to this.rpc.*
270
218
  * }
271
219
  * ```
272
220
  */
@@ -338,7 +286,7 @@ export class RpcClient extends DisposableBase {
338
286
  * 4. Decodes and validates the response
339
287
  *
340
288
  * @param message - The protocol message (without id field)
341
- * @param options - Execution options (timeout, retries, validation)
289
+ * @param options - Execution options (timeout, validation, and abort)
342
290
  * @returns The typed response from Python
343
291
  *
344
292
  * @throws BridgeProtocolError if encoding/decoding fails
@@ -359,7 +307,7 @@ export class RpcClient extends DisposableBase {
359
307
  * ndarrays, or other Arrow-encoded data structures.
360
308
  *
361
309
  * @param message - The protocol message (without id field)
362
- * @param options - Execution options (timeout, retries, validation)
310
+ * @param options - Execution options (timeout, validation, and abort)
363
311
  * @returns The typed response from Python with Arrow decoding applied
364
312
  *
365
313
  * @throws BridgeProtocolError if encoding/decoding fails
@@ -378,7 +326,7 @@ export class RpcClient extends DisposableBase {
378
326
  /**
379
327
  * Shared body for sendMessage/sendMessageAsync: stamp the frame, run the
380
328
  * encode -> transport.send -> decode pipeline inside this.execute() (which
381
- * supplies auto-init, timeout/retry/abort), where the only difference between
329
+ * supplies auto-init and exactly-one-attempt timeout/abort handling), where the only difference between
382
330
  * the sync and Arrow-aware paths is the supplied `decode` step.
383
331
  *
384
332
  * Behavior-preserving extraction of the two twins; ordering, the
@@ -400,7 +348,8 @@ export class RpcClient extends DisposableBase {
400
348
  const responseStr = await this.transport.send(
401
349
  encoded,
402
350
  options?.timeoutMs ?? this.defaultTimeoutMs,
403
- options?.signal
351
+ options?.signal,
352
+ fullMessage.id
404
353
  );
405
354
 
406
355
  // 3. Decode response (sync or Arrow-aware, per caller)
@@ -434,7 +383,8 @@ export class RpcClient extends DisposableBase {
434
383
  const responseStr = await transport.send(
435
384
  encoded,
436
385
  opts?.timeoutMs ?? this.defaultTimeoutMs,
437
- opts?.signal
386
+ opts?.signal,
387
+ fullMessage.id
438
388
  );
439
389
  return this.codec.decodeResponseAsync<T>(responseStr);
440
390
  }
@@ -480,93 +430,35 @@ export class RpcClient extends DisposableBase {
480
430
  module: string,
481
431
  functionName: string,
482
432
  args: unknown[],
483
- kwargs?: Record<string, unknown>
484
- ): Promise<T> {
485
- return this.sendMessageAsync<T>({
486
- method: 'call',
487
- params: {
488
- module,
489
- functionName,
490
- args,
491
- kwargs,
492
- },
493
- });
494
- }
495
-
496
- /**
497
- * Instantiate a Python class.
498
- *
499
- * @param module - Python module path containing the class
500
- * @param className - Class name to instantiate
501
- * @param args - Positional constructor arguments
502
- * @param kwargs - Keyword constructor arguments
503
- * @returns A handle to the created instance
504
- */
505
- async instantiate<T = unknown>(
506
- module: string,
507
- className: string,
508
- args: unknown[],
509
- kwargs?: Record<string, unknown>
510
- ): Promise<T> {
511
- return this.sendMessageAsync<T>({
512
- method: 'instantiate',
513
- params: {
514
- module,
515
- className,
516
- args,
517
- kwargs,
518
- },
519
- });
520
- }
521
-
522
- /**
523
- * Call a method on a Python instance.
524
- *
525
- * @param handle - Instance handle returned from instantiate()
526
- * @param methodName - Method name to call
527
- * @param args - Positional arguments
528
- * @param kwargs - Keyword arguments
529
- * @returns The method result
530
- */
531
- async callMethod<T = unknown>(
532
- handle: string,
533
- methodName: string,
534
- args: unknown[],
535
- kwargs?: Record<string, unknown>
433
+ kwargs?: Record<string, unknown>,
434
+ validate?: (result: T) => void
536
435
  ): Promise<T> {
537
- return this.sendMessageAsync<T>({
538
- method: 'call_method',
539
- params: {
540
- handle,
541
- methodName,
542
- args,
543
- kwargs,
544
- },
545
- });
546
- }
547
-
548
- /**
549
- * Dispose a Python instance.
550
- *
551
- * Releases the instance handle on the Python side, allowing
552
- * the object to be garbage collected.
553
- *
554
- * @param handle - Instance handle to dispose
555
- */
556
- async disposeInstance(handle: string): Promise<void> {
557
- await this.sendMessageAsync<void>({
558
- method: 'dispose_instance',
559
- params: {
560
- handle,
436
+ return this.sendMessageAsync<T>(
437
+ {
438
+ method: 'call',
439
+ params: {
440
+ module,
441
+ functionName,
442
+ args,
443
+ kwargs,
444
+ },
561
445
  },
562
- });
446
+ validate
447
+ ? {
448
+ validate: result => {
449
+ validate(result);
450
+ return result;
451
+ },
452
+ }
453
+ : undefined
454
+ );
563
455
  }
564
456
 
565
457
  /**
566
458
  * Fetch bridge diagnostics and feature availability.
567
459
  *
568
460
  * The Python bridge supports a `meta` method that returns protocol and environment info
569
- * (including optional codec availability and current instance count).
461
+ * (including optional codec availability and a fixed zero instance count).
570
462
  */
571
463
  async getBridgeInfo(options: GetBridgeInfoOptions = {}): Promise<BridgeInfo> {
572
464
  if (!options.refresh && this.bridgeInfoCache) {
@@ -583,6 +475,12 @@ export class RpcClient extends DisposableBase {
583
475
  }
584
476
  );
585
477
 
478
+ if (info.instances !== 0) {
479
+ throw new BridgeProtocolError(
480
+ `Invalid bridge info payload: instances expected 0, got ${String(info.instances)}`
481
+ );
482
+ }
483
+
586
484
  this.bridgeInfoCache = info;
587
485
  return info;
588
486
  }