Skip to content

Commit ef8de50

Browse files
authored
Merge pull request #303 from bbopen/release/0.9.0
chore(release): 0.9.0
2 parents f59c05f + f432c7c commit ef8de50

13 files changed

Lines changed: 174 additions & 40 deletions

File tree

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,31 @@
11
# Changelog
22

3+
## [0.9.0](https://github.com/bbopen/tywrap/compare/v0.8.0...v0.9.0) (2026-07-11)
4+
5+
Typed value-RPC, one job done well. 0.9.0 removes the stateful instance API whose handles silently broke under pooling, collapses the transport stack it no longer needs, and makes the generated types tell the truth: a type you see in a generated wrapper is now either backed by a declaration and a codec, or it is `unknown` — and what comes back over the wire is validated against it at runtime. Generated wrappers must be regenerated: the IR schema is now `0.4.0` on both sides, and a version mismatch fails generation with a clear message.
6+
7+
### Breaking
8+
9+
- **The stateful instance API is gone ([#264](https://github.com/bbopen/tywrap/issues/264), [#265](https://github.com/bbopen/tywrap/issues/265), [#266](https://github.com/bbopen/tywrap/issues/266)).** A handle lived in one pool worker while calls routed to any worker, so instance methods returned wrong results under `maxProcesses > 1`. The server no longer accepts `instantiate`/`call_method`/`dispose_instance` (structured unknown-method error), the client and wire protocol are call-only (`call | meta`), and generated classes expose only static and classmethod members routed through ordinary calls. Classes that lose members carry a migration note pointing at value-returning module functions.
10+
- **Generated types degrade honestly ([#267](https://github.com/bbopen/tywrap/issues/267)).** A return type tywrap cannot resolve to a local declaration or a codec-backed value is emitted as `unknown` instead of a bare, undeclared TypeScript name — and every degrade feeds the generation report, so `--fail-on-warn` builds fail on them by design. Two long-catalogued type lies are fixed with the same stroke: Python `bytes` maps to `Uint8Array` (what the decoder actually delivers), and `set[T]` maps to `T[]` (sets arrive as arrays; sending a `Set` was already a loud error). Iterator-protocol returns (`Generator`, `Iterator`, `AsyncGenerator`, `AsyncIterator`) degrade to `unknown` for the same reason: the bridge rejects iterator objects loudly at serialization, so those annotations could never carry a value — while `Iterable[T]`/`Sequence[T]` keep mapping to types an actually-decoded list satisfies.
11+
- **Returns are validated at runtime ([#268](https://github.com/bbopen/tywrap/issues/268)).** Every generated module-function and static-method call checks the decoded result against its declared type before the promise resolves; a Python function annotated `-> int` that returns a string now throws `BridgeValidationError` (with the declared type, the received shape, and the call site) instead of surfacing as a mistyped `Promise<number>`. Columnar values are checked by provenance — marker, dims, dtype — so DataFrame/ndarray returns validate by shape and the Arrow fast path stays walk-free. `unknown`, `void`, and `Any` returns validate nothing, by design.
12+
- **The IR is a pinned, versioned contract ([#269](https://github.com/bbopen/tywrap/issues/269)).** Generation writes `<module>.contract.json` next to the generated code — byte-stable across machines and Python processes — so `generate --check` flags drift; `contractInput` regenerates from the pinned file without spawning Python; and `IR_VERSION` is `0.4.0` on both sides, checked at generation time with an actionable mismatch error.
13+
- **Call results are never cached, and requests get exactly one attempt.** The heuristic `enableCache` result cache is removed (a "pure function" guess that could serve stale results), along with the request retry machinery (retries could reuse an in-flight request id and cross-wire responses). Timeouts and errors surface to the caller unchanged — once.
14+
- **The codec rejects what JSON would mangle.** `Map` and `Set` values in requests fail loudly with a conversion hint instead of serializing as `{}`; `bytesHandling: 'passthrough'` is removed; non-finite numbers keep failing as before.
15+
- **Pool and transport defaults tightened.** `maxConcurrentPerProcess` defaults to `1` (the Python bridge is a serial loop; concurrency comes from more workers, and pipelining is explicit opt-in). Pool limits are validated at `NodeBridge` construction (synchronous throw) rather than first use. The `TYWRAP_TRANSPORT_CHUNKING`/`TYWRAP_TRANSPORT_FRAME_PROTOCOL`/`TYWRAP_TRANSPORT_MAX_FRAME_BYTES` negotiation env vars are gone: the npm package ships its own Python bridge, so version skew is impossible and `tywrap-frame/1` framing is simply always on for the subprocess transport.
16+
17+
### Features
18+
19+
- **Security model documented.** `SECURITY.md` states the bridge trust model — the two bounds are the `TYWRAP_ALLOWED_MODULES` allowlist and the on-by-default private-attribute block (`TYWRAP_ALLOW_PRIVATE_ATTRS` opts out) — plus the server-author contract for network exposure and private vulnerability reporting. ([#262](https://github.com/bbopen/tywrap/issues/262), [#263](https://github.com/bbopen/tywrap/issues/263))
20+
- **Docs describe what the types actually cover** — annotation-derived typing with `unknown` fallbacks, not "full type safety"; Deno marked experimental and untested in CI. ([#261](https://github.com/bbopen/tywrap/issues/261))
21+
22+
### Internal
23+
24+
- The subprocess transport stack collapsed from six layers to a merged pool/transport with atomic threshold restarts: a restarting worker can never receive dispatches, cancelled requests never count toward restart thresholds, and disposal fences out racing restarts so no orphan Python process survives.
25+
- Python-side codec unified: `safe_codec.py` merged into `tywrap_bridge_core.py`; plain values no longer cold-import the scientific stack (first-call serialize cost 1.6 s → microseconds); duplicate first frames surface the duplicate-sequence protocol error instead of silently resetting reassembly.
26+
- The test suite was rebuilt around discrimination: an adversarial cross-library menagerie (`test/menagerie/`) catalogues every round-trip behavior as honest/loud/known, ~2,600 lines of vacuous or tautological tests were removed, and the resurrected adversarial suite caught real cross-PR interactions before they shipped.
27+
- `tywrap-ir` is published as `0.3.0` (IR schema `0.4.0`).
28+
329
## [0.8.0](https://github.com/bbopen/tywrap/compare/v0.7.0...v0.8.0) (2026-06-01)
430

531
The large-payload transport release — the second half of the scientific data plane. A result that exceeds a single JSONL line no longer has to fit on one line: the subprocess bridge splits it into frames and reassembles it byte-for-byte. The wire protocol is unchanged (`tywrap/1`); framing is a separate, additive `tywrap-frame/1` protocol negotiated at startup, so a 0.7.x bridge and a 0.8.0 client still talk, and an oversize payload sent to a bridge that cannot chunk fails loudly instead of silently truncating.

ROADMAP.md

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,26 @@ The detailed technical appendix for the scientific data plane lives in
88

99
## Recently Shipped
1010

11+
### v0.9.0: typed value-RPC
12+
13+
`v0.9.0` narrowed tywrap to one job done well: typed value-RPC between
14+
TypeScript and Python (roadmap #260#270). The stateful instance API is gone
15+
(#264#266) — a handle lived in one pool worker while calls routed to any
16+
worker, so instance methods silently broke under pooling; generated classes now
17+
expose only static and classmethod members routed through ordinary calls. The
18+
generated types stopped lying (#267): a return tywrap cannot back with a
19+
declaration or a codec is `unknown`, `bytes` maps to `Uint8Array`, `set[T]` to
20+
`T[]`. Decoded returns are validated against the declared type at runtime, with
21+
`BridgeValidationError` carrying the declared type, received shape, and call
22+
site (#268). The IR is a pinned, versioned contract (#269): generation writes a
23+
byte-stable `<module>.contract.json`, `generate --check` catches drift,
24+
`contractInput` regenerates without spawning Python, and `IR_VERSION 0.4.0` is
25+
enforced on both sides. Result caching and request retries are removed, the
26+
subprocess transport stack collapsed from six layers to a merged pool/transport
27+
with atomic restarts, framing negotiation is gone (always-on), and `SECURITY.md`
28+
documents the bridge trust model (#262, #263). Docs claim what the tool does,
29+
not more (#261).
30+
1131
### v0.8.0: large-payload transport
1232

1333
`v0.8.0` is the second half of the scientific data plane (#237). When a request
@@ -93,19 +113,18 @@ and bridge live.
93113

94114
## Now
95115

96-
The scientific data plane (#237) is complete as of `v0.8.0`. The next release
97-
theme is not yet locked; candidates are drawn from **Later** below.
116+
The typed value-RPC contract pass (#260) is complete as of `v0.9.0`, and the
117+
scientific data plane (#237) as of `v0.8.0`. The next release theme is not yet
118+
locked; candidates are drawn from **Later** below.
98119

99120
See [docs/codec-roadmap.md](./docs/codec-roadmap.md) for the deeper technical
100121
appendix behind the data-plane work.
101122

102123
## Later
103124

104-
These items are intentionally not part of the scientific data plane (`0.7.0`/`0.8.0`):
125+
These items are intentionally deferred:
105126

106127
- GPU-native transport such as DLPack or Arrow CUDA
107128
- HTTP server lifecycle management owned by Tywrap
108129
- app-level HMR beyond Tywrap wrapper regeneration and bridge reload
109130
- unsafe default model-serialization paths such as implicit pickle or joblib
110-
- broader runtime surface removals that should wait for a later major-version
111-
contract pass

docs/public/llms-full.txt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2763,7 +2763,7 @@ so a payload can exceed the JSONL line ceiling. The capability is statically
27632763
[Transport framing](https://bbopen.github.io/tywrap/transport-framing.md) for the wire format.
27642764

27652765
`supportsStreaming` (incremental results for a single request) is `false` on
2766-
every backend; it is not implemented in 0.8.0.
2766+
every backend; it is not implemented as of 0.9.0.
27672767

27682768
### `maxFrameBytes`
27692769

@@ -2817,7 +2817,7 @@ through a `PooledTransport` lease reassembles correctly (see
28172817

28182818
## Scope: subprocess only
28192819

2820-
`tywrap-frame/1` is **subprocess-only** for 0.8.0. The subprocess transport is
2820+
`tywrap-frame/1` is **subprocess-only** as of 0.9.0. The subprocess transport is
28212821
the only backend with a real frame ceiling — the JSONL line-length limit. The
28222822
other backends stay single-frame and keep `supportsChunking: false`:
28232823

@@ -2827,7 +2827,7 @@ other backends stay single-frame and keep `supportsChunking: false`:
28272827
- **Pyodide** is in-memory (`maxFrameBytes: Number.POSITIVE_INFINITY`,
28282828
JSON-only); there is no wire to fragment.
28292829

2830-
`supportsStreaming` stays `false` on every backend in 0.8.0.
2830+
`supportsStreaming` stays `false` on every backend as of 0.9.0.
28312831

28322832
## Layering
28332833

@@ -2906,7 +2906,7 @@ construction. The codepoint-boundary rule is a sender obligation: a frame's
29062906
`data` MUST NOT split a multi-byte UTF-8 sequence, so the receiver can decode
29072907
each frame as valid UTF-8 and concatenate without re-aligning bytes across frame
29082908
edges. `utf8-base64` remains a defined alternative in the wire schema for future
2909-
use (e.g. a non-text payload), but tywrap does not emit it in 0.8.0.
2909+
use (e.g. a non-text payload), but tywrap does not emit it as of 0.9.0.
29102910

29112911
> Implementation note for later workstreams: `total` and the per-frame split
29122912
> points are computed over the message's UTF-8 **byte** length against

docs/transport-capabilities.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ so a payload can exceed the JSONL line ceiling. The capability is statically
8282
[Transport framing](./transport-framing.md) for the wire format.
8383

8484
`supportsStreaming` (incremental results for a single request) is `false` on
85-
every backend; it is not implemented in 0.8.0.
85+
every backend; it is not implemented as of 0.9.0.
8686

8787
### `maxFrameBytes`
8888

docs/transport-framing.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ through a `PooledTransport` lease reassembles correctly (see
1717

1818
## Scope: subprocess only
1919

20-
`tywrap-frame/1` is **subprocess-only** for 0.8.0. The subprocess transport is
20+
`tywrap-frame/1` is **subprocess-only** as of 0.9.0. The subprocess transport is
2121
the only backend with a real frame ceiling — the JSONL line-length limit. The
2222
other backends stay single-frame and keep `supportsChunking: false`:
2323

@@ -27,7 +27,7 @@ other backends stay single-frame and keep `supportsChunking: false`:
2727
- **Pyodide** is in-memory (`maxFrameBytes: Number.POSITIVE_INFINITY`,
2828
JSON-only); there is no wire to fragment.
2929

30-
`supportsStreaming` stays `false` on every backend in 0.8.0.
30+
`supportsStreaming` stays `false` on every backend as of 0.9.0.
3131

3232
## Layering
3333

@@ -106,7 +106,7 @@ construction. The codepoint-boundary rule is a sender obligation: a frame's
106106
`data` MUST NOT split a multi-byte UTF-8 sequence, so the receiver can decode
107107
each frame as valid UTF-8 and concatenate without re-aligning bytes across frame
108108
edges. `utf8-base64` remains a defined alternative in the wire schema for future
109-
use (e.g. a non-text payload), but tywrap does not emit it in 0.8.0.
109+
use (e.g. a non-text payload), but tywrap does not emit it as of 0.9.0.
110110

111111
> Implementation note for later workstreams: `total` and the per-frame split
112112
> points are computed over the message's UTF-8 **byte** length against

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "tywrap",
3-
"version": "0.8.0",
3+
"version": "0.9.0",
44
"description": "Generate TypeScript bindings for Python libraries with precise types where tywrap can resolve them — Node.js, experimental Deno, Bun, and browsers via Pyodide.",
55
"type": "module",
66
"main": "dist/index.js",
@@ -34,6 +34,7 @@
3434
"files": [
3535
"dist",
3636
"runtime",
37+
"!runtime/__pycache__",
3738
"src",
3839
"README.md",
3940
"SECURITY.md"

src/core/generator.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ export class CodeGenerator {
5555
'Promise',
5656
'Record',
5757
]);
58+
// Iterator-protocol objects can never cross the bridge: the Python side
59+
// rejects them loudly at serialization, so a return typed as one of these
60+
// could never carry a value. Returns degrade to `unknown` (and count as a
61+
// degrade for --fail-on-warn). Iterable/Sequence are NOT here — a list
62+
// satisfies those annotations and decodes to an array, which structurally
63+
// satisfies the emitted TS type.
64+
private readonly nonDecodableReturnGenerics = new Set([
65+
'Generator',
66+
'AsyncGenerator',
67+
'Iterator',
68+
'AsyncIterator',
69+
]);
5870
private readonly reservedTsIdentifiers = new Set([
5971
'default',
6072
'delete',
@@ -466,18 +478,9 @@ export class CodeGenerator {
466478
if (leaf === 'NDArray' || leaf === 'ndarray') {
467479
return { kind: 'marker', marker: 'ndarray' };
468480
}
469-
if (
470-
[
471-
'list',
472-
'List',
473-
'Sequence',
474-
'Iterable',
475-
'Iterator',
476-
'Generator',
477-
'set',
478-
'frozenset',
479-
].includes(leaf)
480-
) {
481+
// Iterator/Generator are deliberately absent: those returns emit
482+
// `unknown` (see nonDecodableReturnGenerics) and validate nothing.
483+
if (['list', 'List', 'Sequence', 'Iterable', 'set', 'frozenset'].includes(leaf)) {
481484
return {
482485
kind: 'array',
483486
element: schema(current.typeArgs[0] ?? { kind: 'custom', name: 'Any' }),
@@ -1207,6 +1210,9 @@ ${migrationNote}${declarationMethodsSection}
12071210
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(g.name)) {
12081211
return this.degradeType(g);
12091212
}
1213+
if (mappingContext === 'return' && this.nonDecodableReturnGenerics.has(g.name)) {
1214+
return this.degradeType(g);
1215+
}
12101216
if (ctx && !this.builtinGenericNames.has(g.name) && !this.isLocalTypeIdentity(g, ctx)) {
12111217
return this.degradeType(g);
12121218
}

src/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
/**
2-
* tywrap - TypeScript wrapper for Python libraries with full type safety
2+
* tywrap - TypeScript bindings for Python libraries
33
*
4-
* @description Build-time code generation system that makes Python libraries
5-
* feel native in TypeScript with zero runtime overhead
4+
* @description Build-time code generation that produces typed wrappers for
5+
* Python modules: precise types where annotations resolve, `unknown` where
6+
* they cannot, and runtime validation of decoded returns against the
7+
* declared types.
68
*
79
* This is the package root. It intentionally exposes only the stable,
810
* consumer-facing surface. Runtime plumbing (codec, transport, bridge

src/version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@
99
* Regenerate with: node scripts/generate-version.mjs
1010
*/
1111

12-
export const VERSION: string = "0.8.0";
12+
export const VERSION: string = "0.9.0";

test/generator.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,86 @@ describe('CodeGenerator', () => {
188188
expect(degraded).toEqual(['other.Remote']);
189189
});
190190

191+
it('degrades iterator-protocol returns to unknown with a no-op validator', () => {
192+
// A generator/iterator object can never cross the bridge (serialization
193+
// rejects it loudly), so Generator<...> as a return type could never
194+
// carry a value.
195+
const degraded: string[] = [];
196+
const honestGenerator = new CodeGenerator(undefined, {
197+
onTypeDegrade: typeName => degraded.push(typeName),
198+
});
199+
const returnType = {
200+
kind: 'generic',
201+
name: 'Generator',
202+
module: 'typing',
203+
typeArgs: [
204+
{ kind: 'primitive', name: 'int' },
205+
{ kind: 'primitive', name: 'None' },
206+
{ kind: 'primitive', name: 'None' },
207+
],
208+
};
209+
const code = honestGenerator.generateModuleDefinition({
210+
name: 'local_module',
211+
functions: [
212+
{
213+
name: 'counting',
214+
signature: { parameters: [], returnType, isAsync: false, isGenerator: true },
215+
decorators: [],
216+
isAsync: false,
217+
isGenerator: true,
218+
returnType,
219+
parameters: [],
220+
},
221+
],
222+
classes: [],
223+
typeAliases: [],
224+
imports: [],
225+
exports: [],
226+
});
227+
228+
expect(code.typescript).toContain('Promise<unknown>');
229+
expect(code.typescript).not.toContain('Generator<');
230+
expect(code.typescript).toContain('{"kind":"any"}');
231+
expect(degraded).toEqual(['typing.Generator']);
232+
});
233+
234+
it('keeps Iterable returns as arrays — a decoded list satisfies them honestly', () => {
235+
const degraded: string[] = [];
236+
const honestGenerator = new CodeGenerator(undefined, {
237+
onTypeDegrade: typeName => degraded.push(typeName),
238+
});
239+
const returnType = {
240+
kind: 'generic',
241+
name: 'Iterable',
242+
module: 'typing',
243+
typeArgs: [{ kind: 'primitive', name: 'int' }],
244+
};
245+
const code = honestGenerator.generateModuleDefinition({
246+
name: 'local_module',
247+
functions: [
248+
{
249+
name: 'values',
250+
signature: { parameters: [], returnType, isAsync: false, isGenerator: false },
251+
decorators: [],
252+
isAsync: false,
253+
isGenerator: false,
254+
returnType,
255+
parameters: [],
256+
},
257+
],
258+
classes: [],
259+
typeAliases: [],
260+
imports: [],
261+
exports: [],
262+
});
263+
264+
expect(code.typescript).toContain('Promise<Iterable<number>>');
265+
expect(code.typescript).toContain(
266+
'{"kind":"array","element":{"kind":"primitive","type":"number"}}'
267+
);
268+
expect(degraded).toEqual([]);
269+
});
270+
191271
it('degrades qualified generic heads and module-colliding leaves', () => {
192272
const degraded: string[] = [];
193273
const honestGenerator = new CodeGenerator(undefined, {

0 commit comments

Comments
 (0)