diff --git a/.changeset/ready-boxes-lie.md b/.changeset/ready-boxes-lie.md new file mode 100644 index 0000000000..8f4c8aec0e --- /dev/null +++ b/.changeset/ready-boxes-lie.md @@ -0,0 +1,5 @@ +--- +"@hey-api/openapi-ts": patch +--- + +**output**: fix: avoid double sanitizing leading character diff --git a/.changeset/thick-cases-wonder.md b/.changeset/thick-cases-wonder.md new file mode 100644 index 0000000000..b050470661 --- /dev/null +++ b/.changeset/thick-cases-wonder.md @@ -0,0 +1,6 @@ +--- +"@hey-api/codegen-core": patch +"@hey-api/openapi-ts": patch +--- + +**internal**: log symbol meta if name is falsy diff --git a/packages/codegen-core/src/symbols/symbol.ts b/packages/codegen-core/src/symbols/symbol.ts index a7779a1557..d01142e323 100644 --- a/packages/codegen-core/src/symbols/symbol.ts +++ b/packages/codegen-core/src/symbols/symbol.ts @@ -305,7 +305,7 @@ export class Symbol { if (canonical._finalName && canonical._finalName !== canonical._name) { return `[Symbol ${canonical._name} → ${canonical._finalName}#${canonical.id}]`; } - return `[Symbol ${canonical._name}#${canonical.id}]`; + return `[Symbol ${canonical._name || canonical._meta !== undefined ? JSON.stringify(canonical._meta) : ''}#${canonical.id}]`; } /** diff --git a/packages/openapi-python/src/plugins/pydantic/config.ts b/packages/openapi-python/src/plugins/pydantic/config.ts index bf2668ac54..603b19db35 100644 --- a/packages/openapi-python/src/plugins/pydantic/config.ts +++ b/packages/openapi-python/src/plugins/pydantic/config.ts @@ -7,6 +7,7 @@ export const defaultConfig: PydanticPlugin['Config'] = { config: { case: 'PascalCase', comments: true, + enums: 'enum', includeInEntry: false, strict: false, }, diff --git a/packages/openapi-python/src/plugins/pydantic/shared/export.ts b/packages/openapi-python/src/plugins/pydantic/shared/export.ts index 6e8aafa678..b4fc5ed406 100644 --- a/packages/openapi-python/src/plugins/pydantic/shared/export.ts +++ b/packages/openapi-python/src/plugins/pydantic/shared/export.ts @@ -1,11 +1,12 @@ +import type { Symbol } from '@hey-api/codegen-core'; import { applyNaming, pathToName } from '@hey-api/shared'; -// import { createSchemaComment } from '../../../plugins/shared/utils/schema'; import { $ } from '../../../py-dsl'; +import type { PydanticPlugin } from '../types'; +import { identifiers } from '../v2/constants'; +import { createFieldCall } from './field'; import type { ProcessorContext } from './processor'; -// import { identifiers } from '../v2/constants'; -// import { pipesToNode } from './pipes'; -import type { PydanticFinal } from './types'; +import type { PydanticField, PydanticFinal } from './types'; export function exportAst({ final, @@ -29,29 +30,112 @@ export function exportAst({ }, }); - if (final.fields) { - const baseModel = plugin.external('pydantic.BaseModel'); - const classDef = $.class(symbol).extends(baseModel); - // .export() - // .$if(plugin.config.comments && createSchemaComment(schema), (c, v) => c.doc(v)) - // .$if(state.hasLazyExpression['~ref'], (c) => - // c.type($.type(v).attr(ast.typeName || identifiers.types.GenericSchema)), - // ) - // .assign(pipesToNode(ast.pipes, plugin)); - - for (const field of final.fields) { - // TODO: Field(...) constraints in next pass - classDef.do($.var(field.name).assign($.literal('hey'))); - // classDef.do($.var(field.name).annotate(field.typeAnnotation)); - } - - plugin.node(classDef); + if (final.enumMembers) { + exportEnumClass({ final, plugin, symbol }); + } else if (final.fields) { + exportClass({ final, plugin, symbol }); } else { - const statement = $.var(symbol) - // .export() - // .$if(plugin.config.comments && createSchemaComment(schema), (c, v) => c.doc(v)) - .assign(final.typeAnnotation); + exportTypeAlias({ final, plugin, symbol }); + } +} + +function exportClass({ + final, + plugin, + symbol, +}: { + final: PydanticFinal; + plugin: PydanticPlugin['Instance']; + symbol: Symbol; +}): void { + const baseModel = plugin.external('pydantic.BaseModel'); + const classDef = $.class(symbol).extends(baseModel); + + if (plugin.config.strict) { + const configDict = plugin.external('pydantic.ConfigDict'); + classDef.do( + $.var(identifiers.model_config).assign($(configDict).call($.kwarg('extra', 'forbid'))), + ); + } + + for (const field of final.fields!) { + const fieldStatement = createFieldStatement(field, plugin); + classDef.do(fieldStatement); + } + + plugin.node(classDef); +} + +function exportEnumClass({ + final, + plugin, + symbol, +}: { + final: PydanticFinal; + plugin: PydanticPlugin['Instance']; + symbol: Symbol; +}): void { + const members = final.enumMembers ?? []; + const hasStrings = members.some((m) => typeof m.value === 'string'); + const hasNumbers = members.some((m) => typeof m.value === 'number'); + + const enumSymbol = plugin.external('enum.Enum'); + const classDef = $.class(symbol).extends(enumSymbol); - plugin.node(statement); + if (hasStrings && !hasNumbers) { + classDef.extends('str'); + } else if (!hasStrings && hasNumbers) { + classDef.extends('int'); } + + for (const member of final.enumMembers ?? []) { + classDef.do($.var(member.name).assign($.literal(member.value))); + } + + plugin.node(classDef); +} + +function createFieldStatement( + field: PydanticField, + plugin: PydanticPlugin['Instance'], +): ReturnType { + const fieldSymbol = field.name; + const varStatement = $.var(fieldSymbol).$if(field.typeAnnotation, (v, a) => v.annotate(a)); + + const originalName = field.originalName ?? fieldSymbol.name; + const needsAlias = field.originalName !== undefined && fieldSymbol.name !== originalName; + + const constraints = { + ...field.fieldConstraints, + ...(needsAlias && !field.fieldConstraints?.alias && { alias: originalName }), + }; + + if (Object.keys(constraints).length > 0) { + const fieldCall = createFieldCall(constraints, plugin, { + required: !field.isOptional, + }); + return varStatement.assign(fieldCall); + } + + if (field.isOptional) { + return varStatement.assign('None'); + } + + return varStatement; +} + +function exportTypeAlias({ + final, + plugin, + symbol, +}: { + final: PydanticFinal; + plugin: PydanticPlugin['Instance']; + symbol: Symbol; +}): void { + const typeAlias = plugin.external('typing.TypeAlias'); + const statement = $.var(symbol) + .annotate(typeAlias) + .assign(final.typeAnnotation ?? plugin.external('typing.Any')); + plugin.node(statement); } diff --git a/packages/openapi-python/src/plugins/pydantic/shared/field.ts b/packages/openapi-python/src/plugins/pydantic/shared/field.ts new file mode 100644 index 0000000000..9183e6a066 --- /dev/null +++ b/packages/openapi-python/src/plugins/pydantic/shared/field.ts @@ -0,0 +1,70 @@ +import { $ } from '../../../py-dsl'; +import type { PydanticPlugin } from '../types'; +import type { FieldConstraints } from '../v2/constants'; + +type FieldArg = ReturnType; + +export function createFieldCall( + constraints: FieldConstraints, + plugin: PydanticPlugin['Instance'], + options?: { + /** If true, the field is required. */ + required?: boolean; + }, +): ReturnType { + const field = plugin.external('pydantic.Field'); + const args: Array = []; + + const isRequired = options?.required !== false && constraints.default === undefined; + + // For required fields with no default, use ... as first arg + if (isRequired && constraints.default === undefined) { + args.push($('...')); + } + + // TODO: move to DSL + // Add constraint arguments in a consistent order + const orderedKeys: Array = [ + 'default', + 'default_factory', + 'alias', + 'title', + 'description', + 'gt', + 'ge', + 'lt', + 'le', + 'multiple_of', + 'min_length', + 'max_length', + 'pattern', + ]; + + for (const key of orderedKeys) { + const value = constraints[key]; + if (value === undefined) continue; + + // Skip default if we already added ... for required fields + if (key === 'default' && isRequired) continue; + + args.push($.kwarg(key, toKwargValue(value))); + } + + return $(field).call(...(args as Array[1]>)); +} + +/** + * Converts a constraint value to a kwarg-compatible value. + */ +function toKwargValue(value: unknown): string | number | boolean | null { + if ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value; + } + // For complex types, stringify + return String(value); +} diff --git a/packages/openapi-python/src/plugins/pydantic/shared/meta.ts b/packages/openapi-python/src/plugins/pydantic/shared/meta.ts index 8e8cd9cf33..f103a31c56 100644 --- a/packages/openapi-python/src/plugins/pydantic/shared/meta.ts +++ b/packages/openapi-python/src/plugins/pydantic/shared/meta.ts @@ -8,8 +8,7 @@ import type { PydanticMeta, PydanticResult } from './types'; export function defaultMeta(schema: IR.SchemaObject): PydanticMeta { return { default: schema.default, - format: schema.format, - hasLazy: false, + hasForwardReference: false, nullable: false, readonly: schema.accessScope === 'read', }; @@ -18,7 +17,7 @@ export function defaultMeta(schema: IR.SchemaObject): PydanticMeta { /** * Composes metadata from child results. * - * Automatically propagates hasLazy, nullable, readonly from children. + * Automatically propagates hasForwardReference, nullable, readonly from children. * * @param children - Results from walking child schemas * @param overrides - Explicit overrides (e.g., from parent schema) @@ -29,8 +28,8 @@ export function composeMeta( ): PydanticMeta { return { default: overrides?.default, - format: overrides?.format, - hasLazy: overrides?.hasLazy ?? children.some((c) => c.meta.hasLazy), + hasForwardReference: + overrides?.hasForwardReference ?? children.some((c) => c.meta.hasForwardReference), nullable: overrides?.nullable ?? children.some((c) => c.meta.nullable), readonly: overrides?.readonly ?? children.some((c) => c.meta.readonly), }; @@ -48,7 +47,6 @@ export function inheritMeta( ): PydanticMeta { return composeMeta(children, { default: parent.default, - format: parent.format, nullable: false, readonly: parent.accessScope === 'read', }); diff --git a/packages/openapi-python/src/plugins/pydantic/shared/processor.ts b/packages/openapi-python/src/plugins/pydantic/shared/processor.ts index c1eba30cc2..4054f076ec 100644 --- a/packages/openapi-python/src/plugins/pydantic/shared/processor.ts +++ b/packages/openapi-python/src/plugins/pydantic/shared/processor.ts @@ -5,12 +5,13 @@ import type { SchemaProcessorResult, } from '@hey-api/shared'; -import type { IrSchemaToAstOptions } from './types'; +import type { PydanticPlugin } from '../types'; -export type ProcessorContext = Pick & - SchemaProcessorContext & { - naming: NamingConfig; - schema: IR.SchemaObject; - }; +export type ProcessorContext = SchemaProcessorContext & { + naming: NamingConfig; + /** The plugin instance. */ + plugin: PydanticPlugin['Instance']; + schema: IR.SchemaObject; +}; export type ProcessorResult = SchemaProcessorResult; diff --git a/packages/openapi-python/src/plugins/pydantic/shared/types.ts b/packages/openapi-python/src/plugins/pydantic/shared/types.ts index 97ac3cd34c..650289b911 100644 --- a/packages/openapi-python/src/plugins/pydantic/shared/types.ts +++ b/packages/openapi-python/src/plugins/pydantic/shared/types.ts @@ -1,84 +1,24 @@ -import type { Refs, Symbol, SymbolMeta } from '@hey-api/codegen-core'; -import type { IR, SchemaExtractor } from '@hey-api/shared'; +import type { Symbol } from '@hey-api/codegen-core'; -import type { $, MaybePyDsl } from '../../../py-dsl'; -import type { py } from '../../../ts-python'; -import type { PydanticPlugin } from '../types'; -import type { ProcessorContext } from './processor'; - -export type Ast = { - /** - * Field constraints for pydantic.Field() - */ - fieldConstraints?: Record; - /** - * Whether this AST node has a lazy expression (forward reference) - */ - hasLazyExpression?: boolean; - models: Array<{ - baseName: string; - expression: ReturnType; - symbol: Symbol; - }>; - /** - * Type annotation for the field - */ - typeAnnotation: string; - /** - * Type name for the model class - */ - typeName?: string; -}; - -export type IrSchemaToAstOptions = { - /** The plugin instance. */ - plugin: PydanticPlugin['Instance']; - /** Optional schema extractor function. */ - schemaExtractor?: SchemaExtractor; - /** The plugin state references. */ - state: Refs; -}; - -export type PluginState = Pick, 'path'> & - Pick, 'tags'> & { - hasLazyExpression: boolean; - }; +import type { AnnotationExpr } from '../../../py-dsl'; +import type { FieldConstraints } from '../v2/constants'; /** - * Pipe system for building field constraints (similar to Valibot pattern) + * Return type for toType converters. */ -export type Pipes = Array; - -/** - * Context for type resolver functions - */ -export interface ResolverContext { - /** - * Field constraints being built - */ - constraints: Record; - /** - * The plugin instance - */ - plugin: PydanticPlugin['Instance']; - /** - * IR schema being processed - */ - schema: IR.SchemaObject; +export interface PydanticType { + fieldConstraints?: FieldConstraints; + typeAnnotation?: AnnotationExpr; } -// ..... ^^^^^^ OLD - /** * Metadata that flows through schema walking. */ export interface PydanticMeta { /** Default value from schema. */ default?: unknown; - /** Original format (for BigInt coercion). */ - format?: string; - /** Whether this or any child contains a lazy reference. */ - hasLazy: boolean; + /** Whether this or any child contains a forward reference. */ + hasForwardReference: boolean; /** Does this schema explicitly allow null? */ nullable: boolean; /** Is this schema read-only? */ @@ -88,34 +28,20 @@ export interface PydanticMeta { /** * Result from walking a schema node. */ -export interface PydanticResult { - fieldConstraints: Record; - fields?: Array; +export interface PydanticResult extends PydanticType { + enumMembers?: Array<{ name: Symbol; value: string | number }>; + fields?: Array; // present = emit class, absent = emit type alias meta: PydanticMeta; - typeAnnotation: string | MaybePyDsl; } -export interface PydanticField { - fieldConstraints: Record; +export interface PydanticField extends PydanticType { isOptional: boolean; - name: string; - typeAnnotation: string | MaybePyDsl; + name: Symbol; + originalName?: string; } /** * Finalized result after applyModifiers. */ -export interface PydanticFinal { - fieldConstraints: Record; - fields?: Array; // present = emit class, absent = emit type alias - typeAnnotation: string | MaybePyDsl; -} - -/** - * Result from composite handlers that walk children. - */ -export interface PydanticCompositeHandlerResult { - childResults: Array; - fieldConstraints: Record; - typeAnnotation: string | MaybePyDsl; -} +export interface PydanticFinal + extends PydanticType, Pick {} diff --git a/packages/openapi-python/src/plugins/pydantic/types.ts b/packages/openapi-python/src/plugins/pydantic/types.ts index 12f271b8d2..09fd194efe 100644 --- a/packages/openapi-python/src/plugins/pydantic/types.ts +++ b/packages/openapi-python/src/plugins/pydantic/types.ts @@ -53,6 +53,15 @@ export type UserConfig = Plugin.Name<'pydantic'> & */ name?: NameTransformer; }; + /** + * How to generate enum types. + * + * - `'enum'`: Generate Python Enum classes (e.g., `class Status(str, Enum): ...`) + * - `'literal'`: Generate Literal type hints (e.g., `Literal["pending", "active"]`) + * + * @default 'enum' + */ + enums?: 'enum' | 'literal'; /** * Configuration for request-specific Pydantic models. * @@ -181,6 +190,8 @@ export type Config = Plugin.Name<'pydantic'> & case: Casing; /** Configuration for reusable schema definitions. */ definitions: NamingOptions & FeatureToggle; + /** How to generate enum types. */ + enums: 'enum' | 'literal'; /** Configuration for request-specific Pydantic models. */ requests: NamingOptions & FeatureToggle; /** Configuration for response-specific Pydantic models. */ diff --git a/packages/openapi-python/src/plugins/pydantic/v2/constants.ts b/packages/openapi-python/src/plugins/pydantic/v2/constants.ts index 3fb04de752..d7af05634f 100644 --- a/packages/openapi-python/src/plugins/pydantic/v2/constants.ts +++ b/packages/openapi-python/src/plugins/pydantic/v2/constants.ts @@ -1,44 +1,32 @@ export const identifiers = { - Annotated: 'Annotated', - Any: 'Any', - BaseModel: 'BaseModel', - ConfigDict: 'ConfigDict', - Dict: 'Dict', - Field: 'Field', - List: 'List', - Literal: 'Literal', - Optional: 'Optional', - Union: 'Union', - alias: 'alias', - default: 'default', - description: 'description', - ge: 'ge', - gt: 'gt', - le: 'le', - lt: 'lt', - max_length: 'max_length', - min_length: 'min_length', model_config: 'model_config', - multiple_of: 'multiple_of', - pattern: 'pattern', -} as const; - -export const typeMappings: Record = { - array: 'list', - boolean: 'bool', - integer: 'int', - null: 'None', - number: 'float', - object: 'dict', - string: 'str', }; -export const pydanticTypes = { - array: 'list', - boolean: 'bool', - integer: 'int', - null: 'None', - number: 'float', - object: 'dict', - string: 'str', -} as const; +export interface FieldConstraints { + /** Alias for the field name in serialization. */ + alias?: string; + /** Default value for the field. */ + default?: unknown; + /** Default factory function (for mutable defaults). */ + default_factory?: string; + /** Description of the field. */ + description?: string; + /** Greater than or equal constraint for numbers. */ + ge?: number; + /** Greater than constraint for numbers. */ + gt?: number; + /** Less than or equal constraint for numbers. */ + le?: number; + /** Less than constraint for numbers. */ + lt?: number; + /** Maximum length constraint for strings/arrays. */ + max_length?: number; + /** Minimum length constraint for strings/arrays. */ + min_length?: number; + /** Multiple of constraint for numbers. */ + multiple_of?: number; + /** Regex pattern constraint for strings. */ + pattern?: string; + /** Title for the field. */ + title?: string; +} diff --git a/packages/openapi-python/src/plugins/pydantic/v2/plugin.ts b/packages/openapi-python/src/plugins/pydantic/v2/plugin.ts index 05a97ae3e2..cae12dbcf7 100644 --- a/packages/openapi-python/src/plugins/pydantic/v2/plugin.ts +++ b/packages/openapi-python/src/plugins/pydantic/v2/plugin.ts @@ -1,56 +1,89 @@ import { pathToJsonPointer } from '@hey-api/shared'; -// import { $ } from '../../../py-dsl'; import type { PydanticPlugin } from '../types'; import { createProcessor } from './processor'; export const handlerV2: PydanticPlugin['Handler'] = ({ plugin }) => { + // enum + plugin.symbol('Enum', { + external: 'enum', + meta: { + category: 'external', + resource: 'enum.Enum', + }, + }); + + // typing plugin.symbol('Any', { external: 'typing', - importKind: 'named', meta: { category: 'external', resource: 'typing.Any', }, }); - plugin.symbol('BaseModel', { - external: 'pydantic', - importKind: 'named', + plugin.symbol('List', { + external: 'typing', meta: { category: 'external', - resource: 'pydantic.BaseModel', + resource: 'typing.List', }, }); - plugin.symbol('ConfigDict', { - external: 'pydantic', - importKind: 'named', + plugin.symbol('Literal', { + external: 'typing', meta: { category: 'external', - resource: 'pydantic.ConfigDict', + resource: 'typing.Literal', }, }); - plugin.symbol('Field', { - external: 'pydantic', - importKind: 'named', + plugin.symbol('NoReturn', { + external: 'typing', meta: { category: 'external', - resource: 'pydantic.Field', + resource: 'typing.NoReturn', }, }); - plugin.symbol('Literal', { + plugin.symbol('Optional', { external: 'typing', - importKind: 'named', meta: { category: 'external', - resource: 'typing.Literal', + resource: 'typing.Optional', }, }); - plugin.symbol('Optional', { + plugin.symbol('TypeAlias', { external: 'typing', - importKind: 'named', meta: { category: 'external', - resource: 'typing.Optional', + resource: 'typing.TypeAlias', + }, + }); + plugin.symbol('Union', { + external: 'typing', + meta: { + category: 'external', + resource: 'typing.Union', + }, + }); + + // Pydantic + plugin.symbol('BaseModel', { + external: 'pydantic', + meta: { + category: 'external', + resource: 'pydantic.BaseModel', + }, + }); + plugin.symbol('ConfigDict', { + external: 'pydantic', + meta: { + category: 'external', + resource: 'pydantic.ConfigDict', + }, + }); + plugin.symbol('Field', { + external: 'pydantic', + meta: { + category: 'external', + resource: 'pydantic.Field', }, }); diff --git a/packages/openapi-python/src/plugins/pydantic/v2/processor.ts b/packages/openapi-python/src/plugins/pydantic/v2/processor.ts index 301d89da41..32da0f01c1 100644 --- a/packages/openapi-python/src/plugins/pydantic/v2/processor.ts +++ b/packages/openapi-python/src/plugins/pydantic/v2/processor.ts @@ -1,5 +1,5 @@ import { ref } from '@hey-api/codegen-core'; -import type { IR } from '@hey-api/shared'; +import type { Hooks, IR } from '@hey-api/shared'; import { createSchemaProcessor, createSchemaWalker, pathToJsonPointer } from '@hey-api/shared'; import { exportAst } from '../shared/export'; @@ -11,15 +11,24 @@ import { createVisitor } from './walker'; export function createProcessor(plugin: PydanticPlugin['Instance']): ProcessorResult { const processor = createSchemaProcessor(); - const hooks = [plugin.config['~hooks']?.schemas, plugin.context.config.parser.hooks.schemas]; + const extractorHooks: ReadonlyArray['shouldExtract']> = [ + (ctx) => + ctx.schema.type === 'object' && + ctx.schema.properties !== undefined && + Object.keys(ctx.schema.properties).length > 0, + (ctx) => + ctx.schema.type === 'enum' && ctx.schema.items !== undefined && ctx.schema.items.length > 0, + plugin.config['~hooks']?.schemas?.shouldExtract, + plugin.context.config.parser.hooks.schemas?.shouldExtract, + ]; function extractor(ctx: ProcessorContext): IR.SchemaObject { if (processor.hasEmitted(ctx.path)) { return ctx.schema; } - for (const hook of hooks) { - const result = hook?.shouldExtract?.(ctx); + for (const hook of extractorHooks) { + const result = hook?.(ctx); if (result) { process({ namingAnchor: processor.context.anchor, diff --git a/packages/openapi-python/src/plugins/pydantic/v2/toAst/array.ts b/packages/openapi-python/src/plugins/pydantic/v2/toAst/array.ts new file mode 100644 index 0000000000..d7f06acba2 --- /dev/null +++ b/packages/openapi-python/src/plugins/pydantic/v2/toAst/array.ts @@ -0,0 +1,82 @@ +import type { SchemaVisitorContext, SchemaWithType, Walker } from '@hey-api/shared'; +import { childContext, deduplicateSchema } from '@hey-api/shared'; + +import { $ } from '../../../../py-dsl'; +import type { PydanticFinal, PydanticResult, PydanticType } from '../../shared/types'; +import type { PydanticPlugin } from '../../types'; +import type { FieldConstraints } from '../constants'; + +interface ArrayToTypeContext { + applyModifiers: (result: PydanticResult, options?: { optional?: boolean }) => PydanticFinal; + plugin: PydanticPlugin['Instance']; + schema: SchemaWithType<'array'>; + walk: Walker; + walkerCtx: SchemaVisitorContext; +} + +export interface ArrayToTypeResult extends PydanticType { + childResults: Array; +} + +export function arrayToType(ctx: ArrayToTypeContext): ArrayToTypeResult { + const { plugin, walk, walkerCtx } = ctx; + let { schema } = ctx; + + const childResults: Array = []; + const constraints: FieldConstraints = {}; + const list = plugin.external('typing.List'); + const any = plugin.external('typing.Any'); + + if (schema.minItems !== undefined) { + constraints.min_length = schema.minItems; + } + + if (schema.maxItems !== undefined) { + constraints.max_length = schema.maxItems; + } + + if (schema.description !== undefined) { + constraints.description = schema.description; + } + + if (!schema.items) { + return { + childResults, + fieldConstraints: constraints, + typeAnnotation: $(list).slice(any), + }; + } + + schema = deduplicateSchema({ schema }); + + for (let i = 0; i < schema.items!.length; i++) { + const item = schema.items![i]!; + const result = walk(item, childContext(walkerCtx, 'items', i)); + childResults.push(result); + } + + if (childResults.length === 1) { + const itemResult = ctx.applyModifiers(childResults[0]!); + return { + childResults, + fieldConstraints: constraints, + typeAnnotation: $(list).slice(itemResult.typeAnnotation ?? any), + }; + } + + if (childResults.length > 1) { + const union = plugin.external('typing.Union'); + const itemTypes = childResults.map((r) => ctx.applyModifiers(r).typeAnnotation ?? any); + return { + childResults, + fieldConstraints: constraints, + typeAnnotation: $(list).slice($(union).slice(...itemTypes)), + }; + } + + return { + childResults, + fieldConstraints: constraints, + typeAnnotation: $(list).slice(any), + }; +} diff --git a/packages/openapi-python/src/plugins/pydantic/v2/toAst/boolean.ts b/packages/openapi-python/src/plugins/pydantic/v2/toAst/boolean.ts index 20e6c92458..57a6d7db97 100644 --- a/packages/openapi-python/src/plugins/pydantic/v2/toAst/boolean.ts +++ b/packages/openapi-python/src/plugins/pydantic/v2/toAst/boolean.ts @@ -1,7 +1,7 @@ import type { SchemaWithType } from '@hey-api/shared'; -import { defaultMeta } from '../../shared/meta'; -import type { PydanticResult } from '../../shared/types'; +import { $ } from '../../../../py-dsl'; +import type { PydanticType } from '../../shared/types'; import type { PydanticPlugin } from '../../types'; export function booleanToType({ @@ -10,19 +10,15 @@ export function booleanToType({ }: { plugin: PydanticPlugin['Instance']; schema: SchemaWithType<'boolean'>; -}): PydanticResult { +}): PydanticType { if (typeof schema.const === 'boolean') { const literal = plugin.external('typing.Literal'); return { - fieldConstraints: {}, - meta: defaultMeta(schema), - typeAnnotation: `${literal}[${schema.const ? 'True' : 'False'}]`, + typeAnnotation: $(literal).slice($.literal(schema.const)), }; } return { - fieldConstraints: {}, - meta: defaultMeta(schema), typeAnnotation: 'bool', }; } diff --git a/packages/openapi-python/src/plugins/pydantic/v2/toAst/enum.ts b/packages/openapi-python/src/plugins/pydantic/v2/toAst/enum.ts new file mode 100644 index 0000000000..6644890207 --- /dev/null +++ b/packages/openapi-python/src/plugins/pydantic/v2/toAst/enum.ts @@ -0,0 +1,102 @@ +import type { Symbol } from '@hey-api/codegen-core'; +import type { SchemaWithType } from '@hey-api/shared'; +import { toCase } from '@hey-api/shared'; + +import { $ } from '../../../../py-dsl'; +import type { PydanticFinal, PydanticType } from '../../shared/types'; +import type { PydanticPlugin } from '../../types'; + +export interface EnumToTypeResult extends PydanticType { + enumMembers: Required['enumMembers']; + isNullable: boolean; +} + +// TODO: replace with casing utils +function toEnumMemberName(value: string | number): string { + if (typeof value === 'number') { + // For numbers, prefix with underscore if starts with digit + return `VALUE_${value}`.replace(/-/g, '_NEG_').replace(/\./g, '_DOT_'); + } + + return toCase(value, 'SCREAMING_SNAKE_CASE'); +} + +function extractEnumMembers( + schema: SchemaWithType<'enum'>, + plugin: PydanticPlugin['Instance'], +): { + enumMembers: Required['enumMembers']; + isNullable: boolean; +} { + const enumMembers: Required['enumMembers'] = []; + let isNullable = false; + + for (const item of schema.items ?? []) { + if (item.type === 'null' || item.const === null) { + isNullable = true; + continue; + } + + if ( + (item.type === 'string' && typeof item.const === 'string') || + ((item.type === 'integer' || item.type === 'number') && typeof item.const === 'number') + ) { + enumMembers.push({ + name: plugin.symbol(toEnumMemberName(item.const)), + value: item.const, + }); + } + } + + return { enumMembers, isNullable }; +} + +function toLiteralType( + enumMembers: Required['enumMembers'], + plugin: PydanticPlugin['Instance'], +): string | Symbol | ReturnType { + if (enumMembers.length === 0) { + return plugin.external('typing.Any'); + } + + const literal = plugin.external('typing.Literal'); + const values = enumMembers.map((m) => + // TODO: replace + typeof m.value === 'string' ? `"<<<<${m.value}"` : `<<<${m.value}`, + ); + + return $(literal).slice(...values); +} + +export function enumToType({ + mode = 'enum', + plugin, + schema, +}: { + mode?: 'enum' | 'literal'; + plugin: PydanticPlugin['Instance']; + schema: SchemaWithType<'enum'>; +}): EnumToTypeResult { + const { enumMembers, isNullable } = extractEnumMembers(schema, plugin); + + if (enumMembers.length === 0) { + return { + enumMembers, + isNullable, + typeAnnotation: plugin.external('typing.Any'), + }; + } + + if (mode === 'literal') { + return { + enumMembers, + isNullable, + typeAnnotation: toLiteralType(enumMembers, plugin), + }; + } + + return { + enumMembers, + isNullable, + }; +} diff --git a/packages/openapi-python/src/plugins/pydantic/v2/toAst/intersection.ts b/packages/openapi-python/src/plugins/pydantic/v2/toAst/intersection.ts new file mode 100644 index 0000000000..221d3c6e40 --- /dev/null +++ b/packages/openapi-python/src/plugins/pydantic/v2/toAst/intersection.ts @@ -0,0 +1,105 @@ +import type { IR } from '@hey-api/shared'; + +import type { AnnotationExpr } from '../../../../py-dsl'; +import type { + PydanticField, + PydanticFinal, + PydanticResult, + PydanticType, +} from '../../shared/types'; +import type { PydanticPlugin } from '../../types'; +import type { FieldConstraints } from '../constants'; + +export interface IntersectionToTypeResult extends PydanticType { + baseClasses?: Array; + childResults: Array; + mergedFields?: Array; +} + +export function intersectionToType({ + applyModifiers, + childResults, + parentSchema, + plugin, +}: { + applyModifiers: (result: PydanticResult, options?: { optional?: boolean }) => PydanticFinal; + childResults: Array; + parentSchema: IR.SchemaObject; + plugin: PydanticPlugin['Instance']; +}): IntersectionToTypeResult { + const constraints: FieldConstraints = {}; + + if (parentSchema.description !== undefined) { + constraints.description = parentSchema.description; + } + + if (childResults.length === 0) { + return { + childResults, + fieldConstraints: constraints, + typeAnnotation: plugin.external('typing.Any'), + }; + } + + if (childResults.length === 1) { + const finalResult = applyModifiers(childResults[0]!); + return { + childResults, + fieldConstraints: { ...constraints, ...finalResult.fieldConstraints }, + mergedFields: finalResult.fields, + typeAnnotation: finalResult.typeAnnotation, + }; + } + + const baseClasses: Array = []; + const mergedFields: Array = []; + const seenFieldIds = new Set(); + + for (const result of childResults) { + const finalResult = applyModifiers(result); + + // TODO: replace + const typeStr = String(finalResult.typeAnnotation); + const isReference = + !finalResult.fields && + typeStr !== '' && + !typeStr.startsWith('dict[') && + !typeStr.startsWith('Dict[') && + typeStr !== String(plugin.external('typing.Any')); + + if (isReference) { + const baseName = typeStr.replace(/^'|'$/g, ''); + if (baseName && !baseClasses.includes(baseName)) { + baseClasses.push(baseName); + } + } + + if (finalResult.fields) { + for (const field of finalResult.fields) { + if (!seenFieldIds.has(field.name.id)) { + seenFieldIds.add(field.name.id); + mergedFields.push(field); + } + } + } + } + + let typeAnnotation: AnnotationExpr; + + if (baseClasses.length > 0 && mergedFields.length === 0) { + typeAnnotation = baseClasses[0]!; + } else if (mergedFields.length > 0) { + // TODO: replace + typeAnnotation = '__INTERSECTION_PLACEHOLDER__'; + } else { + typeAnnotation = plugin.external('typing.Any'); + } + + return { + baseClasses: baseClasses.length > 0 ? baseClasses : undefined, + childResults, + fieldConstraints: constraints, + mergedFields: mergedFields.length > 0 ? mergedFields : undefined, + typeAnnotation, + }; +} diff --git a/packages/openapi-python/src/plugins/pydantic/v2/toAst/never.ts b/packages/openapi-python/src/plugins/pydantic/v2/toAst/never.ts new file mode 100644 index 0000000000..11e54096b7 --- /dev/null +++ b/packages/openapi-python/src/plugins/pydantic/v2/toAst/never.ts @@ -0,0 +1,15 @@ +import type { SchemaWithType } from '@hey-api/shared'; + +import type { PydanticType } from '../../shared/types'; +import type { PydanticPlugin } from '../../types'; + +export function neverToType({ + plugin, +}: { + plugin: PydanticPlugin['Instance']; + schema: SchemaWithType<'never'>; +}): PydanticType { + return { + typeAnnotation: plugin.external('typing.NoReturn'), + }; +} diff --git a/packages/openapi-python/src/plugins/pydantic/v2/toAst/null.ts b/packages/openapi-python/src/plugins/pydantic/v2/toAst/null.ts new file mode 100644 index 0000000000..a888ad50ca --- /dev/null +++ b/packages/openapi-python/src/plugins/pydantic/v2/toAst/null.ts @@ -0,0 +1,14 @@ +import type { SchemaWithType } from '@hey-api/shared'; + +import type { PydanticType } from '../../shared/types'; +import type { PydanticPlugin } from '../../types'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function nullToType(args: { + plugin: PydanticPlugin['Instance']; + schema: SchemaWithType<'null'>; +}): PydanticType { + return { + typeAnnotation: 'None', + }; +} diff --git a/packages/openapi-python/src/plugins/pydantic/v2/toAst/number.ts b/packages/openapi-python/src/plugins/pydantic/v2/toAst/number.ts new file mode 100644 index 0000000000..85cab1726b --- /dev/null +++ b/packages/openapi-python/src/plugins/pydantic/v2/toAst/number.ts @@ -0,0 +1,48 @@ +import type { SchemaWithType } from '@hey-api/shared'; + +import { $ } from '../../../../py-dsl'; +import type { PydanticType } from '../../shared/types'; +import type { PydanticPlugin } from '../../types'; +import type { FieldConstraints } from '../constants'; + +export function numberToType({ + plugin, + schema, +}: { + plugin: PydanticPlugin['Instance']; + schema: SchemaWithType<'integer' | 'number'>; +}): PydanticType { + const constraints: FieldConstraints = {}; + + if (typeof schema.const === 'number') { + const literal = plugin.external('typing.Literal'); + return { + typeAnnotation: $(literal).slice($.literal(schema.const)), + }; + } + + if (schema.minimum !== undefined) { + constraints.ge = schema.minimum; + } + + if (schema.exclusiveMinimum !== undefined) { + constraints.gt = schema.exclusiveMinimum; + } + + if (schema.maximum !== undefined) { + constraints.le = schema.maximum; + } + + if (schema.exclusiveMaximum !== undefined) { + constraints.lt = schema.exclusiveMaximum; + } + + if (schema.description !== undefined) { + constraints.description = schema.description; + } + + return { + fieldConstraints: constraints, + typeAnnotation: schema.type === 'integer' ? 'int' : 'float', + }; +} diff --git a/packages/openapi-python/src/plugins/pydantic/v2/toAst/object.ts b/packages/openapi-python/src/plugins/pydantic/v2/toAst/object.ts index 07d83b3c08..55f0cea23c 100644 --- a/packages/openapi-python/src/plugins/pydantic/v2/toAst/object.ts +++ b/packages/openapi-python/src/plugins/pydantic/v2/toAst/object.ts @@ -1,8 +1,8 @@ import type { SchemaVisitorContext, SchemaWithType, Walker } from '@hey-api/shared'; -import { childContext } from '@hey-api/shared'; +import { childContext, toCase } from '@hey-api/shared'; -import { $, type MaybePyDsl } from '../../../../py-dsl'; -import type { py } from '../../../../ts-python'; +import { $, type AnnotationExpr } from '../../../../py-dsl'; +import { safeRuntimeName } from '../../../../py-dsl/utils/name'; import type { PydanticField, PydanticFinal, PydanticResult } from '../../shared/types'; import type { PydanticPlugin } from '../../types'; @@ -15,15 +15,13 @@ interface ObjectResolverContext { walkerCtx: SchemaVisitorContext; } -export interface ObjectToFieldsResult { +export interface ObjectToFieldsResult extends Pick { childResults: Array; - fields?: Array; // present = emit class - typeAnnotation?: string | MaybePyDsl; // present = emit type alias (dict case) } function resolveAdditionalProperties( ctx: ObjectResolverContext, -): string | MaybePyDsl | null | undefined { +): AnnotationExpr | null | undefined { const { schema } = ctx; if (!schema.additionalProperties || !schema.additionalProperties.type) return undefined; @@ -50,10 +48,12 @@ function resolveFields(ctx: ObjectResolverContext): Array { ctx._childResults.push(propertyResult); const final = ctx.applyModifiers(propertyResult, { optional: isOptional }); + const snakeCaseName = safeRuntimeName(toCase(name, 'snake_case')); fields.push({ fieldConstraints: final.fieldConstraints, isOptional, - name, + name: ctx.plugin.symbol(snakeCaseName), + originalName: name, typeAnnotation: final.typeAnnotation, }); } @@ -71,8 +71,11 @@ function objectResolver(ctx: ObjectResolverContext): Omit; -}): Ast { - const constraints: Record = {}; +}): PydanticType { + const constraints: FieldConstraints = {}; + + if (typeof schema.const === 'string') { + const literal = plugin.external('typing.Literal'); + return { + typeAnnotation: $(literal).slice($.literal(schema.const)), + }; + } if (schema.minLength !== undefined) { constraints.min_length = schema.minLength; @@ -26,23 +37,8 @@ export function stringToNode({ constraints.description = schema.description; } - if (typeof schema.const === 'string') { - return { - // expression: $.expr(`Literal["${schema.const}"]`), - fieldConstraints: constraints, - hasLazyExpression: false, - models: [], - // pipes: [], - typeAnnotation: `Literal["${schema.const}"]`, - }; - } - return { - // expression: $.expr('str'), fieldConstraints: constraints, - hasLazyExpression: false, - models: [], - // pipes: [], typeAnnotation: 'str', }; } diff --git a/packages/openapi-python/src/plugins/pydantic/v2/toAst/tuple.ts b/packages/openapi-python/src/plugins/pydantic/v2/toAst/tuple.ts new file mode 100644 index 0000000000..58c14531a9 --- /dev/null +++ b/packages/openapi-python/src/plugins/pydantic/v2/toAst/tuple.ts @@ -0,0 +1,67 @@ +import type { SchemaVisitorContext, SchemaWithType, Walker } from '@hey-api/shared'; +import { childContext } from '@hey-api/shared'; + +import { $, type AnnotationExpr } from '../../../../py-dsl'; +import type { PydanticFinal, PydanticResult, PydanticType } from '../../shared/types'; +import type { PydanticPlugin } from '../../types'; +import type { FieldConstraints } from '../constants'; + +interface TupleToTypeContext { + applyModifiers: (result: PydanticResult, options?: { optional?: boolean }) => PydanticFinal; + plugin: PydanticPlugin['Instance']; + schema: SchemaWithType<'tuple'>; + walk: Walker; + walkerCtx: SchemaVisitorContext; +} + +export interface TupleToTypeResult extends PydanticType { + childResults: Array; +} + +export function tupleToType(ctx: TupleToTypeContext): TupleToTypeResult { + const { applyModifiers, plugin, schema, walk, walkerCtx } = ctx; + + const childResults: Array = []; + const constraints: FieldConstraints = {}; + const tuple = plugin.external('typing.Tuple'); + const any = plugin.external('typing.Any'); + + if (schema.description !== undefined) { + constraints.description = schema.description; + } + + if (!schema.items || schema.items.length === 0) { + return { + childResults, + fieldConstraints: constraints, + typeAnnotation: $(tuple).slice(), + }; + } + + const itemTypes: Array = []; + + for (let i = 0; i < schema.items.length; i++) { + const item = schema.items[i]!; + const result = walk(item, childContext(walkerCtx, 'items', i)); + childResults.push(result); + + const finalResult = applyModifiers(result); + if (finalResult.typeAnnotation !== undefined) { + itemTypes.push(finalResult.typeAnnotation); + } + } + + if (itemTypes.length === 0) { + return { + childResults, + fieldConstraints: constraints, + typeAnnotation: $(tuple).slice(any, '...'), + }; + } + + return { + childResults, + fieldConstraints: constraints, + typeAnnotation: $(tuple).slice(...itemTypes), + }; +} diff --git a/packages/openapi-python/src/plugins/pydantic/v2/toAst/undefined.ts b/packages/openapi-python/src/plugins/pydantic/v2/toAst/undefined.ts new file mode 100644 index 0000000000..90b07a7e4d --- /dev/null +++ b/packages/openapi-python/src/plugins/pydantic/v2/toAst/undefined.ts @@ -0,0 +1,14 @@ +import type { SchemaWithType } from '@hey-api/shared'; + +import type { PydanticType } from '../../shared/types'; +import type { PydanticPlugin } from '../../types'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function undefinedToType(args: { + plugin: PydanticPlugin['Instance']; + schema: SchemaWithType<'undefined'>; +}): PydanticType { + return { + typeAnnotation: 'None', + }; +} diff --git a/packages/openapi-python/src/plugins/pydantic/v2/toAst/union.ts b/packages/openapi-python/src/plugins/pydantic/v2/toAst/union.ts new file mode 100644 index 0000000000..bd213d3891 --- /dev/null +++ b/packages/openapi-python/src/plugins/pydantic/v2/toAst/union.ts @@ -0,0 +1,77 @@ +import type { IR } from '@hey-api/shared'; + +import { $ } from '../../../../py-dsl'; +import type { PydanticFinal, PydanticResult, PydanticType } from '../../shared/types'; +import type { PydanticPlugin } from '../../types'; +import type { FieldConstraints } from '../constants'; + +export interface UnionToTypeResult extends PydanticType { + childResults: Array; + isNullable: boolean; +} + +export function unionToType({ + applyModifiers, + childResults, + parentSchema, + plugin, +}: { + applyModifiers: (result: PydanticResult, options?: { optional?: boolean }) => PydanticFinal; + childResults: Array; + parentSchema: IR.SchemaObject; + plugin: PydanticPlugin['Instance']; +}): UnionToTypeResult { + const constraints: FieldConstraints = {}; + + if (parentSchema.description !== undefined) { + constraints.description = parentSchema.description; + } + + const nonNullResults: Array = []; + let isNullable = false; + + for (const result of childResults) { + if (result.typeAnnotation === 'None') { + isNullable = true; + } else { + nonNullResults.push(result); + } + } + + isNullable = isNullable || childResults.some((r) => r.meta.nullable); + + if (nonNullResults.length === 0) { + return { + childResults, + fieldConstraints: constraints, + isNullable: true, + typeAnnotation: 'None', + }; + } + + if (nonNullResults.length === 1) { + const finalResult = applyModifiers(nonNullResults[0]!); + return { + childResults, + fieldConstraints: { ...constraints, ...finalResult.fieldConstraints }, + isNullable, + typeAnnotation: finalResult.typeAnnotation, + }; + } + + const union = plugin.external('typing.Union'); + const itemTypes = nonNullResults.map( + (r) => applyModifiers(r).typeAnnotation ?? plugin.external('typing.Any'), + ); + + if (isNullable) { + itemTypes.push('None'); + } + + return { + childResults, + fieldConstraints: constraints, + isNullable, + typeAnnotation: $(union).slice(...itemTypes), + }; +} diff --git a/packages/openapi-python/src/plugins/pydantic/v2/toAst/unknown.ts b/packages/openapi-python/src/plugins/pydantic/v2/toAst/unknown.ts new file mode 100644 index 0000000000..2092ba779f --- /dev/null +++ b/packages/openapi-python/src/plugins/pydantic/v2/toAst/unknown.ts @@ -0,0 +1,15 @@ +import type { SchemaWithType } from '@hey-api/shared'; + +import type { PydanticType } from '../../shared/types'; +import type { PydanticPlugin } from '../../types'; + +export function unknownToType({ + plugin, +}: { + plugin: PydanticPlugin['Instance']; + schema: SchemaWithType<'unknown'>; +}): PydanticType { + return { + typeAnnotation: plugin.external('typing.Any'), + }; +} diff --git a/packages/openapi-python/src/plugins/pydantic/v2/toAst/void.ts b/packages/openapi-python/src/plugins/pydantic/v2/toAst/void.ts new file mode 100644 index 0000000000..6b0d3c9a6f --- /dev/null +++ b/packages/openapi-python/src/plugins/pydantic/v2/toAst/void.ts @@ -0,0 +1,14 @@ +import type { SchemaWithType } from '@hey-api/shared'; + +import type { PydanticType } from '../../shared/types'; +import type { PydanticPlugin } from '../../types'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function voidToType(args: { + plugin: PydanticPlugin['Instance']; + schema: SchemaWithType<'void'>; +}): PydanticType { + return { + typeAnnotation: 'None', + }; +} diff --git a/packages/openapi-python/src/plugins/pydantic/v2/walker.ts b/packages/openapi-python/src/plugins/pydantic/v2/walker.ts index 5bc64fdf7e..98ec2614b6 100644 --- a/packages/openapi-python/src/plugins/pydantic/v2/walker.ts +++ b/packages/openapi-python/src/plugins/pydantic/v2/walker.ts @@ -3,14 +3,28 @@ import { fromRef } from '@hey-api/codegen-core'; import type { SchemaExtractor, SchemaVisitor } from '@hey-api/shared'; import { pathToJsonPointer } from '@hey-api/shared'; +import { $ } from '../../../py-dsl'; import { composeMeta, defaultMeta, inheritMeta } from '../shared/meta'; import type { ProcessorContext } from '../shared/processor'; import type { PydanticFinal, PydanticResult } from '../shared/types'; import type { PydanticPlugin } from '../types'; +import { arrayToType } from './toAst/array'; import { booleanToType } from './toAst/boolean'; +import { enumToType } from './toAst/enum'; +import { intersectionToType } from './toAst/intersection'; +import { neverToType } from './toAst/never'; +import { nullToType } from './toAst/null'; +import { numberToType } from './toAst/number'; import { objectToFields } from './toAst/object'; +import { stringToType } from './toAst/string'; +import { tupleToType } from './toAst/tuple'; +import { undefinedToType } from './toAst/undefined'; +import { unionToType } from './toAst/union'; +import { unknownToType } from './toAst/unknown'; +import { voidToType } from './toAst/void'; export interface VisitorConfig { + /** Optional schema extractor function. */ schemaExtractor?: SchemaExtractor; } @@ -27,40 +41,63 @@ export function createVisitor( const needsOptional = optional || hasDefault; const needsNullable = result.meta.nullable; - let { typeAnnotation } = result; - const fieldConstraints: Record = { ...result.fieldConstraints }; + let typeAnnotation = result.typeAnnotation; + const fieldConstraints = { ...result.fieldConstraints }; if (needsOptional || needsNullable) { - const optional = ctx.plugin.external('typing.Optional'); - typeAnnotation = `${optional}[${typeAnnotation}]`; + const optionalType = ctx.plugin.external('typing.Optional'); + typeAnnotation = $(optionalType).slice(typeAnnotation ?? ctx.plugin.external('typing.Any')); if (needsOptional) { fieldConstraints.default = hasDefault ? result.meta.default : null; } } - return { fieldConstraints, fields: result.fields, typeAnnotation }; + return { + enumMembers: result.enumMembers, + fieldConstraints, + fields: result.fields, + typeAnnotation, + }; }, - // @ts-expect-error - array(schema, ctx) { + array(schema, ctx, walk) { + const applyModifiers = (result: PydanticResult, opts?: { optional?: boolean }) => + this.applyModifiers(result, ctx, opts) as PydanticFinal; + + const { childResults, fieldConstraints, typeAnnotation } = arrayToType({ + applyModifiers, + plugin: ctx.plugin, + schema, + walk, + walkerCtx: ctx, + }); + return { - fieldConstraints: {}, - meta: defaultMeta(schema), - typeAnnotation: ctx.plugin.external('typing.Any'), + fieldConstraints, + meta: composeMeta(childResults, { ...defaultMeta(schema) }), + typeAnnotation, }; }, boolean(schema, ctx) { - return booleanToType({ plugin: ctx.plugin, schema }); + const result = booleanToType({ plugin: ctx.plugin, schema }); + return { + ...result, + meta: defaultMeta(schema), + }; }, - // @ts-expect-error enum(schema, ctx) { + const mode = ctx.plugin.config.enums ?? 'enum'; + const result = enumToType({ mode, plugin: ctx.plugin, schema }); return { - fieldConstraints: {}, + ...result, meta: defaultMeta(schema), - typeAnnotation: ctx.plugin.external('typing.Any'), }; }, - integer(schema) { - return { fieldConstraints: {}, meta: defaultMeta(schema), typeAnnotation: 'int' }; + integer(schema, ctx) { + const result = numberToType({ plugin: ctx.plugin, schema }); + return { + ...result, + meta: defaultMeta(schema), + }; }, intercept(schema, ctx, walk) { if (schemaExtractor && !schema.$ref) { @@ -74,31 +111,50 @@ export function createVisitor( if (extracted !== schema) return walk(extracted, ctx); } }, - // @ts-expect-error intersection(items, schemas, parentSchema, ctx) { + const applyModifiers = (result: PydanticResult, opts?: { optional?: boolean }) => + this.applyModifiers(result, ctx, opts) as PydanticFinal; + + const result = intersectionToType({ + applyModifiers, + childResults: items, + parentSchema, + plugin: ctx.plugin, + }); + return { - fieldConstraints: {}, + ...result, meta: composeMeta(items, { default: parentSchema.default }), - typeAnnotation: ctx.plugin.external('typing.Any'), }; }, - // @ts-expect-error never(schema, ctx) { + const result = neverToType({ plugin: ctx.plugin, schema }); return { - fieldConstraints: {}, - meta: { ...defaultMeta(schema), nullable: false, readonly: false }, - typeAnnotation: ctx.plugin.external('typing.Any'), + ...result, + meta: { + ...defaultMeta(schema), + nullable: false, + readonly: false, + }, }; }, - null(schema) { + null(schema, ctx) { + const result = nullToType({ plugin: ctx.plugin, schema }); return { - fieldConstraints: {}, - meta: { ...defaultMeta(schema), nullable: false, readonly: false }, - typeAnnotation: 'None', + ...result, + meta: { + ...defaultMeta(schema), + nullable: true, + readonly: false, + }, }; }, - number(schema) { - return { fieldConstraints: {}, meta: defaultMeta(schema), typeAnnotation: 'float' }; + number(schema, ctx) { + const result = numberToType({ plugin: ctx.plugin, schema }); + return { + ...result, + meta: defaultMeta(schema), + }; }, object(schema, ctx, walk) { const applyModifiers = (result: PydanticResult, opts?: { optional?: boolean }) => @@ -113,7 +169,6 @@ export function createVisitor( }); return { - fieldConstraints: {}, fields, meta: inheritMeta(schema, childResults), typeAnnotation: typeAnnotation ?? '', @@ -133,53 +188,86 @@ export function createVisitor( const refSymbol = ctx.plugin.referenceSymbol(query); const isRegistered = ctx.plugin.isSymbolRegistered(query); - // TODO: replace string with symbol - const refName = typeof refSymbol === 'string' ? refSymbol : refSymbol.name; return { - fieldConstraints: {}, - meta: { ...defaultMeta(schema), hasLazy: !isRegistered }, - typeAnnotation: isRegistered ? refName : `'${refName}'`, + meta: { + ...defaultMeta(schema), + hasForwardReference: !isRegistered, + }, + typeAnnotation: refSymbol, }; }, - string(schema) { - return { fieldConstraints: {}, meta: defaultMeta(schema), typeAnnotation: 'str' }; - }, - // @ts-expect-error - tuple(schema, ctx) { + string(schema, ctx) { + const result = stringToType({ plugin: ctx.plugin, schema }); return { - fieldConstraints: {}, + ...result, meta: defaultMeta(schema), - typeAnnotation: ctx.plugin.external('typing.Any'), }; }, - undefined(schema) { + tuple(schema, ctx, walk) { + const applyModifiers = (result: PydanticResult, opts?: { optional?: boolean }) => + this.applyModifiers(result, ctx, opts) as PydanticFinal; + + const { childResults, fieldConstraints, typeAnnotation } = tupleToType({ + applyModifiers, + plugin: ctx.plugin, + schema, + walk, + walkerCtx: ctx, + }); + + return { + fieldConstraints, + meta: composeMeta(childResults, { ...defaultMeta(schema) }), + typeAnnotation, + }; + }, + undefined(schema, ctx) { + const result = undefinedToType({ plugin: ctx.plugin, schema }); return { - fieldConstraints: {}, - meta: { ...defaultMeta(schema), nullable: false, readonly: false }, - typeAnnotation: 'None', + ...result, + meta: { + ...defaultMeta(schema), + nullable: false, + readonly: false, + }, }; }, - // @ts-expect-error union(items, schemas, parentSchema, ctx) { + const applyModifiers = (result: PydanticResult, opts?: { optional?: boolean }) => + this.applyModifiers(result, ctx, opts) as PydanticFinal; + + const result = unionToType({ + applyModifiers, + childResults: items, + parentSchema, + plugin: ctx.plugin, + }); + return { - fieldConstraints: {}, + ...result, meta: composeMeta(items, { default: parentSchema.default }), - typeAnnotation: ctx.plugin.external('typing.Any'), }; }, - // @ts-expect-error unknown(schema, ctx) { + const result = unknownToType({ plugin: ctx.plugin, schema }); return { - fieldConstraints: {}, - meta: { ...defaultMeta(schema), nullable: false, readonly: false }, - typeAnnotation: ctx.plugin.external('typing.Any'), + ...result, + meta: { + ...defaultMeta(schema), + nullable: false, + readonly: false, + }, }; }, - void(schema) { + void(schema, ctx) { + const result = voidToType({ plugin: ctx.plugin, schema }); return { - fieldConstraints: {}, - meta: { ...defaultMeta(schema), nullable: false, readonly: false }, - typeAnnotation: 'None', + ...result, + meta: { + ...defaultMeta(schema), + nullable: false, + readonly: false, + }, }; }, }; diff --git a/packages/openapi-python/src/py-dsl/decl/class.ts b/packages/openapi-python/src/py-dsl/decl/class.ts index 9e340412ae..b54e558b81 100644 --- a/packages/openapi-python/src/py-dsl/decl/class.ts +++ b/packages/openapi-python/src/py-dsl/decl/class.ts @@ -72,7 +72,7 @@ export class ClassPyDsl extends Mixed { return this; } - override toAst(): py.ClassDeclaration { + override toAst() { this.$validate(); // const uniqueClasses: Array = []; diff --git a/packages/openapi-python/src/py-dsl/decl/func.ts b/packages/openapi-python/src/py-dsl/decl/func.ts index af68fd58be..dd89c5fa1a 100644 --- a/packages/openapi-python/src/py-dsl/decl/func.ts +++ b/packages/openapi-python/src/py-dsl/decl/func.ts @@ -62,7 +62,7 @@ export class FuncPyDsl extends Mixed { return this; } - override toAst(): py.FunctionDeclaration { + override toAst() { this.$validate(); return py.factory.createFunctionDeclaration( this.name.toString(), diff --git a/packages/openapi-python/src/py-dsl/expr/attr.ts b/packages/openapi-python/src/py-dsl/expr/attr.ts index d379854a40..6ca4859665 100644 --- a/packages/openapi-python/src/py-dsl/expr/attr.ts +++ b/packages/openapi-python/src/py-dsl/expr/attr.ts @@ -34,7 +34,7 @@ export class AttrPyDsl extends Mixed { return this.missingRequiredCalls().length === 0; } - override toAst(): py.MemberExpression { + override toAst() { this.$validate(); const leftNode = this.$node(this.left); diff --git a/packages/openapi-python/src/py-dsl/expr/binary.ts b/packages/openapi-python/src/py-dsl/expr/binary.ts index 594908dec0..0f4be8fae8 100644 --- a/packages/openapi-python/src/py-dsl/expr/binary.ts +++ b/packages/openapi-python/src/py-dsl/expr/binary.ts @@ -132,7 +132,7 @@ export class BinaryPyDsl extends Mixed { return this.opAndExpr('*', right); } - override toAst(): py.BinaryExpression { + override toAst() { this.$validate(); return py.factory.createBinaryExpression( diff --git a/packages/openapi-python/src/py-dsl/expr/call.ts b/packages/openapi-python/src/py-dsl/expr/call.ts index 48da8c4cc8..2f233d789c 100644 --- a/packages/openapi-python/src/py-dsl/expr/call.ts +++ b/packages/openapi-python/src/py-dsl/expr/call.ts @@ -35,7 +35,7 @@ export class CallPyDsl extends Mixed { return this.missingRequiredCalls().length === 0; } - override toAst(): py.CallExpression { + override toAst() { this.$validate(); return py.factory.createCallExpression(this.$node(this._callee!), this.$args()); diff --git a/packages/openapi-python/src/py-dsl/expr/dict.ts b/packages/openapi-python/src/py-dsl/expr/dict.ts index 6c36b86e5c..8ea642da5b 100644 --- a/packages/openapi-python/src/py-dsl/expr/dict.ts +++ b/packages/openapi-python/src/py-dsl/expr/dict.ts @@ -42,7 +42,7 @@ export class DictPyDsl extends Mixed { return this; } - override toAst(): py.DictExpression { + override toAst() { const astEntries = this._entries.map((entry) => ({ key: this.$node(entry.key), value: this.$node(entry.value), diff --git a/packages/openapi-python/src/py-dsl/expr/identifier.ts b/packages/openapi-python/src/py-dsl/expr/identifier.ts index 2650210f51..0fc53ca84c 100644 --- a/packages/openapi-python/src/py-dsl/expr/identifier.ts +++ b/packages/openapi-python/src/py-dsl/expr/identifier.ts @@ -18,7 +18,7 @@ export class IdPyDsl extends Mixed { ctx.analyze(this.name); } - override toAst(): py.Identifier { + override toAst() { return py.factory.createIdentifier(this.name.toString()); } } diff --git a/packages/openapi-python/src/py-dsl/expr/kwarg.ts b/packages/openapi-python/src/py-dsl/expr/kwarg.ts new file mode 100644 index 0000000000..1950d5088f --- /dev/null +++ b/packages/openapi-python/src/py-dsl/expr/kwarg.ts @@ -0,0 +1,30 @@ +import { py } from '../../ts-python'; +import type { MaybePyDsl } from '../base'; +import { PyDsl } from '../base'; + +export type KwargValue = string | number | boolean | null | MaybePyDsl; + +export class KwargPyDsl extends PyDsl { + readonly '~dsl' = 'KwargPyDsl'; + + constructor( + private readonly argName: string, + private readonly argValue: KwargValue, + ) { + super(); + } + + override toAst() { + return py.factory.createKeywordArgument(this.argName, this.$valueToNode(this.argValue)); + } + + private $valueToNode(value: KwargValue) { + if (value === null) { + return py.factory.createIdentifier('None'); + } + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return py.factory.createLiteral(value); + } + return this.$node(value); + } +} diff --git a/packages/openapi-python/src/py-dsl/expr/list.ts b/packages/openapi-python/src/py-dsl/expr/list.ts index 27e178a8c2..dea32265fe 100644 --- a/packages/openapi-python/src/py-dsl/expr/list.ts +++ b/packages/openapi-python/src/py-dsl/expr/list.ts @@ -34,7 +34,7 @@ export class ListPyDsl extends Mixed { return this; } - override toAst(): py.ListExpression { + override toAst() { const astElements = this._elements.map((el) => this.$node(el)); return py.factory.createListExpression(astElements); } diff --git a/packages/openapi-python/src/py-dsl/expr/literal.ts b/packages/openapi-python/src/py-dsl/expr/literal.ts index 903abae260..adddd22cfc 100644 --- a/packages/openapi-python/src/py-dsl/expr/literal.ts +++ b/packages/openapi-python/src/py-dsl/expr/literal.ts @@ -21,7 +21,7 @@ export class LiteralPyDsl extends Mixed { super.analyze(_ctx); } - override toAst(): py.Literal { + override toAst() { return py.factory.createLiteral(this.value); } } diff --git a/packages/openapi-python/src/py-dsl/expr/set.ts b/packages/openapi-python/src/py-dsl/expr/set.ts index 0da686c5bc..d506aa38f6 100644 --- a/packages/openapi-python/src/py-dsl/expr/set.ts +++ b/packages/openapi-python/src/py-dsl/expr/set.ts @@ -34,7 +34,7 @@ export class SetPyDsl extends Mixed { return this; } - override toAst(): py.SetExpression { + override toAst() { const astElements = this._elements.map((el) => this.$node(el)); return py.factory.createSetExpression(astElements); } diff --git a/packages/openapi-python/src/py-dsl/expr/subscript.ts b/packages/openapi-python/src/py-dsl/expr/subscript.ts index 4da1c7619f..93339c7247 100644 --- a/packages/openapi-python/src/py-dsl/expr/subscript.ts +++ b/packages/openapi-python/src/py-dsl/expr/subscript.ts @@ -34,7 +34,7 @@ export class SubscriptPyDsl extends Mixed { } } - override toAst(): py.SubscriptExpression { + override toAst() { const slice = this._slices.length === 1 ? this.$node(this._slices[0]!) diff --git a/packages/openapi-python/src/py-dsl/expr/tuple.ts b/packages/openapi-python/src/py-dsl/expr/tuple.ts index 1d9bcbde42..e4ee062a33 100644 --- a/packages/openapi-python/src/py-dsl/expr/tuple.ts +++ b/packages/openapi-python/src/py-dsl/expr/tuple.ts @@ -36,7 +36,7 @@ export class TuplePyDsl extends Mixed { return this; } - override toAst(): py.TupleExpression { + override toAst() { const astElements = this._elements.map((el) => this.$node(el)); return py.factory.createTupleExpression(astElements); } diff --git a/packages/openapi-python/src/py-dsl/index.ts b/packages/openapi-python/src/py-dsl/index.ts index 114172c424..7f02acd9b4 100644 --- a/packages/openapi-python/src/py-dsl/index.ts +++ b/packages/openapi-python/src/py-dsl/index.ts @@ -23,6 +23,7 @@ import { DictPyDsl } from './expr/dict'; import { ExprPyDsl } from './expr/expr'; // import { fromValue as exprValue } from './expr/fromValue'; import { IdPyDsl } from './expr/identifier'; +import { KwargPyDsl } from './expr/kwarg'; import { ListPyDsl } from './expr/list'; import { LiteralPyDsl } from './expr/literal'; // import { NewPyDsl } from './expr/new'; @@ -146,6 +147,9 @@ const pyDsl = { /** Creates an import statement. */ import: (...args: ConstructorParameters) => new ImportPyDsl(...args), + /** Creates a keyword argument expression (e.g. `name=value`). */ + kwarg: (...args: ConstructorParameters) => new KwargPyDsl(...args), + /** Creates an initialization block or statement. */ // init: (...args: ConstructorParameters) => new InitTsDsl(...args), @@ -316,6 +320,7 @@ export type { MaybePyDsl } from './base'; // export type { MaybePyDsl, TypePyDsl } from './base'; export { PyDsl } from './base'; export type { CallArgs } from './expr/call'; +export type { AnnotationExpr } from './stmt/var'; export type { ExampleOptions } from './utils/context'; export { ctx, PyDslContext } from './utils/context'; export { keywords } from './utils/keywords'; diff --git a/packages/openapi-python/src/py-dsl/layout/doc.ts b/packages/openapi-python/src/py-dsl/layout/doc.ts index 3f1ed5f45e..89a13931dc 100644 --- a/packages/openapi-python/src/py-dsl/layout/doc.ts +++ b/packages/openapi-python/src/py-dsl/layout/doc.ts @@ -42,7 +42,7 @@ export class DocPyDsl extends PyDsl { return lines.join('\n'); } - override toAst(): py.Comment { + override toAst() { // Return a dummy comment node for compliance. return py.factory.createComment(this.resolve() ?? ''); // return this.$node(new IdTsDsl('')); diff --git a/packages/openapi-python/src/py-dsl/layout/hint.ts b/packages/openapi-python/src/py-dsl/layout/hint.ts index ac2100281a..f8924a22ca 100644 --- a/packages/openapi-python/src/py-dsl/layout/hint.ts +++ b/packages/openapi-python/src/py-dsl/layout/hint.ts @@ -40,7 +40,7 @@ export class HintPyDsl extends PyDsl { return node; } - override toAst(): py.Comment { + override toAst() { // Return a dummy comment node for compliance. const lines = this._resolveLines(); return py.factory.createComment(lines.join('\n')); diff --git a/packages/openapi-python/src/py-dsl/layout/newline.ts b/packages/openapi-python/src/py-dsl/layout/newline.ts index 080b29cffb..b2562b5ac9 100644 --- a/packages/openapi-python/src/py-dsl/layout/newline.ts +++ b/packages/openapi-python/src/py-dsl/layout/newline.ts @@ -10,7 +10,7 @@ export class NewlinePyDsl extends PyDsl { super.analyze(ctx); } - override toAst(): py.EmptyStatement { + override toAst() { return py.factory.createEmptyStatement(); } } diff --git a/packages/openapi-python/src/py-dsl/mixins/value.ts b/packages/openapi-python/src/py-dsl/mixins/value.ts index 998eb7808c..06243bb367 100644 --- a/packages/openapi-python/src/py-dsl/mixins/value.ts +++ b/packages/openapi-python/src/py-dsl/mixins/value.ts @@ -1,10 +1,10 @@ -import type { AnalysisContext, Node } from '@hey-api/codegen-core'; +import type { AnalysisContext, Node, NodeName } from '@hey-api/codegen-core'; import type { py } from '../../ts-python'; import type { MaybePyDsl } from '../base'; import type { BaseCtor, MixinCtor } from './types'; -export type ValueExpr = string | MaybePyDsl; +export type ValueExpr = NodeName | MaybePyDsl; export interface ValueMethods extends Node { $value(): py.Expression | undefined; diff --git a/packages/openapi-python/src/py-dsl/stmt/break.ts b/packages/openapi-python/src/py-dsl/stmt/break.ts index 25db8b145f..55d39f29f6 100644 --- a/packages/openapi-python/src/py-dsl/stmt/break.ts +++ b/packages/openapi-python/src/py-dsl/stmt/break.ts @@ -12,7 +12,7 @@ export class BreakPyDsl extends Mixed { super.analyze(_ctx); } - override toAst(): py.BreakStatement { + override toAst() { return py.factory.createBreakStatement(); } } diff --git a/packages/openapi-python/src/py-dsl/stmt/continue.ts b/packages/openapi-python/src/py-dsl/stmt/continue.ts index 2ddd6fdd61..3913c681ca 100644 --- a/packages/openapi-python/src/py-dsl/stmt/continue.ts +++ b/packages/openapi-python/src/py-dsl/stmt/continue.ts @@ -12,7 +12,7 @@ export class ContinuePyDsl extends Mixed { super.analyze(_ctx); } - override toAst(): py.ContinueStatement { + override toAst() { return py.factory.createContinueStatement(); } } diff --git a/packages/openapi-python/src/py-dsl/stmt/for.ts b/packages/openapi-python/src/py-dsl/stmt/for.ts index 01c3d7e9a5..7c6873eea6 100644 --- a/packages/openapi-python/src/py-dsl/stmt/for.ts +++ b/packages/openapi-python/src/py-dsl/stmt/for.ts @@ -67,7 +67,7 @@ export class ForPyDsl extends Mixed { return this; } - override toAst(): py.ForStatement { + override toAst() { this.$validate(); const body = new BlockPyDsl(...this._body!).$do(); diff --git a/packages/openapi-python/src/py-dsl/stmt/import.ts b/packages/openapi-python/src/py-dsl/stmt/import.ts index 1e8782c42e..3309359681 100644 --- a/packages/openapi-python/src/py-dsl/stmt/import.ts +++ b/packages/openapi-python/src/py-dsl/stmt/import.ts @@ -46,12 +46,7 @@ export class ImportPyDsl extends Mixed { super.analyze(_ctx); } - override toAst(): py.ImportStatement { - return { - isFrom: this.isFrom, - kind: py.PyNodeKind.ImportStatement, - module: this.module, - names: this.names, - }; + override toAst() { + return py.factory.createImportStatement(this.module, this.names, this.isFrom); } } diff --git a/packages/openapi-python/src/py-dsl/stmt/var.ts b/packages/openapi-python/src/py-dsl/stmt/var.ts index f7688e18b0..3e84fcc83d 100644 --- a/packages/openapi-python/src/py-dsl/stmt/var.ts +++ b/packages/openapi-python/src/py-dsl/stmt/var.ts @@ -2,26 +2,20 @@ import type { AnalysisContext, NodeName } from '@hey-api/codegen-core'; import { isSymbol } from '@hey-api/codegen-core'; import { py } from '../../ts-python'; -// import { TypePyDsl } from '../base'; +import type { MaybePyDsl } from '../base'; import { PyDsl } from '../base'; -// import { DocMixin } from '../mixins/doc'; -// import { HintMixin } from '../mixins/hint'; -// import { DefaultMixin, ExportMixin } from '../mixins/modifiers'; -// import { PatternMixin } from '../mixins/pattern'; import { ValueMixin } from '../mixins/value'; -// import { TypeExprPyDsl } from '../type/expr'; import { safeRuntimeName } from '../utils/name'; -// const Mixed = DefaultMixin( -// DocMixin(ExportMixin(HintMixin(PatternMixin(ValueMixin(PyDsl))))), -// ); const Mixed = ValueMixin(PyDsl); +export type AnnotationExpr = NodeName | MaybePyDsl; + export class VarPyDsl extends Mixed { readonly '~dsl' = 'VarPyDsl'; override readonly nameSanitizer = safeRuntimeName; - // protected _type?: TypePyDsl; + protected _annotation?: AnnotationExpr; constructor(name?: NodeName) { super(); @@ -34,7 +28,7 @@ export class VarPyDsl extends Mixed { override analyze(ctx: AnalysisContext): void { super.analyze(ctx); ctx.analyze(this.name); - // ctx.analyze(this._type); + ctx.analyze(this._annotation); } /** Returns true when all required builder calls are present. */ @@ -42,15 +36,19 @@ export class VarPyDsl extends Mixed { return this.missingRequiredCalls().length === 0; } - // /** Sets the variable type annotation. */ - // type(type: string | TypePyDsl): this { - // this._type = type instanceof TypePyDsl ? type : new TypeExprPyDsl(type); - // return this; - // } + /** Sets the type annotation for the variable. */ + annotate(annotation: AnnotationExpr): this { + this._annotation = annotation; + return this; + } override toAst() { this.$validate(); - return py.factory.createAssignment(this.$node(this.name)!, this.$value()!); + const target = this.$node(this.name)!; + const annotation = this.$annotation(); + const value = this.$value(); + + return py.factory.createAssignment(target, annotation, value); } $validate(): asserts this { @@ -59,10 +57,18 @@ export class VarPyDsl extends Mixed { throw new Error(`Variable assignment missing ${missing.join(' and ')}`); } + protected $annotation(): py.Expression | undefined { + return this.$node(this._annotation); + } + private missingRequiredCalls(): ReadonlyArray { const missing: Array = []; if (!this.$node(this.name)) missing.push('name'); - if (!this.$value()) missing.push('.value()'); + const hasAnnotation = this.$annotation(); + const hasValue = this.$value(); + if (!hasAnnotation && !hasValue) { + missing.push('.annotate() or .assign()'); + } return missing; } } diff --git a/packages/openapi-python/src/py-dsl/stmt/while.ts b/packages/openapi-python/src/py-dsl/stmt/while.ts index f6396b7871..5649c7c5ec 100644 --- a/packages/openapi-python/src/py-dsl/stmt/while.ts +++ b/packages/openapi-python/src/py-dsl/stmt/while.ts @@ -60,7 +60,7 @@ export class WhilePyDsl extends Mixed { return this; } - override toAst(): py.WhileStatement { + override toAst() { this.$validate(); const body = new BlockPyDsl(...this._body!).$do(); diff --git a/packages/openapi-python/src/py-dsl/stmt/with.ts b/packages/openapi-python/src/py-dsl/stmt/with.ts index 85fef3d0b9..d2fbe571df 100644 --- a/packages/openapi-python/src/py-dsl/stmt/with.ts +++ b/packages/openapi-python/src/py-dsl/stmt/with.ts @@ -72,7 +72,7 @@ export class WithPyDsl extends Mixed { return this; } - override toAst(): py.WithStatement { + override toAst() { this.$validate(); const astItems = this._items.map((item) => { diff --git a/packages/openapi-python/src/py-dsl/utils/__tests__/name.test.ts b/packages/openapi-python/src/py-dsl/utils/__tests__/name.test.ts new file mode 100644 index 0000000000..f16f45fff9 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/utils/__tests__/name.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { safeRuntimeName } from '../name'; + +describe('safeRuntimeName', () => { + const scenarios = [ + // Digits: valid as regular char, can reprocess → leading underscore + { name: '3foo', output: '_3foo' }, + { name: '123', output: '_123' }, + + // $ sign: invalid in Python as regular char → single underscore, skip reprocess + { name: '$schema', output: '_schema' }, + { name: '$foo', output: '_foo' }, + + // Hyphen: first char is valid (a, f), hyphen becomes underscore in loop + { name: 'api-version', output: 'api_version' }, + { name: 'foo-bar', output: 'foo_bar' }, + + // Normal cases + { name: 'foo', output: 'foo' }, + { name: '_private', output: '_private' }, + + // Reserved words + { name: 'class', output: 'class_' }, + ] as const; + + it.each(scenarios)('transforms $name -> $output', ({ name, output }) => { + expect(safeRuntimeName(name)).toEqual(output); + }); +}); diff --git a/packages/openapi-python/src/py-dsl/utils/lazy.ts b/packages/openapi-python/src/py-dsl/utils/lazy.ts index 271ac90d57..28e21a0731 100644 --- a/packages/openapi-python/src/py-dsl/utils/lazy.ts +++ b/packages/openapi-python/src/py-dsl/utils/lazy.ts @@ -26,7 +26,7 @@ export class LazyPyDsl extends PyDsl { return this._thunk(ctx); } - override toAst(): T { + override toAst() { return this.toResult().toAst(); } } diff --git a/packages/openapi-python/src/py-dsl/utils/name.ts b/packages/openapi-python/src/py-dsl/utils/name.ts index 90a0ffeb4e..ffb7864e42 100644 --- a/packages/openapi-python/src/py-dsl/utils/name.ts +++ b/packages/openapi-python/src/py-dsl/utils/name.ts @@ -15,6 +15,8 @@ export const safeAccessorName = (name: string): string => { return `'${name}'`; }; +const validPythonChar = /^[a-zA-Z0-9_]$/; + const safeName = (name: string, reserved: ReservedList): string => { let sanitized = ''; let index: number; @@ -22,8 +24,14 @@ const safeName = (name: string, reserved: ReservedList): string => { const first = name[0] ?? ''; regexp.illegalStartCharacters.lastIndex = 0; if (regexp.illegalStartCharacters.test(first)) { - sanitized += '_'; - index = 0; + // Check if character becomes valid when not in leading position (e.g., digits) + if (validPythonChar.test(first)) { + sanitized += '_'; + index = 0; + } else { + sanitized += '_'; + index = 1; + } } else { sanitized += first; index = 1; @@ -31,7 +39,7 @@ const safeName = (name: string, reserved: ReservedList): string => { while (index < name.length) { const char = name[index] ?? ''; - sanitized += /^[a-zA-Z0-9_]$/.test(char) ? char : '_'; + sanitized += validPythonChar.test(char) ? char : '_'; index += 1; } diff --git a/packages/openapi-python/src/ts-python/__snapshots__/nodes/expressions/kwarg/__init__.py b/packages/openapi-python/src/ts-python/__snapshots__/nodes/expressions/kwarg/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/openapi-python/src/ts-python/__snapshots__/nodes/expressions/kwarg/multiple.py b/packages/openapi-python/src/ts-python/__snapshots__/nodes/expressions/kwarg/multiple.py new file mode 100644 index 0000000000..2197209f7f --- /dev/null +++ b/packages/openapi-python/src/ts-python/__snapshots__/nodes/expressions/kwarg/multiple.py @@ -0,0 +1 @@ +result = Field(..., min_length=1, max_length=100, description="A field") diff --git a/packages/openapi-python/src/ts-python/__snapshots__/nodes/expressions/kwarg/number.py b/packages/openapi-python/src/ts-python/__snapshots__/nodes/expressions/kwarg/number.py new file mode 100644 index 0000000000..aac7986bd5 --- /dev/null +++ b/packages/openapi-python/src/ts-python/__snapshots__/nodes/expressions/kwarg/number.py @@ -0,0 +1 @@ +result = func(count=42) diff --git a/packages/openapi-python/src/ts-python/__snapshots__/nodes/expressions/kwarg/string.py b/packages/openapi-python/src/ts-python/__snapshots__/nodes/expressions/kwarg/string.py new file mode 100644 index 0000000000..7302b81377 --- /dev/null +++ b/packages/openapi-python/src/ts-python/__snapshots__/nodes/expressions/kwarg/string.py @@ -0,0 +1 @@ +result = func(name="test") diff --git a/packages/openapi-python/src/ts-python/__snapshots__/nodes/statements/assignment/annotation-only.py b/packages/openapi-python/src/ts-python/__snapshots__/nodes/statements/assignment/annotation-only.py new file mode 100644 index 0000000000..1622fb5b83 --- /dev/null +++ b/packages/openapi-python/src/ts-python/__snapshots__/nodes/statements/assignment/annotation-only.py @@ -0,0 +1 @@ +name: str diff --git a/packages/openapi-python/src/ts-python/__snapshots__/nodes/statements/assignment/annotation-with-value.py b/packages/openapi-python/src/ts-python/__snapshots__/nodes/statements/assignment/annotation-with-value.py new file mode 100644 index 0000000000..177b3db415 --- /dev/null +++ b/packages/openapi-python/src/ts-python/__snapshots__/nodes/statements/assignment/annotation-with-value.py @@ -0,0 +1 @@ +name: str = "default" diff --git a/packages/openapi-python/src/ts-python/__snapshots__/nodes/statements/assignment/complex-annotation.py b/packages/openapi-python/src/ts-python/__snapshots__/nodes/statements/assignment/complex-annotation.py new file mode 100644 index 0000000000..619568af33 --- /dev/null +++ b/packages/openapi-python/src/ts-python/__snapshots__/nodes/statements/assignment/complex-annotation.py @@ -0,0 +1 @@ +items: List[str] = Field(..., min_length=1) diff --git a/packages/openapi-python/src/ts-python/__snapshots__/nodes/statements/assignment/optional-annotation.py b/packages/openapi-python/src/ts-python/__snapshots__/nodes/statements/assignment/optional-annotation.py new file mode 100644 index 0000000000..71bd98f9cc --- /dev/null +++ b/packages/openapi-python/src/ts-python/__snapshots__/nodes/statements/assignment/optional-annotation.py @@ -0,0 +1 @@ +name: Optional[str] = None diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/binary.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/binary.test.ts index 6cc2b62842..b802aec728 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/binary.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/binary.test.ts @@ -4,10 +4,19 @@ import { assertPrintedMatchesSnapshot } from '../utils'; describe('binary expression', () => { it('add', async () => { const file = py.factory.createSourceFile([ - py.factory.createAssignment(py.factory.createIdentifier('a'), py.factory.createLiteral(42)), - py.factory.createAssignment(py.factory.createIdentifier('b'), py.factory.createLiteral(84)), + py.factory.createAssignment( + py.factory.createIdentifier('a'), + undefined, + py.factory.createLiteral(42), + ), + py.factory.createAssignment( + py.factory.createIdentifier('b'), + undefined, + py.factory.createLiteral(84), + ), py.factory.createAssignment( py.factory.createIdentifier('z'), + undefined, py.factory.createBinaryExpression( py.factory.createIdentifier('a'), '+', @@ -20,10 +29,19 @@ describe('binary expression', () => { it('subtract', async () => { const file = py.factory.createSourceFile([ - py.factory.createAssignment(py.factory.createIdentifier('a'), py.factory.createLiteral(42)), - py.factory.createAssignment(py.factory.createIdentifier('b'), py.factory.createLiteral(84)), + py.factory.createAssignment( + py.factory.createIdentifier('a'), + undefined, + py.factory.createLiteral(42), + ), + py.factory.createAssignment( + py.factory.createIdentifier('b'), + undefined, + py.factory.createLiteral(84), + ), py.factory.createAssignment( py.factory.createIdentifier('z'), + undefined, py.factory.createBinaryExpression( py.factory.createIdentifier('a'), '-', diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/dict.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/dict.test.ts index 0c1244fe8b..8bfe1e6a3d 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/dict.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/dict.test.ts @@ -11,6 +11,7 @@ describe('dict comprehension', () => { [ py.factory.createAssignment( py.factory.createIdentifier('items'), + undefined, py.factory.createDictExpression([ { key: py.factory.createLiteral('key1'), diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/list.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/list.test.ts index b95e285884..58ead279c2 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/list.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/list.test.ts @@ -11,6 +11,7 @@ describe('list comprehension', () => { [ py.factory.createAssignment( py.factory.createIdentifier('items'), + undefined, py.factory.createListExpression([ py.factory.createLiteral(1), py.factory.createLiteral(2), @@ -19,6 +20,7 @@ describe('list comprehension', () => { ), py.factory.createAssignment( py.factory.createIdentifier('evens'), + undefined, py.factory.createListComprehension( py.factory.createIdentifier('x'), py.factory.createIdentifier('x'), diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/nested.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/nested.test.ts index 28cbfc017c..dac89eff1f 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/nested.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/nested.test.ts @@ -6,6 +6,7 @@ describe('nested comprehension', () => { const file = py.factory.createSourceFile([ py.factory.createAssignment( py.factory.createIdentifier('data'), + undefined, py.factory.createDictExpression([ { key: py.factory.createLiteral('numbers'), diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/set.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/set.test.ts index 481843e7a1..9e700c7b4b 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/set.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/set.test.ts @@ -11,6 +11,7 @@ describe('set comprehension', () => { [ py.factory.createAssignment( py.factory.createIdentifier('items'), + undefined, py.factory.createListExpression([ py.factory.createLiteral(1), py.factory.createLiteral(2), @@ -19,6 +20,7 @@ describe('set comprehension', () => { ), py.factory.createAssignment( py.factory.createIdentifier('unique_evens'), + undefined, py.factory.createSetComprehension( py.factory.createIdentifier('x'), py.factory.createIdentifier('x'), diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/dict.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/dict.test.ts index e92f3c0404..c8cd8138f2 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/dict.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/dict.test.ts @@ -6,6 +6,7 @@ describe('dict expression', () => { const file = py.factory.createSourceFile([ py.factory.createAssignment( py.factory.createIdentifier('person'), + undefined, py.factory.createDictExpression([ { key: py.factory.createLiteral('name'), diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/fString.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/fString.test.ts index 5d737a7bce..5cbab98ce1 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/fString.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/fString.test.ts @@ -6,6 +6,7 @@ describe('f-string expression', () => { const file = py.factory.createSourceFile([ py.factory.createAssignment( py.factory.createIdentifier('name'), + undefined, py.factory.createLiteral('Joe'), ), py.factory.createExpressionStatement( @@ -19,8 +20,16 @@ describe('f-string expression', () => { it('with multiple expressions', async () => { const file = py.factory.createSourceFile([ - py.factory.createAssignment(py.factory.createIdentifier('a'), py.factory.createLiteral(1)), - py.factory.createAssignment(py.factory.createIdentifier('b'), py.factory.createLiteral(2)), + py.factory.createAssignment( + py.factory.createIdentifier('a'), + undefined, + py.factory.createLiteral(1), + ), + py.factory.createAssignment( + py.factory.createIdentifier('b'), + undefined, + py.factory.createLiteral(2), + ), py.factory.createExpressionStatement( py.factory.createCallExpression(py.factory.createIdentifier('print'), [ py.factory.createFStringExpression([ diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/generator.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/generator.test.ts index 76f695fec6..a2800f8283 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/generator.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/generator.test.ts @@ -6,6 +6,7 @@ describe('generator expression', () => { const file = py.factory.createSourceFile([ py.factory.createAssignment( py.factory.createIdentifier('x_iter'), + undefined, py.factory.createListExpression([ py.factory.createLiteral(1), py.factory.createLiteral(2), @@ -27,6 +28,7 @@ describe('generator expression', () => { const file = py.factory.createSourceFile([ py.factory.createAssignment( py.factory.createIdentifier('x_iter'), + undefined, py.factory.createListExpression([ py.factory.createLiteral(1), py.factory.createLiteral(2), @@ -55,6 +57,7 @@ describe('generator expression', () => { const file = py.factory.createSourceFile([ py.factory.createAssignment( py.factory.createIdentifier('x_iter'), + undefined, py.factory.createListExpression([ py.factory.createLiteral(1), py.factory.createLiteral(2), diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/identifier.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/identifier.test.ts index dfa528334c..125911f88d 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/identifier.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/identifier.test.ts @@ -4,9 +4,14 @@ import { assertPrintedMatchesSnapshot } from '../utils'; describe('identifier expression', () => { it('assignment', async () => { const file = py.factory.createSourceFile([ - py.factory.createAssignment(py.factory.createIdentifier('y'), py.factory.createLiteral(42)), + py.factory.createAssignment( + py.factory.createIdentifier('y'), + undefined, + py.factory.createLiteral(42), + ), py.factory.createAssignment( py.factory.createIdentifier('x'), + undefined, py.factory.createIdentifier('y'), ), ]); diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/kwarg.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/kwarg.test.ts new file mode 100644 index 0000000000..acb40972c6 --- /dev/null +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/kwarg.test.ts @@ -0,0 +1,46 @@ +import { py } from '../../../index'; +import { assertPrintedMatchesSnapshot } from '../utils'; + +describe('keyword argument expression', () => { + it('string value', async () => { + const file = py.factory.createSourceFile([ + py.factory.createAssignment( + py.factory.createIdentifier('result'), + undefined, + py.factory.createCallExpression(py.factory.createIdentifier('func'), [ + py.factory.createKeywordArgument('name', py.factory.createLiteral('test')), + ]), + ), + ]); + await assertPrintedMatchesSnapshot(file, 'string.py'); + }); + + it('number value', async () => { + const file = py.factory.createSourceFile([ + py.factory.createAssignment( + py.factory.createIdentifier('result'), + undefined, + py.factory.createCallExpression(py.factory.createIdentifier('func'), [ + py.factory.createKeywordArgument('count', py.factory.createLiteral(42)), + ]), + ), + ]); + await assertPrintedMatchesSnapshot(file, 'number.py'); + }); + + it('multiple keyword arguments', async () => { + const file = py.factory.createSourceFile([ + py.factory.createAssignment( + py.factory.createIdentifier('result'), + undefined, + py.factory.createCallExpression(py.factory.createIdentifier('Field'), [ + py.factory.createIdentifier('...'), + py.factory.createKeywordArgument('min_length', py.factory.createLiteral(1)), + py.factory.createKeywordArgument('max_length', py.factory.createLiteral(100)), + py.factory.createKeywordArgument('description', py.factory.createLiteral('A field')), + ]), + ), + ]); + await assertPrintedMatchesSnapshot(file, 'multiple.py'); + }); +}); diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/lambda.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/lambda.test.ts index 0178f8f8bf..24d6fef5dc 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/lambda.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/lambda.test.ts @@ -4,7 +4,11 @@ import { assertPrintedMatchesSnapshot } from '../utils'; describe('lambda expression', () => { it('simple', async () => { const file = py.factory.createSourceFile([ - py.factory.createAssignment(py.factory.createIdentifier('x'), py.factory.createLiteral(5)), + py.factory.createAssignment( + py.factory.createIdentifier('x'), + undefined, + py.factory.createLiteral(5), + ), py.factory.createExpressionStatement( py.factory.createLambdaExpression( [], diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/list.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/list.test.ts index cb29454fde..1d5e8ba699 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/list.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/list.test.ts @@ -6,6 +6,7 @@ describe('list expression', () => { const file = py.factory.createSourceFile([ py.factory.createAssignment( py.factory.createIdentifier('nums'), + undefined, py.factory.createListExpression([ py.factory.createLiteral(1), py.factory.createLiteral(2), diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/literal.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/literal.test.ts index 7d72f6a112..3bc930effb 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/literal.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/literal.test.ts @@ -6,12 +6,22 @@ describe('literal expression', () => { const file = py.factory.createSourceFile([ py.factory.createAssignment( py.factory.createIdentifier('s'), + undefined, py.factory.createLiteral('hello'), ), - py.factory.createAssignment(py.factory.createIdentifier('n'), py.factory.createLiteral(123)), - py.factory.createAssignment(py.factory.createIdentifier('b'), py.factory.createLiteral(true)), + py.factory.createAssignment( + py.factory.createIdentifier('n'), + undefined, + py.factory.createLiteral(123), + ), + py.factory.createAssignment( + py.factory.createIdentifier('b'), + undefined, + py.factory.createLiteral(true), + ), py.factory.createAssignment( py.factory.createIdentifier('none'), + undefined, py.factory.createLiteral(null), ), ]); diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/set.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/set.test.ts index eeb8591d48..79600ca8dc 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/set.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/set.test.ts @@ -6,14 +6,17 @@ describe('set expression', () => { const file = py.factory.createSourceFile([ py.factory.createAssignment( py.factory.createIdentifier('foo'), + undefined, py.factory.createLiteral('bar'), ), py.factory.createAssignment( py.factory.createIdentifier('emptySet'), + undefined, py.factory.createSetExpression([]), ), py.factory.createAssignment( py.factory.createIdentifier('numberSet'), + undefined, py.factory.createSetExpression([ py.factory.createLiteral(1), py.factory.createLiteral(2), @@ -22,6 +25,7 @@ describe('set expression', () => { ), py.factory.createAssignment( py.factory.createIdentifier('mixedSet'), + undefined, py.factory.createSetExpression([ py.factory.createLiteral('a'), py.factory.createLiteral(true), diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/subscript.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/subscript.test.ts index d1fa044fb2..0e2beaac03 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/subscript.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/subscript.test.ts @@ -6,6 +6,7 @@ describe('subscript expression', () => { const file = py.factory.createSourceFile([ py.factory.createAssignment( py.factory.createIdentifier('numbers'), + undefined, py.factory.createSubscriptExpression( py.factory.createIdentifier('list'), py.factory.createIdentifier('int'), @@ -19,6 +20,7 @@ describe('subscript expression', () => { const file = py.factory.createSourceFile([ py.factory.createAssignment( py.factory.createIdentifier('data'), + undefined, py.factory.createSubscriptExpression( py.factory.createIdentifier('dict'), py.factory.createSubscriptSlice([ @@ -35,6 +37,7 @@ describe('subscript expression', () => { const file = py.factory.createSourceFile([ py.factory.createAssignment( py.factory.createIdentifier('items'), + undefined, py.factory.createTupleExpression([ py.factory.createLiteral(1), py.factory.createLiteral(2), @@ -43,6 +46,7 @@ describe('subscript expression', () => { ), py.factory.createAssignment( py.factory.createIdentifier('first'), + undefined, py.factory.createSubscriptExpression( py.factory.createIdentifier('items'), py.factory.createLiteral(0), @@ -56,6 +60,7 @@ describe('subscript expression', () => { const file = py.factory.createSourceFile([ py.factory.createAssignment( py.factory.createIdentifier('matrix'), + undefined, py.factory.createSubscriptExpression( py.factory.createIdentifier('list'), py.factory.createSubscriptExpression( diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/tuple.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/tuple.test.ts index 00be79a158..8931d12888 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/tuple.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/tuple.test.ts @@ -6,6 +6,7 @@ describe('tuple expression', () => { const file = py.factory.createSourceFile([ py.factory.createAssignment( py.factory.createIdentifier('t'), + undefined, py.factory.createTupleExpression([ py.factory.createLiteral(1), py.factory.createLiteral(2), @@ -14,6 +15,7 @@ describe('tuple expression', () => { ), py.factory.createAssignment( py.factory.createIdentifier('single'), + undefined, py.factory.createTupleExpression([py.factory.createLiteral(42)]), ), ]); diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/yield.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/yield.test.ts index 5404f1ac70..b3c18aeafb 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/yield.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/yield.test.ts @@ -26,6 +26,7 @@ describe('yield expression', () => { const file = py.factory.createSourceFile([ py.factory.createAssignment( py.factory.createIdentifier('iterable'), + undefined, py.factory.createListExpression([ py.factory.createLiteral(1), py.factory.createLiteral(2), diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/assignment.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/assignment.test.ts index 0539f9862c..0af6b28f85 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/assignment.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/assignment.test.ts @@ -4,8 +4,64 @@ import { assertPrintedMatchesSnapshot } from '../utils'; describe('assignment statement', () => { it('primitive variables', async () => { const file = py.factory.createSourceFile([ - py.factory.createAssignment(py.factory.createIdentifier('foo'), py.factory.createLiteral(42)), + py.factory.createAssignment( + py.factory.createIdentifier('foo'), + undefined, + py.factory.createLiteral(42), + ), ]); await assertPrintedMatchesSnapshot(file, 'primitive.py'); }); + + it('annotation only', async () => { + const file = py.factory.createSourceFile([ + py.factory.createAssignment( + py.factory.createIdentifier('name'), + py.factory.createIdentifier('str'), + ), + ]); + await assertPrintedMatchesSnapshot(file, 'annotation-only.py'); + }); + + it('annotation with value', async () => { + const file = py.factory.createSourceFile([ + py.factory.createAssignment( + py.factory.createIdentifier('name'), + py.factory.createIdentifier('str'), + py.factory.createLiteral('default'), + ), + ]); + await assertPrintedMatchesSnapshot(file, 'annotation-with-value.py'); + }); + + it('optional annotation', async () => { + const file = py.factory.createSourceFile([ + py.factory.createAssignment( + py.factory.createIdentifier('name'), + py.factory.createSubscriptExpression( + py.factory.createIdentifier('Optional'), + py.factory.createIdentifier('str'), + ), + py.factory.createIdentifier('None'), + ), + ]); + await assertPrintedMatchesSnapshot(file, 'optional-annotation.py'); + }); + + it('complex type annotation', async () => { + const file = py.factory.createSourceFile([ + py.factory.createAssignment( + py.factory.createIdentifier('items'), + py.factory.createSubscriptExpression( + py.factory.createIdentifier('List'), + py.factory.createIdentifier('str'), + ), + py.factory.createCallExpression(py.factory.createIdentifier('Field'), [ + py.factory.createIdentifier('...'), + py.factory.createKeywordArgument('min_length', py.factory.createLiteral(1)), + ]), + ), + ]); + await assertPrintedMatchesSnapshot(file, 'complex-annotation.py'); + }); }); diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/augmentedAssignment.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/augmentedAssignment.test.ts index ea040ac3c7..36ec4b9ca2 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/augmentedAssignment.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/augmentedAssignment.test.ts @@ -4,12 +4,36 @@ import { assertPrintedMatchesSnapshot } from '../utils'; describe('augmented assignment statement', () => { it('arithmetic operators', async () => { const file = py.factory.createSourceFile([ - py.factory.createAssignment(py.factory.createIdentifier('x'), py.factory.createLiteral(0)), - py.factory.createAssignment(py.factory.createIdentifier('y'), py.factory.createLiteral(0)), - py.factory.createAssignment(py.factory.createIdentifier('z'), py.factory.createLiteral(0)), - py.factory.createAssignment(py.factory.createIdentifier('a'), py.factory.createLiteral(0.0)), - py.factory.createAssignment(py.factory.createIdentifier('b'), py.factory.createLiteral(0)), - py.factory.createAssignment(py.factory.createIdentifier('c'), py.factory.createLiteral(0)), + py.factory.createAssignment( + py.factory.createIdentifier('x'), + undefined, + py.factory.createLiteral(0), + ), + py.factory.createAssignment( + py.factory.createIdentifier('y'), + undefined, + py.factory.createLiteral(0), + ), + py.factory.createAssignment( + py.factory.createIdentifier('z'), + undefined, + py.factory.createLiteral(0), + ), + py.factory.createAssignment( + py.factory.createIdentifier('a'), + undefined, + py.factory.createLiteral(0.0), + ), + py.factory.createAssignment( + py.factory.createIdentifier('b'), + undefined, + py.factory.createLiteral(0), + ), + py.factory.createAssignment( + py.factory.createIdentifier('c'), + undefined, + py.factory.createLiteral(0), + ), py.factory.createAugmentedAssignment( py.factory.createIdentifier('x'), @@ -47,12 +71,36 @@ describe('augmented assignment statement', () => { it('power and bitwise operators', async () => { const file = py.factory.createSourceFile([ - py.factory.createAssignment(py.factory.createIdentifier('x'), py.factory.createLiteral(1)), - py.factory.createAssignment(py.factory.createIdentifier('y'), py.factory.createLiteral(1)), - py.factory.createAssignment(py.factory.createIdentifier('z'), py.factory.createLiteral(1)), - py.factory.createAssignment(py.factory.createIdentifier('a'), py.factory.createLiteral(1)), - py.factory.createAssignment(py.factory.createIdentifier('b'), py.factory.createLiteral(1)), - py.factory.createAssignment(py.factory.createIdentifier('c'), py.factory.createLiteral(1)), + py.factory.createAssignment( + py.factory.createIdentifier('x'), + undefined, + py.factory.createLiteral(1), + ), + py.factory.createAssignment( + py.factory.createIdentifier('y'), + undefined, + py.factory.createLiteral(1), + ), + py.factory.createAssignment( + py.factory.createIdentifier('z'), + undefined, + py.factory.createLiteral(1), + ), + py.factory.createAssignment( + py.factory.createIdentifier('a'), + undefined, + py.factory.createLiteral(1), + ), + py.factory.createAssignment( + py.factory.createIdentifier('b'), + undefined, + py.factory.createLiteral(1), + ), + py.factory.createAssignment( + py.factory.createIdentifier('c'), + undefined, + py.factory.createLiteral(1), + ), py.factory.createAugmentedAssignment( py.factory.createIdentifier('x'), diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/for.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/for.test.ts index 6397044a6c..6e9fa2ad68 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/for.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/for.test.ts @@ -25,6 +25,7 @@ describe('for statement', () => { const file = py.factory.createSourceFile([ py.factory.createAssignment( py.factory.createIdentifier('items'), + undefined, py.factory.createListExpression([ py.factory.createLiteral(1), py.factory.createLiteral(2), diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/if.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/if.test.ts index 95dfe2daf5..9af2e15d17 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/if.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/if.test.ts @@ -17,7 +17,11 @@ describe('if statement', () => { it('with else', async () => { const file = py.factory.createSourceFile([ - py.factory.createAssignment(py.factory.createIdentifier('x'), py.factory.createLiteral(0)), + py.factory.createAssignment( + py.factory.createIdentifier('x'), + undefined, + py.factory.createLiteral(0), + ), py.factory.createIfStatement( py.factory.createBinaryExpression( py.factory.createIdentifier('x'), diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/while.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/while.test.ts index 0cb875aea2..a8f98f99d9 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/while.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/while.test.ts @@ -4,7 +4,11 @@ import { assertPrintedMatchesSnapshot } from '../utils'; describe('while statement', () => { it('simple', async () => { const file = py.factory.createSourceFile([ - py.factory.createAssignment(py.factory.createIdentifier('x'), py.factory.createLiteral(3)), + py.factory.createAssignment( + py.factory.createIdentifier('x'), + undefined, + py.factory.createLiteral(3), + ), py.factory.createWhileStatement( py.factory.createBinaryExpression( py.factory.createIdentifier('x'), @@ -19,6 +23,7 @@ describe('while statement', () => { ), py.factory.createAssignment( py.factory.createIdentifier('x'), + undefined, py.factory.createBinaryExpression( py.factory.createIdentifier('x'), '-', diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/structure/sourceFile.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/structure/sourceFile.test.ts index b1d95811ad..641348bf76 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/structure/sourceFile.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/structure/sourceFile.test.ts @@ -4,8 +4,16 @@ import { assertPrintedMatchesSnapshot } from '../utils'; describe('source file', () => { it('simple', async () => { const file = py.factory.createSourceFile([ - py.factory.createAssignment(py.factory.createIdentifier('a'), py.factory.createLiteral(1)), - py.factory.createAssignment(py.factory.createIdentifier('b'), py.factory.createLiteral(2)), + py.factory.createAssignment( + py.factory.createIdentifier('a'), + undefined, + py.factory.createLiteral(1), + ), + py.factory.createAssignment( + py.factory.createIdentifier('b'), + undefined, + py.factory.createLiteral(2), + ), ]); await assertPrintedMatchesSnapshot(file, 'simple.py'); }); @@ -15,6 +23,7 @@ describe('source file', () => { [ py.factory.createAssignment( py.factory.createIdentifier('foo'), + undefined, py.factory.createLiteral(1), ), ], diff --git a/packages/openapi-python/src/ts-python/index.ts b/packages/openapi-python/src/ts-python/index.ts index 5f9d798f56..614c439c85 100644 --- a/packages/openapi-python/src/ts-python/index.ts +++ b/packages/openapi-python/src/ts-python/index.ts @@ -21,6 +21,7 @@ import type { PyDictExpression as _PyDictExpression } from './nodes/expressions/ import type { PyFStringExpression as _PyFStringExpression } from './nodes/expressions/fString'; import type { PyGeneratorExpression as _PyGeneratorExpression } from './nodes/expressions/generator'; import type { PyIdentifier as _PyIdentifier } from './nodes/expressions/identifier'; +import type { PyKeywordArgument as _PyKeywordArgument } from './nodes/expressions/keywordArg'; import type { PyLambdaExpression as _PyLambdaExpression } from './nodes/expressions/lambda'; import type { PyListExpression as _PyListExpression } from './nodes/expressions/list'; import type { PyLiteral as _PyLiteral } from './nodes/expressions/literal'; @@ -107,6 +108,7 @@ export namespace py { export type FStringExpression = _PyFStringExpression; export type GeneratorExpression = _PyGeneratorExpression; export type Identifier = _PyIdentifier; + export type KeywordArgument = _PyKeywordArgument; export type LambdaExpression = _PyLambdaExpression; export type ListExpression = _PyListExpression; export type Literal = _PyLiteral; diff --git a/packages/openapi-python/src/ts-python/nodes/expression.ts b/packages/openapi-python/src/ts-python/nodes/expression.ts index 4899cdc1be..496f9b4279 100644 --- a/packages/openapi-python/src/ts-python/nodes/expression.ts +++ b/packages/openapi-python/src/ts-python/nodes/expression.ts @@ -7,6 +7,7 @@ import type { PyDictExpression } from './expressions/dict'; import type { PyFStringExpression } from './expressions/fString'; import type { PyGeneratorExpression } from './expressions/generator'; import type { PyIdentifier } from './expressions/identifier'; +import type { PyKeywordArgument } from './expressions/keywordArg'; import type { PyLambdaExpression } from './expressions/lambda'; import type { PyListExpression } from './expressions/list'; import type { PyLiteral } from './expressions/literal'; @@ -28,6 +29,7 @@ export type PyExpression = | PyFStringExpression | PyGeneratorExpression | PyIdentifier + | PyKeywordArgument | PyLambdaExpression | PyListExpression | PyLiteral diff --git a/packages/openapi-python/src/ts-python/nodes/expressions/keywordArg.ts b/packages/openapi-python/src/ts-python/nodes/expressions/keywordArg.ts new file mode 100644 index 0000000000..e84c7afb68 --- /dev/null +++ b/packages/openapi-python/src/ts-python/nodes/expressions/keywordArg.ts @@ -0,0 +1,24 @@ +import type { PyNodeBase } from '../base'; +import type { PyExpression } from '../expression'; +import { PyNodeKind } from '../kinds'; + +export interface PyKeywordArgument extends PyNodeBase { + kind: PyNodeKind.KeywordArgument; + name: string; + value: PyExpression; +} + +export function createKeywordArgument( + name: string, + value: PyExpression, + leadingComments?: ReadonlyArray, + trailingComments?: ReadonlyArray, +): PyKeywordArgument { + return { + kind: PyNodeKind.KeywordArgument, + leadingComments, + name, + trailingComments, + value, + }; +} diff --git a/packages/openapi-python/src/ts-python/nodes/factory.ts b/packages/openapi-python/src/ts-python/nodes/factory.ts index 3a045c715a..b7de35b27d 100644 --- a/packages/openapi-python/src/ts-python/nodes/factory.ts +++ b/packages/openapi-python/src/ts-python/nodes/factory.ts @@ -12,6 +12,7 @@ import { createDictExpression } from './expressions/dict'; import { createFStringExpression } from './expressions/fString'; import { createGeneratorExpression } from './expressions/generator'; import { createIdentifier } from './expressions/identifier'; +import { createKeywordArgument } from './expressions/keywordArg'; import { createLambdaExpression } from './expressions/lambda'; import { createListExpression } from './expressions/list'; import { createLiteral } from './expressions/literal'; @@ -67,6 +68,7 @@ export const factory = { createIdentifier, createIfStatement, createImportStatement, + createKeywordArgument, createLambdaExpression, createListComprehension, createListExpression, diff --git a/packages/openapi-python/src/ts-python/nodes/kinds.ts b/packages/openapi-python/src/ts-python/nodes/kinds.ts index b6d1cd8d16..d9e1b0cc7d 100644 --- a/packages/openapi-python/src/ts-python/nodes/kinds.ts +++ b/packages/openapi-python/src/ts-python/nodes/kinds.ts @@ -23,6 +23,7 @@ export enum PyNodeKind { Identifier = 'Identifier', IfStatement = 'IfStatement', ImportStatement = 'ImportStatement', + KeywordArgument = 'KeywordArgument', LambdaExpression = 'LambdaExpression', ListComprehension = 'ListComprehension', ListExpression = 'ListExpression', diff --git a/packages/openapi-python/src/ts-python/nodes/statements/assignment.ts b/packages/openapi-python/src/ts-python/nodes/statements/assignment.ts index ffa45de7c6..28c4603c5b 100644 --- a/packages/openapi-python/src/ts-python/nodes/statements/assignment.ts +++ b/packages/openapi-python/src/ts-python/nodes/statements/assignment.ts @@ -3,18 +3,25 @@ import type { PyExpression } from '../expression'; import { PyNodeKind } from '../kinds'; export interface PyAssignment extends PyNodeBase { + annotation?: PyExpression; kind: PyNodeKind.Assignment; target: PyExpression; - value: PyExpression; + value?: PyExpression; } export function createAssignment( target: PyExpression, - value: PyExpression, + annotation?: PyExpression, + value?: PyExpression, leadingComments?: ReadonlyArray, trailingComments?: ReadonlyArray, ): PyAssignment { + if (!annotation && !value) { + throw new Error('Assignment requires at least annotation or value'); + } + return { + annotation, kind: PyNodeKind.Assignment, leadingComments, target, diff --git a/packages/openapi-python/src/ts-python/printer.ts b/packages/openapi-python/src/ts-python/printer.ts index d8446a22fe..973b859955 100644 --- a/packages/openapi-python/src/ts-python/printer.ts +++ b/packages/openapi-python/src/ts-python/printer.ts @@ -48,9 +48,20 @@ export function createPrinter(options?: PyPrinterOptions) { let indentTrailingComments = false; switch (node.kind) { - case PyNodeKind.Assignment: - parts.push(printLine(`${printNode(node.target)} = ${printNode(node.value)}`)); + case PyNodeKind.Assignment: { + const target = printNode(node.target); + if (node.annotation) { + const annotation = printNode(node.annotation); + if (node.value) { + parts.push(printLine(`${target}: ${annotation} = ${printNode(node.value)}`)); + } else { + parts.push(printLine(`${target}: ${annotation}`)); + } + } else { + parts.push(printLine(`${target} = ${printNode(node.value!)}`)); + } break; + } case PyNodeKind.AsyncExpression: parts.push(`async ${printNode(node.expression)}`); @@ -213,6 +224,10 @@ export function createPrinter(options?: PyPrinterOptions) { } break; + case PyNodeKind.KeywordArgument: + parts.push(`${node.name}=${printNode(node.value)}`); + break; + case PyNodeKind.ImportStatement: { const fromPrefix = node.isFrom ? `from ${node.module} ` : ''; if (fromPrefix) { @@ -399,8 +414,7 @@ export function createPrinter(options?: PyPrinterOptions) { break; default: - // @ts-expect-error - throw new Error(`Unsupported node kind: ${node.kind}`); + throw new Error(`Unsupported node kind: ${(node as { kind: string }).kind}`); } if (node.trailingComments) { diff --git a/packages/openapi-ts/src/plugins/valibot/v1/processor.ts b/packages/openapi-ts/src/plugins/valibot/v1/processor.ts index 164c204b76..4337b6e84a 100644 --- a/packages/openapi-ts/src/plugins/valibot/v1/processor.ts +++ b/packages/openapi-ts/src/plugins/valibot/v1/processor.ts @@ -1,5 +1,5 @@ import { ref } from '@hey-api/codegen-core'; -import type { IR } from '@hey-api/shared'; +import type { Hooks, IR } from '@hey-api/shared'; import { createSchemaProcessor, createSchemaWalker, pathToJsonPointer } from '@hey-api/shared'; import { exportAst } from '../shared/export'; @@ -11,15 +11,18 @@ import { createVisitor } from './walker'; export function createProcessor(plugin: ValibotPlugin['Instance']): ProcessorResult { const processor = createSchemaProcessor(); - const hooks = [plugin.config['~hooks']?.schemas, plugin.context.config.parser.hooks.schemas]; + const extractorHooks: ReadonlyArray['shouldExtract']> = [ + plugin.config['~hooks']?.schemas?.shouldExtract, + plugin.context.config.parser.hooks.schemas?.shouldExtract, + ]; function extractor(ctx: ProcessorContext): IR.SchemaObject { if (processor.hasEmitted(ctx.path)) { return ctx.schema; } - for (const hook of hooks) { - const result = hook?.shouldExtract?.(ctx); + for (const hook of extractorHooks) { + const result = hook?.(ctx); if (result) { process({ namingAnchor: processor.context.anchor, diff --git a/packages/openapi-ts/src/plugins/zod/shared/processor.ts b/packages/openapi-ts/src/plugins/zod/shared/processor.ts index a7de612eed..8eba692618 100644 --- a/packages/openapi-ts/src/plugins/zod/shared/processor.ts +++ b/packages/openapi-ts/src/plugins/zod/shared/processor.ts @@ -5,12 +5,14 @@ import type { SchemaProcessorResult, } from '@hey-api/shared'; -import type { IrSchemaToAstOptions, TypeOptions } from './types'; +import type { ZodPlugin } from '../types'; +import type { TypeOptions } from './types'; -export type ProcessorContext = Pick & - SchemaProcessorContext & { - naming: NamingConfig & TypeOptions; - schema: IR.SchemaObject; - }; +export type ProcessorContext = SchemaProcessorContext & { + naming: NamingConfig & TypeOptions; + /** The plugin instance. */ + plugin: ZodPlugin['Instance']; + schema: IR.SchemaObject; +}; export type ProcessorResult = SchemaProcessorResult; diff --git a/packages/openapi-ts/src/ts-dsl/layout/doc.ts b/packages/openapi-ts/src/ts-dsl/layout/doc.ts index c6e1d28fa8..d3e98a26a8 100644 --- a/packages/openapi-ts/src/ts-dsl/layout/doc.ts +++ b/packages/openapi-ts/src/ts-dsl/layout/doc.ts @@ -60,7 +60,7 @@ export class DocTsDsl extends TsDsl { return node; } - override toAst(): ts.Node { + override toAst() { // this class does not build a standalone node; // it modifies other nodes via `apply()`. // Return a dummy comment node for compliance. diff --git a/packages/openapi-ts/src/ts-dsl/layout/hint.ts b/packages/openapi-ts/src/ts-dsl/layout/hint.ts index b0b0e532a8..ae9f73a6fb 100644 --- a/packages/openapi-ts/src/ts-dsl/layout/hint.ts +++ b/packages/openapi-ts/src/ts-dsl/layout/hint.ts @@ -48,7 +48,7 @@ export class HintTsDsl extends TsDsl { return node; } - override toAst(): ts.Node { + override toAst() { // this class does not build a standalone node; // it modifies other nodes via `apply()`. // Return a dummy comment node for compliance. diff --git a/packages/openapi-ts/src/ts-dsl/layout/newline.ts b/packages/openapi-ts/src/ts-dsl/layout/newline.ts index 7053cbd113..b49aa01796 100644 --- a/packages/openapi-ts/src/ts-dsl/layout/newline.ts +++ b/packages/openapi-ts/src/ts-dsl/layout/newline.ts @@ -11,7 +11,7 @@ export class NewlineTsDsl extends TsDsl { super.analyze(ctx); } - override toAst(): ts.Identifier { + override toAst() { return this.$node(new IdTsDsl('\n')); } } diff --git a/packages/openapi-ts/src/ts-dsl/layout/note.ts b/packages/openapi-ts/src/ts-dsl/layout/note.ts index 9322d5018a..914b026d6d 100644 --- a/packages/openapi-ts/src/ts-dsl/layout/note.ts +++ b/packages/openapi-ts/src/ts-dsl/layout/note.ts @@ -51,7 +51,7 @@ export class NoteTsDsl extends TsDsl { return node; } - override toAst(): ts.Node { + override toAst() { // this class does not build a standalone node; // it modifies other nodes via `apply()`. // Return a dummy comment node for compliance. diff --git a/packages/openapi-ts/src/ts-dsl/utils/lazy.ts b/packages/openapi-ts/src/ts-dsl/utils/lazy.ts index 6817a7d5e5..f25d5cfb92 100644 --- a/packages/openapi-ts/src/ts-dsl/utils/lazy.ts +++ b/packages/openapi-ts/src/ts-dsl/utils/lazy.ts @@ -26,7 +26,7 @@ export class LazyTsDsl extends TsDsl { return this._thunk(ctx); } - override toAst(): T { + override toAst() { return this.toResult().toAst(); } } diff --git a/packages/openapi-ts/src/ts-dsl/utils/name.ts b/packages/openapi-ts/src/ts-dsl/utils/name.ts index 8c2c93908f..ecb8ec171b 100644 --- a/packages/openapi-ts/src/ts-dsl/utils/name.ts +++ b/packages/openapi-ts/src/ts-dsl/utils/name.ts @@ -46,6 +46,8 @@ export const safePropName = ( return new LiteralTsDsl(name) as TsDsl; }; +const validTypeScriptChar = /^[\u200c\u200d\p{ID_Continue}]$/u; + const safeName = (name: string, reserved: ReservedList): string => { let sanitized = ''; let index: number; @@ -53,8 +55,14 @@ const safeName = (name: string, reserved: ReservedList): string => { const first = name[0] ?? ''; regexp.illegalStartCharacters.lastIndex = 0; if (regexp.illegalStartCharacters.test(first)) { - sanitized += '_'; - index = 0; + // Check if character becomes valid when not in leading position (e.g., digits) + if (validTypeScriptChar.test(first)) { + sanitized += '_'; + index = 0; + } else { + sanitized += '_'; + index = 1; + } } else { sanitized += first; index = 1; @@ -62,7 +70,7 @@ const safeName = (name: string, reserved: ReservedList): string => { while (index < name.length) { const char = name[index] ?? ''; - sanitized += /^[\u200c\u200d\p{ID_Continue}]$/u.test(char) ? char : '_'; + sanitized += validTypeScriptChar.test(char) ? char : '_'; index += 1; }