Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@
const VALID_COMPRESSION = ['auto', 'gzip', 'brotli', 'none'];
const VALID_TYPE_HINTS = ['strict', 'loose', 'ignore'];
const VALID_TYPE_PRESETS = new Set([
// Accepted as an explicit no-op in 0.9; ndarray shape typing depends on #268.
'numpy',
'pandas',
'pydantic',
Expand Down Expand Up @@ -347,7 +348,7 @@

// Dispatch by file extension. Each loader receives the resolved absolute path
// and the lowercased extension and returns the parsed config object.
const loader = CONFIG_LOADERS[ext];

Check warning on line 351 in src/config/index.ts

View workflow job for this annotation

GitHub Actions / lint

Variable Assigned to Object Injection Sink
if (!loader) {
throw new Error(`Unsupported configuration file extension: ${ext}`);
}
Expand Down
9 changes: 6 additions & 3 deletions src/core/annotation-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,8 +407,7 @@ export function parseAnnotationToPythonType(
const builtInClassMatch = raw.match(/^<class ['"][^'"]+['"]>$/);
if (builtInClassMatch) {
const inner = (raw.match(/^<class ['"]([^'"]+)['"]>$/) ?? [])[1] ?? '';
const name = (inner.split('.').pop() ?? '').toString();
return mapSimpleName(name);
return mapSimpleName(inner);
}

if (raw.includes('|')) {
Expand Down Expand Up @@ -506,7 +505,11 @@ export function parseAnnotationToPythonType(
};
}

return mapSimpleName(raw);
const qualified = splitQualifiedName(raw);
if (qualified.module) {
return { kind: 'custom', name: qualified.name, module: qualified.module };
}
return mapSimpleName(qualified.name);
};

return parse(annotation, 0);
Expand Down
136 changes: 108 additions & 28 deletions src/core/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,30 @@

interface GenericRenderContext {
currentModule?: string;
localDeclaredNames: Set<string>;
declaration: string;
typeArguments: string;
emittedNames: Set<string>;
emittedParamSpecs: Set<string>;
}

export interface CodeGeneratorOptions {
/** Reports a generated annotation that cannot be represented by emitted declarations. */
onTypeDegrade?: (typeName: string) => void;
}

export class CodeGenerator {
private readonly mapper: TypeMapper;
private readonly onTypeDegrade?: (typeName: string) => void;
private readonly builtinGenericNames = new Set([
'Array',
'AsyncIterator',
'Generator',
'Iterable',
'Iterator',
'Promise',
'Record',
]);
private readonly reservedTsIdentifiers = new Set([
'default',
'delete',
Expand Down Expand Up @@ -66,8 +82,9 @@
'false',
]);

constructor(mapper: TypeMapper = new TypeMapper()) {
constructor(mapper: TypeMapper = new TypeMapper(), options: CodeGeneratorOptions = {}) {
this.mapper = mapper;
this.onTypeDegrade = options.onTypeDegrade;
}

/**
Expand Down Expand Up @@ -140,7 +157,8 @@
private buildGenericRenderContext(
typeParameters: readonly PythonGenericParameter[],
types: readonly PythonType[],
currentModule?: string
currentModule?: string,
localDeclaredNames: Set<string> = new Set()
): GenericRenderContext {
const callableParamSpecs = new Set<string>();
types.forEach(type => this.collectCallableParamSpecs(type, callableParamSpecs));
Expand Down Expand Up @@ -170,6 +188,7 @@

return {
currentModule,
localDeclaredNames,
declaration:
emitted.length > 0 ? `<${emitted.map(param => param.declaration).join(', ')}>` : '',
typeArguments: emitted.length > 0 ? `<${emitted.map(param => param.name).join(', ')}>` : '',
Expand All @@ -184,6 +203,7 @@
): GenericRenderContext {
return {
currentModule: inner.currentModule ?? outer.currentModule,
localDeclaredNames: inner.localDeclaredNames,
declaration: inner.declaration,
typeArguments: inner.typeArguments,
emittedNames: new Set([...outer.emittedNames, ...inner.emittedNames]),
Expand Down Expand Up @@ -308,7 +328,27 @@
ctx: GenericRenderContext,
mappingContext: 'value' | 'return'
): string {
return this.typeToTs(this.mapper.mapPythonType(this.sanitizeType(type, ctx), mappingContext));
return this.typeToTs(
this.mapper.mapPythonType(this.sanitizeType(type, ctx), mappingContext),
ctx,
mappingContext
);
}

private isLocalTypeIdentity(
type: { name: string; module?: string },
ctx: GenericRenderContext
): boolean {
if (!ctx.localDeclaredNames.has(type.name)) {
return false;
}
return type.module === undefined || type.module === ctx.currentModule;
}

private degradeType(type: { name: string; module?: string }): string {
const identity = type.module ? `${type.module}.${type.name}` : type.name;
this.onTypeDegrade?.(identity);
return 'unknown';
}

private renderLooksLikeKwargsExpr(
Expand All @@ -321,7 +361,7 @@
): string {
const base = `typeof ${valueExpr} === 'object' && ${valueExpr} !== null && !globalThis.Array.isArray(${valueExpr}) && (Object.getPrototypeOf(${valueExpr}) === Object.prototype || Object.getPrototypeOf(${valueExpr}) === null)`;

const keyCheck = (() => {

Check warning on line 364 in src/core/generator.ts

View workflow job for this annotation

GitHub Actions / lint

Missing return type on function
if (options.requiredKwOnlyNames.length > 0) {
return options.requiredKwOnlyNames
.map(k => `Object.prototype.hasOwnProperty.call(${valueExpr}, ${JSON.stringify(k)})`)
Expand Down Expand Up @@ -360,7 +400,8 @@
generateFunctionWrapper(
func: PythonFunction,
moduleName?: string,
annotatedJSDoc = false
annotatedJSDoc = false,
localDeclaredNames: Set<string> = new Set()
): GeneratedCode {
const jsdoc = this.generateJsDoc(
func.docstring,
Expand All @@ -379,14 +420,15 @@
const genericContext = this.buildGenericRenderContext(
this.getTypeParameters(func.typeParameters),
[func.returnType, ...filteredParams.map(param => param.type)],
moduleName
moduleName,
localDeclaredNames
);
const typeParamDecl = genericContext.declaration;

const tsTypeForValue = (p: (typeof filteredParams)[number]): string =>
this.typeToTsFromPython(p.type, genericContext, 'value');

const kwargsType = (() => {

Check warning on line 431 in src/core/generator.ts

View workflow job for this annotation

GitHub Actions / lint

Missing return type on function
if (!needsKwargsParam) {
return '';
}
Expand Down Expand Up @@ -465,7 +507,7 @@
for (let i = requiredPosCount; i <= positionalParams.length; i++) {
const head = positionalParams.slice(0, i).map(p => renderPositionalParam(p, true));
const rest: string[] = [];
const v = (() => {

Check warning on line 510 in src/core/generator.ts

View workflow job for this annotation

GitHub Actions / lint

Missing return type on function
if (!varArgsParam) {
return null;
}
Expand Down Expand Up @@ -553,8 +595,11 @@
generateClassWrapper(
cls: PythonClass,
moduleName?: string,
_annotatedJSDoc = false
_annotatedJSDoc = false,
localDeclaredNames: Set<string> = new Set()
): GeneratedCode {
const moduleDeclaredNames = new Set(localDeclaredNames);
moduleDeclaredNames.add(cls.name);
const jsdoc = this.generateJsDoc(cls.docstring);
const classGenericContext = this.buildGenericRenderContext(
this.getTypeParameters(cls.typeParameters),
Expand All @@ -566,7 +611,8 @@
...method.parameters.map(p => p.type),
]),
],
moduleName
moduleName,
moduleDeclaredNames
);
const classTypeParamDecl = classGenericContext.declaration;
const cname = this.escapeIdentifier(cls.name);
Expand Down Expand Up @@ -617,7 +663,8 @@
const methodOwnGenericContext = this.buildGenericRenderContext(
this.getTypeParameters(m.typeParameters),
[m.returnType, ...fparams.map(param => param.type)],
moduleName
moduleName,
moduleDeclaredNames
);
const methodGenericContext = this.mergeGenericRenderContexts(
classGenericContext,
Expand Down Expand Up @@ -668,7 +715,8 @@
const methodOwnGenericContext = this.buildGenericRenderContext(
this.getTypeParameters(method.typeParameters),
[method.returnType, ...fparams.map(param => param.type)],
moduleName
moduleName,
moduleDeclaredNames
);
const methodGenericContext = this.mergeGenericRenderContexts(
classGenericContext,
Expand Down Expand Up @@ -698,7 +746,7 @@
return `${pname}${opt}: ${methodTsValueType(p)}`;
};

const kwargsType = (() => {

Check warning on line 749 in src/core/generator.ts

View workflow job for this annotation

GitHub Actions / lint

Missing return type on function
if (!needsKwargsParam) {
return '';
}
Expand Down Expand Up @@ -738,7 +786,7 @@
const overloads: string[] = [];
if (needsKwargsParam && requiredKwOnlyNames.length > 0) {
const firstOptionalIndex = positionalParams.findIndex(p => p.optional);
const requiredPosCount =

Check warning on line 789 in src/core/generator.ts

View workflow job for this annotation

GitHub Actions / lint

'requiredPosCount' is already declared in the upper scope on line 736 column 15
firstOptionalIndex >= 0 ? firstOptionalIndex : positionalParams.length;
for (let i = requiredPosCount; i <= positionalParams.length; i++) {
const head = positionalParams.slice(0, i).map(p => renderPositionalParam(p, true));
Expand Down Expand Up @@ -816,11 +864,16 @@
return this.wrap(ts, declaration, [cls.name]);
}

generateTypeAlias(alias: PythonTypeAlias, moduleName?: string): GeneratedCode {
generateTypeAlias(
alias: PythonTypeAlias,
moduleName?: string,
localDeclaredNames: Set<string> = new Set()
): GeneratedCode {
const genericContext = this.buildGenericRenderContext(
this.getTypeParameters(alias.typeParameters),
[alias.type],
moduleName
moduleName,
localDeclaredNames
);
const aliasName = this.escapeIdentifier(alias.name, { preserveCase: true });
const body = this.typeToTsFromPython(alias.type, genericContext, 'value');
Expand Down Expand Up @@ -854,15 +907,19 @@
}

generateModuleDefinition(module: PythonModule, annotatedJSDoc = false): GeneratedCode {
const localDeclaredNames = new Set([
...module.classes.map(cls => cls.name),
...(module.typeAliases ?? []).map(alias => alias.name),
]);
const functionResults = [...module.functions]
.sort((a, b) => a.name.localeCompare(b.name))
.map(f => this.generateFunctionWrapper(f, module.name, annotatedJSDoc));
.map(f => this.generateFunctionWrapper(f, module.name, annotatedJSDoc, localDeclaredNames));
const classResults = [...module.classes]
.sort((a, b) => a.name.localeCompare(b.name))
.map(c => this.generateClassWrapper(c, module.name, annotatedJSDoc));
.map(c => this.generateClassWrapper(c, module.name, annotatedJSDoc, localDeclaredNames));
const typeAliasResults = [...(module.typeAliases ?? [])]
.sort((a, b) => a.name.localeCompare(b.name))
.map(alias => this.generateTypeAlias(alias, module.name));
.map(alias => this.generateTypeAlias(alias, module.name, localDeclaredNames));

const functionCodes = functionResults.map(result => result.typescript).join('\n');
const classCodes = classResults.map(result => result.typescript).join('\n');
Expand Down Expand Up @@ -917,23 +974,27 @@
};
}

private typeToTs(type: TypescriptType): string {
private typeToTs(
type: TypescriptType,
ctx?: GenericRenderContext,
mappingContext: 'value' | 'return' = 'value'
): string {
switch (type.kind) {
case 'primitive':
return type.name;
case 'array':
return `${this.typeToTs(type.elementType)}[]`;
return `${this.typeToTs(type.elementType, ctx, mappingContext)}[]`;
case 'tuple': {
const t = type as { kind: 'tuple'; elementTypes: TypescriptType[] };
const parts = t.elementTypes.map(e => this.typeToTs(e)).join(', ');
const parts = t.elementTypes.map(e => this.typeToTs(e, ctx, mappingContext)).join(', ');
return `[${parts}]`;
}
case 'object': {
const t = type;
// If it's a simple Record<string, T> pattern, use that syntax
if (t.properties.length === 0 && t.indexSignature) {
const keyType = this.typeToTs(t.indexSignature.keyType);
const valueType = this.typeToTs(t.indexSignature.valueType);
const keyType = this.typeToTs(t.indexSignature.keyType, ctx, mappingContext);
const valueType = this.typeToTs(t.indexSignature.valueType, ctx, mappingContext);
if (keyType === 'string') {
return `Record<string, ${valueType}>`;
}
Expand All @@ -942,34 +1003,53 @@
const props = t.properties
.map(
p =>
`${p.readonly ? 'readonly ' : ''}${p.name}${p.optional ? '?' : ''}: ${this.typeToTs(p.type)};`
`${p.readonly ? 'readonly ' : ''}${p.name}${p.optional ? '?' : ''}: ${this.typeToTs(p.type, ctx, mappingContext)};`
)
.join(' ');
const indexSig = t.indexSignature
? `[key: ${this.typeToTs(t.indexSignature.keyType)}]: ${this.typeToTs(t.indexSignature.valueType)};`
? `[key: ${this.typeToTs(t.indexSignature.keyType, ctx, mappingContext)}]: ${this.typeToTs(t.indexSignature.valueType, ctx, mappingContext)};`
: '';
return `{ ${props} ${indexSig} }`;
}
case 'union':
return type.types.map(t => this.typeToTs(t)).join(' | ');
return type.types.map(t => this.typeToTs(t, ctx, mappingContext)).join(' | ');
case 'function': {
const ft = type;
const params = ft.parameters
.map(
p => `${p.rest ? '...' : ''}${p.name}${p.optional ? '?' : ''}: ${this.typeToTs(p.type)}`
p =>
`${p.rest ? '...' : ''}${p.name}${p.optional ? '?' : ''}: ${this.typeToTs(p.type, ctx, mappingContext)}`
)
.join(', ');
return `(${params}) => ${this.typeToTs(ft.returnType)}`;
return `(${params}) => ${this.typeToTs(ft.returnType, ctx, mappingContext)}`;
}
case 'generic': {
const g = type as { kind: 'generic'; name: string; typeArgs: TypescriptType[] };
const args = g.typeArgs.map(a => this.typeToTs(a)).join(', ');
const g = type as {
kind: 'generic';
name: string;
module?: string;
typeArgs: TypescriptType[];
};
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(g.name)) {
return this.degradeType(g);
}
if (ctx && !this.builtinGenericNames.has(g.name) && !this.isLocalTypeIdentity(g, ctx)) {
return this.degradeType(g);
}
const args = g.typeArgs.map(a => this.typeToTs(a, ctx, mappingContext)).join(', ');
return `${g.name}<${args}>`;
}
case 'custom': {
const c = type as { kind: 'custom'; name: string };
const c = type as { kind: 'custom'; name: string; module?: string };
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(c.name)) {
return 'unknown';
return this.degradeType(c);
}
if (
ctx &&
!this.isLocalTypeIdentity(c, ctx) &&
!(c.module === 'typing' && ctx.emittedNames.has(c.name))
) {
return this.degradeType(c);
}
return c.name;
}
Expand Down
12 changes: 6 additions & 6 deletions src/core/mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
TypeVarType as PyTypeVarType,
ParamSpecType as PyParamSpecType,
ParamSpecArgsType as PyParamSpecArgsType,
ParamSpecKwargsType as PyParamSpecKwargsType,

Check warning on line 16 in src/core/mapper.ts

View workflow job for this annotation

GitHub Actions / lint

'PyParamSpecKwargsType' is defined but never used
TypeVarTupleType as PyTypeVarTupleType,
UnpackType as PyUnpackType,
FinalType as PyFinalType,
Expand Down Expand Up @@ -94,7 +94,7 @@
: type.name === 'bool'
? 'boolean'
: type.name === 'bytes'
? 'string'
? 'Uint8Array'
: // None
context === 'return'
? 'void'
Expand Down Expand Up @@ -137,16 +137,15 @@
return { kind: 'tuple', elementTypes } satisfies TSTupleType;
}

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

// dict[K, V] -> { [key: K]: V }
Expand Down Expand Up @@ -204,6 +203,7 @@
return {
kind: 'generic',
name: normalized.name,
module: normalized.module,
typeArgs,
};
}
Expand Down Expand Up @@ -529,7 +529,7 @@
preset: 'stdlib',
allowModule: m =>
!m || m === 'datetime' || m === 'decimal' || m === 'uuid' || m === 'pathlib',
resolve: n => {

Check warning on line 532 in src/core/mapper.ts

View workflow job for this annotation

GitHub Actions / lint

Missing return type on function
if (n === 'datetime' || n === 'date' || n === 'time') {
return { kind: 'primitive', name: 'string' };
}
Expand All @@ -548,7 +548,7 @@
{
preset: 'pandas',
allowModule: m => !m || m.startsWith('pandas'),
resolve: n => {

Check warning on line 551 in src/core/mapper.ts

View workflow job for this annotation

GitHub Actions / lint

Missing return type on function
if (n === 'DataFrame') {
const recordsArray: TSArrayType = { kind: 'array', elementType: recordObject };
return { kind: 'union', types: [recordObject, recordsArray] };
Expand All @@ -571,7 +571,7 @@
{
preset: 'scipy',
allowModule: m => !m || m.startsWith('scipy'),
resolve: n => {

Check warning on line 574 in src/core/mapper.ts

View workflow job for this annotation

GitHub Actions / lint

Missing return type on function
if (n === 'csr_matrix') {
return buildSparse('csr');
}
Expand Down
6 changes: 5 additions & 1 deletion src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,8 @@ export interface TSPrimitiveType {
| 'void'
| 'unknown'
| 'never'
| 'object';
| 'object'
| 'Uint8Array';
}

export interface TSArrayType {
Expand Down Expand Up @@ -314,6 +315,7 @@ export interface TSParameter {
export interface TSGenericType {
kind: 'generic';
name: string;
module?: string;
typeArgs: TypescriptType[];
}

Expand Down Expand Up @@ -404,6 +406,8 @@ export interface PerformanceConfig {
compression: 'auto' | 'gzip' | 'brotli' | 'none';
}

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

export interface TypeMappingConfig {
Expand Down
Loading
Loading