Skip to content

Commit 2a2fc45

Browse files
committed
feat: add kwarg nodes
1 parent 320c30a commit 2a2fc45

65 files changed

Lines changed: 1470 additions & 182 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/codegen-core/src/symbols/symbol.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ export class Symbol<Node extends INode = INode> {
305305
if (canonical._finalName && canonical._finalName !== canonical._name) {
306306
return `[Symbol ${canonical._name}${canonical._finalName}#${canonical.id}]`;
307307
}
308-
return `[Symbol ${canonical._name}#${canonical.id}]`;
308+
return `[Symbol ${canonical._name || canonical._meta !== undefined ? JSON.stringify(canonical._meta) : '<unknown>'}#${canonical.id}]`;
309309
}
310310

311311
/**

packages/openapi-python/src/plugins/pydantic/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const defaultConfig: PydanticPlugin['Config'] = {
77
config: {
88
case: 'PascalCase',
99
comments: true,
10+
enums: 'enum',
1011
includeInEntry: false,
1112
strict: false,
1213
},
Lines changed: 64 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1+
import type { Symbol } from '@hey-api/codegen-core';
12
import { applyNaming, pathToName } from '@hey-api/shared';
23

3-
// import { createSchemaComment } from '../../../plugins/shared/utils/schema';
44
import { $ } from '../../../py-dsl';
5+
import type { PydanticPlugin } from '../types';
6+
import { createFieldCall, hasConstraints } from './field';
57
import type { ProcessorContext } from './processor';
6-
// import { identifiers } from '../v2/constants';
7-
// import { pipesToNode } from './pipes';
8-
import type { PydanticFinal } from './types';
8+
import type { PydanticField, PydanticFinal } from './types';
99

1010
export function exportAst({
1111
final,
@@ -30,28 +30,67 @@ export function exportAst({
3030
});
3131

3232
if (final.fields) {
33-
const baseModel = plugin.external('pydantic.BaseModel');
34-
const classDef = $.class(symbol).extends(baseModel);
35-
// .export()
36-
// .$if(plugin.config.comments && createSchemaComment(schema), (c, v) => c.doc(v))
37-
// .$if(state.hasLazyExpression['~ref'], (c) =>
38-
// c.type($.type(v).attr(ast.typeName || identifiers.types.GenericSchema)),
39-
// )
40-
// .assign(pipesToNode(ast.pipes, plugin));
41-
42-
for (const field of final.fields) {
43-
// TODO: Field(...) constraints in next pass
44-
classDef.do($.var(field.name).assign($.literal('hey')));
45-
// classDef.do($.var(field.name).annotate(field.typeAnnotation));
46-
}
47-
48-
plugin.node(classDef);
33+
exportClass({ final, plugin, symbol });
4934
} else {
50-
const statement = $.var(symbol)
51-
// .export()
52-
// .$if(plugin.config.comments && createSchemaComment(schema), (c, v) => c.doc(v))
53-
.assign(final.typeAnnotation);
35+
exportTypeAlias({ final, plugin, symbol });
36+
}
37+
}
38+
39+
function exportClass({
40+
final,
41+
plugin,
42+
symbol,
43+
}: {
44+
final: PydanticFinal;
45+
plugin: PydanticPlugin['Instance'];
46+
symbol: Symbol;
47+
}): void {
48+
const baseModel = plugin.external('pydantic.BaseModel');
49+
const classDef = $.class(symbol).extends(baseModel);
50+
51+
if (plugin.config.strict) {
52+
const configDict = plugin.external('pydantic.ConfigDict');
53+
classDef.do($.var('model_config').assign($(configDict).call($.kwarg('extra', 'forbid'))));
54+
}
5455

55-
plugin.node(statement);
56+
for (const field of final.fields!) {
57+
const fieldStatement = createFieldStatement(field, plugin);
58+
classDef.do(fieldStatement);
5659
}
60+
61+
plugin.node(classDef);
62+
}
63+
64+
function createFieldStatement(
65+
field: PydanticField,
66+
plugin: PydanticPlugin['Instance'],
67+
): ReturnType<typeof $.var> {
68+
const varStatement = $.var(field.name).annotate(field.typeAnnotation);
69+
70+
if (hasConstraints(field.fieldConstraints)) {
71+
const fieldCall = createFieldCall(field.fieldConstraints, plugin, {
72+
required: !field.isOptional,
73+
});
74+
return varStatement.assign(fieldCall);
75+
}
76+
77+
if (field.isOptional) {
78+
return varStatement.assign('None');
79+
}
80+
81+
return varStatement;
82+
}
83+
84+
function exportTypeAlias({
85+
final,
86+
plugin,
87+
symbol,
88+
}: {
89+
final: PydanticFinal;
90+
plugin: PydanticPlugin['Instance'];
91+
symbol: Symbol;
92+
}): void {
93+
const typeAlias = plugin.external('typing.TypeAlias');
94+
const statement = $.var(symbol).annotate(typeAlias).assign(final.typeAnnotation);
95+
plugin.node(statement);
5796
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { $ } from '../../../py-dsl';
2+
import type { KwargPyDsl } from '../../../py-dsl/expr/kwarg';
3+
import type { PydanticPlugin } from '../types';
4+
5+
/**
6+
* Field constraint keys that map to Pydantic Field() parameters.
7+
*/
8+
export interface FieldConstraints {
9+
/** Alias for the field name in serialization */
10+
alias?: string;
11+
/** Default value for the field */
12+
default?: unknown;
13+
/** Default factory function (for mutable defaults) */
14+
default_factory?: string;
15+
/** Description of the field */
16+
description?: string;
17+
/** Greater than or equal constraint for numbers */
18+
ge?: number;
19+
/** Greater than constraint for numbers */
20+
gt?: number;
21+
/** Less than or equal constraint for numbers */
22+
le?: number;
23+
/** Less than constraint for numbers */
24+
lt?: number;
25+
/** Maximum length constraint for strings/arrays */
26+
max_length?: number;
27+
/** Minimum length constraint for strings/arrays */
28+
min_length?: number;
29+
/** Multiple of constraint for numbers */
30+
multiple_of?: number;
31+
/** Regex pattern constraint for strings */
32+
pattern?: string;
33+
/** Title for the field */
34+
title?: string;
35+
}
36+
37+
/**
38+
* Checks if any constraints require a Field() call.
39+
*/
40+
export function hasConstraints(constraints: Record<string, unknown>): boolean {
41+
const relevantKeys: Array<keyof FieldConstraints> = [
42+
'alias',
43+
'default',
44+
'default_factory',
45+
'description',
46+
'ge',
47+
'gt',
48+
'le',
49+
'lt',
50+
'max_length',
51+
'min_length',
52+
'multiple_of',
53+
'pattern',
54+
'title',
55+
];
56+
57+
return relevantKeys.some((key) => constraints[key] !== undefined);
58+
}
59+
60+
type FieldArg = ReturnType<typeof $.literal> | ReturnType<typeof $> | KwargPyDsl;
61+
62+
/**
63+
* Creates a Pydantic Field() call expression with the given constraints.
64+
*
65+
* @example
66+
* // With constraints
67+
* createFieldCall({ min_length: 1, description: "Name" }, plugin)
68+
* // Returns: Field(..., min_length=1, description="Name")
69+
*
70+
* // Without constraints but with default
71+
* createFieldCall({ default: "test" }, plugin)
72+
* // Returns: Field(default="test")
73+
*/
74+
export function createFieldCall(
75+
constraints: Record<string, unknown>,
76+
plugin: PydanticPlugin['Instance'],
77+
options?: {
78+
/** If true, the field is required (default behavior) */
79+
required?: boolean;
80+
},
81+
): ReturnType<typeof $.call> {
82+
const field = plugin.external('pydantic.Field');
83+
const args: Array<FieldArg> = [];
84+
85+
// Handle required vs optional
86+
const isRequired = options?.required !== false && constraints.default === undefined;
87+
88+
// For required fields with no default, use ... as first arg
89+
if (isRequired && constraints.default === undefined) {
90+
args.push($('...'));
91+
}
92+
93+
// Add constraint arguments in a consistent order
94+
const orderedKeys: Array<keyof FieldConstraints> = [
95+
'default',
96+
'default_factory',
97+
'alias',
98+
'title',
99+
'description',
100+
'gt',
101+
'ge',
102+
'lt',
103+
'le',
104+
'multiple_of',
105+
'min_length',
106+
'max_length',
107+
'pattern',
108+
];
109+
110+
for (const key of orderedKeys) {
111+
const value = constraints[key];
112+
if (value === undefined) continue;
113+
114+
// Skip default if we already added ... for required fields
115+
if (key === 'default' && isRequired) continue;
116+
117+
// Create keyword argument using $.kwarg
118+
args.push($.kwarg(key, toKwargValue(value)));
119+
}
120+
121+
// Type assertion needed because args include KwargPyDsl which produces KeywordArgument
122+
return $(field).call(...(args as Parameters<typeof $.call>[1][]));
123+
}
124+
125+
/**
126+
* Converts a constraint value to a kwarg-compatible value.
127+
*/
128+
function toKwargValue(value: unknown): string | number | boolean | null {
129+
if (value === null) return null;
130+
if (typeof value === 'string') return value;
131+
if (typeof value === 'number') return value;
132+
if (typeof value === 'boolean') return value;
133+
// For complex types, stringify
134+
return String(value);
135+
}
136+
137+
/**
138+
* Merges multiple constraint objects, with later objects taking precedence.
139+
*/
140+
export function mergeConstraints(
141+
...constraintSets: Array<Record<string, unknown>>
142+
): Record<string, unknown> {
143+
const merged: Record<string, unknown> = {};
144+
145+
for (const constraints of constraintSets) {
146+
for (const [key, value] of Object.entries(constraints)) {
147+
if (value !== undefined) {
148+
merged[key] = value;
149+
}
150+
}
151+
}
152+
153+
return merged;
154+
}

packages/openapi-python/src/plugins/pydantic/shared/types.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import type { Refs, Symbol, SymbolMeta } from '@hey-api/codegen-core';
22
import type { IR, SchemaExtractor } from '@hey-api/shared';
33

4-
import type { $, MaybePyDsl } from '../../../py-dsl';
5-
import type { py } from '../../../ts-python';
4+
import type { $, AnnotationExpr } from '../../../py-dsl';
65
import type { PydanticPlugin } from '../types';
76
import type { ProcessorContext } from './processor';
87

@@ -69,6 +68,15 @@ export interface ResolverContext {
6968

7069
// ..... ^^^^^^ OLD
7170

71+
/**
72+
* Return type for toType converters - just the type data without meta.
73+
* Meta is constructed by the walker using defaultMeta/composeMeta.
74+
*/
75+
export interface PydanticType {
76+
fieldConstraints: Record<string, unknown>;
77+
typeAnnotation: AnnotationExpr;
78+
}
79+
7280
/**
7381
* Metadata that flows through schema walking.
7482
*/
@@ -92,14 +100,14 @@ export interface PydanticResult {
92100
fieldConstraints: Record<string, unknown>;
93101
fields?: Array<PydanticField>;
94102
meta: PydanticMeta;
95-
typeAnnotation: string | MaybePyDsl<py.Expression>;
103+
typeAnnotation: AnnotationExpr;
96104
}
97105

98106
export interface PydanticField {
99107
fieldConstraints: Record<string, unknown>;
100108
isOptional: boolean;
101109
name: string;
102-
typeAnnotation: string | MaybePyDsl<py.Expression>;
110+
typeAnnotation: AnnotationExpr;
103111
}
104112

105113
/**
@@ -108,7 +116,7 @@ export interface PydanticField {
108116
export interface PydanticFinal {
109117
fieldConstraints: Record<string, unknown>;
110118
fields?: Array<PydanticField>; // present = emit class, absent = emit type alias
111-
typeAnnotation: string | MaybePyDsl<py.Expression>;
119+
typeAnnotation: AnnotationExpr;
112120
}
113121

114122
/**
@@ -117,5 +125,5 @@ export interface PydanticFinal {
117125
export interface PydanticCompositeHandlerResult {
118126
childResults: Array<PydanticResult>;
119127
fieldConstraints: Record<string, unknown>;
120-
typeAnnotation: string | MaybePyDsl<py.Expression>;
128+
typeAnnotation: AnnotationExpr;
121129
}

packages/openapi-python/src/plugins/pydantic/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ export type UserConfig = Plugin.Name<'pydantic'> &
5353
*/
5454
name?: NameTransformer;
5555
};
56+
/**
57+
* How to generate enum types.
58+
*
59+
* - `'enum'`: Generate Python Enum classes (e.g., `class Status(str, Enum): ...`)
60+
* - `'literal'`: Generate Literal type hints (e.g., `Literal["pending", "active"]`)
61+
*
62+
* @default 'enum'
63+
*/
64+
enums?: 'enum' | 'literal';
5665
/**
5766
* Configuration for request-specific Pydantic models.
5867
*
@@ -181,6 +190,8 @@ export type Config = Plugin.Name<'pydantic'> &
181190
case: Casing;
182191
/** Configuration for reusable schema definitions. */
183192
definitions: NamingOptions & FeatureToggle;
193+
/** How to generate enum types. */
194+
enums: 'enum' | 'literal';
184195
/** Configuration for request-specific Pydantic models. */
185196
requests: NamingOptions & FeatureToggle;
186197
/** Configuration for response-specific Pydantic models. */

packages/openapi-python/src/plugins/pydantic/v2/plugin.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ export const handlerV2: PydanticPlugin['Handler'] = ({ plugin }) => {
3737
resource: 'pydantic.Field',
3838
},
3939
});
40+
plugin.symbol('List', {
41+
external: 'typing',
42+
importKind: 'named',
43+
meta: {
44+
category: 'external',
45+
resource: 'typing.List',
46+
},
47+
});
4048
plugin.symbol('Literal', {
4149
external: 'typing',
4250
importKind: 'named',
@@ -53,6 +61,22 @@ export const handlerV2: PydanticPlugin['Handler'] = ({ plugin }) => {
5361
resource: 'typing.Optional',
5462
},
5563
});
64+
plugin.symbol('TypeAlias', {
65+
external: 'typing',
66+
importKind: 'named',
67+
meta: {
68+
category: 'external',
69+
resource: 'typing.TypeAlias',
70+
},
71+
});
72+
plugin.symbol('Union', {
73+
external: 'typing',
74+
importKind: 'named',
75+
meta: {
76+
category: 'external',
77+
resource: 'typing.Union',
78+
},
79+
});
5680

5781
const processor = createProcessor(plugin);
5882

0 commit comments

Comments
 (0)