tywrap 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +15 -5
  2. package/dist/core/annotation-parser.d.ts.map +1 -1
  3. package/dist/core/annotation-parser.js.map +1 -1
  4. package/dist/core/emit-call.d.ts.map +1 -1
  5. package/dist/core/emit-call.js +1 -1
  6. package/dist/core/emit-call.js.map +1 -1
  7. package/dist/dev.d.ts.map +1 -1
  8. package/dist/dev.js +1 -3
  9. package/dist/dev.js.map +1 -1
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/runtime/frame-codec.d.ts +111 -0
  14. package/dist/runtime/frame-codec.d.ts.map +1 -0
  15. package/dist/runtime/frame-codec.js +352 -0
  16. package/dist/runtime/frame-codec.js.map +1 -0
  17. package/dist/runtime/index.d.ts +1 -1
  18. package/dist/runtime/index.d.ts.map +1 -1
  19. package/dist/runtime/index.js +1 -1
  20. package/dist/runtime/index.js.map +1 -1
  21. package/dist/runtime/node.d.ts +13 -0
  22. package/dist/runtime/node.d.ts.map +1 -1
  23. package/dist/runtime/node.js +5 -0
  24. package/dist/runtime/node.js.map +1 -1
  25. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  26. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  27. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  28. package/dist/runtime/rpc-client.d.ts.map +1 -1
  29. package/dist/runtime/rpc-client.js +53 -6
  30. package/dist/runtime/rpc-client.js.map +1 -1
  31. package/dist/runtime/subprocess-transport.d.ts +172 -7
  32. package/dist/runtime/subprocess-transport.d.ts.map +1 -1
  33. package/dist/runtime/subprocess-transport.js +513 -31
  34. package/dist/runtime/subprocess-transport.js.map +1 -1
  35. package/dist/runtime/transport.d.ts +85 -3
  36. package/dist/runtime/transport.d.ts.map +1 -1
  37. package/dist/runtime/transport.js +20 -0
  38. package/dist/runtime/transport.js.map +1 -1
  39. package/dist/types/index.d.ts +24 -0
  40. package/dist/types/index.d.ts.map +1 -1
  41. package/dist/tywrap.js +1 -9
  42. package/dist/tywrap.js.map +1 -1
  43. package/dist/utils/codec.d.ts.map +1 -1
  44. package/dist/utils/codec.js +152 -4
  45. package/dist/utils/codec.js.map +1 -1
  46. package/dist/version.js +1 -1
  47. package/package.json +1 -1
  48. package/runtime/__pycache__/_tywrap_conformance_chunking_fixtures.cpython-311.pyc +0 -0
  49. package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
  50. package/runtime/__pycache__/_tywrap_w4_chunking_fixture.cpython-311.pyc +0 -0
  51. package/runtime/__pycache__/_tywrap_w5_request_chunking_fixture.cpython-311.pyc +0 -0
  52. package/runtime/__pycache__/_tywrap_w6_pool_chunking_fixture.cpython-311.pyc +0 -0
  53. package/runtime/__pycache__/frame_codec.cpython-311.pyc +0 -0
  54. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  55. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  56. package/runtime/frame_codec.py +424 -0
  57. package/runtime/python_bridge.py +241 -42
  58. package/runtime/tywrap_bridge_core.py +97 -10
  59. package/src/core/annotation-parser.ts +2 -1
  60. package/src/core/emit-call.ts +1 -7
  61. package/src/dev.ts +1 -3
  62. package/src/index.ts +1 -0
  63. package/src/runtime/frame-codec.ts +469 -0
  64. package/src/runtime/index.ts +1 -6
  65. package/src/runtime/node.ts +25 -1
  66. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  67. package/src/runtime/rpc-client.ts +74 -7
  68. package/src/runtime/subprocess-transport.ts +615 -35
  69. package/src/runtime/transport.ts +101 -3
  70. package/src/types/index.ts +25 -0
  71. package/src/tywrap.ts +1 -9
  72. package/src/utils/codec.ts +184 -3
  73. package/src/version.ts +1 -1
@@ -0,0 +1,424 @@
1
+ """
2
+ Pure frame codec + reassembler for the ``tywrap-frame/1`` framing protocol.
3
+
4
+ This is the Python mirror of ``src/runtime/frame-codec.ts``. It fragments one
5
+ complete logical JSON message into chunk frames and reassembles a stream of
6
+ frames back into the original string. It performs NO I/O and reads NO env vars
7
+ (PURITY, matching ``tywrap_bridge_core``): the read/write loop in
8
+ ``python_bridge.py`` (W4/W5) wires these functions onto stdin/stdout.
9
+
10
+ Encoding is ``utf8-slice`` (plan decision #6, docs/transport-framing.md): the
11
+ logical payload is already valid-UTF-8 JSON, so each frame's ``data`` is a raw
12
+ substring split on a UTF-8 codepoint boundary at or before ``max_frame_bytes``
13
+ UTF-8 bytes. Reassembly is plain concatenation -- no base64, no ~33% inflation.
14
+ A frame's ``data`` MUST NOT split a multi-byte UTF-8 sequence; ``encode_frames``
15
+ guarantees this by snapping every boundary back to the nearest codepoint
16
+ boundary.
17
+
18
+ The two implementations MUST agree byte-for-byte on the wire (see
19
+ test/python/test_frame_codec.py and test/frame-codec.test.ts for the
20
+ cross-language parity vectors).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Any, Dict, List, Optional
26
+
27
+ # Framing protocol identifier; mirrors FRAME_PROTOCOL_ID in
28
+ # src/runtime/transport.ts. Any other value on the wire is rejected.
29
+ FRAME_PROTOCOL_ID = 'tywrap-frame/1'
30
+
31
+ # Numeric framing-protocol version, derived from the trailing number so the two
32
+ # cannot drift (same pattern as PROTOCOL_VERSION in tywrap_bridge_core).
33
+ FRAME_PROTOCOL_VERSION = int(FRAME_PROTOCOL_ID.split('/')[1])
34
+
35
+ # The single per-frame encoding tywrap emits/accepts in 0.8.0.
36
+ FRAME_ENCODING = 'utf8-slice'
37
+
38
+
39
+ class FrameError(Exception):
40
+ """A framing-protocol violation (malformed/duplicate/inconsistent frame).
41
+
42
+ Carries a stable ``code`` mirroring the BridgeProtocolError codes on the TS
43
+ side so callers can branch on the failure class.
44
+ """
45
+
46
+ def __init__(self, message: str, *, code: str) -> None:
47
+ super().__init__(message)
48
+ self.code = code
49
+
50
+
51
+ def _utf8_bytes_of_codepoint(code_point: int) -> int:
52
+ """UTF-8 byte length of a single Unicode codepoint."""
53
+ if code_point <= 0x7F:
54
+ return 1
55
+ if code_point <= 0x7FF:
56
+ return 2
57
+ if code_point <= 0xFFFF:
58
+ return 3
59
+ return 4
60
+
61
+
62
+ def utf8_byte_length(value: str) -> int:
63
+ """Exact UTF-8 byte length of a string."""
64
+ return len(value.encode('utf-8'))
65
+
66
+
67
+ def encode_frames(
68
+ logical_json: str,
69
+ *,
70
+ id: int,
71
+ stream: str,
72
+ max_frame_bytes: int,
73
+ ) -> List[Dict[str, Any]]:
74
+ """Fragment a complete logical JSON message into ``tywrap-frame/1`` frames.
75
+
76
+ Splits ``logical_json`` on UTF-8 codepoint boundaries so each frame's
77
+ ``data`` is at most ``max_frame_bytes`` UTF-8 bytes and never splits a
78
+ multi-byte sequence. ``totalBytes`` is the exact UTF-8 byte length of the
79
+ full message; ``total`` is the resulting frame count; ``seq`` is zero-based
80
+ and dense.
81
+
82
+ An empty ``logical_json`` still produces exactly one (empty) frame so the
83
+ receiver always sees ``total >= 1`` and a well-formed stream.
84
+
85
+ :raises FrameError: if ``max_frame_bytes`` is not an int >= 4, or ``stream``
86
+ is not ``"request"``/``"response"``.
87
+ """
88
+ if not isinstance(max_frame_bytes, int) or isinstance(max_frame_bytes, bool) or max_frame_bytes < 4:
89
+ raise FrameError(
90
+ f'encode_frames: max_frame_bytes must be an int >= 4 (got {max_frame_bytes!r})',
91
+ code='FRAME_BAD_MAX_BYTES',
92
+ )
93
+ if stream not in ('request', 'response'):
94
+ raise FrameError(
95
+ f'encode_frames: stream must be "request" or "response" (got {stream!r})',
96
+ code='FRAME_MALFORMED',
97
+ )
98
+
99
+ total_bytes = utf8_byte_length(logical_json)
100
+
101
+ # Walk codepoints, accumulating UTF-8 bytes into the current slice until the
102
+ # next codepoint would exceed max_frame_bytes; that boundary is, by
103
+ # construction, a codepoint boundary so no multi-byte sequence is split.
104
+ slices: List[str] = []
105
+ current_chars: List[str] = []
106
+ current_bytes = 0
107
+ for ch in logical_json:
108
+ ch_bytes = _utf8_bytes_of_codepoint(ord(ch))
109
+ if current_bytes + ch_bytes > max_frame_bytes and current_chars:
110
+ slices.append(''.join(current_chars))
111
+ current_chars = []
112
+ current_bytes = 0
113
+ current_chars.append(ch)
114
+ current_bytes += ch_bytes
115
+ # Always emit a final slice (covers the empty-string case: one empty frame).
116
+ slices.append(''.join(current_chars))
117
+
118
+ total = len(slices)
119
+ return [
120
+ {
121
+ '__tywrap_frame__': 'chunk',
122
+ 'frameProtocol': FRAME_PROTOCOL_ID,
123
+ 'stream': stream,
124
+ 'id': id,
125
+ 'seq': seq,
126
+ 'total': total,
127
+ 'totalBytes': total_bytes,
128
+ 'encoding': FRAME_ENCODING,
129
+ 'data': data,
130
+ }
131
+ for seq, data in enumerate(slices)
132
+ ]
133
+
134
+
135
+ def parse_chunk_frame(value: Any) -> Dict[str, Any]:
136
+ """Validate that ``value`` is a structurally well-formed data frame.
137
+
138
+ Returns the frame dict, or raises :class:`FrameError`. Only
139
+ ``__tywrap_frame__ == "chunk"`` frames flow through reassembly; ``"error"``
140
+ frames are a transport-layer concern handled above this module.
141
+ """
142
+ if not isinstance(value, dict):
143
+ raise FrameError('frame: expected an object', code='FRAME_MALFORMED')
144
+
145
+ if value.get('__tywrap_frame__') != 'chunk':
146
+ raise FrameError(
147
+ f'frame: __tywrap_frame__ must be "chunk" (got {value.get("__tywrap_frame__")!r})',
148
+ code='FRAME_MALFORMED',
149
+ )
150
+ if value.get('frameProtocol') != FRAME_PROTOCOL_ID:
151
+ raise FrameError(
152
+ f'frame: unknown frameProtocol {value.get("frameProtocol")!r} '
153
+ f'(expected {FRAME_PROTOCOL_ID})',
154
+ code='FRAME_UNKNOWN_PROTOCOL',
155
+ )
156
+ if value.get('stream') not in ('request', 'response'):
157
+ raise FrameError(
158
+ f'frame: stream must be "request" or "response" (got {value.get("stream")!r})',
159
+ code='FRAME_MALFORMED',
160
+ )
161
+ if value.get('encoding') != FRAME_ENCODING:
162
+ # utf8-base64 is reserved in the schema but never emitted/accepted here.
163
+ raise FrameError(
164
+ f'frame: unsupported encoding {value.get("encoding")!r} '
165
+ f'(only "utf8-slice" in 0.8.0)',
166
+ code='FRAME_MALFORMED',
167
+ )
168
+
169
+ frame_id = value.get('id')
170
+ if not _is_int(frame_id):
171
+ raise FrameError(f'frame: id must be an integer (got {frame_id!r})', code='FRAME_MALFORMED')
172
+
173
+ seq = value.get('seq')
174
+ if not _is_int(seq) or seq < 0:
175
+ raise FrameError(
176
+ f'frame: seq must be a non-negative integer (got {seq!r})',
177
+ code='FRAME_MALFORMED',
178
+ )
179
+
180
+ total = value.get('total')
181
+ if not _is_int(total) or total < 1:
182
+ raise FrameError(
183
+ f'frame: total must be an integer >= 1 (got {total!r})',
184
+ code='FRAME_MALFORMED',
185
+ )
186
+
187
+ total_bytes = value.get('totalBytes')
188
+ if not _is_int(total_bytes) or total_bytes < 0:
189
+ raise FrameError(
190
+ f'frame: totalBytes must be a non-negative integer (got {total_bytes!r})',
191
+ code='FRAME_MALFORMED',
192
+ )
193
+
194
+ data = value.get('data')
195
+ if not isinstance(data, str):
196
+ raise FrameError(
197
+ f'frame: data must be a string (got {type(data).__name__})',
198
+ code='FRAME_MALFORMED',
199
+ )
200
+
201
+ if seq >= total:
202
+ raise FrameError(
203
+ f'frame: seq {seq} out of range for total {total}',
204
+ code='FRAME_MALFORMED',
205
+ )
206
+
207
+ return value
208
+
209
+
210
+ def _is_int(value: Any) -> bool:
211
+ """True for a real integer (rejecting bool, which is an int subclass)."""
212
+ return isinstance(value, int) and not isinstance(value, bool)
213
+
214
+
215
+ # Defensive bounds on per-id reassembly state — mirror of src/runtime/frame-codec.ts.
216
+ # The peer is the local Python bridge, but a buggy/corrupt bridge must not grow
217
+ # memory without limit: cap concurrent mid-reassembly ids (fail loud past the cap)
218
+ # and FIFO-bound the timed-out-id discard set so a long-lived process whose
219
+ # timed-out streams never see their final frame does not leak markers forever.
220
+ MAX_CONCURRENT_STREAMS = 1024
221
+ MAX_DISCARDED_IDS = 4096
222
+
223
+
224
+ class Reassembler:
225
+ """Accumulate ``tywrap-frame/1`` frames by ``id`` and reconstruct the string.
226
+
227
+ Mirror of the TypeScript ``Reassembler`` class. A single instance handles
228
+ many concurrent ids. Validation is enforced on every :meth:`accept`:
229
+
230
+ - matching ``FRAME_PROTOCOL_ID`` on every frame;
231
+ - consistent ``total`` / ``totalBytes`` across all frames of an id;
232
+ - no duplicate ``seq``;
233
+ - on completion, exactly ``total`` frames covering ``[0, total)``;
234
+ - the concatenated payload's UTF-8 byte length equals ``totalBytes`` exactly;
235
+ - the concatenated payload is valid UTF-8.
236
+
237
+ Timed-out ids: the transport marks an id timed out via :meth:`discard`.
238
+ Every subsequent frame for that id is dropped (returning ``None``) until its
239
+ final frame arrives, at which point the id is forgotten so the slot can be
240
+ reused. This prevents late multi-frame responses from desyncing the stream.
241
+ """
242
+
243
+ def __init__(
244
+ self,
245
+ max_reassembly_bytes: Optional[int] = None,
246
+ expected_stream: Optional[str] = None,
247
+ ) -> None:
248
+ # id -> {'total', 'totalBytes', 'bytesSoFar', 'slices': {seq: data}}
249
+ self._streams: Dict[int, Dict[str, Any]] = {}
250
+ # Insertion-ordered set-as-dict so the oldest marker can be FIFO-evicted
251
+ # once the discard set exceeds MAX_DISCARDED_IDS.
252
+ self._discarded: Dict[int, None] = {}
253
+ # Fail loud past this many UTF-8 bytes per stream (declared OR
254
+ # accumulated) so a huge payload cannot be buffered to OOM; None = no cap.
255
+ self._max_reassembly_bytes = max_reassembly_bytes
256
+ # If set, every frame must carry this stream direction (defense-in-depth).
257
+ self._expected_stream = expected_stream
258
+
259
+ def accept(self, raw_frame: Any) -> Optional[str]:
260
+ """Feed one frame.
261
+
262
+ Returns the fully reassembled logical string when this frame completes
263
+ the stream for its id, ``None`` if more frames are still needed (or the
264
+ frame was dropped because its id is timed out).
265
+
266
+ :raises FrameError: on any framing violation.
267
+ """
268
+ frame = parse_chunk_frame(raw_frame)
269
+ frame_id = frame['id']
270
+ seq = frame['seq']
271
+ total = frame['total']
272
+ total_bytes = frame['totalBytes']
273
+ data = frame['data']
274
+ stream = frame['stream']
275
+
276
+ if self._expected_stream is not None and stream != self._expected_stream:
277
+ raise FrameError(
278
+ f"frame: unexpected stream '{stream}' for id {frame_id} "
279
+ f"(this reassembler handles '{self._expected_stream}')",
280
+ code='FRAME_WRONG_STREAM',
281
+ )
282
+
283
+ # Late-frame discard: drop frames for a timed-out id; forget the id once
284
+ # its declared final frame has been seen so the stream stays aligned and
285
+ # the id can be reused.
286
+ if frame_id in self._discarded:
287
+ if seq == total - 1:
288
+ self._discarded.pop(frame_id, None)
289
+ return None
290
+
291
+ state = self._streams.get(frame_id)
292
+ if state is None:
293
+ if len(self._streams) >= MAX_CONCURRENT_STREAMS:
294
+ raise FrameError(
295
+ f'frame: too many concurrent reassembly streams '
296
+ f'(>= {MAX_CONCURRENT_STREAMS}); refusing to buffer id {frame_id}',
297
+ code='FRAME_TOO_MANY_STREAMS',
298
+ )
299
+ if (
300
+ self._max_reassembly_bytes is not None
301
+ and total_bytes > self._max_reassembly_bytes
302
+ ):
303
+ raise FrameError(
304
+ f'frame: declared payload {total_bytes} bytes exceeds max '
305
+ f'reassembly {self._max_reassembly_bytes} bytes for id {frame_id}',
306
+ code='FRAME_PAYLOAD_TOO_LARGE',
307
+ )
308
+ state = {
309
+ 'total': total,
310
+ 'totalBytes': total_bytes,
311
+ 'bytesSoFar': 0,
312
+ 'slices': {},
313
+ }
314
+ self._streams[frame_id] = state
315
+ else:
316
+ if state['total'] != total:
317
+ del self._streams[frame_id]
318
+ raise FrameError(
319
+ f'frame: total mismatch for id {frame_id} '
320
+ f'(saw {state["total"]}, frame says {total})',
321
+ code='FRAME_INCONSISTENT',
322
+ )
323
+ if state['totalBytes'] != total_bytes:
324
+ del self._streams[frame_id]
325
+ raise FrameError(
326
+ f'frame: totalBytes mismatch for id {frame_id} '
327
+ f'(saw {state["totalBytes"]}, frame says {total_bytes})',
328
+ code='FRAME_INCONSISTENT',
329
+ )
330
+
331
+ slices: Dict[int, str] = state['slices']
332
+ if seq in slices:
333
+ del self._streams[frame_id]
334
+ raise FrameError(
335
+ f'frame: duplicate seq {seq} for id {frame_id}',
336
+ code='FRAME_DUPLICATE_SEQ',
337
+ )
338
+ slices[seq] = data
339
+
340
+ # Running memory bound (mirrors the declared-size check above): a peer
341
+ # that under-declares totalBytes then overshoots is caught before the
342
+ # full payload is buffered.
343
+ state['bytesSoFar'] += utf8_byte_length(data)
344
+ if (
345
+ self._max_reassembly_bytes is not None
346
+ and state['bytesSoFar'] > self._max_reassembly_bytes
347
+ ):
348
+ del self._streams[frame_id]
349
+ raise FrameError(
350
+ f'frame: accumulated payload exceeds max reassembly '
351
+ f'{self._max_reassembly_bytes} bytes for id {frame_id}',
352
+ code='FRAME_PAYLOAD_TOO_LARGE',
353
+ )
354
+
355
+ if len(slices) < total:
356
+ return None
357
+
358
+ # All `total` frames present; the dense [0, total) range is guaranteed
359
+ # because each seq is in range, unique, and there are exactly `total` of
360
+ # them. Concatenate in seq order.
361
+ del self._streams[frame_id]
362
+ parts: List[str] = []
363
+ for i in range(total):
364
+ if i not in slices:
365
+ # Unreachable given the count + uniqueness + range invariants,
366
+ # but kept explicit rather than a silent gap.
367
+ raise FrameError(
368
+ f'frame: missing seq {i} for id {frame_id}',
369
+ code='FRAME_SEQ_GAP',
370
+ )
371
+ parts.append(slices[i])
372
+ payload = ''.join(parts)
373
+
374
+ actual_bytes = utf8_byte_length(payload)
375
+ if actual_bytes != total_bytes:
376
+ raise FrameError(
377
+ f'frame: reassembled byte length {actual_bytes} != declared '
378
+ f'totalBytes {total_bytes} for id {frame_id}',
379
+ code='FRAME_BYTES_MISMATCH',
380
+ )
381
+
382
+ # Strict UTF-8 validation. With utf8-slice the concatenation cannot
383
+ # introduce invalid sequences (each slice is whole codepoints), but the
384
+ # spec requires the check explicitly.
385
+ try:
386
+ payload.encode('utf-8').decode('utf-8')
387
+ except UnicodeError as exc: # pragma: no cover - defensive
388
+ raise FrameError(
389
+ f'frame: reassembled payload is not valid UTF-8 for id {frame_id}',
390
+ code='FRAME_INVALID_UTF8',
391
+ ) from exc
392
+
393
+ return payload
394
+
395
+ def discard(self, frame_id: int) -> None:
396
+ """Mark an id as timed out / aborted.
397
+
398
+ Drops any partial state immediately and discards every subsequent frame
399
+ for this id until its declared final frame arrives. Idempotent.
400
+ """
401
+ self._streams.pop(frame_id, None)
402
+ self._discarded[frame_id] = None
403
+ # Bound the discard set (FIFO): a timed-out id whose declared final frame
404
+ # never arrives would otherwise linger forever.
405
+ if len(self._discarded) > MAX_DISCARDED_IDS:
406
+ oldest = next(iter(self._discarded))
407
+ self._discarded.pop(oldest, None)
408
+
409
+ def is_pending(self, frame_id: int) -> bool:
410
+ """Whether any frame for ``frame_id`` is still being accumulated."""
411
+ return frame_id in self._streams
412
+
413
+ @property
414
+ def pending_count(self) -> int:
415
+ """Number of ids currently mid-reassembly (for diagnostics/tests)."""
416
+ return len(self._streams)
417
+
418
+ @property
419
+ def discarded_count(self) -> int:
420
+ """Number of timed-out ids whose late frames are still being dropped.
421
+
422
+ FIFO-bounded by ``MAX_DISCARDED_IDS`` (for diagnostics/tests).
423
+ """
424
+ return len(self._discarded)