From 8f9f0c37c075a6399c281b805340e3aa8388ef1b Mon Sep 17 00:00:00 2001 From: brett-bonner_infodesk Date: Sat, 11 Jul 2026 14:50:27 -0700 Subject: [PATCH 1/3] feat(runtime): validate generated returns --- docs/guide/getting-started.md | 8 + docs/public/llms-full.txt | 8 + src/core/generator.ts | 200 +++++++++++++++-- src/index.ts | 1 + src/runtime/base-bridge.ts | 5 +- src/runtime/bridge-codec.ts | 3 + src/runtime/errors.ts | 21 ++ src/runtime/index.ts | 6 + src/runtime/rpc-client.ts | 29 ++- src/runtime/validators.ts | 204 ++++++++++++++++++ src/types/index.ts | 3 +- src/utils/codec.ts | 34 +-- test/api_surface.test.ts | 1 + test/generated_snapshot.test.ts | 2 +- test/generator.test.ts | 72 ++++++- test/integration.test.ts | 6 +- test/menagerie/__snapshots__/gen.test.ts.snap | 189 ++++++++++++---- test/menagerie/fixtures/typing_torture.py | 8 + test/runtime_return_validation.test.ts | 167 ++++++++++++++ tywrap_ir/tywrap_ir/ir.py | 14 +- 20 files changed, 889 insertions(+), 92 deletions(-) create mode 100644 test/runtime_return_validation.test.ts diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 3df7bc4e..196e08b1 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -118,6 +118,14 @@ async function example() { example().catch(console.error); ``` +## Runtime return validation + +Generated wrappers validate returns at runtime after decoding. If a Python +implementation returns a value that does not match its annotation, tywrap throws +`BridgeValidationError` with the wrapped call site and received shape. Fix the +Python annotation or implementation; use `-> Any` only when that return is +intentionally untyped. + ## Custom Module Example Create a wrapper for a custom Python module: diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index b4d7cc8b..950aebfc 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -177,6 +177,14 @@ async function example() { example().catch(console.error); ``` +## Runtime return validation + +Generated wrappers validate returns at runtime after decoding. If a Python +implementation returns a value that does not match its annotation, tywrap throws +`BridgeValidationError` with the wrapped call site and received shape. Fix the +Python annotation or implementation; use `-> Any` only when that return is +intentionally untyped. + ## Custom Module Example Create a wrapper for a custom Python module: diff --git a/src/core/generator.ts b/src/core/generator.ts index a135dfef..d8ba2c20 100644 --- a/src/core/generator.ts +++ b/src/core/generator.ts @@ -36,6 +36,8 @@ interface GenericRenderContext { emittedParamSpecs: Set; } +type ReturnDefinitionNames = ReadonlySet; + export interface CodeGeneratorOptions { /** Reports a generated annotation that cannot be represented by emitted declarations. */ onTypeDegrade?: (typeName: string) => void; @@ -397,11 +399,164 @@ export class CodeGenerator { }; } + /** + * Preserve Python provenance in generated return validators instead of + * reconstructing a checker from the final TypeScript spelling. In particular + * pandas/NumPy values retain their marker contract after Arrow decoding. + */ + private returnSchema(type: PythonType, definitions: ReturnDefinitionNames = new Set()): unknown { + // A bare `-> None` return maps to TS void and validates nothing, but a + // NESTED None (union member, tuple element) is a real wire value — it + // decodes to null and must be checked as null, or a union containing it + // would carry an any-member and accept everything. + if (type.kind === 'primitive' && type.name === 'None') { + return { kind: 'any' }; + } + const schema = (current: PythonType): unknown => { + switch (current.kind) { + case 'primitive': + return current.name === 'int' || current.name === 'float' + ? { kind: 'primitive', type: 'number' } + : current.name === 'str' + ? { kind: 'primitive', type: 'string' } + : current.name === 'bool' + ? { kind: 'primitive', type: 'boolean' } + : current.name === 'bytes' + ? { kind: 'primitive', type: 'Uint8Array' } + : current.name === 'None' + ? { kind: 'primitive', type: 'null' } + : { kind: 'any' }; + case 'literal': + return { kind: 'literal', value: current.value }; + case 'annotated': + return schema(current.base); + case 'final': + case 'classvar': + return schema(current.type); + case 'optional': + return { + kind: 'union', + options: [schema(current.type), { kind: 'primitive', type: 'null' }], + }; + case 'union': + return { kind: 'union', options: current.types.map(schema) }; + case 'collection': { + if (current.name === 'tuple') { + if (current.itemTypes[1]?.kind === 'custom' && current.itemTypes[1].name === '...') { + return { + kind: 'array', + element: schema(current.itemTypes[0] ?? { kind: 'custom', name: 'Any' }), + }; + } + return { kind: 'tuple', elements: current.itemTypes.map(schema) }; + } + if (current.name === 'dict') { + return { + kind: 'record', + values: schema(current.itemTypes[1] ?? { kind: 'custom', name: 'Any' }), + }; + } + return { + kind: 'array', + element: schema(current.itemTypes[0] ?? { kind: 'custom', name: 'Any' }), + }; + } + case 'generic': { + const leaf = current.name.split('.').at(-1) ?? current.name; + if (leaf === 'NDArray' || leaf === 'ndarray') { + return { kind: 'marker', marker: 'ndarray' }; + } + if ( + [ + 'list', + 'List', + 'Sequence', + 'Iterable', + 'Iterator', + 'Generator', + 'set', + 'frozenset', + ].includes(leaf) + ) { + return { + kind: 'array', + element: schema(current.typeArgs[0] ?? { kind: 'custom', name: 'Any' }), + }; + } + if (['dict', 'Dict', 'Mapping', 'MutableMapping'].includes(leaf)) { + return { + kind: 'record', + values: schema(current.typeArgs[1] ?? { kind: 'custom', name: 'Any' }), + }; + } + return definitions.has(leaf) ? { kind: 'ref', name: leaf } : { kind: 'any' }; + } + case 'custom': { + const leaf = current.name.split('.').at(-1) ?? current.name; + const full = `${current.module ?? ''}.${leaf}`; + if (leaf === 'None') { + return { kind: 'primitive', type: 'null' }; + } + if (leaf === 'Any' || leaf === 'object' || leaf === 'NoReturn' || leaf === 'Never') { + return { kind: 'any' }; + } + if (leaf === 'DataFrame' && (!current.module || current.module.startsWith('pandas'))) { + return { kind: 'marker', marker: 'dataframe' }; + } + if (leaf === 'Series' && (!current.module || current.module.startsWith('pandas'))) { + return { kind: 'marker', marker: 'series' }; + } + if (leaf === 'ndarray' || leaf === 'NDArray' || full.includes('numpy')) { + return { kind: 'marker', marker: 'ndarray' }; + } + return definitions.has(leaf) ? { kind: 'ref', name: leaf } : { kind: 'any' }; + } + case 'typevar': + case 'paramspec': + case 'paramspec_args': + case 'paramspec_kwargs': + case 'typevartuple': + case 'unpack': + case 'callable': + return { kind: 'any' }; + } + }; + return schema(type); + } + + private returnDefinitions(module: PythonModule): ReturnDefinitionNames { + return new Set( + module.classes + .filter(cls => cls.kind === 'typed_dict' || cls.decorators.includes('__typed_dict__')) + .map(cls => cls.name) + ); + } + + private emitReturnDefinitions(module: PythonModule): string { + const definitions = this.returnDefinitions(module); + const entries = module.classes + .filter(cls => cls.kind === 'typed_dict' || cls.decorators.includes('__typed_dict__')) + .map(cls => { + const fields = Object.fromEntries( + cls.properties.map(property => [ + property.name, + { + schema: this.returnSchema(property.type, definitions), + optional: property.optional === true, + }, + ]) + ); + return [cls.name, { kind: 'record', fields }]; + }); + return `const __tywrapReturnDefinitions: Record = ${JSON.stringify(Object.fromEntries(entries))};\n\n`; + } + generateFunctionWrapper( func: PythonFunction, moduleName?: string, annotatedJSDoc = false, - localDeclaredNames: Set = new Set() + localDeclaredNames: Set = new Set(), + returnDefinitions: ReturnDefinitionNames = new Set() ): GeneratedCode { const jsdoc = this.generateJsDoc( func.docstring, @@ -489,6 +644,8 @@ export class CodeGenerator { const returnType = this.typeToTsFromPython(func.returnType, genericContext, 'return'); const fname = this.escapeIdentifier(func.name); const moduleId = moduleName ?? '__main__'; + const validatorName = `__validate${this.escapeIdentifier(func.name, { preserveCase: true })}Result`; + const returnValidator = `const ${validatorName} = createReturnValidator(${JSON.stringify(this.returnSchema(func.returnType, returnDefinitions))}, ${JSON.stringify(`${moduleId}.${func.name}`)}, __tywrapReturnDefinitions);\n\n`; // Overloads: generate trailing optional parameter drop variants (exclude *args/**kwargs). // Why: Python APIs frequently have many optional tail params. TypeScript callers expect @@ -577,10 +734,8 @@ export class CodeGenerator { const callPreludeLines = emitCallPrelude(callDescriptor, this.callEmitHelpers()); const callPrelude = callPreludeLines.length > 0 ? `${callPreludeLines.join('\n')}\n` : ''; - const ts = `${jsdoc}${overloadDecl}export async function ${fname}${typeParamDecl}(${paramDecl}): Promise<${returnType}> { -${callPrelude}${guards} return getRuntimeBridge().call('${moduleId}', '${func.name}', __args${ - hasKwArgs ? ', __kwargs' : '' - }); + const ts = `${jsdoc}${returnValidator}${overloadDecl}export async function ${fname}${typeParamDecl}(${paramDecl}): Promise<${returnType}> { +${callPrelude}${guards} return getRuntimeBridge().call<${returnType}>('${moduleId}', '${func.name}', __args, ${hasKwArgs ? '__kwargs' : 'undefined'}, ${validatorName}); } `; @@ -596,7 +751,8 @@ ${callPrelude}${guards} return getRuntimeBridge().call('${moduleId}', '${func.n cls: PythonClass, moduleName?: string, _annotatedJSDoc = false, - localDeclaredNames: Set = new Set() + localDeclaredNames: Set = new Set(), + returnDefinitions: ReturnDefinitionNames = new Set() ): GeneratedCode { const moduleDeclaredNames = new Set(localDeclaredNames); moduleDeclaredNames.add(cls.name); @@ -782,6 +938,8 @@ ${callPrelude}${guards} return getRuntimeBridge().call('${moduleId}', '${func.n 'return' ); const mname = this.escapeIdentifier(method.name); + const validatorName = `__validate${this.escapeIdentifier(cls.name, { preserveCase: true })}${this.escapeIdentifier(method.name, { preserveCase: true })}Result`; + const returnValidator = `const ${validatorName} = createReturnValidator(${JSON.stringify(this.returnSchema(method.returnType, returnDefinitions))}, ${JSON.stringify(`${moduleId}.${cls.name}.${method.name}`)}, __tywrapReturnDefinitions);\n\n`; const overloads: string[] = []; if (needsKwargsParam && requiredKwOnlyNames.length > 0) { @@ -829,11 +987,9 @@ ${callPrelude}${guards} return getRuntimeBridge().call('${moduleId}', '${func.n const guardLines = emitArgGuards(callDescriptor); const guards = guardLines.length > 0 ? `${guardLines.join('\n')}\n` : ''; - const callExpr = `getRuntimeBridge().call('${moduleId}', '${cls.name}.${method.name}', __args${ - needsKwargsParam ? ', __kwargs' : '' - })`; + const callExpr = `getRuntimeBridge().call<${returnType}>('${moduleId}', '${cls.name}.${method.name}', __args, ${needsKwargsParam ? '__kwargs' : 'undefined'}, ${validatorName})`; methodBodies.push(`${overloadDecl} ${staticPrefix}async ${mname}${methodTypeParamDecl}(${paramsDecl}): Promise<${returnType}> { -${callPrelude}${guards} return ${callExpr}; + ${returnValidator}${callPrelude}${guards} return ${callExpr}; }`); methodDeclarations.push( `${overloadDecl}${overloads.length > 0 ? '' : ` ${staticPrefix}${mname}${methodTypeParamDecl}(${paramsDecl}): Promise<${returnType}>;\n`}` @@ -913,10 +1069,26 @@ ${migrationNote}${declarationMethodsSection} ]); const functionResults = [...module.functions] .sort((a, b) => a.name.localeCompare(b.name)) - .map(f => this.generateFunctionWrapper(f, module.name, annotatedJSDoc, localDeclaredNames)); + .map(f => + this.generateFunctionWrapper( + f, + module.name, + annotatedJSDoc, + localDeclaredNames, + this.returnDefinitions(module) + ) + ); const classResults = [...module.classes] .sort((a, b) => a.name.localeCompare(b.name)) - .map(c => this.generateClassWrapper(c, module.name, annotatedJSDoc, localDeclaredNames)); + .map(c => + this.generateClassWrapper( + c, + module.name, + annotatedJSDoc, + localDeclaredNames, + this.returnDefinitions(module) + ) + ); const typeAliasResults = [...(module.typeAliases ?? [])] .sort((a, b) => a.name.localeCompare(b.name)) .map(alias => this.generateTypeAlias(alias, module.name, localDeclaredNames)); @@ -932,7 +1104,9 @@ ${migrationNote}${declarationMethodsSection} return kind === 'class' && !c.decorators.includes('__typed_dict__'); }); const needsRuntime = module.functions.length > 0 || hasRuntimeClasses; - const bridgeDecl = needsRuntime ? `import { getRuntimeBridge } from 'tywrap/runtime';\n\n` : ''; + const bridgeDecl = needsRuntime + ? `import { createReturnValidator, getRuntimeBridge, type ReturnSchema } from 'tywrap/runtime';\n\n${this.emitReturnDefinitions(module)}` + : ''; const ts = `${`${header}${bridgeDecl}${functionCodes}\n${classCodes}\n${typeAliasCodes}`.trimEnd()}\n`; const declaration = `${`${declarationHeader}${functionResults diff --git a/src/index.ts b/src/index.ts index c59b475a..83511a2c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ export { BridgeError, BridgeCodecError, BridgeProtocolError, + BridgeValidationError, BridgeTimeoutError, BridgeDisposedError, BridgeExecutionError, diff --git a/src/runtime/base-bridge.ts b/src/runtime/base-bridge.ts index 813c52f3..0749a9d2 100644 --- a/src/runtime/base-bridge.ts +++ b/src/runtime/base-bridge.ts @@ -43,10 +43,11 @@ export abstract class BasePythonBridge extends DisposableBase implements PythonR module: string, functionName: string, args: unknown[], - kwargs?: Record + kwargs?: Record, + validate?: (result: T) => void ): Promise { await this.ensureReady(); - return this.getRpcClient().call(module, functionName, args, kwargs); + return this.getRpcClient().call(module, functionName, args, kwargs, validate); } /** diff --git a/src/runtime/bridge-codec.ts b/src/runtime/bridge-codec.ts index c3af6265..5367b930 100644 --- a/src/runtime/bridge-codec.ts +++ b/src/runtime/bridge-codec.ts @@ -643,6 +643,9 @@ export class BridgeCodec { // Note: Arrow decoders can introduce NaN/Infinity from binary representations. this.assertNoSpecialFloats(decoded); + // Return-contract validation is intentionally downstream of this codec. At + // this boundary we guarantee only a sound wire envelope and decoded value; + // generated wrappers validate the Python annotation after Arrow/JSON decode. return decoded as T; } diff --git a/src/runtime/errors.ts b/src/runtime/errors.ts index 6789499a..de46cca6 100644 --- a/src/runtime/errors.ts +++ b/src/runtime/errors.ts @@ -14,6 +14,27 @@ export class BridgeProtocolError extends BridgeError {} export class BridgeTimeoutError extends BridgeError {} export class BridgeDisposedError extends BridgeError {} +/** + * A decoded value did not match the return annotation emitted into a wrapper. + * + * This is deliberately separate from protocol/codec errors: the wire response + * was sound, but the Python implementation violated its declared contract. + */ +export class BridgeValidationError extends BridgeError { + readonly declaredType: string; + readonly receivedShape: string; + readonly callSite: string; + + constructor(options: { declaredType: string; receivedShape: string; callSite: string }) { + super( + `Return validation failed for ${options.callSite}: expected ${options.declaredType}, received ${options.receivedShape}` + ); + this.declaredType = options.declaredType; + this.receivedShape = options.receivedShape; + this.callSite = options.callSite; + } +} + export class BridgeCodecError extends BridgeError { codecPhase?: string; valueType?: string; diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 5d5e462d..c7f335ad 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -11,6 +11,12 @@ import type { RuntimeExecution } from '../types/index.js'; // BridgeCodec — validation and serialization for the JS<->Python boundary export { BridgeCodec, type CodecOptions } from './bridge-codec.js'; +export { + createReturnValidator, + describeReceivedShape, + type ReturnSchema, + type ReturnValidator, +} from './validators.js'; // Transport contract — abstract I/O channel interface and guards export type { diff --git a/src/runtime/rpc-client.ts b/src/runtime/rpc-client.ts index 930795ad..3eee208f 100644 --- a/src/runtime/rpc-client.ts +++ b/src/runtime/rpc-client.ts @@ -430,17 +430,28 @@ export class RpcClient extends DisposableBase { module: string, functionName: string, args: unknown[], - kwargs?: Record + kwargs?: Record, + validate?: (result: T) => void ): Promise { - return this.sendMessageAsync({ - method: 'call', - params: { - module, - functionName, - args, - kwargs, + return this.sendMessageAsync( + { + method: 'call', + params: { + module, + functionName, + args, + kwargs, + }, }, - }); + validate + ? { + validate: result => { + validate(result); + return result; + }, + } + : undefined + ); } /** diff --git a/src/runtime/validators.ts b/src/runtime/validators.ts index 91a771da..a607f28a 100644 --- a/src/runtime/validators.ts +++ b/src/runtime/validators.ts @@ -1,3 +1,5 @@ +import { BridgeValidationError } from './errors.js'; + /** * Pure validation functions for runtime value checking. * @@ -15,6 +17,208 @@ export class ValidationError extends Error { } } +/** A serializable return-contract description emitted by the code generator. */ +export type ReturnSchema = + | { kind: 'any' } + | { + kind: 'primitive'; + type: 'number' | 'string' | 'boolean' | 'null' | 'undefined' | 'Uint8Array' | 'object'; + } + | { kind: 'literal'; value: string | number | boolean | null } + | { kind: 'array'; element: ReturnSchema } + | { kind: 'tuple'; elements: ReturnSchema[] } + | { + kind: 'record'; + fields?: Record; + values?: ReturnSchema; + } + | { kind: 'union'; options: ReturnSchema[] } + | { kind: 'ref'; name: string } + | { kind: 'marker'; marker: 'dataframe' | 'series' | 'ndarray'; dims?: number; dtype?: string }; + +export type ReturnValidator = (result: T) => T; + +export interface DecodedShapeMetadata { + marker: 'dataframe' | 'series' | 'ndarray'; + dims?: number; + dtype?: string; +} + +const decodedShapeMetadata = new WeakMap(); + +/** @internal Preserve wire provenance after codec decoding changes the JS shape. */ +export function tagDecodedShape(value: T, metadata: DecodedShapeMetadata): T { + if (value !== null && (typeof value === 'object' || typeof value === 'function')) { + decodedShapeMetadata.set(value as object, metadata); + } + return value; +} + +function isObjectLike(value: unknown): value is object { + return value !== null && (typeof value === 'object' || typeof value === 'function'); +} + +/** A short, bounded description suitable for an error message. */ +export function describeReceivedShape(value: unknown): string { + if (value === null) { + return 'null'; + } + if (value === undefined) { + return 'undefined'; + } + if (value instanceof Uint8Array) { + return `Uint8Array(${value.byteLength})`; + } + if (Array.isArray(value)) { + return `array(${value.length})`; + } + if (typeof value === 'object') { + const marker = decodedShapeMetadata.get(value); + if (marker) { + const details = [marker.dims === undefined ? undefined : `${marker.dims}d`, marker.dtype] + .filter(Boolean) + .join(', '); + return `${marker.marker}${details ? ` (${details})` : ''}`; + } + const name = (value as { constructor?: { name?: unknown } }).constructor?.name; + return typeof name === 'string' && name !== 'Object' ? name : 'object'; + } + return typeof value; +} + +function renderSchema(schema: ReturnSchema): string { + switch (schema.kind) { + case 'any': + return 'unknown'; + case 'primitive': + return schema.type; + case 'literal': + return JSON.stringify(schema.value); + case 'array': + return `${renderSchema(schema.element)}[]`; + case 'tuple': + return `[${schema.elements.map(renderSchema).join(', ')}]`; + case 'record': + return 'record'; + case 'union': + return schema.options.map(renderSchema).join(' | '); + case 'ref': + return schema.name; + case 'marker': + return schema.marker; + } +} + +interface CheckState { + readonly definitions: Readonly>; + readonly pairs: WeakMap>; +} + +function check(schema: ReturnSchema, value: unknown, state: CheckState): boolean { + if (schema.kind === 'any') { + return true; + } + + if (schema.kind === 'ref') { + const definition = state.definitions[schema.name]; + if (!definition) { + return true; + } // unresolved/erased types deliberately degrade to unknown. + if (isObjectLike(value)) { + const seen = state.pairs.get(value) ?? new Set(); + if (seen.has(schema.name)) { + return true; + } + seen.add(schema.name); + state.pairs.set(value, seen); + } + return check(definition, value, state); + } + + switch (schema.kind) { + case 'primitive': + if (schema.type === 'null') { + return value === null; + } + if (schema.type === 'undefined') { + return value === undefined; + } + if (schema.type === 'Uint8Array') { + return value instanceof Uint8Array; + } + if (schema.type === 'object') { + return isPlainObject(value); + } + return typeof value === schema.type; + case 'literal': + return Object.is(value, schema.value); + case 'array': + return Array.isArray(value) && value.every(item => check(schema.element, item, state)); + case 'tuple': + return ( + Array.isArray(value) && + value.length === schema.elements.length && + schema.elements.every((entry, index) => check(entry, value[index], state)) + ); + case 'record': { + if (!isPlainObject(value)) { + return false; + } + if (schema.fields) { + for (const [key, field] of Object.entries(schema.fields)) { + if (!(key in value)) { + if (field.optional) { + continue; + } + return false; + } + if (!check(field.schema, value[key], state)) { + return false; + } + } + } + return ( + !schema.values || + Object.values(value).every(item => check(schema.values as ReturnSchema, item, state)) + ); + } + case 'union': + return schema.options.some(option => check(option, value, state)); + case 'marker': { + const metadata = isObjectLike(value) ? decodedShapeMetadata.get(value) : undefined; + if (metadata?.marker !== schema.marker) { + return false; + } + return ( + (schema.dims === undefined || metadata.dims === schema.dims) && + (schema.dtype === undefined || metadata.dtype === schema.dtype) + ); + } + } +} + +/** + * Construct a cycle-safe structural validator for one generated callable. + * Unknown/Any/void schemas intentionally validate nothing. + */ +export function createReturnValidator( + schema: ReturnSchema, + callSite: string, + definitions: Readonly> = {} +): ReturnValidator { + const declaredType = renderSchema(schema); + return (result: T): T => { + if (!check(schema, result, { definitions, pairs: new WeakMap>() })) { + throw new BridgeValidationError({ + declaredType, + receivedShape: describeReceivedShape(result), + callSite, + }); + } + return result; + }; +} + // ═══════════════════════════════════════════════════════════════════════════ // TYPE GUARDS // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/types/index.ts b/src/types/index.ts index a6b86cc6..c5b0d408 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -502,7 +502,8 @@ export interface PythonRuntime { module: string, functionName: string, args: unknown[], - kwargs?: Record + kwargs?: Record, + validate?: (result: T) => void ): Promise; } diff --git a/src/utils/codec.ts b/src/utils/codec.ts index 184b47f2..8c239406 100644 --- a/src/utils/codec.ts +++ b/src/utils/codec.ts @@ -9,6 +9,8 @@ * Sklearn estimators: { "__tywrap__": "sklearn.estimator", "encoding": "json", ... } */ +import { tagDecodedShape } from '../runtime/validators.js'; + // Avoid hard dependency on apache-arrow types at compile time to keep install optional. export type ArrowTable = { readonly numCols?: number; readonly numRows?: number } & Record< string, @@ -68,6 +70,7 @@ export type ValueEnvelope = readonly b64?: string; // when encoding=arrow readonly data?: unknown; // when encoding=json readonly shape?: readonly number[]; + readonly dtype?: string | null; } | { readonly __tywrap__: 'scipy.sparse'; @@ -442,13 +445,16 @@ function decodeArrowOrJsonEnvelope( throw new Error(`Invalid ${typeTag} envelope: missing b64`); } const bytes = fromBase64(b64); - return decodeArrow(bytes); + const decoded = decodeArrow(bytes); + return isPromiseLike(decoded) + ? decoded.then(item => tagDecodedShape(item, { marker: typeTag as 'dataframe' | 'series' })) + : tagDecodedShape(decoded, { marker: typeTag as 'dataframe' | 'series' }); } if (encoding === 'json') { if (!('data' in value)) { throw new Error(`Invalid ${typeTag} envelope: missing data`); } - return value.data; + return tagDecodedShape(value.data, { marker: typeTag as 'dataframe' | 'series' }); } throw new Error(`Invalid ${typeTag} envelope: unsupported encoding ${String(encoding)}`); } @@ -463,6 +469,8 @@ const decodeNdarrayEnvelope: EnvelopeHandler = (value, decodeArrow) => { const encoding = value.encoding; const shapeValue = value.shape; const shape = isNumberArray(shapeValue) ? shapeValue : undefined; + const dtype = typeof value.dtype === 'string' ? value.dtype : undefined; + const metadata = { marker: 'ndarray' as const, dims: shape?.length, dtype }; if (encoding === 'arrow') { const b64 = value.b64; @@ -480,30 +488,30 @@ const decodeNdarrayEnvelope: EnvelopeHandler = (value, decodeArrow) => { return decoded.then(data => { const values = extractArrowValues(data); if (!values) { - return data; // Fallback: return raw data if extraction fails + return tagDecodedShape(data, metadata); // Fallback: keep provenance on raw data. } // Reshape scalars and multi-dimensional arrays, but not 1D - if (shape && shape.length !== 1) { - return reshapeArray(values, shape); - } - return values; + return tagDecodedShape( + shape && shape.length !== 1 ? reshapeArray(values, shape) : values, + metadata + ); }); } const values = extractArrowValues(decoded); if (!values) { - return decoded; // Fallback: return raw data if extraction fails + return tagDecodedShape(decoded, metadata); // Fallback: keep provenance on raw data. } // Reshape scalars and multi-dimensional arrays, but not 1D - if (shape && shape.length !== 1) { - return reshapeArray(values, shape); - } - return values; + return tagDecodedShape( + shape && shape.length !== 1 ? reshapeArray(values, shape) : values, + metadata + ); } if (encoding === 'json') { if (!('data' in value)) { throw new Error('Invalid ndarray envelope: missing data'); } - return value.data; + return tagDecodedShape(value.data, metadata); } throw new Error(`Invalid ndarray envelope: unsupported encoding ${String(encoding)}`); }; diff --git a/test/api_surface.test.ts b/test/api_surface.test.ts index 3ad0fcff..1aa37566 100644 --- a/test/api_surface.test.ts +++ b/test/api_surface.test.ts @@ -20,6 +20,7 @@ describe('public API surface', () => { 'BridgeExecutionError', 'BridgeProtocolError', 'BridgeTimeoutError', + 'BridgeValidationError', 'VERSION', 'autoRegisterArrowDecoder', 'clearArrowDecoder', diff --git a/test/generated_snapshot.test.ts b/test/generated_snapshot.test.ts index 875187fe..2c5d3860 100644 --- a/test/generated_snapshot.test.ts +++ b/test/generated_snapshot.test.ts @@ -18,7 +18,7 @@ describe('Generated TS snapshot - math', () => { const content = await fsUtils.readFile(`${outDir}/math.generated.ts`); expect(content).toContain('Generated by tywrap'); expect(content).toContain('export async function sqrt'); - expect(content).toContain("getRuntimeBridge().call('math', 'sqrt'"); + expect(content).toContain("getRuntimeBridge().call('math', 'sqrt'"); // basic typing checks expect(content).toMatch(/export async function \w+\(.*: .*\): Promise<.*>/); }); diff --git a/test/generator.test.ts b/test/generator.test.ts index 7333d573..01dbceff 100644 --- a/test/generator.test.ts +++ b/test/generator.test.ts @@ -55,7 +55,67 @@ describe('CodeGenerator', () => { ); expect(code.typescript).toContain('export async function add'); expect(code.typescript).toContain('Add two numbers'); - expect(code.typescript).toContain("getRuntimeBridge().call('math', 'add'"); + expect(code.typescript).toContain("getRuntimeBridge().call('math', 'add'"); + expect(code.typescript).toContain('createReturnValidator'); + }); + + it('emits a null union member for X | None returns, not an accept-everything any', () => { + const code = gen.generateFunctionWrapper( + { + name: 'maybe_count', + signature: { + parameters: [], + returnType: { + kind: 'union', + types: [ + { kind: 'primitive', name: 'int' }, + { kind: 'primitive', name: 'None' }, + ], + }, + isAsync: false, + isGenerator: false, + }, + docstring: undefined, + decorators: [], + isAsync: false, + isGenerator: false, + returnType: { + kind: 'union', + types: [ + { kind: 'primitive', name: 'int' }, + { kind: 'primitive', name: 'None' }, + ], + }, + parameters: [], + } as any, + 'maybe_mod' + ); + + expect(code.typescript).toContain('"type":"null"'); + expect(code.typescript).not.toContain('"kind":"any"'); + }); + + it('keeps a bare -> None return as a validation no-op', () => { + const code = gen.generateFunctionWrapper( + { + name: 'do_nothing', + signature: { + parameters: [], + returnType: { kind: 'primitive', name: 'None' }, + isAsync: false, + isGenerator: false, + }, + docstring: undefined, + decorators: [], + isAsync: false, + isGenerator: false, + returnType: { kind: 'primitive', name: 'None' }, + parameters: [], + } as any, + 'maybe_mod' + ); + + expect(code.typescript).toContain('{"kind":"any"}'); }); it('serializes tuple types as TS tuples', () => { @@ -1386,11 +1446,13 @@ describe('CodeGenerator', () => { // The TS member name is escaped (camelCased) while the dotted RPC target // (Class.method) keeps the raw Python names. expect(code.typescript).toContain('static async createDog(name: string): Promise'); - expect(code.typescript).toContain("getRuntimeBridge().call('pets', 'Pet.create_dog', __args)"); + expect(code.typescript).toContain( + "getRuntimeBridge().call('pets', 'Pet.create_dog', __args" + ); // staticmethod: static member, no implicit first param, class-routed call. expect(code.typescript).toContain('static async isValidName(name: string): Promise'); expect(code.typescript).toContain( - "getRuntimeBridge().call('pets', 'Pet.is_valid_name', __args)" + "getRuntimeBridge().call('pets', 'Pet.is_valid_name', __args" ); // Must NOT route static/class members through the instance handle. expect(code.typescript).not.toContain('callMethod'); @@ -1563,6 +1625,8 @@ describe('CodeGenerator', () => { ); expect(code.typescript).toMatch(/^export async function _default_\(\): Promise \{$/m); - expect(code.typescript).toContain("getRuntimeBridge().call('keywords', 'default', __args)"); + expect(code.typescript).toContain( + "getRuntimeBridge().call('keywords', 'default', __args" + ); }); }); diff --git a/test/integration.test.ts b/test/integration.test.ts index 68902098..659f67c2 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -71,7 +71,7 @@ describe('IR-only integration', () => { const content = await fsUtils.readFile(`${outDir}/math.generated.ts`); expect(content).toContain('Generated by tywrap'); expect(content).toContain('export async function'); - expect(content).toContain("getRuntimeBridge().call('math', 'sqrt'"); + expect(content).toContain("getRuntimeBridge().call('math', 'sqrt'"); }); it('supports local Python modules via pythonImportPath', async () => { @@ -338,10 +338,12 @@ class Container(Generic[T]): `// Minimal stub covering only the bridge methods generated wrappers call. // The real RuntimeExecution also has dispose() and other members; expand this if codegen starts using them. export interface RuntimeBridge { - call(module: string, functionName: string, args: unknown[], kwargs?: Record): Promise; + call(module: string, functionName: string, args: unknown[], kwargs?: Record, validate?: (result: T) => void): Promise; } export declare function getRuntimeBridge(): RuntimeBridge; +export type ReturnSchema = unknown; +export declare function createReturnValidator(schema: ReturnSchema, callSite: string, definitions?: Readonly>): (result: T) => T; `, 'utf-8' ); diff --git a/test/menagerie/__snapshots__/gen.test.ts.snap b/test/menagerie/__snapshots__/gen.test.ts.snap index 12f39a8f..29d29d21 100644 --- a/test/menagerie/__snapshots__/gen.test.ts.snap +++ b/test/menagerie/__snapshots__/gen.test.ts.snap @@ -6,73 +6,103 @@ exports[`menagerie generation gate > generates and typechecks fixtures.typing_to // Module: fixtures.typing_torture // DO NOT EDIT MANUALLY -import { getRuntimeBridge } from 'tywrap/runtime'; +import { createReturnValidator, getRuntimeBridge, type ReturnSchema } from 'tywrap/runtime'; + +const __tywrapReturnDefinitions: Record = {"Movie":{"kind":"record","fields":{"title":{"schema":{"kind":"primitive","type":"string"},"optional":false},"year":{"schema":{"kind":"primitive","type":"number"},"optional":false}}},"RatedMovie":{"kind":"record","fields":{"title":{"schema":{"kind":"primitive","type":"string"},"optional":false},"year":{"schema":{"kind":"primitive","type":"number"},"optional":false},"rating":{"schema":{"kind":"primitive","type":"number"},"optional":true}}}}; + +const __validateannotated_echoResult = createReturnValidator({"kind":"primitive","type":"number"}, "fixtures.typing_torture.annotated_echo", __tywrapReturnDefinitions); export async function annotatedEcho(value: number): Promise { const __args: unknown[] = [value]; - return getRuntimeBridge().call('fixtures.typing_torture', 'annotated_echo', __args); + return getRuntimeBridge().call('fixtures.typing_torture', 'annotated_echo', __args, undefined, __validateannotated_echoResult); } +const __validateasync_textResult = createReturnValidator({"kind":"primitive","type":"string"}, "fixtures.typing_torture.async_text", __tywrapReturnDefinitions); + export async function asyncText(): Promise { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.typing_torture', 'async_text', __args); + return getRuntimeBridge().call('fixtures.typing_torture', 'async_text', __args, undefined, __validateasync_textResult); } +const __validatebytes_echoResult = createReturnValidator({"kind":"primitive","type":"Uint8Array"}, "fixtures.typing_torture.bytes_echo", __tywrapReturnDefinitions); + export async function bytesEcho(value: Uint8Array): Promise { const __args: unknown[] = [value]; - return getRuntimeBridge().call('fixtures.typing_torture', 'bytes_echo', __args); + return getRuntimeBridge().call('fixtures.typing_torture', 'bytes_echo', __args, undefined, __validatebytes_echoResult); } +const __validatefrozen_valuesResult = createReturnValidator({"kind":"array","element":{"kind":"primitive","type":"string"}}, "fixtures.typing_torture.frozen_values", __tywrapReturnDefinitions); + export async function frozenValues(value: string[]): Promise { const __args: unknown[] = [value]; - return getRuntimeBridge().call('fixtures.typing_torture', 'frozen_values', __args); + return getRuntimeBridge().call('fixtures.typing_torture', 'frozen_values', __args, undefined, __validatefrozen_valuesResult); } +const __validategenerator_valuesResult = createReturnValidator({"kind":"array","element":{"kind":"primitive","type":"number"}}, "fixtures.typing_torture.generator_values", __tywrapReturnDefinitions); + export async function generatorValues(): Promise> { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.typing_torture', 'generator_values', __args); + return getRuntimeBridge().call>('fixtures.typing_torture', 'generator_values', __args, undefined, __validategenerator_valuesResult); } +const __validategeneric_identityResult = createReturnValidator({"kind":"any"}, "fixtures.typing_torture.generic_identity", __tywrapReturnDefinitions); + export async function genericIdentity(value: T): Promise { const __args: unknown[] = [value]; - return getRuntimeBridge().call('fixtures.typing_torture', 'generic_identity', __args); + return getRuntimeBridge().call('fixtures.typing_torture', 'generic_identity', __args, undefined, __validategeneric_identityResult); } +const __validateint_key_dictResult = createReturnValidator({"kind":"record","values":{"kind":"primitive","type":"string"}}, "fixtures.typing_torture.int_key_dict", __tywrapReturnDefinitions); + export async function intKeyDict(value: { [key: number]: string; }): Promise<{ [key: number]: string; }> { const __args: unknown[] = [value]; - return getRuntimeBridge().call('fixtures.typing_torture', 'int_key_dict', __args); + return getRuntimeBridge().call<{ [key: number]: string; }>('fixtures.typing_torture', 'int_key_dict', __args, undefined, __validateint_key_dictResult); } +const __validateiterator_valuesResult = createReturnValidator({"kind":"array","element":{"kind":"primitive","type":"number"}}, "fixtures.typing_torture.iterator_values", __tywrapReturnDefinitions); + export async function iteratorValues(): Promise> { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.typing_torture', 'iterator_values', __args); + return getRuntimeBridge().call>('fixtures.typing_torture', 'iterator_values', __args, undefined, __validateiterator_valuesResult); } +const __validateliteral_echoResult = createReturnValidator({"kind":"union","options":[{"kind":"literal","value":"red"},{"kind":"literal","value":"blue"},{"kind":"literal","value":7}]}, "fixtures.typing_torture.literal_echo", __tywrapReturnDefinitions); + export async function literalEcho(value: "red" | "blue" | 7): Promise<"red" | "blue" | 7> { const __args: unknown[] = [value]; - return getRuntimeBridge().call('fixtures.typing_torture', 'literal_echo', __args); + return getRuntimeBridge().call<"red" | "blue" | 7>('fixtures.typing_torture', 'literal_echo', __args, undefined, __validateliteral_echoResult); } +const __validatenested_containersResult = createReturnValidator({"kind":"array","element":{"kind":"record","values":{"kind":"array","element":{"kind":"tuple","elements":[{"kind":"primitive","type":"number"},{"kind":"primitive","type":"string"}]}}}}, "fixtures.typing_torture.nested_containers", __tywrapReturnDefinitions); + export async function nestedContainers(value: Record[]): Promise[]> { const __args: unknown[] = [value]; - return getRuntimeBridge().call('fixtures.typing_torture', 'nested_containers', __args); + return getRuntimeBridge().call[]>('fixtures.typing_torture', 'nested_containers', __args, undefined, __validatenested_containersResult); } +const __validatenone_returnResult = createReturnValidator({"kind":"any"}, "fixtures.typing_torture.none_return", __tywrapReturnDefinitions); + export async function noneReturn(): Promise { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.typing_torture', 'none_return', __args); + return getRuntimeBridge().call('fixtures.typing_torture', 'none_return', __args, undefined, __validatenone_returnResult); } +const __validateoptional_unionResult = createReturnValidator({"kind":"union","options":[{"kind":"primitive","type":"string"},{"kind":"primitive","type":"number"},{"kind":"primitive","type":"null"}]}, "fixtures.typing_torture.optional_union", __tywrapReturnDefinitions); + export async function optionalUnion(value: string | number | null): Promise { const __args: unknown[] = [value]; - return getRuntimeBridge().call('fixtures.typing_torture', 'optional_union', __args); + return getRuntimeBridge().call('fixtures.typing_torture', 'optional_union', __args, undefined, __validateoptional_unionResult); } +const __validateoverloadedResult = createReturnValidator({"kind":"union","options":[{"kind":"primitive","type":"number"},{"kind":"primitive","type":"string"}]}, "fixtures.typing_torture.overloaded", __tywrapReturnDefinitions); + export async function overloaded(value: number | string): Promise { const __args: unknown[] = [value]; - return getRuntimeBridge().call('fixtures.typing_torture', 'overloaded', __args); + return getRuntimeBridge().call('fixtures.typing_torture', 'overloaded', __args, undefined, __validateoverloadedResult); } +const __validateparamspec_applyResult = createReturnValidator({"kind":"any"}, "fixtures.typing_torture.paramspec_apply", __tywrapReturnDefinitions); + export async function paramspecApply

(callback: (...args: P) => T, args?: unknown[], kwargs?: Record): Promise { let __kwargs = kwargs; const __args: unknown[] = [callback]; @@ -87,37 +117,56 @@ export async function paramspecApply

(callback: (...args: } } __args.push(...__varargs); - return getRuntimeBridge().call('fixtures.typing_torture', 'paramspec_apply', __args, __kwargs); + return getRuntimeBridge().call('fixtures.typing_torture', 'paramspec_apply', __args, __kwargs, __validateparamspec_applyResult); } +const __validateprotocol_identityResult = createReturnValidator({"kind":"any"}, "fixtures.typing_torture.protocol_identity", __tywrapReturnDefinitions); + export async function protocolIdentity(value: SupportsClose): Promise { const __args: unknown[] = [value]; - return getRuntimeBridge().call('fixtures.typing_torture', 'protocol_identity', __args); + return getRuntimeBridge().call('fixtures.typing_torture', 'protocol_identity', __args, undefined, __validateprotocol_identityResult); +} + +const __validaterated_movieResult = createReturnValidator({"kind":"ref","name":"RatedMovie"}, "fixtures.typing_torture.rated_movie", __tywrapReturnDefinitions); + +export async function ratedMovie(): Promise { + const __args: unknown[] = []; + return getRuntimeBridge().call('fixtures.typing_torture', 'rated_movie', __args, undefined, __validaterated_movieResult); } +const __validateset_valuesResult = createReturnValidator({"kind":"array","element":{"kind":"primitive","type":"number"}}, "fixtures.typing_torture.set_values", __tywrapReturnDefinitions); + export async function setValues(value: number[]): Promise { const __args: unknown[] = [value]; - return getRuntimeBridge().call('fixtures.typing_torture', 'set_values', __args); + return getRuntimeBridge().call('fixtures.typing_torture', 'set_values', __args, undefined, __validateset_valuesResult); } +const __validatetuple_key_dictResult = createReturnValidator({"kind":"record","values":{"kind":"primitive","type":"string"}}, "fixtures.typing_torture.tuple_key_dict", __tywrapReturnDefinitions); + export async function tupleKeyDict(value: Record): Promise> { const __args: unknown[] = [value]; - return getRuntimeBridge().call('fixtures.typing_torture', 'tuple_key_dict', __args); + return getRuntimeBridge().call>('fixtures.typing_torture', 'tuple_key_dict', __args, undefined, __validatetuple_key_dictResult); } +const __validatetyped_dict_echoResult = createReturnValidator({"kind":"ref","name":"Movie"}, "fixtures.typing_torture.typed_dict_echo", __tywrapReturnDefinitions); + export async function typedDictEcho(value: Movie): Promise { const __args: unknown[] = [value]; - return getRuntimeBridge().call('fixtures.typing_torture', 'typed_dict_echo', __args); + return getRuntimeBridge().call('fixtures.typing_torture', 'typed_dict_echo', __args, undefined, __validatetyped_dict_echoResult); } +const __validateuser_id_echoResult = createReturnValidator({"kind":"any"}, "fixtures.typing_torture.user_id_echo", __tywrapReturnDefinitions); + export async function userIdEcho(value: UserId): Promise { const __args: unknown[] = [value]; - return getRuntimeBridge().call('fixtures.typing_torture', 'user_id_echo', __args); + return getRuntimeBridge().call('fixtures.typing_torture', 'user_id_echo', __args, undefined, __validateuser_id_echoResult); } +const __validatevariadic_tupleResult = createReturnValidator({"kind":"array","element":{"kind":"primitive","type":"number"}}, "fixtures.typing_torture.variadic_tuple", __tywrapReturnDefinitions); + export async function variadicTuple(value: number[]): Promise { const __args: unknown[] = [value]; - return getRuntimeBridge().call('fixtures.typing_torture', 'variadic_tuple', __args); + return getRuntimeBridge().call('fixtures.typing_torture', 'variadic_tuple', __args, undefined, __validatevariadic_tupleResult); } export class Box { @@ -128,6 +177,8 @@ export class Box { export type Movie = { title: string; year: number; } +export type RatedMovie = { title: string; year: number; rating?: number; } + export type SupportsClose = { close: () => void; } export type UserId = number @@ -166,6 +217,8 @@ export function paramspecApply

(callback: (...args: P) => export function protocolIdentity(value: SupportsClose): Promise; +export function ratedMovie(): Promise; + export function setValues(value: number[]): Promise; export function tupleKeyDict(value: Record): Promise>; @@ -184,6 +237,8 @@ export class Box { export type Movie = { title: string; year: number; } +export type RatedMovie = { title: string; year: number; rating?: number; } + export type SupportsClose = { close: () => void; } export type UserId = number @@ -197,93 +252,131 @@ exports[`menagerie generation gate > generates and typechecks fixtures.values_to // Module: fixtures.values_torture // DO NOT EDIT MANUALLY -import { getRuntimeBridge } from 'tywrap/runtime'; +import { createReturnValidator, getRuntimeBridge, type ReturnSchema } from 'tywrap/runtime'; + +const __tywrapReturnDefinitions: Record = {}; + +const __validatebools_and_intsResult = createReturnValidator({"kind":"array","element":{"kind":"union","options":[{"kind":"primitive","type":"boolean"},{"kind":"primitive","type":"number"}]}}, "fixtures.values_torture.bools_and_ints", __tywrapReturnDefinitions); export async function boolsAndInts(): Promise { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.values_torture', 'bools_and_ints', __args); + return getRuntimeBridge().call('fixtures.values_torture', 'bools_and_ints', __args, undefined, __validatebools_and_intsResult); } +const __validatebytes_echoResult = createReturnValidator({"kind":"primitive","type":"Uint8Array"}, "fixtures.values_torture.bytes_echo", __tywrapReturnDefinitions); + export async function bytesEcho(value: Uint8Array): Promise { const __args: unknown[] = [value]; - return getRuntimeBridge().call('fixtures.values_torture', 'bytes_echo', __args); + return getRuntimeBridge().call('fixtures.values_torture', 'bytes_echo', __args, undefined, __validatebytes_echoResult); } +const __validatecomplex_valueResult = createReturnValidator({"kind":"any"}, "fixtures.values_torture.complex_value", __tywrapReturnDefinitions); + export async function complexValue(): Promise { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.values_torture', 'complex_value', __args); + return getRuntimeBridge().call('fixtures.values_torture', 'complex_value', __args, undefined, __validatecomplex_valueResult); } +const __validatecoroutine_valueResult = createReturnValidator({"kind":"primitive","type":"string"}, "fixtures.values_torture.coroutine_value", __tywrapReturnDefinitions); + export async function coroutineValue(): Promise { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.values_torture', 'coroutine_value', __args); + return getRuntimeBridge().call('fixtures.values_torture', 'coroutine_value', __args, undefined, __validatecoroutine_valueResult); } +const __validatedataclass_instanceResult = createReturnValidator({"kind":"any"}, "fixtures.values_torture.dataclass_instance", __tywrapReturnDefinitions); + export async function dataclassInstance(): Promise { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.values_torture', 'dataclass_instance', __args); + return getRuntimeBridge().call('fixtures.values_torture', 'dataclass_instance', __args, undefined, __validatedataclass_instanceResult); } +const __validatedecimal_valuesResult = createReturnValidator({"kind":"array","element":{"kind":"any"}}, "fixtures.values_torture.decimal_values", __tywrapReturnDefinitions); + export async function decimalValues(): Promise { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.values_torture', 'decimal_values', __args); + return getRuntimeBridge().call('fixtures.values_torture', 'decimal_values', __args, undefined, __validatedecimal_valuesResult); } +const __validatedeeply_nestedResult = createReturnValidator({"kind":"array","element":{"kind":"any"}}, "fixtures.values_torture.deeply_nested", __tywrapReturnDefinitions); + export async function deeplyNested(): Promise { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.values_torture', 'deeply_nested', __args); + return getRuntimeBridge().call('fixtures.values_torture', 'deeply_nested', __args, undefined, __validatedeeply_nestedResult); } +const __validateecho_intResult = createReturnValidator({"kind":"primitive","type":"number"}, "fixtures.values_torture.echo_int", __tywrapReturnDefinitions); + export async function echoInt(value: number): Promise { const __args: unknown[] = [value]; - return getRuntimeBridge().call('fixtures.values_torture', 'echo_int', __args); + return getRuntimeBridge().call('fixtures.values_torture', 'echo_int', __args, undefined, __validateecho_intResult); } +const __validateempty_valuesResult = createReturnValidator({"kind":"tuple","elements":[{"kind":"tuple","elements":[{"kind":"any"}]},{"kind":"array","element":{"kind":"any"}},{"kind":"record","values":{"kind":"any"}},{"kind":"array","element":{"kind":"any"}}]}, "fixtures.values_torture.empty_values", __tywrapReturnDefinitions); + export async function emptyValues(): Promise<[[unknown], object[], Record, object[]]> { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.values_torture', 'empty_values', __args); + return getRuntimeBridge().call<[[unknown], object[], Record, object[]]>('fixtures.values_torture', 'empty_values', __args, undefined, __validateempty_valuesResult); } +const __validateenum_memberResult = createReturnValidator({"kind":"any"}, "fixtures.values_torture.enum_member", __tywrapReturnDefinitions); + export async function enumMember(): Promise { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.values_torture', 'enum_member', __args); + return getRuntimeBridge().call('fixtures.values_torture', 'enum_member', __args, undefined, __validateenum_memberResult); } +const __validatefinite_float_edgesResult = createReturnValidator({"kind":"array","element":{"kind":"primitive","type":"number"}}, "fixtures.values_torture.finite_float_edges", __tywrapReturnDefinitions); + export async function finiteFloatEdges(): Promise { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.values_torture', 'finite_float_edges', __args); + return getRuntimeBridge().call('fixtures.values_torture', 'finite_float_edges', __args, undefined, __validatefinite_float_edgesResult); } +const __validategenerator_valueResult = createReturnValidator({"kind":"any"}, "fixtures.values_torture.generator_value", __tywrapReturnDefinitions); + export async function generatorValue(): Promise { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.values_torture', 'generator_value', __args); + return getRuntimeBridge().call('fixtures.values_torture', 'generator_value', __args, undefined, __validategenerator_valueResult); } +const __validateint_key_dictResult = createReturnValidator({"kind":"record","values":{"kind":"primitive","type":"string"}}, "fixtures.values_torture.int_key_dict", __tywrapReturnDefinitions); + export async function intKeyDict(): Promise<{ [key: number]: string; }> { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.values_torture', 'int_key_dict', __args); + return getRuntimeBridge().call<{ [key: number]: string; }>('fixtures.values_torture', 'int_key_dict', __args, undefined, __validateint_key_dictResult); } +const __validateinteger_boundariesResult = createReturnValidator({"kind":"array","element":{"kind":"primitive","type":"number"}}, "fixtures.values_torture.integer_boundaries", __tywrapReturnDefinitions); + export async function integerBoundaries(): Promise { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.values_torture', 'integer_boundaries', __args); + return getRuntimeBridge().call('fixtures.values_torture', 'integer_boundaries', __args, undefined, __validateinteger_boundariesResult); } +const __validatelone_surrogateResult = createReturnValidator({"kind":"primitive","type":"string"}, "fixtures.values_torture.lone_surrogate", __tywrapReturnDefinitions); + export async function loneSurrogate(): Promise { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.values_torture', 'lone_surrogate', __args); + return getRuntimeBridge().call('fixtures.values_torture', 'lone_surrogate', __args, undefined, __validatelone_surrogateResult); } +const __validatemegabyte_textResult = createReturnValidator({"kind":"primitive","type":"string"}, "fixtures.values_torture.megabyte_text", __tywrapReturnDefinitions); + export async function megabyteText(): Promise { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.values_torture', 'megabyte_text', __args); + return getRuntimeBridge().call('fixtures.values_torture', 'megabyte_text', __args, undefined, __validatemegabyte_textResult); } +const __validateset_and_frozensetResult = createReturnValidator({"kind":"tuple","elements":[{"kind":"array","element":{"kind":"primitive","type":"number"}},{"kind":"array","element":{"kind":"primitive","type":"string"}}]}, "fixtures.values_torture.set_and_frozenset", __tywrapReturnDefinitions); + export async function setAndFrozenset(): Promise<[number[], string[]]> { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.values_torture', 'set_and_frozenset', __args); + return getRuntimeBridge().call<[number[], string[]]>('fixtures.values_torture', 'set_and_frozenset', __args, undefined, __validateset_and_frozensetResult); } +const __validatespecial_floatsResult = createReturnValidator({"kind":"array","element":{"kind":"primitive","type":"number"}}, "fixtures.values_torture.special_floats", __tywrapReturnDefinitions); + export function specialFloats(): Promise; export function specialFloats(include?: boolean): Promise; export async function specialFloats(include?: boolean): Promise { @@ -291,27 +384,35 @@ export async function specialFloats(include?: boolean): Promise { while (__args.length > 0 && __args[__args.length - 1] === undefined) { __args.pop(); } - return getRuntimeBridge().call('fixtures.values_torture', 'special_floats', __args); + return getRuntimeBridge().call('fixtures.values_torture', 'special_floats', __args, undefined, __validatespecial_floatsResult); } +const __validatetemporal_valuesResult = createReturnValidator({"kind":"record","values":{"kind":"any"}}, "fixtures.values_torture.temporal_values", __tywrapReturnDefinitions); + export async function temporalValues(): Promise> { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.values_torture', 'temporal_values', __args); + return getRuntimeBridge().call>('fixtures.values_torture', 'temporal_values', __args, undefined, __validatetemporal_valuesResult); } +const __validatetuple_key_dictResult = createReturnValidator({"kind":"record","values":{"kind":"primitive","type":"string"}}, "fixtures.values_torture.tuple_key_dict", __tywrapReturnDefinitions); + export async function tupleKeyDict(): Promise> { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.values_torture', 'tuple_key_dict', __args); + return getRuntimeBridge().call>('fixtures.values_torture', 'tuple_key_dict', __args, undefined, __validatetuple_key_dictResult); } +const __validateunicode_textResult = createReturnValidator({"kind":"primitive","type":"string"}, "fixtures.values_torture.unicode_text", __tywrapReturnDefinitions); + export async function unicodeText(): Promise { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.values_torture', 'unicode_text', __args); + return getRuntimeBridge().call('fixtures.values_torture', 'unicode_text', __args, undefined, __validateunicode_textResult); } +const __validateuuid_and_pathResult = createReturnValidator({"kind":"record","values":{"kind":"any"}}, "fixtures.values_torture.uuid_and_path", __tywrapReturnDefinitions); + export async function uuidAndPath(): Promise> { const __args: unknown[] = []; - return getRuntimeBridge().call('fixtures.values_torture', 'uuid_and_path', __args); + return getRuntimeBridge().call>('fixtures.values_torture', 'uuid_and_path', __args, undefined, __validateuuid_and_pathResult); } /** diff --git a/test/menagerie/fixtures/typing_torture.py b/test/menagerie/fixtures/typing_torture.py index dc46bc33..6b2f67a9 100644 --- a/test/menagerie/fixtures/typing_torture.py +++ b/test/menagerie/fixtures/typing_torture.py @@ -28,6 +28,10 @@ class Movie(_TypedDict): year: int +class RatedMovie(Movie, total=False): + rating: float + + class SupportsClose(Protocol): def close(self) -> None: ... @@ -45,6 +49,10 @@ def typed_dict_echo(value: Movie) -> Movie: return value +def rated_movie() -> RatedMovie: + return {"title": "Arrival", "year": 2016} + + def protocol_identity(value: SupportsClose) -> SupportsClose: return value diff --git a/test/runtime_return_validation.test.ts b/test/runtime_return_validation.test.ts new file mode 100644 index 00000000..a23084fe --- /dev/null +++ b/test/runtime_return_validation.test.ts @@ -0,0 +1,167 @@ +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { BridgeValidationError } from '../src/runtime/errors.js'; +import { HttpBridge } from '../src/runtime/http.js'; +import { NodeBridge } from '../src/runtime/node.js'; +import { + createReturnValidator, + tagDecodedShape, + type ReturnSchema, +} from '../src/runtime/validators.js'; +import { PYTHON_AVAILABLE, PYTHON } from './helpers/python-probe.js'; + +describe('generated return validators', () => { + it('rejects a mistyped primitive return with its generated call site', () => { + const validator = createReturnValidator( + { kind: 'primitive', type: 'number' }, + 'fixture.answer' + ); + expect(() => validator('not a number')).toThrow(BridgeValidationError); + expect(() => validator('not a number')).toThrow( + /fixture\.answer.*expected number, received string/ + ); + expect(validator(42)).toBe(42); + }); + + it('checks unions, optionals, tuples, TypedDict records, and no-op schemas', () => { + const schema: ReturnSchema = { + kind: 'tuple', + elements: [ + { + kind: 'union', + options: [ + { kind: 'primitive', type: 'number' }, + { kind: 'primitive', type: 'null' }, + ], + }, + { kind: 'ref', name: 'Movie' }, + ], + }; + const validator = createReturnValidator(schema, 'fixture.movie', { + Movie: { + kind: 'record', + fields: { + title: { schema: { kind: 'primitive', type: 'string' } }, + year: { schema: { kind: 'primitive', type: 'number' } }, + tagline: { schema: { kind: 'primitive', type: 'string' }, optional: true }, + }, + }, + }); + expect(validator([null, { title: 'Alien', year: 1979 }])).toEqual([ + null, + { title: 'Alien', year: 1979 }, + ]); + expect(() => validator([false, { title: 'Alien', year: '1979' }])).toThrow( + BridgeValidationError + ); + expect(createReturnValidator({ kind: 'any' }, 'fixture.any')({ wrong: 'by design' })).toEqual({ + wrong: 'by design', + }); + }); + + it('uses decoded columnar provenance and never walks Arrow table elements', () => { + const table = new Proxy( + { numRows: 1, numCols: 1 }, + { + get(target, property, receiver) { + if (property === 'numRows' || property === 'numCols') + return Reflect.get(target, property, receiver); + throw new Error(`unexpected deep table access: ${String(property)}`); + }, + ownKeys() { + throw new Error('unexpected table enumeration'); + }, + } + ); + tagDecodedShape(table, { marker: 'dataframe' }); + expect( + createReturnValidator({ kind: 'marker', marker: 'dataframe' }, 'fixture.frame')(table) + ).toBe(table); + + const array = tagDecodedShape([[1, 2]], { marker: 'ndarray', dims: 2, dtype: 'float64' }); + expect( + createReturnValidator( + { kind: 'marker', marker: 'ndarray', dims: 2, dtype: 'float64' }, + 'fixture.matrix' + )(array) + ).toBe(array); + expect(() => + createReturnValidator( + { kind: 'marker', marker: 'ndarray', dims: 1, dtype: 'int64' }, + 'fixture.matrix' + )(array) + ).toThrow(BridgeValidationError); + }); + + it('terminates on a recursive forward reference', () => { + const schema: ReturnSchema = { kind: 'ref', name: 'Node' }; + const definitions: Record = { + Node: { + kind: 'record', + fields: { + value: { schema: { kind: 'primitive', type: 'number' } }, + next: { schema: { kind: 'ref', name: 'Node' }, optional: true }, + }, + }, + }; + const value: { value: number; next?: unknown } = { value: 1 }; + value.next = value; + expect(createReturnValidator(schema, 'fixture.link', definitions)(value)).toBe(value); + }); +}); + +describe('return validator bridge propagation', () => { + let bridge: HttpBridge | undefined; + + afterEach(async () => { + await bridge?.dispose(); + bridge = undefined; + }); + + it('survives the mocked non-subprocess HTTP facade', async () => { + const server = createServer((_, response) => { + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify({ id: 1, result: 4 })); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address() as AddressInfo; + bridge = new HttpBridge({ baseURL: `http://127.0.0.1:${address.port}` }); + const validate = vi.fn((value: number) => { + if (value !== 4) throw new Error('wrong result'); + }); + try { + await expect(bridge.call('math', 'sqrt', [16], undefined, validate)).resolves.toBe(4); + expect(validate).toHaveBeenCalledOnce(); + expect(validate).toHaveBeenCalledWith(4); + } finally { + await new Promise((resolve, reject) => + server.close(error => (error ? reject(error) : resolve())) + ); + } + }); + + it.skipIf(!PYTHON_AVAILABLE)( + 'survives the Node facade and preserves BridgeValidationError', + async () => { + const node = new NodeBridge({ + pythonPath: PYTHON ?? undefined, + scriptPath: 'runtime/python_bridge.py', + }); + try { + await expect( + node.call( + 'builtins', + 'str', + [123], + undefined, + createReturnValidator({ kind: 'primitive', type: 'number' }, 'builtins.str') + ) + ).rejects.toMatchObject({ name: 'BridgeValidationError', callSite: 'builtins.str' }); + } finally { + await node.dispose(); + } + } + ); +}); diff --git a/tywrap_ir/tywrap_ir/ir.py b/tywrap_ir/tywrap_ir/ir.py index 74bc3a17..95c4f5bc 100644 --- a/tywrap_ir/tywrap_ir/ir.py +++ b/tywrap_ir/tywrap_ir/ir.py @@ -738,12 +738,20 @@ def _extract_class(cls: type, module_name: str, include_private: bool) -> Option if hasattr(typing, "get_origin") else getattr(cls, "__annotations__", {}) ) + # __optional_keys__ is the runtime's own answer and, unlike the + # class-level __total__ flag, is correct under inheritance: a + # total=False subclass keeps fields inherited from a total=True + # parent required. + optional_keys = getattr(cls, "__optional_keys__", None) for fname, ftype in ann.items(): text = _stringify_annotation(ftype) s = str(ftype) - is_not_required = "NotRequired[" in s or "typing.NotRequired[" in s - is_required = "Required[" in s or "typing.Required[" in s - optional_flag = is_not_required or (not is_required and total is False) + if optional_keys is not None: + optional_flag = fname in optional_keys + else: + is_not_required = "NotRequired[" in s or "typing.NotRequired[" in s + is_required = "Required[" in s or "typing.Required[" in s + optional_flag = is_not_required or (not is_required and total is False) fields.append(IRParam(name=fname, kind="FIELD", annotation=text, default=optional_flag)) except Exception: pass From f8ff7a217bc1e4bfb38cdaa0e8b3b5e1a5ea3bb9 Mon Sep 17 00:00:00 2001 From: brett-bonner_infodesk Date: Sat, 11 Jul 2026 15:07:11 -0700 Subject: [PATCH 2/3] fix(test): platform-aware fake interpreter for the IR version-mismatch test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fake-python shim was a #!/bin/sh script, which Windows cannot spawn (ENOENT) — the failure surfaced as ir-unavailable instead of the asserted ir-version-mismatch. Windows now gets a .cmd shim. (Defect arrived with #301, whose Windows job did not re-run after the stack retarget.) --- test/ir_contract.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/ir_contract.test.ts b/test/ir_contract.test.ts index 3cc55b14..e6ced0bd 100644 --- a/test/ir_contract.test.ts +++ b/test/ir_contract.test.ts @@ -99,10 +99,15 @@ describe('pinned IR contracts', () => { it('fails clearly when Python IR reports a different schema version', async () => { const tempDir = await mkdtemp(join(tmpdir(), 'tywrap-ir-contract-version-')); try { - const fakePython = join(tempDir, 'fake-python'); + // Windows cannot spawn an extensionless sh script (ENOENT), so the fake + // interpreter is a .cmd shim there and a sh shim elsewhere. + const isWindows = process.platform === 'win32'; + const fakePython = join(tempDir, isWindows ? 'fake-python.cmd' : 'fake-python'); await writeFile( fakePython, - '#!/bin/sh\nprintf \'{"ir_version":"0.3.0","module":"math"}\\n\'\n', + isWindows + ? '@echo {"ir_version":"0.3.0","module":"math"}\r\n' + : '#!/bin/sh\nprintf \'{"ir_version":"0.3.0","module":"math"}\\n\'\n', 'utf8' ); await chmod(fakePython, 0o755); From 244f165f0c2351dc9a5b211d487f1272d39bc526 Mon Sep 17 00:00:00 2001 From: brett-bonner_infodesk Date: Sat, 11 Jul 2026 15:11:06 -0700 Subject: [PATCH 3/3] fix(test): skip the fake-interpreter version-mismatch test on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows cannot run the shim either way: an extensionless sh script fails with ENOENT and a .cmd fails with EINVAL (Node's batch-file spawn mitigation — the production spawn rightly never sets shell:true). The version-check logic is platform-independent TypeScript and remains exercised on the Linux and macOS matrix legs. --- test/ir_contract.test.ts | 63 +++++++++++++++++++++------------------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/test/ir_contract.test.ts b/test/ir_contract.test.ts index e6ced0bd..cf57e5da 100644 --- a/test/ir_contract.test.ts +++ b/test/ir_contract.test.ts @@ -96,38 +96,41 @@ describe('pinned IR contracts', () => { } }); - it('fails clearly when Python IR reports a different schema version', async () => { - const tempDir = await mkdtemp(join(tmpdir(), 'tywrap-ir-contract-version-')); - try { - // Windows cannot spawn an extensionless sh script (ENOENT), so the fake - // interpreter is a .cmd shim there and a sh shim elsewhere. - const isWindows = process.platform === 'win32'; - const fakePython = join(tempDir, isWindows ? 'fake-python.cmd' : 'fake-python'); - await writeFile( - fakePython, - isWindows - ? '@echo {"ir_version":"0.3.0","module":"math"}\r\n' - : '#!/bin/sh\nprintf \'{"ir_version":"0.3.0","module":"math"}\\n\'\n', - 'utf8' - ); - await chmod(fakePython, 0o755); + // Windows cannot run the fake-interpreter shim: extensionless sh scripts + // fail with ENOENT and .cmd files fail with EINVAL (Node's batch-file spawn + // mitigation; the production spawn rightly never sets shell:true). The + // version-check logic under test is platform-independent TypeScript and is + // exercised on the Linux and macOS matrix legs. + it.skipIf(process.platform === 'win32')( + 'fails clearly when Python IR reports a different schema version', + async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'tywrap-ir-contract-version-')); + try { + const fakePython = join(tempDir, 'fake-python'); + await writeFile( + fakePython, + '#!/bin/sh\nprintf \'{"ir_version":"0.3.0","module":"math"}\\n\'\n', + 'utf8' + ); + await chmod(fakePython, 0o755); - const result = await generate({ - ...options(join(tempDir, 'generated')), - runtime: { node: { pythonPath: fakePython } }, - }); - expect(result.failures).toEqual([ - expect.objectContaining({ - code: 'ir-version-mismatch', - message: expect.stringContaining( - 'TypeScript expects 0.4.0, but Python IR for math declares 0.3.0' - ), - }), - ]); - } finally { - await rm(tempDir, { recursive: true, force: true }); + const result = await generate({ + ...options(join(tempDir, 'generated')), + runtime: { node: { pythonPath: fakePython } }, + }); + expect(result.failures).toEqual([ + expect.objectContaining({ + code: 'ir-version-mismatch', + message: expect.stringContaining( + 'TypeScript expects 0.4.0, but Python IR for math declares 0.3.0' + ), + }), + ]); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } } - }); + ); it('rejects same-version contracts with missing collection fields', async () => { const tempDir = await mkdtemp(join(tmpdir(), 'tywrap-ir-contract-shape-'));