tywrap 0.7.0 → 0.9.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 (165) hide show
  1. package/README.md +36 -11
  2. package/SECURITY.md +39 -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/emit-call.d.ts.map +1 -1
  11. package/dist/core/emit-call.js +1 -1
  12. package/dist/core/emit-call.js.map +1 -1
  13. package/dist/core/generator.d.ts +23 -4
  14. package/dist/core/generator.d.ts.map +1 -1
  15. package/dist/core/generator.js +243 -170
  16. package/dist/core/generator.js.map +1 -1
  17. package/dist/core/mapper.d.ts +3 -2
  18. package/dist/core/mapper.d.ts.map +1 -1
  19. package/dist/core/mapper.js +5 -5
  20. package/dist/core/mapper.js.map +1 -1
  21. package/dist/dev.d.ts.map +1 -1
  22. package/dist/dev.js +1 -3
  23. package/dist/dev.js.map +1 -1
  24. package/dist/index.d.ts +7 -5
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +6 -4
  27. package/dist/index.js.map +1 -1
  28. package/dist/runtime/base-bridge.d.ts +3 -7
  29. package/dist/runtime/base-bridge.d.ts.map +1 -1
  30. package/dist/runtime/base-bridge.js +4 -17
  31. package/dist/runtime/base-bridge.js.map +1 -1
  32. package/dist/runtime/bounded-context.d.ts +3 -22
  33. package/dist/runtime/bounded-context.d.ts.map +1 -1
  34. package/dist/runtime/bounded-context.js +13 -53
  35. package/dist/runtime/bounded-context.js.map +1 -1
  36. package/dist/runtime/bridge-codec.d.ts +1 -1
  37. package/dist/runtime/bridge-codec.d.ts.map +1 -1
  38. package/dist/runtime/bridge-codec.js +78 -55
  39. package/dist/runtime/bridge-codec.js.map +1 -1
  40. package/dist/runtime/errors.d.ts +16 -0
  41. package/dist/runtime/errors.d.ts.map +1 -1
  42. package/dist/runtime/errors.js +17 -0
  43. package/dist/runtime/errors.js.map +1 -1
  44. package/dist/runtime/frame-codec.d.ts +111 -0
  45. package/dist/runtime/frame-codec.d.ts.map +1 -0
  46. package/dist/runtime/frame-codec.js +352 -0
  47. package/dist/runtime/frame-codec.js.map +1 -0
  48. package/dist/runtime/http-transport.d.ts +1 -1
  49. package/dist/runtime/http-transport.d.ts.map +1 -1
  50. package/dist/runtime/http-transport.js +1 -1
  51. package/dist/runtime/http-transport.js.map +1 -1
  52. package/dist/runtime/index.d.ts +2 -1
  53. package/dist/runtime/index.d.ts.map +1 -1
  54. package/dist/runtime/index.js +2 -1
  55. package/dist/runtime/index.js.map +1 -1
  56. package/dist/runtime/node.d.ts +7 -23
  57. package/dist/runtime/node.d.ts.map +1 -1
  58. package/dist/runtime/node.js +5 -78
  59. package/dist/runtime/node.js.map +1 -1
  60. package/dist/runtime/pooled-transport.d.ts +120 -59
  61. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  62. package/dist/runtime/pooled-transport.js +345 -78
  63. package/dist/runtime/pooled-transport.js.map +1 -1
  64. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  65. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  66. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  67. package/dist/runtime/pyodide-transport.d.ts +1 -1
  68. package/dist/runtime/pyodide-transport.d.ts.map +1 -1
  69. package/dist/runtime/pyodide-transport.js +2 -3
  70. package/dist/runtime/pyodide-transport.js.map +1 -1
  71. package/dist/runtime/rpc-client.d.ts +7 -36
  72. package/dist/runtime/rpc-client.d.ts.map +1 -1
  73. package/dist/runtime/rpc-client.js +37 -72
  74. package/dist/runtime/rpc-client.js.map +1 -1
  75. package/dist/runtime/subprocess-transport.d.ts +134 -8
  76. package/dist/runtime/subprocess-transport.d.ts.map +1 -1
  77. package/dist/runtime/subprocess-transport.js +490 -65
  78. package/dist/runtime/subprocess-transport.js.map +1 -1
  79. package/dist/runtime/timed-out-request-tracker.d.ts +2 -1
  80. package/dist/runtime/timed-out-request-tracker.d.ts.map +1 -1
  81. package/dist/runtime/transport.d.ts +90 -18
  82. package/dist/runtime/transport.d.ts.map +1 -1
  83. package/dist/runtime/transport.js +21 -1
  84. package/dist/runtime/transport.js.map +1 -1
  85. package/dist/runtime/validators.d.ts +49 -0
  86. package/dist/runtime/validators.d.ts.map +1 -1
  87. package/dist/runtime/validators.js +152 -0
  88. package/dist/runtime/validators.js.map +1 -1
  89. package/dist/types/index.d.ts +14 -15
  90. package/dist/types/index.d.ts.map +1 -1
  91. package/dist/tywrap.d.ts +3 -2
  92. package/dist/tywrap.d.ts.map +1 -1
  93. package/dist/tywrap.js +140 -21
  94. package/dist/tywrap.js.map +1 -1
  95. package/dist/utils/cache.d.ts +3 -16
  96. package/dist/utils/cache.d.ts.map +1 -1
  97. package/dist/utils/codec.d.ts +1 -0
  98. package/dist/utils/codec.d.ts.map +1 -1
  99. package/dist/utils/codec.js +165 -17
  100. package/dist/utils/codec.js.map +1 -1
  101. package/dist/utils/ir-cache.d.ts +2 -1
  102. package/dist/utils/ir-cache.d.ts.map +1 -1
  103. package/dist/utils/runtime.d.ts +0 -29
  104. package/dist/utils/runtime.d.ts.map +1 -1
  105. package/dist/utils/runtime.js +16 -107
  106. package/dist/utils/runtime.js.map +1 -1
  107. package/dist/version.js +1 -1
  108. package/package.json +7 -6
  109. package/runtime/frame_codec.py +430 -0
  110. package/runtime/python_bridge.py +213 -54
  111. package/runtime/tywrap_bridge_core.py +217 -147
  112. package/src/config/index.ts +11 -0
  113. package/src/core/annotation-parser.ts +9 -5
  114. package/src/core/emit-call.ts +1 -7
  115. package/src/core/generator.ts +315 -205
  116. package/src/core/mapper.ts +8 -8
  117. package/src/dev.ts +1 -3
  118. package/src/index.ts +7 -3
  119. package/src/runtime/base-bridge.ts +5 -30
  120. package/src/runtime/bounded-context.ts +12 -67
  121. package/src/runtime/bridge-codec.ts +94 -65
  122. package/src/runtime/errors.ts +21 -0
  123. package/src/runtime/frame-codec.ts +469 -0
  124. package/src/runtime/http-transport.ts +6 -1
  125. package/src/runtime/index.ts +7 -6
  126. package/src/runtime/node.ts +17 -104
  127. package/src/runtime/pooled-transport.ts +424 -90
  128. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  129. package/src/runtime/pyodide-transport.ts +7 -3
  130. package/src/runtime/rpc-client.ts +58 -93
  131. package/src/runtime/subprocess-transport.ts +585 -80
  132. package/src/runtime/timed-out-request-tracker.ts +1 -1
  133. package/src/runtime/transport.ts +112 -22
  134. package/src/runtime/validators.ts +204 -0
  135. package/src/types/index.ts +21 -35
  136. package/src/tywrap.ts +157 -30
  137. package/src/utils/cache.ts +3 -3
  138. package/src/utils/codec.ts +205 -16
  139. package/src/utils/ir-cache.ts +1 -1
  140. package/src/utils/runtime.ts +17 -128
  141. package/src/version.ts +1 -1
  142. package/dist/core/discovery.d.ts +0 -103
  143. package/dist/core/discovery.d.ts.map +0 -1
  144. package/dist/core/discovery.js +0 -380
  145. package/dist/core/discovery.js.map +0 -1
  146. package/dist/core/validation.d.ts +0 -102
  147. package/dist/core/validation.d.ts.map +0 -1
  148. package/dist/core/validation.js +0 -490
  149. package/dist/core/validation.js.map +0 -1
  150. package/dist/runtime/base.d.ts +0 -22
  151. package/dist/runtime/base.d.ts.map +0 -1
  152. package/dist/runtime/base.js +0 -23
  153. package/dist/runtime/base.js.map +0 -1
  154. package/dist/runtime/transport-pool.d.ts +0 -196
  155. package/dist/runtime/transport-pool.d.ts.map +0 -1
  156. package/dist/runtime/transport-pool.js +0 -418
  157. package/dist/runtime/transport-pool.js.map +0 -1
  158. package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
  159. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  160. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  161. package/runtime/safe_codec.py +0 -352
  162. package/src/core/discovery.ts +0 -477
  163. package/src/core/validation.ts +0 -729
  164. package/src/runtime/base.ts +0 -24
  165. package/src/runtime/transport-pool.ts +0 -538
@@ -43,12 +43,11 @@ derived from TYWRAP_CODEC_FALLBACK=json so that "Node in json-fallback mode" and
43
43
  import base64
44
44
  import datetime as dt
45
45
  import decimal
46
- import functools
47
46
  import importlib
48
47
  import importlib.util
49
- import inspect
50
48
  import json
51
49
  import math
50
+ import sys
52
51
  import traceback
53
52
  import uuid
54
53
  from pathlib import Path, PurePath
@@ -64,10 +63,6 @@ class ProtocolError(Exception):
64
63
  """Raised for malformed requests (bad protocol/id/method/params)."""
65
64
 
66
65
 
67
- class InstanceHandleError(ValueError):
68
- """Raised when an instance handle is unknown or no longer valid."""
69
-
70
-
71
66
  class ImportNotAllowedError(PermissionError):
72
67
  """Raised when a requested module import is not on the active allowlist."""
73
68
 
@@ -96,8 +91,8 @@ class AttributeNotAllowedError(PermissionError):
96
91
  # IMPORT / ATTRIBUTE ALLOWLIST (trust boundary enforcement)
97
92
  # =============================================================================
98
93
  #
99
- # The bridge dispatches call/instantiate/call_method by importing the requested
100
- # module and getattr-ing the requested function/class/method. That is an
94
+ # The bridge dispatches call requests by importing the requested module and
95
+ # getattr-ing the requested function/class method. That is an
101
96
  # arbitrary import+getattr+call surface, so two complementary guards live here.
102
97
  # Both are PURE (no env reads) so the rules behave identically under the
103
98
  # subprocess server and the in-WASM Pyodide server; the subprocess server derives
@@ -204,22 +199,6 @@ def resolve_allowed_attr_path(root, dotted_name, *, allow_private_attrs):
204
199
  return obj
205
200
 
206
201
 
207
- def is_accessor_attr(obj, attr_name):
208
- """
209
- True when attr_name resolves to a @property or functools.cached_property on
210
- obj's type — i.e. it is read by attribute access, not called.
211
-
212
- Inspects type(obj)'s MRO via getattr_static (which never triggers the
213
- descriptor protocol), NOT the instance dict. That matters for
214
- cached_property: after the first read it stores its value in the instance
215
- __dict__, so an instance-level static lookup would return the cached value
216
- rather than the descriptor and misclassify it as a method on the next read.
217
- Reading from the type keeps the classification stable across repeated reads.
218
- """
219
- descriptor = inspect.getattr_static(type(obj), attr_name, None)
220
- return isinstance(descriptor, (property, functools.cached_property))
221
-
222
-
223
202
  class CodecError(Exception):
224
203
  """Raised when value encoding fails (e.g. NaN/Infinity not allowed)."""
225
204
 
@@ -269,22 +248,24 @@ def _deserialize_bytes_envelope(value):
269
248
  return _NO_DESERIALIZE
270
249
 
271
250
 
272
- def deserialize(value):
251
+ def deserialize(value, *, has_envelope_markers=True):
273
252
  """
274
253
  Recursively deserialize request values into Python-native types.
275
254
 
276
255
  Why: requests are JSON-only; we need a small set of explicit decoders
277
256
  (currently bytes) to restore Python semantics at the boundary.
278
257
  """
258
+ if not has_envelope_markers:
259
+ return value
279
260
  decoded = _deserialize_bytes_envelope(value)
280
261
  if decoded is not _NO_DESERIALIZE:
281
262
  return decoded
282
263
 
283
264
  if isinstance(value, list):
284
- return [deserialize(item) for item in value]
265
+ return [deserialize(item, has_envelope_markers=True) for item in value]
285
266
  if isinstance(value, dict):
286
267
  # Preserve dict shape while decoding nested values.
287
- return {k: deserialize(v) for k, v in value.items()}
268
+ return {k: deserialize(v, has_envelope_markers=True) for k, v in value.items()}
288
269
  return value
289
270
 
290
271
 
@@ -314,33 +295,33 @@ def module_available(module_name):
314
295
 
315
296
 
316
297
  def is_numpy_array(obj):
317
- try:
318
- import numpy as np # noqa: F401
319
- except Exception:
298
+ """Return whether obj is an ndarray without importing NumPy."""
299
+ np = sys.modules.get('numpy')
300
+ if np is None:
320
301
  return False
321
302
  return isinstance(obj, np.ndarray)
322
303
 
323
304
 
324
305
  def is_pandas_dataframe(obj):
325
- try:
326
- import pandas as pd # noqa: F401
327
- except Exception:
306
+ """Return whether obj is a DataFrame without importing pandas."""
307
+ pd = sys.modules.get('pandas')
308
+ if pd is None:
328
309
  return False
329
310
  return isinstance(obj, pd.DataFrame)
330
311
 
331
312
 
332
313
  def is_pandas_series(obj):
333
- try:
334
- import pandas as pd # noqa: F401
335
- except Exception:
314
+ """Return whether obj is a Series without importing pandas."""
315
+ pd = sys.modules.get('pandas')
316
+ if pd is None:
336
317
  return False
337
318
  return isinstance(obj, pd.Series)
338
319
 
339
320
 
340
321
  def is_scipy_sparse(obj):
341
- try:
342
- import scipy.sparse as sp # noqa: F401
343
- except Exception:
322
+ """Return whether obj is sparse without importing SciPy."""
323
+ sp = sys.modules.get('scipy.sparse')
324
+ if sp is None:
344
325
  return False
345
326
  try:
346
327
  return sp.issparse(obj)
@@ -349,9 +330,9 @@ def is_scipy_sparse(obj):
349
330
 
350
331
 
351
332
  def is_torch_tensor(obj):
352
- try:
353
- import torch # noqa: F401
354
- except Exception:
333
+ """Return whether obj is a Tensor without importing PyTorch."""
334
+ torch = sys.modules.get('torch')
335
+ if torch is None:
355
336
  return False
356
337
  try:
357
338
  return torch.is_tensor(obj)
@@ -360,11 +341,11 @@ def is_torch_tensor(obj):
360
341
 
361
342
 
362
343
  def is_sklearn_estimator(obj):
363
- try:
364
- from sklearn.base import BaseEstimator # noqa: F401
365
- except Exception:
344
+ """Return whether obj is an estimator without importing scikit-learn."""
345
+ sklearn_base = sys.modules.get('sklearn.base')
346
+ if sklearn_base is None:
366
347
  return False
367
- return isinstance(obj, BaseEstimator)
348
+ return isinstance(obj, sklearn_base.BaseEstimator)
368
349
 
369
350
 
370
351
  # =============================================================================
@@ -536,7 +517,10 @@ def serialize_sparse_matrix(obj):
536
517
  raise RuntimeError('Failed to inspect scipy sparse matrix format') from exc
537
518
 
538
519
  if fmt not in ('csr', 'csc', 'coo'):
539
- raise RuntimeError(f'Unsupported scipy sparse format: {fmt}')
520
+ raise RuntimeError(
521
+ f'Unsupported scipy sparse format: {fmt}; only csr/csc/coo are supported. '
522
+ 'Convert explicitly (e.g. matrix.tocsr()) before returning'
523
+ )
540
524
 
541
525
  dtype = None
542
526
  try:
@@ -544,7 +528,10 @@ def serialize_sparse_matrix(obj):
544
528
  except Exception:
545
529
  dtype = None
546
530
  if getattr(obj.dtype, 'kind', None) == 'c':
547
- raise RuntimeError('Complex sparse matrices are not supported by JSON codec')
531
+ raise RuntimeError(
532
+ 'Complex scipy sparse matrices are not supported by the JSON codec; '
533
+ 'split into real/imag components explicitly before returning'
534
+ )
548
535
 
549
536
  if fmt in ('csr', 'csc'):
550
537
  data = obj.data.tolist()
@@ -584,8 +571,58 @@ def serialize_torch_tensor(obj, *, force_json_markers, torch_allow_copy=False):
584
571
  Serialize torch.Tensor values via the nested ndarray envelope. CPU-only by
585
572
  default; device/copy behavior is explicit. force_json_markers is threaded
586
573
  into the nested ndarray serialization so Pyodide gets a JSON ndarray value.
574
+
575
+ Rejection order is significant: the categorical rejections (sparse / quantized
576
+ / meta / complex) are checked BEFORE the device/contiguous opt-in branch so
577
+ they fail with a clear, specific message and are NOT bypassable by
578
+ TYWRAP_TORCH_ALLOW_COPY. The opt-in only governs the lossy-but-lossless device
579
+ transfer and contiguous copy, never an unrepresentable layout/dtype.
587
580
  """
581
+ import torch # already importable: is_torch_tensor() gated the dispatch
582
+
588
583
  tensor = obj.detach()
584
+
585
+ # Sparse tensors (COO/CSR/CSC/BSR/BSC -> any non-strided layout) have no dense
586
+ # numpy representation without a densify step, which is not the round-trip this
587
+ # envelope promises. Reject explicitly rather than emitting a misleading
588
+ # "not contiguous" error or silently densifying.
589
+ layout = getattr(tensor, 'layout', None)
590
+ if getattr(tensor, 'is_sparse', False) or (
591
+ layout is not None and layout != torch.strided
592
+ ):
593
+ raise RuntimeError(
594
+ f'Torch sparse tensors are not supported (layout={layout}); '
595
+ 'convert to a dense CPU tensor explicitly (e.g. tensor.to_dense()) before returning'
596
+ )
597
+
598
+ # Quantized tensors carry a qscheme/scale/zero_point that numpy() cannot
599
+ # represent; .numpy() raises an opaque "unsupported ScalarType" deep in torch.
600
+ # Reject up front with an actionable message.
601
+ if getattr(tensor, 'is_quantized', False):
602
+ raise RuntimeError(
603
+ 'Torch quantized tensors are not supported; dequantize explicitly '
604
+ '(e.g. tensor.dequantize()) before returning'
605
+ )
606
+
607
+ # Meta tensors have shape/dtype but NO storage; copying to CPU yields garbage,
608
+ # so this is never a lossy-but-honest transfer the opt-in could authorize.
609
+ if getattr(tensor, 'is_meta', False) or (
610
+ getattr(tensor, 'device', None) is not None and tensor.device.type == 'meta'
611
+ ):
612
+ raise RuntimeError(
613
+ 'Torch meta tensors carry no data and cannot be serialized; '
614
+ 'materialize the tensor on a real device before returning'
615
+ )
616
+
617
+ # Complex tensors round-trip to numpy complex arrays, which are not
618
+ # JSON-serializable and have no codec envelope. Reject explicitly instead of
619
+ # emitting Python complex tuples that the JS decoder cannot parse.
620
+ if torch.is_complex(tensor):
621
+ raise RuntimeError(
622
+ f'Torch complex tensors are not supported (dtype={tensor.dtype}); '
623
+ 'split into real/imag components explicitly before returning'
624
+ )
625
+
589
626
  if getattr(tensor, 'device', None) is not None and tensor.device.type != 'cpu':
590
627
  if not torch_allow_copy:
591
628
  raise RuntimeError(
@@ -622,12 +659,22 @@ def serialize_sklearn_estimator(obj):
622
659
  raise RuntimeError('scikit-learn is not available') from exc
623
660
 
624
661
  params = obj.get_params(deep=False)
625
- try:
626
- json.dumps(params)
627
- except Exception as exc:
628
- raise RuntimeError(
629
- 'scikit-learn estimator params are not JSON-serializable; avoid returning estimators or sanitize params'
630
- ) from exc
662
+
663
+ # Metadata-only: NEVER pickle/joblib. Every param value must be plain JSON
664
+ # (no callables, nested estimators, numpy arrays, or other objects). Probe
665
+ # each value individually so the error names the offending param instead of
666
+ # failing opaquely on the whole dict. allow_nan=False also rejects NaN/Inf
667
+ # params here for parity with the response codec.
668
+ for key, value in params.items():
669
+ try:
670
+ json.dumps(value, allow_nan=False)
671
+ except (TypeError, ValueError) as exc:
672
+ raise RuntimeError(
673
+ f'scikit-learn estimator param {key!r} is not JSON-serializable '
674
+ f'(got {type(value).__name__}); estimators are serialized as metadata only '
675
+ '(no pickle/joblib), so every param must be a plain JSON value. '
676
+ 'Sanitize or drop the param before returning'
677
+ ) from exc
631
678
 
632
679
  return {
633
680
  '__tywrap__': 'sklearn.estimator',
@@ -682,26 +729,45 @@ def serialize_stdlib(obj):
682
729
 
683
730
  def serialize(obj, *, force_json_markers, torch_allow_copy=False):
684
731
  """
685
- Top-level result serializer. Dispatch order is significant: numpy ndarray ->
686
- dataframe -> series -> scipy.sparse -> torch -> sklearn -> Pydantic -> stdlib
687
- -> passthrough. The remaining BridgeCodec value behaviors (numpy/pandas scalars,
688
- bytes, sets, complex rejection, NaN/Infinity) are applied later during JSON
689
- encoding by default_encoder.
732
+ Top-level result serializer.
733
+
734
+ Scientific codecs are type-first and only inspect packages that the value can
735
+ belong to. A value from an optional package implies that package is already in
736
+ sys.modules, so these checks never cold-import the scientific stack. The
737
+ package dispatch deliberately precedes the JSON-native fast path: e.g. a
738
+ package-defined subclass of dict still receives its relevant codec check.
739
+ The remaining BridgeCodec value behaviors (numpy/pandas scalars, bytes, sets,
740
+ complex rejection, NaN/Infinity) are applied later during JSON encoding by
741
+ default_encoder.
690
742
  """
691
- if is_numpy_array(obj):
692
- return serialize_ndarray(obj, force_json_markers=force_json_markers)
693
- if is_pandas_dataframe(obj):
694
- return serialize_dataframe(obj, force_json_markers=force_json_markers)
695
- if is_pandas_series(obj):
696
- return serialize_series(obj, force_json_markers=force_json_markers)
697
- if is_scipy_sparse(obj):
698
- return serialize_sparse_matrix(obj)
699
- if is_torch_tensor(obj):
700
- return serialize_torch_tensor(
701
- obj, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy
702
- )
703
- if is_sklearn_estimator(obj):
743
+ package = type(obj).__module__.split('.', 1)[0]
744
+
745
+ if package == 'numpy' and 'numpy' in sys.modules:
746
+ if is_numpy_array(obj):
747
+ return serialize_ndarray(obj, force_json_markers=force_json_markers)
748
+ elif package == 'pandas' and 'pandas' in sys.modules:
749
+ if is_pandas_dataframe(obj):
750
+ return serialize_dataframe(obj, force_json_markers=force_json_markers)
751
+ if is_pandas_series(obj):
752
+ return serialize_series(obj, force_json_markers=force_json_markers)
753
+ elif package == 'scipy' and 'scipy.sparse' in sys.modules:
754
+ if is_scipy_sparse(obj):
755
+ return serialize_sparse_matrix(obj)
756
+ elif package == 'torch' and 'torch' in sys.modules:
757
+ if is_torch_tensor(obj):
758
+ return serialize_torch_tensor(
759
+ obj, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy
760
+ )
761
+ elif 'sklearn.base' in sys.modules and is_sklearn_estimator(obj):
762
+ # No package gate here, unlike the branches above: subclassing
763
+ # BaseEstimator is sklearn's documented extension point, so user-defined
764
+ # estimators live outside the 'sklearn' package and must still get the
765
+ # estimator serializer (and its param-naming errors).
704
766
  return serialize_sklearn_estimator(obj)
767
+
768
+ if isinstance(obj, (type(None), bool, int, float, str, dict, list, tuple)):
769
+ return obj
770
+
705
771
  pydantic_value = serialize_pydantic(obj)
706
772
  if pydantic_value is not _NO_PYDANTIC:
707
773
  return pydantic_value
@@ -712,14 +778,12 @@ def serialize(obj, *, force_json_markers, torch_allow_copy=False):
712
778
 
713
779
 
714
780
  # =============================================================================
715
- # JSON ENCODE: BridgeCodec-equivalent value handling (NaN reject, scalars, bytes)
781
+ # JSON CODEC: value handling and size-limited encode/decode
716
782
  # =============================================================================
717
783
  #
718
- # This mirrors BridgeCodec._default_encoder (runtime/safe_codec.py) for the VALUE
719
- # behaviors that are part of the wire contract. The subprocess server still uses
720
- # the real BridgeCodec for its final encode (it also enforces size limits); this
721
- # core encoder exists so the Pyodide server gets identical value handling without
722
- # depending on safe_codec.py. The conformance suite asserts these behaviors match.
784
+ # This is the single Python implementation used by both the subprocess bridge and
785
+ # the embedded Pyodide core. BridgeCodec adds payload-size enforcement around the
786
+ # shared encoder; encode_value is the unbounded form used by the in-memory bridge.
723
787
 
724
788
  def _is_nan_or_inf(value):
725
789
  if not isinstance(value, (int, float)):
@@ -844,6 +908,56 @@ def encode_value(value, *, allow_nan):
844
908
  raise CodecError(f'JSON encoding failed: {exc}') from exc
845
909
 
846
910
 
911
+ class BridgeCodec:
912
+ """Safe JSON codec with explicit value handling and payload-size limits."""
913
+
914
+ def __init__(self, allow_nan=False, max_payload_bytes=10 * 1024 * 1024):
915
+ self.allow_nan = allow_nan
916
+ self.max_payload_bytes = max_payload_bytes
917
+ self._encoder = make_default_encoder(allow_nan=allow_nan)
918
+
919
+ def encode(self, value):
920
+ result = encode_value(value, allow_nan=self.allow_nan)
921
+ if len(result.encode('utf-8')) > self.max_payload_bytes:
922
+ raise CodecError(f'Payload exceeds {self.max_payload_bytes} bytes')
923
+ return result
924
+
925
+ def decode(self, payload):
926
+ if len(payload.encode('utf-8')) > self.max_payload_bytes:
927
+ raise CodecError(f'Payload exceeds {self.max_payload_bytes} bytes')
928
+ try:
929
+ return json.loads(payload)
930
+ except json.JSONDecodeError as exc:
931
+ raise CodecError(f'JSON decoding failed: {exc}') from exc
932
+
933
+ def _default_encoder(self, obj):
934
+ """Compatibility hook for callers that used the codec's JSON encoder."""
935
+ return self._encoder(obj)
936
+
937
+
938
+ _default_codec = None
939
+
940
+
941
+ def get_default_codec():
942
+ """Return the lazily-created default BridgeCodec instance."""
943
+ global _default_codec
944
+ if _default_codec is None:
945
+ _default_codec = BridgeCodec()
946
+ return _default_codec
947
+
948
+
949
+ def encode(value, *, allow_nan=False):
950
+ """Encode a value with the default codec settings."""
951
+ if allow_nan:
952
+ return BridgeCodec(allow_nan=True).encode(value)
953
+ return get_default_codec().encode(value)
954
+
955
+
956
+ def decode(payload):
957
+ """Decode a JSON payload with the default codec settings."""
958
+ return get_default_codec().decode(payload)
959
+
960
+
847
961
  # =============================================================================
848
962
  # REQUEST VALIDATION + HANDLERS + DISPATCH
849
963
  # =============================================================================
@@ -883,11 +997,19 @@ def coerce_dict(value, key):
883
997
  return value
884
998
 
885
999
 
886
- def handle_call(params, *, force_json_markers, torch_allow_copy, allowed_modules, allow_private_attrs):
1000
+ def handle_call(
1001
+ params,
1002
+ *,
1003
+ force_json_markers,
1004
+ torch_allow_copy,
1005
+ allowed_modules,
1006
+ allow_private_attrs,
1007
+ has_envelope_markers,
1008
+ ):
887
1009
  module_name = require_str(params, 'module')
888
1010
  function_name = require_str(params, 'functionName')
889
- args = deserialize(coerce_list(params.get('args'), 'args'))
890
- kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))
1011
+ args = deserialize(coerce_list(params.get('args'), 'args'), has_envelope_markers=has_envelope_markers)
1012
+ kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'), has_envelope_markers=has_envelope_markers)
891
1013
  mod = import_allowed_module(module_name, allowed_modules)
892
1014
  # function_name may be dotted ('Class.method') for @classmethod/@staticmethod
893
1015
  # calls, which the generated wrapper routes through call() rather than an
@@ -897,53 +1019,14 @@ def handle_call(params, *, force_json_markers, torch_allow_copy, allowed_modules
897
1019
  return serialize(res, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy)
898
1020
 
899
1021
 
900
- def handle_instantiate(params, instances, *, allowed_modules, allow_private_attrs):
901
- module_name = require_str(params, 'module')
902
- class_name = require_str(params, 'className')
903
- args = deserialize(coerce_list(params.get('args'), 'args'))
904
- kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))
905
- mod = import_allowed_module(module_name, allowed_modules)
906
- cls = get_allowed_attr(mod, class_name, allow_private_attrs=allow_private_attrs)
907
- obj = cls(*args, **kwargs)
908
- handle_id = str(id(obj))
909
- instances[handle_id] = obj
910
- return handle_id
911
-
912
-
913
- def handle_call_method(params, instances, *, force_json_markers, torch_allow_copy, allow_private_attrs):
914
- handle_id = require_str(params, 'handle')
915
- method_name = require_str(params, 'methodName')
916
- args = deserialize(coerce_list(params.get('args'), 'args'))
917
- kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))
918
- if handle_id not in instances:
919
- raise InstanceHandleError(f'Unknown instance handle: {handle_id}')
920
- obj = instances[handle_id]
921
- # A @property / functools.cached_property is read, not called: the generated
922
- # `get prop()` accessor emits callMethod(handle, name, []). Classify before
923
- # touching the value (so cached_property is detected on its first read) and
924
- # return the attribute directly; everything else is a bound method to call.
925
- if is_accessor_attr(obj, method_name):
926
- # An accessor is read, never called: a generated `get prop()` always
927
- # sends empty args. Reject a malformed request that supplies any so it
928
- # fails loudly instead of silently dropping the arguments.
929
- if args or kwargs:
930
- raise ProtocolError(f'Accessor {method_name!r} does not accept arguments')
931
- res = get_allowed_attr(obj, method_name, allow_private_attrs=allow_private_attrs)
932
- else:
933
- func = get_allowed_attr(obj, method_name, allow_private_attrs=allow_private_attrs)
934
- res = func(*args, **kwargs)
935
- return serialize(res, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy)
936
-
937
-
938
- def handle_dispose_instance(params, instances):
939
- handle_id = require_str(params, 'handle')
940
- if handle_id not in instances:
941
- return False
942
- del instances[handle_id]
943
- return True
944
-
945
-
946
- def build_meta(instances, *, bridge, pid, python_version, codec_fallback, arrow_available_override=None):
1022
+ def build_meta(
1023
+ *,
1024
+ bridge,
1025
+ pid,
1026
+ python_version,
1027
+ codec_fallback,
1028
+ arrow_available_override=None,
1029
+ ):
947
1030
  """
948
1031
  Build the bridge metadata payload.
949
1032
 
@@ -958,7 +1041,7 @@ def build_meta(instances, *, bridge, pid, python_version, codec_fallback, arrow_
958
1041
  pyarrow happens to be importable in the WASM environment.
959
1042
  """
960
1043
  arrow = arrow_available() if arrow_available_override is None else arrow_available_override
961
- return {
1044
+ meta = {
962
1045
  'protocol': PROTOCOL,
963
1046
  'protocolVersion': PROTOCOL_VERSION,
964
1047
  'bridge': bridge,
@@ -969,13 +1052,13 @@ def build_meta(instances, *, bridge, pid, python_version, codec_fallback, arrow_
969
1052
  'scipyAvailable': module_available('scipy'),
970
1053
  'torchAvailable': module_available('torch'),
971
1054
  'sklearnAvailable': module_available('sklearn'),
972
- 'instances': len(instances),
1055
+ 'instances': 0,
973
1056
  }
1057
+ return meta
974
1058
 
975
1059
 
976
1060
  def dispatch_request(
977
1061
  msg,
978
- instances,
979
1062
  *,
980
1063
  bridge,
981
1064
  pid,
@@ -986,6 +1069,7 @@ def dispatch_request(
986
1069
  arrow_available_override=None,
987
1070
  allowed_modules=None,
988
1071
  allow_private_attrs=False,
1072
+ has_envelope_markers=True,
989
1073
  ):
990
1074
  """
991
1075
  Validate and route a request, returning the fully-serialized response dict
@@ -997,7 +1081,7 @@ def dispatch_request(
997
1081
  the final encode_value() call, which the caller performs.
998
1082
 
999
1083
  allowed_modules: None (default) disables the import allowlist so existing
1000
- behavior is preserved. Supplying a set restricts call/instantiate imports to
1084
+ behavior is preserved. Supplying a set restricts call imports to
1001
1085
  those modules (plus the stdlib the bridge itself needs) and raises
1002
1086
  ImportNotAllowedError otherwise. allow_private_attrs=False (default) blocks
1003
1087
  getattr of underscore-prefixed names; True restores unrestricted access. See
@@ -1015,28 +1099,14 @@ def dispatch_request(
1015
1099
  torch_allow_copy=torch_allow_copy,
1016
1100
  allowed_modules=allowed_modules,
1017
1101
  allow_private_attrs=allow_private_attrs,
1102
+ has_envelope_markers=has_envelope_markers,
1018
1103
  )
1019
- elif method == 'instantiate':
1020
- result = handle_instantiate(
1021
- params, instances, allowed_modules=allowed_modules, allow_private_attrs=allow_private_attrs
1022
- )
1023
- elif method == 'call_method':
1024
- result = handle_call_method(
1025
- params,
1026
- instances,
1027
- force_json_markers=force_json_markers,
1028
- torch_allow_copy=torch_allow_copy,
1029
- allow_private_attrs=allow_private_attrs,
1030
- )
1031
- elif method == 'dispose_instance':
1032
- result = handle_dispose_instance(params, instances)
1033
1104
  elif method == 'meta':
1034
1105
  if python_version is None:
1035
1106
  import sys
1036
1107
  python_version = sys.version.split()[0]
1037
1108
  codec_fallback = 'json' if force_json_markers else 'none'
1038
1109
  result = build_meta(
1039
- instances,
1040
1110
  bridge=bridge,
1041
1111
  pid=pid,
1042
1112
  python_version=python_version,
@@ -118,6 +118,7 @@ function detectLegacyFields(config: TywrapConfig): void {
118
118
  const ALLOWED_TOP_LEVEL = new Set([
119
119
  'pythonModules',
120
120
  'pythonImportPath',
121
+ 'contractInput',
121
122
  'output',
122
123
  'runtime',
123
124
  'performance',
@@ -129,6 +130,7 @@ const VALID_OUTPUT_FORMATS = ['esm', 'cjs', 'both'];
129
130
  const VALID_COMPRESSION = ['auto', 'gzip', 'brotli', 'none'];
130
131
  const VALID_TYPE_HINTS = ['strict', 'loose', 'ignore'];
131
132
  const VALID_TYPE_PRESETS = new Set([
133
+ // Accepted as an explicit no-op in 0.9; ndarray shape typing depends on #268.
132
134
  'numpy',
133
135
  'pandas',
134
136
  'pydantic',
@@ -276,6 +278,15 @@ function validateConfig(config: ResolvedTywrapConfig): void {
276
278
  throw new Error('pythonImportPath must be an array of strings');
277
279
  }
278
280
 
281
+ if (
282
+ config.contractInput !== undefined &&
283
+ typeof config.contractInput !== 'string' &&
284
+ (!isPlainObject(config.contractInput) ||
285
+ !Object.values(config.contractInput).every(value => typeof value === 'string'))
286
+ ) {
287
+ throw new Error('contractInput must be a path string or a record of module paths');
288
+ }
289
+
279
290
  validateOutput(config.output);
280
291
  validateRuntime(config.runtime);
281
292
  validatePerformance(config.performance);
@@ -1,6 +1,6 @@
1
1
  import type { PythonGenericParameter, PythonType } from '../types/index.js';
2
2
 
3
- export interface AnnotationParserOptions {
3
+ interface AnnotationParserOptions {
4
4
  onUnknownTypeName?: (name: string) => void;
5
5
  knownTypeVarNames?: Iterable<string>;
6
6
  typeParameters?: readonly PythonGenericParameter[];
@@ -253,7 +253,8 @@ export function parseAnnotationToPythonType(
253
253
  // Returns the content between the first `[` and the last `]` of a special-form
254
254
  // annotation (e.g. the `int, str` of `Union[int, str]`). Callers gate this on a
255
255
  // prefix check, so the brackets are known to be present.
256
- const bracketInner = (raw: string): string => raw.slice(raw.indexOf('[') + 1, raw.lastIndexOf(']'));
256
+ const bracketInner = (raw: string): string =>
257
+ raw.slice(raw.indexOf('[') + 1, raw.lastIndexOf(']'));
257
258
 
258
259
  // True when `raw` opens with one of the supported module prefixes followed by
259
260
  // `name[`. Mirrors the inlined `raw.startsWith('typing.Name[') || ...` checks so
@@ -406,8 +407,7 @@ export function parseAnnotationToPythonType(
406
407
  const builtInClassMatch = raw.match(/^<class ['"][^'"]+['"]>$/);
407
408
  if (builtInClassMatch) {
408
409
  const inner = (raw.match(/^<class ['"]([^'"]+)['"]>$/) ?? [])[1] ?? '';
409
- const name = (inner.split('.').pop() ?? '').toString();
410
- return mapSimpleName(name);
410
+ return mapSimpleName(inner);
411
411
  }
412
412
 
413
413
  if (raw.includes('|')) {
@@ -505,7 +505,11 @@ export function parseAnnotationToPythonType(
505
505
  };
506
506
  }
507
507
 
508
- return mapSimpleName(raw);
508
+ const qualified = splitQualifiedName(raw);
509
+ if (qualified.module) {
510
+ return { kind: 'custom', name: qualified.name, module: qualified.module };
511
+ }
512
+ return mapSimpleName(qualified.name);
509
513
  };
510
514
 
511
515
  return parse(annotation, 0);
@@ -132,13 +132,7 @@ export function emitCallPrelude(desc: CallDescriptor, helpers: CallEmitHelpers):
132
132
  * keywords and enforce required keyword-only arguments.
133
133
  */
134
134
  export function emitArgGuards(desc: CallDescriptor): string[] {
135
- const {
136
- hasKwArgs,
137
- positionalOnlyNames,
138
- requiredKwOnlyNames,
139
- indent: i,
140
- errorLabel,
141
- } = desc;
135
+ const { hasKwArgs, positionalOnlyNames, requiredKwOnlyNames, indent: i, errorLabel } = desc;
142
136
  const i2 = `${i} `;
143
137
  const i3 = `${i2} `;
144
138