Skip to content

Commit 05b1d59

Browse files
feat(python): add Literal type support for const properties in Pydantic preset (#2388)
1 parent 4d23365 commit 05b1d59

File tree

3 files changed

+125
-42
lines changed

3 files changed

+125
-42
lines changed

src/generators/python/presets/Pydantic.ts

Lines changed: 60 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,60 @@ import {
55
import { PythonOptions } from '../PythonGenerator';
66
import { ClassPresetType, PythonPreset } from '../PythonPreset';
77

8+
function formatPythonConstValue(constValue: unknown): string {
9+
if (typeof constValue === 'string') {
10+
return `'${constValue}'`;
11+
}
12+
if (typeof constValue === 'boolean') {
13+
return constValue ? 'True' : 'False';
14+
}
15+
return String(constValue);
16+
}
17+
18+
function formatLiteralType(constValue: unknown): string {
19+
return `Literal[${formatPythonConstValue(constValue)}]`;
20+
}
21+
22+
function buildFieldArgs(
23+
property: ConstrainedObjectPropertyModel,
24+
isOptional: boolean,
25+
constOptions?: { originalInput: unknown }
26+
): string[] {
27+
const decoratorArgs: string[] = [];
28+
29+
if (property.property.originalInput['description']) {
30+
decoratorArgs.push(
31+
`description='''${property.property.originalInput['description']}'''`
32+
);
33+
}
34+
35+
if (constOptions) {
36+
decoratorArgs.push(
37+
`default=${formatPythonConstValue(constOptions.originalInput)}`
38+
);
39+
decoratorArgs.push('frozen=True');
40+
} else if (isOptional) {
41+
decoratorArgs.push('default=None');
42+
}
43+
44+
const isUnwrappedDict =
45+
property.property instanceof ConstrainedDictionaryModel &&
46+
property.property.serializationType === 'unwrap';
47+
48+
if (isUnwrappedDict) {
49+
decoratorArgs.push('exclude=True');
50+
}
51+
52+
if (
53+
property.propertyName !== property.unconstrainedPropertyName &&
54+
!isUnwrappedDict
55+
) {
56+
decoratorArgs.push(`alias='''${property.unconstrainedPropertyName}'''`);
57+
}
58+
59+
return decoratorArgs;
60+
}
61+
862
const PYTHON_PYDANTIC_CLASS_PRESET: ClassPresetType<PythonOptions> = {
963
async self({ renderer, model }) {
1064
renderer.dependencyManager.addDependency(
@@ -22,61 +76,25 @@ const PYTHON_PYDANTIC_CLASS_PRESET: ClassPresetType<PythonOptions> = {
2276
);
2377
},
2478
property({ property, model, renderer }) {
25-
let type = property.property.type;
2679
const propertyName = property.propertyName;
27-
80+
const constOptions = property.property.options.const;
2881
const isOptional =
2982
!property.required || property.property.options.isNullable === true;
83+
84+
let type = property.property.type;
3085
if (isOptional) {
3186
type = `Optional[${type}]`;
3287
}
33-
if (
34-
property.property.options.const &&
35-
model.options.discriminator?.discriminator ===
36-
property.unconstrainedPropertyName
37-
) {
88+
if (constOptions) {
3889
renderer.dependencyManager.addDependency('from typing import Literal');
39-
type = `Literal['${property.property.options.const.originalInput}']`;
90+
type = formatLiteralType(constOptions.originalInput);
4091
}
4192
type = renderer.renderPropertyType({
4293
modelType: model.type,
4394
propertyType: type
4495
});
4596

46-
const decoratorArgs: string[] = [];
47-
48-
if (property.property.originalInput['description']) {
49-
decoratorArgs.push(
50-
`description='''${property.property.originalInput['description']}'''`
51-
);
52-
}
53-
if (isOptional) {
54-
decoratorArgs.push('default=None');
55-
}
56-
if (property.property.options.const) {
57-
let value = property.property.options.const.value;
58-
if (
59-
model.options.discriminator?.discriminator ===
60-
property.unconstrainedPropertyName
61-
) {
62-
value = property.property.options.const.originalInput;
63-
}
64-
decoratorArgs.push(`default='${value}'`);
65-
decoratorArgs.push('frozen=True');
66-
}
67-
if (
68-
property.property instanceof ConstrainedDictionaryModel &&
69-
property.property.serializationType === 'unwrap'
70-
) {
71-
decoratorArgs.push('exclude=True');
72-
}
73-
if (
74-
property.propertyName !== property.unconstrainedPropertyName &&
75-
(!(property.property instanceof ConstrainedDictionaryModel) ||
76-
property.property.serializationType !== 'unwrap')
77-
) {
78-
decoratorArgs.push(`alias='''${property.unconstrainedPropertyName}'''`);
79-
}
97+
const decoratorArgs = buildFieldArgs(property, isOptional, constOptions);
8098

8199
return `${propertyName}: ${type} = Field(${decoratorArgs.join(', ')})`;
82100
},

test/generators/python/presets/Pydantic.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,4 +204,25 @@ describe('PYTHON_PYDANTIC_PRESET', () => {
204204
const models = await generator.generate(doc);
205205
expect(models.map((model) => model.result)).toMatchSnapshot();
206206
});
207+
208+
test('should render Literal type for const properties', async () => {
209+
const doc = {
210+
title: 'ConstTest',
211+
type: 'object',
212+
properties: {
213+
country: {
214+
const: 'United States of America'
215+
},
216+
version: {
217+
const: 42
218+
},
219+
isActive: {
220+
const: true
221+
}
222+
}
223+
};
224+
225+
const models = await generator.generate(doc);
226+
expect(models.map((model) => model.result)).toMatchSnapshot();
227+
});
207228
});

test/generators/python/presets/__snapshots__/Pydantic.spec.ts.snap

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,50 @@ Array [
4242
]
4343
`;
4444

45+
exports[`PYTHON_PYDANTIC_PRESET should render Literal type for const properties 1`] = `
46+
Array [
47+
"class ConstTest(BaseModel):
48+
country: Literal['United States of America'] = Field(default='United States of America', frozen=True)
49+
version: Literal[42] = Field(default=42, frozen=True)
50+
is_active: Literal[True] = Field(default=True, frozen=True, alias='''isActive''')
51+
additional_properties: Optional[dict[str, Any]] = Field(default=None, exclude=True)
52+
53+
@model_serializer(mode='wrap')
54+
def custom_serializer(self, handler):
55+
serialized_self = handler(self)
56+
additional_properties = getattr(self, \\"additional_properties\\")
57+
if additional_properties is not None:
58+
for key, value in additional_properties.items():
59+
# Never overwrite existing values, to avoid clashes
60+
if not key in serialized_self:
61+
serialized_self[key] = value
62+
63+
return serialized_self
64+
65+
@model_validator(mode='before')
66+
@classmethod
67+
def unwrap_additional_properties(cls, data):
68+
if not isinstance(data, dict):
69+
data = data.model_dump()
70+
json_properties = list(data.keys())
71+
known_object_properties = ['country', 'version', 'is_active', 'additional_properties']
72+
unknown_object_properties = [element for element in json_properties if element not in known_object_properties]
73+
# Ignore attempts that validate regular models, only when unknown input is used we add unwrap extensions
74+
if len(unknown_object_properties) == 0:
75+
return data
76+
77+
known_json_properties = ['country', 'version', 'isActive', 'additionalProperties']
78+
additional_properties = data.get('additional_properties', {})
79+
for obj_key in unknown_object_properties:
80+
if not known_json_properties.__contains__(obj_key):
81+
additional_properties[obj_key] = data.pop(obj_key, None)
82+
data['additional_properties'] = additional_properties
83+
return data
84+
85+
",
86+
]
87+
`;
88+
4589
exports[`PYTHON_PYDANTIC_PRESET should render default value for discriminator when using polymorphism 1`] = `
4690
Array [
4791
"",

0 commit comments

Comments
 (0)