Skip to content

Commit f59c05f

Browse files
authored
Merge pull request #302 from bbopen/feat/0.9-268-runtime-validation
feat(runtime)!: validate decoded returns against declared types
2 parents cfc8542 + 244f165 commit f59c05f

21 files changed

Lines changed: 922 additions & 117 deletions

docs/guide/getting-started.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,14 @@ async function example() {
118118
example().catch(console.error);
119119
```
120120

121+
## Runtime return validation
122+
123+
Generated wrappers validate returns at runtime after decoding. If a Python
124+
implementation returns a value that does not match its annotation, tywrap throws
125+
`BridgeValidationError` with the wrapped call site and received shape. Fix the
126+
Python annotation or implementation; use `-> Any` only when that return is
127+
intentionally untyped.
128+
121129
## Custom Module Example
122130

123131
Create a wrapper for a custom Python module:

docs/public/llms-full.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,14 @@ async function example() {
177177
example().catch(console.error);
178178
```
179179

180+
## Runtime return validation
181+
182+
Generated wrappers validate returns at runtime after decoding. If a Python
183+
implementation returns a value that does not match its annotation, tywrap throws
184+
`BridgeValidationError` with the wrapped call site and received shape. Fix the
185+
Python annotation or implementation; use `-> Any` only when that return is
186+
intentionally untyped.
187+
180188
## Custom Module Example
181189

182190
Create a wrapper for a custom Python module:

src/core/generator.ts

Lines changed: 187 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ interface GenericRenderContext {
3636
emittedParamSpecs: Set<string>;
3737
}
3838

39+
type ReturnDefinitionNames = ReadonlySet<string>;
40+
3941
export interface CodeGeneratorOptions {
4042
/** Reports a generated annotation that cannot be represented by emitted declarations. */
4143
onTypeDegrade?: (typeName: string) => void;
@@ -397,11 +399,164 @@ export class CodeGenerator {
397399
};
398400
}
399401

402+
/**
403+
* Preserve Python provenance in generated return validators instead of
404+
* reconstructing a checker from the final TypeScript spelling. In particular
405+
* pandas/NumPy values retain their marker contract after Arrow decoding.
406+
*/
407+
private returnSchema(type: PythonType, definitions: ReturnDefinitionNames = new Set()): unknown {
408+
// A bare `-> None` return maps to TS void and validates nothing, but a
409+
// NESTED None (union member, tuple element) is a real wire value — it
410+
// decodes to null and must be checked as null, or a union containing it
411+
// would carry an any-member and accept everything.
412+
if (type.kind === 'primitive' && type.name === 'None') {
413+
return { kind: 'any' };
414+
}
415+
const schema = (current: PythonType): unknown => {
416+
switch (current.kind) {
417+
case 'primitive':
418+
return current.name === 'int' || current.name === 'float'
419+
? { kind: 'primitive', type: 'number' }
420+
: current.name === 'str'
421+
? { kind: 'primitive', type: 'string' }
422+
: current.name === 'bool'
423+
? { kind: 'primitive', type: 'boolean' }
424+
: current.name === 'bytes'
425+
? { kind: 'primitive', type: 'Uint8Array' }
426+
: current.name === 'None'
427+
? { kind: 'primitive', type: 'null' }
428+
: { kind: 'any' };
429+
case 'literal':
430+
return { kind: 'literal', value: current.value };
431+
case 'annotated':
432+
return schema(current.base);
433+
case 'final':
434+
case 'classvar':
435+
return schema(current.type);
436+
case 'optional':
437+
return {
438+
kind: 'union',
439+
options: [schema(current.type), { kind: 'primitive', type: 'null' }],
440+
};
441+
case 'union':
442+
return { kind: 'union', options: current.types.map(schema) };
443+
case 'collection': {
444+
if (current.name === 'tuple') {
445+
if (current.itemTypes[1]?.kind === 'custom' && current.itemTypes[1].name === '...') {
446+
return {
447+
kind: 'array',
448+
element: schema(current.itemTypes[0] ?? { kind: 'custom', name: 'Any' }),
449+
};
450+
}
451+
return { kind: 'tuple', elements: current.itemTypes.map(schema) };
452+
}
453+
if (current.name === 'dict') {
454+
return {
455+
kind: 'record',
456+
values: schema(current.itemTypes[1] ?? { kind: 'custom', name: 'Any' }),
457+
};
458+
}
459+
return {
460+
kind: 'array',
461+
element: schema(current.itemTypes[0] ?? { kind: 'custom', name: 'Any' }),
462+
};
463+
}
464+
case 'generic': {
465+
const leaf = current.name.split('.').at(-1) ?? current.name;
466+
if (leaf === 'NDArray' || leaf === 'ndarray') {
467+
return { kind: 'marker', marker: 'ndarray' };
468+
}
469+
if (
470+
[
471+
'list',
472+
'List',
473+
'Sequence',
474+
'Iterable',
475+
'Iterator',
476+
'Generator',
477+
'set',
478+
'frozenset',
479+
].includes(leaf)
480+
) {
481+
return {
482+
kind: 'array',
483+
element: schema(current.typeArgs[0] ?? { kind: 'custom', name: 'Any' }),
484+
};
485+
}
486+
if (['dict', 'Dict', 'Mapping', 'MutableMapping'].includes(leaf)) {
487+
return {
488+
kind: 'record',
489+
values: schema(current.typeArgs[1] ?? { kind: 'custom', name: 'Any' }),
490+
};
491+
}
492+
return definitions.has(leaf) ? { kind: 'ref', name: leaf } : { kind: 'any' };
493+
}
494+
case 'custom': {
495+
const leaf = current.name.split('.').at(-1) ?? current.name;
496+
const full = `${current.module ?? ''}.${leaf}`;
497+
if (leaf === 'None') {
498+
return { kind: 'primitive', type: 'null' };
499+
}
500+
if (leaf === 'Any' || leaf === 'object' || leaf === 'NoReturn' || leaf === 'Never') {
501+
return { kind: 'any' };
502+
}
503+
if (leaf === 'DataFrame' && (!current.module || current.module.startsWith('pandas'))) {
504+
return { kind: 'marker', marker: 'dataframe' };
505+
}
506+
if (leaf === 'Series' && (!current.module || current.module.startsWith('pandas'))) {
507+
return { kind: 'marker', marker: 'series' };
508+
}
509+
if (leaf === 'ndarray' || leaf === 'NDArray' || full.includes('numpy')) {
510+
return { kind: 'marker', marker: 'ndarray' };
511+
}
512+
return definitions.has(leaf) ? { kind: 'ref', name: leaf } : { kind: 'any' };
513+
}
514+
case 'typevar':
515+
case 'paramspec':
516+
case 'paramspec_args':
517+
case 'paramspec_kwargs':
518+
case 'typevartuple':
519+
case 'unpack':
520+
case 'callable':
521+
return { kind: 'any' };
522+
}
523+
};
524+
return schema(type);
525+
}
526+
527+
private returnDefinitions(module: PythonModule): ReturnDefinitionNames {
528+
return new Set(
529+
module.classes
530+
.filter(cls => cls.kind === 'typed_dict' || cls.decorators.includes('__typed_dict__'))
531+
.map(cls => cls.name)
532+
);
533+
}
534+
535+
private emitReturnDefinitions(module: PythonModule): string {
536+
const definitions = this.returnDefinitions(module);
537+
const entries = module.classes
538+
.filter(cls => cls.kind === 'typed_dict' || cls.decorators.includes('__typed_dict__'))
539+
.map(cls => {
540+
const fields = Object.fromEntries(
541+
cls.properties.map(property => [
542+
property.name,
543+
{
544+
schema: this.returnSchema(property.type, definitions),
545+
optional: property.optional === true,
546+
},
547+
])
548+
);
549+
return [cls.name, { kind: 'record', fields }];
550+
});
551+
return `const __tywrapReturnDefinitions: Record<string, ReturnSchema> = ${JSON.stringify(Object.fromEntries(entries))};\n\n`;
552+
}
553+
400554
generateFunctionWrapper(
401555
func: PythonFunction,
402556
moduleName?: string,
403557
annotatedJSDoc = false,
404-
localDeclaredNames: Set<string> = new Set()
558+
localDeclaredNames: Set<string> = new Set(),
559+
returnDefinitions: ReturnDefinitionNames = new Set()
405560
): GeneratedCode {
406561
const jsdoc = this.generateJsDoc(
407562
func.docstring,
@@ -489,6 +644,8 @@ export class CodeGenerator {
489644
const returnType = this.typeToTsFromPython(func.returnType, genericContext, 'return');
490645
const fname = this.escapeIdentifier(func.name);
491646
const moduleId = moduleName ?? '__main__';
647+
const validatorName = `__validate${this.escapeIdentifier(func.name, { preserveCase: true })}Result`;
648+
const returnValidator = `const ${validatorName} = createReturnValidator(${JSON.stringify(this.returnSchema(func.returnType, returnDefinitions))}, ${JSON.stringify(`${moduleId}.${func.name}`)}, __tywrapReturnDefinitions);\n\n`;
492649

493650
// Overloads: generate trailing optional parameter drop variants (exclude *args/**kwargs).
494651
// Why: Python APIs frequently have many optional tail params. TypeScript callers expect
@@ -577,10 +734,8 @@ export class CodeGenerator {
577734
const callPreludeLines = emitCallPrelude(callDescriptor, this.callEmitHelpers());
578735
const callPrelude = callPreludeLines.length > 0 ? `${callPreludeLines.join('\n')}\n` : '';
579736

580-
const ts = `${jsdoc}${overloadDecl}export async function ${fname}${typeParamDecl}(${paramDecl}): Promise<${returnType}> {
581-
${callPrelude}${guards} return getRuntimeBridge().call('${moduleId}', '${func.name}', __args${
582-
hasKwArgs ? ', __kwargs' : ''
583-
});
737+
const ts = `${jsdoc}${returnValidator}${overloadDecl}export async function ${fname}${typeParamDecl}(${paramDecl}): Promise<${returnType}> {
738+
${callPrelude}${guards} return getRuntimeBridge().call<${returnType}>('${moduleId}', '${func.name}', __args, ${hasKwArgs ? '__kwargs' : 'undefined'}, ${validatorName});
584739
}
585740
`;
586741

@@ -596,7 +751,8 @@ ${callPrelude}${guards} return getRuntimeBridge().call('${moduleId}', '${func.n
596751
cls: PythonClass,
597752
moduleName?: string,
598753
_annotatedJSDoc = false,
599-
localDeclaredNames: Set<string> = new Set()
754+
localDeclaredNames: Set<string> = new Set(),
755+
returnDefinitions: ReturnDefinitionNames = new Set()
600756
): GeneratedCode {
601757
const moduleDeclaredNames = new Set(localDeclaredNames);
602758
moduleDeclaredNames.add(cls.name);
@@ -782,6 +938,8 @@ ${callPrelude}${guards} return getRuntimeBridge().call('${moduleId}', '${func.n
782938
'return'
783939
);
784940
const mname = this.escapeIdentifier(method.name);
941+
const validatorName = `__validate${this.escapeIdentifier(cls.name, { preserveCase: true })}${this.escapeIdentifier(method.name, { preserveCase: true })}Result`;
942+
const returnValidator = `const ${validatorName} = createReturnValidator(${JSON.stringify(this.returnSchema(method.returnType, returnDefinitions))}, ${JSON.stringify(`${moduleId}.${cls.name}.${method.name}`)}, __tywrapReturnDefinitions);\n\n`;
785943

786944
const overloads: string[] = [];
787945
if (needsKwargsParam && requiredKwOnlyNames.length > 0) {
@@ -829,11 +987,9 @@ ${callPrelude}${guards} return getRuntimeBridge().call('${moduleId}', '${func.n
829987
const guardLines = emitArgGuards(callDescriptor);
830988
const guards = guardLines.length > 0 ? `${guardLines.join('\n')}\n` : '';
831989

832-
const callExpr = `getRuntimeBridge().call('${moduleId}', '${cls.name}.${method.name}', __args${
833-
needsKwargsParam ? ', __kwargs' : ''
834-
})`;
990+
const callExpr = `getRuntimeBridge().call<${returnType}>('${moduleId}', '${cls.name}.${method.name}', __args, ${needsKwargsParam ? '__kwargs' : 'undefined'}, ${validatorName})`;
835991
methodBodies.push(`${overloadDecl} ${staticPrefix}async ${mname}${methodTypeParamDecl}(${paramsDecl}): Promise<${returnType}> {
836-
${callPrelude}${guards} return ${callExpr};
992+
${returnValidator}${callPrelude}${guards} return ${callExpr};
837993
}`);
838994
methodDeclarations.push(
839995
`${overloadDecl}${overloads.length > 0 ? '' : ` ${staticPrefix}${mname}${methodTypeParamDecl}(${paramsDecl}): Promise<${returnType}>;\n`}`
@@ -913,10 +1069,26 @@ ${migrationNote}${declarationMethodsSection}
9131069
]);
9141070
const functionResults = [...module.functions]
9151071
.sort((a, b) => a.name.localeCompare(b.name))
916-
.map(f => this.generateFunctionWrapper(f, module.name, annotatedJSDoc, localDeclaredNames));
1072+
.map(f =>
1073+
this.generateFunctionWrapper(
1074+
f,
1075+
module.name,
1076+
annotatedJSDoc,
1077+
localDeclaredNames,
1078+
this.returnDefinitions(module)
1079+
)
1080+
);
9171081
const classResults = [...module.classes]
9181082
.sort((a, b) => a.name.localeCompare(b.name))
919-
.map(c => this.generateClassWrapper(c, module.name, annotatedJSDoc, localDeclaredNames));
1083+
.map(c =>
1084+
this.generateClassWrapper(
1085+
c,
1086+
module.name,
1087+
annotatedJSDoc,
1088+
localDeclaredNames,
1089+
this.returnDefinitions(module)
1090+
)
1091+
);
9201092
const typeAliasResults = [...(module.typeAliases ?? [])]
9211093
.sort((a, b) => a.name.localeCompare(b.name))
9221094
.map(alias => this.generateTypeAlias(alias, module.name, localDeclaredNames));
@@ -932,7 +1104,9 @@ ${migrationNote}${declarationMethodsSection}
9321104
return kind === 'class' && !c.decorators.includes('__typed_dict__');
9331105
});
9341106
const needsRuntime = module.functions.length > 0 || hasRuntimeClasses;
935-
const bridgeDecl = needsRuntime ? `import { getRuntimeBridge } from 'tywrap/runtime';\n\n` : '';
1107+
const bridgeDecl = needsRuntime
1108+
? `import { createReturnValidator, getRuntimeBridge, type ReturnSchema } from 'tywrap/runtime';\n\n${this.emitReturnDefinitions(module)}`
1109+
: '';
9361110

9371111
const ts = `${`${header}${bridgeDecl}${functionCodes}\n${classCodes}\n${typeAliasCodes}`.trimEnd()}\n`;
9381112
const declaration = `${`${declarationHeader}${functionResults

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export {
2121
BridgeError,
2222
BridgeCodecError,
2323
BridgeProtocolError,
24+
BridgeValidationError,
2425
BridgeTimeoutError,
2526
BridgeDisposedError,
2627
BridgeExecutionError,

src/runtime/base-bridge.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,11 @@ export abstract class BasePythonBridge extends DisposableBase implements PythonR
4343
module: string,
4444
functionName: string,
4545
args: unknown[],
46-
kwargs?: Record<string, unknown>
46+
kwargs?: Record<string, unknown>,
47+
validate?: (result: T) => void
4748
): Promise<T> {
4849
await this.ensureReady();
49-
return this.getRpcClient().call<T>(module, functionName, args, kwargs);
50+
return this.getRpcClient().call<T>(module, functionName, args, kwargs, validate);
5051
}
5152

5253
/**

src/runtime/bridge-codec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,9 @@ export class BridgeCodec {
643643
// Note: Arrow decoders can introduce NaN/Infinity from binary representations.
644644
this.assertNoSpecialFloats(decoded);
645645

646+
// Return-contract validation is intentionally downstream of this codec. At
647+
// this boundary we guarantee only a sound wire envelope and decoded value;
648+
// generated wrappers validate the Python annotation after Arrow/JSON decode.
646649
return decoded as T;
647650
}
648651

src/runtime/errors.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,27 @@ export class BridgeProtocolError extends BridgeError {}
1414
export class BridgeTimeoutError extends BridgeError {}
1515
export class BridgeDisposedError extends BridgeError {}
1616

17+
/**
18+
* A decoded value did not match the return annotation emitted into a wrapper.
19+
*
20+
* This is deliberately separate from protocol/codec errors: the wire response
21+
* was sound, but the Python implementation violated its declared contract.
22+
*/
23+
export class BridgeValidationError extends BridgeError {
24+
readonly declaredType: string;
25+
readonly receivedShape: string;
26+
readonly callSite: string;
27+
28+
constructor(options: { declaredType: string; receivedShape: string; callSite: string }) {
29+
super(
30+
`Return validation failed for ${options.callSite}: expected ${options.declaredType}, received ${options.receivedShape}`
31+
);
32+
this.declaredType = options.declaredType;
33+
this.receivedShape = options.receivedShape;
34+
this.callSite = options.callSite;
35+
}
36+
}
37+
1738
export class BridgeCodecError extends BridgeError {
1839
codecPhase?: string;
1940
valueType?: string;

src/runtime/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ import type { RuntimeExecution } from '../types/index.js';
1111

1212
// BridgeCodec — validation and serialization for the JS<->Python boundary
1313
export { BridgeCodec, type CodecOptions } from './bridge-codec.js';
14+
export {
15+
createReturnValidator,
16+
describeReceivedShape,
17+
type ReturnSchema,
18+
type ReturnValidator,
19+
} from './validators.js';
1420

1521
// Transport contract — abstract I/O channel interface and guards
1622
export type {

0 commit comments

Comments
 (0)