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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ready-boxes-lie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hey-api/openapi-ts": patch
---

**output**: fix: avoid double sanitizing leading character
6 changes: 6 additions & 0 deletions .changeset/thick-cases-wonder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@hey-api/codegen-core": patch
"@hey-api/openapi-ts": patch
---

**internal**: log symbol meta if name is falsy
2 changes: 1 addition & 1 deletion packages/codegen-core/src/symbols/symbol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ export class Symbol<Node extends INode = INode> {
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) : '<unknown>'}#${canonical.id}]`;
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/openapi-python/src/plugins/pydantic/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const defaultConfig: PydanticPlugin['Config'] = {
config: {
case: 'PascalCase',
comments: true,
enums: 'enum',
includeInEntry: false,
strict: false,
},
Expand Down
136 changes: 110 additions & 26 deletions packages/openapi-python/src/plugins/pydantic/shared/export.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<typeof $.var> {
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);
}
70 changes: 70 additions & 0 deletions packages/openapi-python/src/plugins/pydantic/shared/field.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { $ } from '../../../py-dsl';
import type { PydanticPlugin } from '../types';
import type { FieldConstraints } from '../v2/constants';

type FieldArg = ReturnType<typeof $.expr | typeof $.kwarg | typeof $.literal>;

export function createFieldCall(
constraints: FieldConstraints,
plugin: PydanticPlugin['Instance'],
options?: {
/** If true, the field is required. */
required?: boolean;
},
): ReturnType<typeof $.call> {
const field = plugin.external('pydantic.Field');
const args: Array<FieldArg> = [];

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<keyof FieldConstraints> = [
'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<Parameters<typeof $.call>[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);
}
10 changes: 4 additions & 6 deletions packages/openapi-python/src/plugins/pydantic/shared/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
Expand All @@ -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)
Expand All @@ -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),
};
Expand All @@ -48,7 +47,6 @@ export function inheritMeta(
): PydanticMeta {
return composeMeta(children, {
default: parent.default,
format: parent.format,
nullable: false,
readonly: parent.accessScope === 'read',
});
Expand Down
13 changes: 7 additions & 6 deletions packages/openapi-python/src/plugins/pydantic/shared/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IrSchemaToAstOptions, 'plugin'> &
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<ProcessorContext>;
Loading
Loading