Skip to content

Commit 4c313d5

Browse files
authored
Merge pull request #300 from bbopen/feat/0.9-267-unknown-returns
feat(generator)!: emit unknown for unresolvable return types; honest bytes/set mappings
2 parents 360f2e7 + 10a597f commit 4c313d5

17 files changed

Lines changed: 399 additions & 129 deletions

src/config/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ const VALID_OUTPUT_FORMATS = ['esm', 'cjs', 'both'];
129129
const VALID_COMPRESSION = ['auto', 'gzip', 'brotli', 'none'];
130130
const VALID_TYPE_HINTS = ['strict', 'loose', 'ignore'];
131131
const VALID_TYPE_PRESETS = new Set([
132+
// Accepted as an explicit no-op in 0.9; ndarray shape typing depends on #268.
132133
'numpy',
133134
'pandas',
134135
'pydantic',

src/core/annotation-parser.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -407,8 +407,7 @@ export function parseAnnotationToPythonType(
407407
const builtInClassMatch = raw.match(/^<class ['"][^'"]+['"]>$/);
408408
if (builtInClassMatch) {
409409
const inner = (raw.match(/^<class ['"]([^'"]+)['"]>$/) ?? [])[1] ?? '';
410-
const name = (inner.split('.').pop() ?? '').toString();
411-
return mapSimpleName(name);
410+
return mapSimpleName(inner);
412411
}
413412

414413
if (raw.includes('|')) {
@@ -506,7 +505,11 @@ export function parseAnnotationToPythonType(
506505
};
507506
}
508507

509-
return mapSimpleName(raw);
508+
const qualified = splitQualifiedName(raw);
509+
if (qualified.module) {
510+
return { kind: 'custom', name: qualified.name, module: qualified.module };
511+
}
512+
return mapSimpleName(qualified.name);
510513
};
511514

512515
return parse(annotation, 0);

src/core/generator.ts

Lines changed: 108 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,30 @@ interface GenericRenderParam {
2929

3030
interface GenericRenderContext {
3131
currentModule?: string;
32+
localDeclaredNames: Set<string>;
3233
declaration: string;
3334
typeArguments: string;
3435
emittedNames: Set<string>;
3536
emittedParamSpecs: Set<string>;
3637
}
3738

39+
export interface CodeGeneratorOptions {
40+
/** Reports a generated annotation that cannot be represented by emitted declarations. */
41+
onTypeDegrade?: (typeName: string) => void;
42+
}
43+
3844
export class CodeGenerator {
3945
private readonly mapper: TypeMapper;
46+
private readonly onTypeDegrade?: (typeName: string) => void;
47+
private readonly builtinGenericNames = new Set([
48+
'Array',
49+
'AsyncIterator',
50+
'Generator',
51+
'Iterable',
52+
'Iterator',
53+
'Promise',
54+
'Record',
55+
]);
4056
private readonly reservedTsIdentifiers = new Set([
4157
'default',
4258
'delete',
@@ -66,8 +82,9 @@ export class CodeGenerator {
6682
'false',
6783
]);
6884

69-
constructor(mapper: TypeMapper = new TypeMapper()) {
85+
constructor(mapper: TypeMapper = new TypeMapper(), options: CodeGeneratorOptions = {}) {
7086
this.mapper = mapper;
87+
this.onTypeDegrade = options.onTypeDegrade;
7188
}
7289

7390
/**
@@ -140,7 +157,8 @@ export class CodeGenerator {
140157
private buildGenericRenderContext(
141158
typeParameters: readonly PythonGenericParameter[],
142159
types: readonly PythonType[],
143-
currentModule?: string
160+
currentModule?: string,
161+
localDeclaredNames: Set<string> = new Set()
144162
): GenericRenderContext {
145163
const callableParamSpecs = new Set<string>();
146164
types.forEach(type => this.collectCallableParamSpecs(type, callableParamSpecs));
@@ -170,6 +188,7 @@ export class CodeGenerator {
170188

171189
return {
172190
currentModule,
191+
localDeclaredNames,
173192
declaration:
174193
emitted.length > 0 ? `<${emitted.map(param => param.declaration).join(', ')}>` : '',
175194
typeArguments: emitted.length > 0 ? `<${emitted.map(param => param.name).join(', ')}>` : '',
@@ -184,6 +203,7 @@ export class CodeGenerator {
184203
): GenericRenderContext {
185204
return {
186205
currentModule: inner.currentModule ?? outer.currentModule,
206+
localDeclaredNames: inner.localDeclaredNames,
187207
declaration: inner.declaration,
188208
typeArguments: inner.typeArguments,
189209
emittedNames: new Set([...outer.emittedNames, ...inner.emittedNames]),
@@ -308,7 +328,27 @@ export class CodeGenerator {
308328
ctx: GenericRenderContext,
309329
mappingContext: 'value' | 'return'
310330
): string {
311-
return this.typeToTs(this.mapper.mapPythonType(this.sanitizeType(type, ctx), mappingContext));
331+
return this.typeToTs(
332+
this.mapper.mapPythonType(this.sanitizeType(type, ctx), mappingContext),
333+
ctx,
334+
mappingContext
335+
);
336+
}
337+
338+
private isLocalTypeIdentity(
339+
type: { name: string; module?: string },
340+
ctx: GenericRenderContext
341+
): boolean {
342+
if (!ctx.localDeclaredNames.has(type.name)) {
343+
return false;
344+
}
345+
return type.module === undefined || type.module === ctx.currentModule;
346+
}
347+
348+
private degradeType(type: { name: string; module?: string }): string {
349+
const identity = type.module ? `${type.module}.${type.name}` : type.name;
350+
this.onTypeDegrade?.(identity);
351+
return 'unknown';
312352
}
313353

314354
private renderLooksLikeKwargsExpr(
@@ -360,7 +400,8 @@ export class CodeGenerator {
360400
generateFunctionWrapper(
361401
func: PythonFunction,
362402
moduleName?: string,
363-
annotatedJSDoc = false
403+
annotatedJSDoc = false,
404+
localDeclaredNames: Set<string> = new Set()
364405
): GeneratedCode {
365406
const jsdoc = this.generateJsDoc(
366407
func.docstring,
@@ -379,7 +420,8 @@ export class CodeGenerator {
379420
const genericContext = this.buildGenericRenderContext(
380421
this.getTypeParameters(func.typeParameters),
381422
[func.returnType, ...filteredParams.map(param => param.type)],
382-
moduleName
423+
moduleName,
424+
localDeclaredNames
383425
);
384426
const typeParamDecl = genericContext.declaration;
385427

@@ -553,8 +595,11 @@ ${callPrelude}${guards} return getRuntimeBridge().call('${moduleId}', '${func.n
553595
generateClassWrapper(
554596
cls: PythonClass,
555597
moduleName?: string,
556-
_annotatedJSDoc = false
598+
_annotatedJSDoc = false,
599+
localDeclaredNames: Set<string> = new Set()
557600
): GeneratedCode {
601+
const moduleDeclaredNames = new Set(localDeclaredNames);
602+
moduleDeclaredNames.add(cls.name);
558603
const jsdoc = this.generateJsDoc(cls.docstring);
559604
const classGenericContext = this.buildGenericRenderContext(
560605
this.getTypeParameters(cls.typeParameters),
@@ -566,7 +611,8 @@ ${callPrelude}${guards} return getRuntimeBridge().call('${moduleId}', '${func.n
566611
...method.parameters.map(p => p.type),
567612
]),
568613
],
569-
moduleName
614+
moduleName,
615+
moduleDeclaredNames
570616
);
571617
const classTypeParamDecl = classGenericContext.declaration;
572618
const cname = this.escapeIdentifier(cls.name);
@@ -617,7 +663,8 @@ ${callPrelude}${guards} return getRuntimeBridge().call('${moduleId}', '${func.n
617663
const methodOwnGenericContext = this.buildGenericRenderContext(
618664
this.getTypeParameters(m.typeParameters),
619665
[m.returnType, ...fparams.map(param => param.type)],
620-
moduleName
666+
moduleName,
667+
moduleDeclaredNames
621668
);
622669
const methodGenericContext = this.mergeGenericRenderContexts(
623670
classGenericContext,
@@ -668,7 +715,8 @@ ${callPrelude}${guards} return getRuntimeBridge().call('${moduleId}', '${func.n
668715
const methodOwnGenericContext = this.buildGenericRenderContext(
669716
this.getTypeParameters(method.typeParameters),
670717
[method.returnType, ...fparams.map(param => param.type)],
671-
moduleName
718+
moduleName,
719+
moduleDeclaredNames
672720
);
673721
const methodGenericContext = this.mergeGenericRenderContexts(
674722
classGenericContext,
@@ -816,11 +864,16 @@ ${migrationNote}${declarationMethodsSection}
816864
return this.wrap(ts, declaration, [cls.name]);
817865
}
818866

819-
generateTypeAlias(alias: PythonTypeAlias, moduleName?: string): GeneratedCode {
867+
generateTypeAlias(
868+
alias: PythonTypeAlias,
869+
moduleName?: string,
870+
localDeclaredNames: Set<string> = new Set()
871+
): GeneratedCode {
820872
const genericContext = this.buildGenericRenderContext(
821873
this.getTypeParameters(alias.typeParameters),
822874
[alias.type],
823-
moduleName
875+
moduleName,
876+
localDeclaredNames
824877
);
825878
const aliasName = this.escapeIdentifier(alias.name, { preserveCase: true });
826879
const body = this.typeToTsFromPython(alias.type, genericContext, 'value');
@@ -854,15 +907,19 @@ ${migrationNote}${declarationMethodsSection}
854907
}
855908

856909
generateModuleDefinition(module: PythonModule, annotatedJSDoc = false): GeneratedCode {
910+
const localDeclaredNames = new Set([
911+
...module.classes.map(cls => cls.name),
912+
...(module.typeAliases ?? []).map(alias => alias.name),
913+
]);
857914
const functionResults = [...module.functions]
858915
.sort((a, b) => a.name.localeCompare(b.name))
859-
.map(f => this.generateFunctionWrapper(f, module.name, annotatedJSDoc));
916+
.map(f => this.generateFunctionWrapper(f, module.name, annotatedJSDoc, localDeclaredNames));
860917
const classResults = [...module.classes]
861918
.sort((a, b) => a.name.localeCompare(b.name))
862-
.map(c => this.generateClassWrapper(c, module.name, annotatedJSDoc));
919+
.map(c => this.generateClassWrapper(c, module.name, annotatedJSDoc, localDeclaredNames));
863920
const typeAliasResults = [...(module.typeAliases ?? [])]
864921
.sort((a, b) => a.name.localeCompare(b.name))
865-
.map(alias => this.generateTypeAlias(alias, module.name));
922+
.map(alias => this.generateTypeAlias(alias, module.name, localDeclaredNames));
866923

867924
const functionCodes = functionResults.map(result => result.typescript).join('\n');
868925
const classCodes = classResults.map(result => result.typescript).join('\n');
@@ -917,23 +974,27 @@ ${migrationNote}${declarationMethodsSection}
917974
};
918975
}
919976

920-
private typeToTs(type: TypescriptType): string {
977+
private typeToTs(
978+
type: TypescriptType,
979+
ctx?: GenericRenderContext,
980+
mappingContext: 'value' | 'return' = 'value'
981+
): string {
921982
switch (type.kind) {
922983
case 'primitive':
923984
return type.name;
924985
case 'array':
925-
return `${this.typeToTs(type.elementType)}[]`;
986+
return `${this.typeToTs(type.elementType, ctx, mappingContext)}[]`;
926987
case 'tuple': {
927988
const t = type as { kind: 'tuple'; elementTypes: TypescriptType[] };
928-
const parts = t.elementTypes.map(e => this.typeToTs(e)).join(', ');
989+
const parts = t.elementTypes.map(e => this.typeToTs(e, ctx, mappingContext)).join(', ');
929990
return `[${parts}]`;
930991
}
931992
case 'object': {
932993
const t = type;
933994
// If it's a simple Record<string, T> pattern, use that syntax
934995
if (t.properties.length === 0 && t.indexSignature) {
935-
const keyType = this.typeToTs(t.indexSignature.keyType);
936-
const valueType = this.typeToTs(t.indexSignature.valueType);
996+
const keyType = this.typeToTs(t.indexSignature.keyType, ctx, mappingContext);
997+
const valueType = this.typeToTs(t.indexSignature.valueType, ctx, mappingContext);
937998
if (keyType === 'string') {
938999
return `Record<string, ${valueType}>`;
9391000
}
@@ -942,34 +1003,53 @@ ${migrationNote}${declarationMethodsSection}
9421003
const props = t.properties
9431004
.map(
9441005
p =>
945-
`${p.readonly ? 'readonly ' : ''}${p.name}${p.optional ? '?' : ''}: ${this.typeToTs(p.type)};`
1006+
`${p.readonly ? 'readonly ' : ''}${p.name}${p.optional ? '?' : ''}: ${this.typeToTs(p.type, ctx, mappingContext)};`
9461007
)
9471008
.join(' ');
9481009
const indexSig = t.indexSignature
949-
? `[key: ${this.typeToTs(t.indexSignature.keyType)}]: ${this.typeToTs(t.indexSignature.valueType)};`
1010+
? `[key: ${this.typeToTs(t.indexSignature.keyType, ctx, mappingContext)}]: ${this.typeToTs(t.indexSignature.valueType, ctx, mappingContext)};`
9501011
: '';
9511012
return `{ ${props} ${indexSig} }`;
9521013
}
9531014
case 'union':
954-
return type.types.map(t => this.typeToTs(t)).join(' | ');
1015+
return type.types.map(t => this.typeToTs(t, ctx, mappingContext)).join(' | ');
9551016
case 'function': {
9561017
const ft = type;
9571018
const params = ft.parameters
9581019
.map(
959-
p => `${p.rest ? '...' : ''}${p.name}${p.optional ? '?' : ''}: ${this.typeToTs(p.type)}`
1020+
p =>
1021+
`${p.rest ? '...' : ''}${p.name}${p.optional ? '?' : ''}: ${this.typeToTs(p.type, ctx, mappingContext)}`
9601022
)
9611023
.join(', ');
962-
return `(${params}) => ${this.typeToTs(ft.returnType)}`;
1024+
return `(${params}) => ${this.typeToTs(ft.returnType, ctx, mappingContext)}`;
9631025
}
9641026
case 'generic': {
965-
const g = type as { kind: 'generic'; name: string; typeArgs: TypescriptType[] };
966-
const args = g.typeArgs.map(a => this.typeToTs(a)).join(', ');
1027+
const g = type as {
1028+
kind: 'generic';
1029+
name: string;
1030+
module?: string;
1031+
typeArgs: TypescriptType[];
1032+
};
1033+
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(g.name)) {
1034+
return this.degradeType(g);
1035+
}
1036+
if (ctx && !this.builtinGenericNames.has(g.name) && !this.isLocalTypeIdentity(g, ctx)) {
1037+
return this.degradeType(g);
1038+
}
1039+
const args = g.typeArgs.map(a => this.typeToTs(a, ctx, mappingContext)).join(', ');
9671040
return `${g.name}<${args}>`;
9681041
}
9691042
case 'custom': {
970-
const c = type as { kind: 'custom'; name: string };
1043+
const c = type as { kind: 'custom'; name: string; module?: string };
9711044
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(c.name)) {
972-
return 'unknown';
1045+
return this.degradeType(c);
1046+
}
1047+
if (
1048+
ctx &&
1049+
!this.isLocalTypeIdentity(c, ctx) &&
1050+
!(c.module === 'typing' && ctx.emittedNames.has(c.name))
1051+
) {
1052+
return this.degradeType(c);
9731053
}
9741054
return c.name;
9751055
}

src/core/mapper.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export class TypeMapper {
9494
: type.name === 'bool'
9595
? 'boolean'
9696
: type.name === 'bytes'
97-
? 'string'
97+
? 'Uint8Array'
9898
: // None
9999
context === 'return'
100100
? 'void'
@@ -137,16 +137,15 @@ export class TypeMapper {
137137
return { kind: 'tuple', elementTypes } satisfies TSTupleType;
138138
}
139139

140-
// set[T] -> Set<T>
140+
// Python sets cross the JSON bridge as arrays in both directions.
141141
if (type.name === 'set' || type.name === 'frozenset') {
142142
const elementType = this.mapPythonType(
143143
type.itemTypes[0] ?? { kind: 'custom', name: 'Any', module: 'typing' }
144144
);
145145
return {
146-
kind: 'generic',
147-
name: 'Set',
148-
typeArgs: [elementType],
149-
} satisfies TSGenericType;
146+
kind: 'array',
147+
elementType,
148+
} satisfies TSArrayType;
150149
}
151150

152151
// dict[K, V] -> { [key: K]: V }
@@ -204,6 +203,7 @@ export class TypeMapper {
204203
return {
205204
kind: 'generic',
206205
name: normalized.name,
206+
module: normalized.module,
207207
typeArgs,
208208
};
209209
}

src/types/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,8 @@ export interface TSPrimitiveType {
261261
| 'void'
262262
| 'unknown'
263263
| 'never'
264-
| 'object';
264+
| 'object'
265+
| 'Uint8Array';
265266
}
266267

267268
export interface TSArrayType {
@@ -314,6 +315,7 @@ export interface TSParameter {
314315
export interface TSGenericType {
315316
kind: 'generic';
316317
name: string;
318+
module?: string;
317319
typeArgs: TypescriptType[];
318320
}
319321

@@ -404,6 +406,8 @@ export interface PerformanceConfig {
404406
compression: 'auto' | 'gzip' | 'brotli' | 'none';
405407
}
406408

409+
// `numpy` is intentionally accepted as a no-op in 0.9: decoded ndarrays are JS
410+
// arrays, and a faithful static shape awaits #268 return validation.
407411
export type TypePreset = 'numpy' | 'pandas' | 'pydantic' | 'stdlib' | 'scipy' | 'torch' | 'sklearn';
408412

409413
export interface TypeMappingConfig {

0 commit comments

Comments
 (0)