1use crate::codegen::traits::file_writer::FileInfo;
8use crate::ir::types::{
9 IrEnum, IrEnumValueType, IrIntersection, IrObject, IrPrimitive, IrProperty, IrSchema,
10 IrSchemaKind, IrSpec, IrTaggedUnion, IrTaggedVariant, IrTypeExpr, IrUnion, TaggingStyle,
11};
12use heck::{ToPascalCase, ToSnakeCase};
13use sigil_stitch::code_block::CodeBlock;
14use sigil_stitch::lang::python::Python;
15use sigil_stitch::prelude::*;
16
17pub fn generate_model_files(ir: &IrSpec, header: &str) -> Result<Vec<FileInfo>, String> {
19 let mut files = Vec::new();
20 for (_name, schema) in &ir.schemas {
21 let body = emit_model_body(schema, ir).ok_or_else(|| {
22 format!(
23 "unsupported schema kind for {}: {:?}",
24 schema.name, schema.kind
25 )
26 })?;
27 let stem = schema.name.to_snake_case();
28 let filename = format!("{stem}.py");
29 let mut content = String::with_capacity(header.len() + body.len());
30 content.push_str(header);
31 content.push_str(&body);
32 files.push(FileInfo::model(filename, content));
33 }
34 Ok(files)
35}
36
37fn emit_model_body(schema: &IrSchema, ir: &IrSpec) -> Option<String> {
38 let file_spec = match &schema.kind {
39 IrSchemaKind::Object(obj) => emit_object(schema, obj, ir),
40 IrSchemaKind::Enum(en) => emit_enum(schema, en),
41 IrSchemaKind::Alias(expr) => emit_alias(schema, expr),
42 IrSchemaKind::Union(u) => emit_union(schema, u),
43 IrSchemaKind::Intersection(i) => emit_intersection(schema, i, ir),
44 IrSchemaKind::TaggedUnion(tu) => emit_tagged_union(schema, tu, ir),
45 }?;
46 file_spec.render(100).ok()
47}
48
49pub fn future_annotations_header() -> CodeBlock {
50 CodeBlock::of("from __future__ import annotations", ()).expect("static header")
51}
52
53fn emit_object(schema: &IrSchema, obj: &IrObject, ir: &IrSpec) -> Option<FileSpec> {
58 let name = schema.name.to_pascal_case();
59
60 let mut file =
61 FileSpec::builder_with("model.py", Python::new()).header(future_annotations_header());
62
63 if needs_typing_literal_in_props(&obj.properties) {
64 file = file.add_import(ImportSpec::named("typing", "Literal"));
65 }
66
67 for (_, prop) in &obj.properties {
69 for named_ref in collect_tagged_union_refs(&prop.type_expr, ir) {
70 let snake = named_ref.to_snake_case();
71 let module = format!(".{snake}");
72 file = file.add_import(ImportSpec::named(&module, &format!("{snake}_from_dict")));
73 file = file.add_import(ImportSpec::named(&module, &format!("{snake}_to_dict")));
74 }
75 }
76
77 let dataclass_tn = TypeName::importable("dataclasses", "dataclass");
78 let mut cls = TypeSpec::builder(&name, TypeKind::Class)
79 .annotate(AnnotationSpec::importable(dataclass_tn));
80
81 if let Some(doc) = &schema.description {
82 cls = cls.doc(&format!("{}.", escape_docstring(doc)));
83 }
84
85 let mut required: Vec<(&String, &IrProperty)> = Vec::new();
86 let mut optional: Vec<(&String, &IrProperty)> = Vec::new();
87 for (json_name, prop) in &obj.properties {
88 if prop.required && !prop.nullable {
89 required.push((json_name, prop));
90 } else {
91 optional.push((json_name, prop));
92 }
93 }
94
95 let all_fields: Vec<(&String, &IrProperty)> =
96 required.iter().chain(optional.iter()).copied().collect();
97
98 if all_fields.is_empty() {
99 cls = cls.extra_member(CodeBlock::of("pass", ()).expect("pass"));
100 } else {
101 for (_json_name, prop) in &required {
102 let field_name = python_field_name(&prop.name);
103 let type_name = python_type_name(&prop.type_expr);
104 cls = cls.add_field(
105 FieldSpec::builder(&field_name, type_name)
106 .build()
107 .expect("required field"),
108 );
109 }
110 for (_json_name, prop) in &optional {
111 let field_name = python_field_name(&prop.name);
112 let type_name = python_type_name(&prop.type_expr);
113 cls = cls.add_field(
114 FieldSpec::builder(&field_name, TypeName::optional(type_name))
115 .initializer(CodeBlock::of("None", ()).expect("None init"))
116 .build()
117 .expect("optional field"),
118 );
119 }
120
121 cls = cls.add_method(build_to_dict_method(&all_fields, ir, &obj.properties));
122 cls = cls.add_method(build_from_dict_method(
123 &name,
124 &all_fields,
125 ir,
126 &obj.properties,
127 ));
128 }
129
130 file = file.add_type(cls.build().ok()?);
131 file.build().ok()
132}
133
134fn build_to_dict_method(
135 all_fields: &[(&String, &IrProperty)],
136 ir: &IrSpec,
137 properties: &indexmap::IndexMap<String, IrProperty>,
138) -> FunSpec {
139 let self_param = ParameterSpec::of("self", TypeName::primitive(""));
140 let return_type = TypeName::generic(
141 TypeName::primitive("dict"),
142 vec![TypeName::primitive("str"), TypeName::primitive("object")],
143 );
144
145 let mut body = CodeBlock::builder();
146 body.add_statement("result: dict[str, object] = {}", ());
147 for (json_name, prop) in all_fields {
148 let field_name = python_field_name(&prop.name);
149 let to_expr = render_to_dict_expr(&format!("self.{field_name}"), json_name, ir, properties);
150 if prop.required && !prop.nullable {
151 body.add_statement(&format!("result[\"{json_name}\"] = {to_expr}"), ());
152 } else {
153 body.add_statement(&format!("if self.{field_name} is not None:%>"), ());
154 body.add_statement(&format!("result[\"{json_name}\"] = {to_expr}%<"), ());
155 }
156 }
157 body.add_statement("return result", ());
158
159 FunSpec::builder("to_dict")
160 .add_param(self_param)
161 .returns(return_type)
162 .body(body.build().expect("to_dict body"))
163 .build()
164 .expect("to_dict method")
165}
166
167fn build_from_dict_method(
168 class_name: &str,
169 all_fields: &[(&String, &IrProperty)],
170 ir: &IrSpec,
171 properties: &indexmap::IndexMap<String, IrProperty>,
172) -> FunSpec {
173 let cls_param = ParameterSpec::of("cls", TypeName::primitive(""));
174 let data_param = ParameterSpec::of(
175 "data",
176 TypeName::generic(
177 TypeName::primitive("dict"),
178 vec![TypeName::primitive("str"), TypeName::primitive("object")],
179 ),
180 );
181
182 let mut body = CodeBlock::builder();
183 body.add_statement("return cls(%>", ());
184 for (json_name, prop) in all_fields {
185 let field_name = python_field_name(&prop.name);
186 let is_required = prop.required && !prop.nullable;
187 let expr = if is_required {
188 render_from_dict_expr(json_name, ir, properties)
189 } else {
190 render_from_dict_optional_expr(json_name, ir, properties)
191 };
192 if let Some(comment_start) = expr.find(" #") {
193 let (value_part, comment_part) = expr.split_at(comment_start);
194 body.add_statement(&format!("{field_name}={value_part},{comment_part}"), ());
195 } else {
196 body.add_statement(&format!("{field_name}={expr},"), ());
197 }
198 }
199 body.add("%<", ());
200 body.add_statement(")", ());
201
202 FunSpec::builder("from_dict")
203 .annotation(CodeBlock::of("@classmethod", ()).expect("classmethod"))
204 .add_param(cls_param)
205 .add_param(data_param)
206 .returns(TypeName::primitive(class_name))
207 .body(body.build().expect("from_dict body"))
208 .build()
209 .expect("from_dict method")
210}
211
212fn emit_enum(schema: &IrSchema, en: &IrEnum) -> Option<FileSpec> {
217 if en.value_type == IrEnumValueType::Mixed {
218 return emit_type_alias_raw(schema, "object");
219 }
220
221 let name = schema.name.to_pascal_case();
222 let base = match en.value_type {
223 IrEnumValueType::String => TypeName::primitive("str"),
224 IrEnumValueType::Integer | IrEnumValueType::Number => TypeName::primitive("int"),
225 IrEnumValueType::Mixed => unreachable!(),
226 };
227
228 let mut ts = TypeSpec::builder(&name, TypeKind::Enum)
229 .extends(base)
230 .extends(TypeName::importable("enum", "Enum"));
231
232 if let Some(doc) = &schema.description {
233 ts = ts.doc(&format!("{}.", escape_docstring(doc)));
234 }
235
236 for v in &en.values {
237 let (member_name, value_code) = match en.value_type {
238 IrEnumValueType::String => {
239 let s = v.value.as_str()?;
240 (
241 python_enum_member_name(s),
242 format!("\"{}\"", escape_python_string(s)),
243 )
244 }
245 IrEnumValueType::Integer | IrEnumValueType::Number => {
246 let n = v
247 .value
248 .as_i64()
249 .or_else(|| v.value.as_f64().map(|f| f as i64))?;
250 (format!("N{n}").replace('-', "NEG"), format!("{n}"))
251 }
252 IrEnumValueType::Mixed => unreachable!(),
253 };
254 ts = ts.add_variant(
255 EnumVariantSpec::builder(&member_name)
256 .value(CodeBlock::of(&value_code, ()).expect("enum value"))
257 .build()
258 .expect("enum variant"),
259 );
260 }
261
262 let file = FileSpec::builder_with("model.py", Python::new())
263 .header(future_annotations_header())
264 .add_type(ts.build().ok()?);
265 file.build().ok()
266}
267
268fn emit_alias(schema: &IrSchema, expr: &IrTypeExpr) -> Option<FileSpec> {
273 let name = schema.name.to_pascal_case();
274 let rhs_type = python_type_name(expr);
275
276 let type_alias = sigil_quote!(Python {
277 type $N(name.as_str()) = ($T(rhs_type));
278 })
279 .ok()?;
280
281 let mut file =
282 FileSpec::builder_with("model.py", Python::new()).header(future_annotations_header());
283 if needs_typing_literal(expr) {
284 file = file.add_import(ImportSpec::named("typing", "Literal"));
285 }
286 if let Some(doc) = &schema.description {
287 file = file.add_raw(&format!("# {}\n", escape_docstring(doc)));
288 }
289 file = file.add_code(type_alias);
290 file.build().ok()
291}
292
293fn emit_type_alias_raw(schema: &IrSchema, rhs: &str) -> Option<FileSpec> {
294 let name = schema.name.to_pascal_case();
295
296 let type_alias = sigil_quote!(Python {
297 type $N(name.as_str()) = $L(rhs);
298 })
299 .ok()?;
300
301 let mut file =
302 FileSpec::builder_with("model.py", Python::new()).header(future_annotations_header());
303 if let Some(doc) = &schema.description {
304 file = file.add_raw(&format!("# {}\n", escape_docstring(doc)));
305 }
306 file = file.add_code(type_alias);
307 file.build().ok()
308}
309
310fn format_type_alias(name: &str, members: &[TypeName]) -> CodeBlock {
315 if members.is_empty() {
316 return sigil_quote!(Python {
317 type $N(name) = ($T(TypeName::importable("typing", "Any")));
318 })
319 .unwrap();
320 }
321 if members.len() == 1 {
322 return sigil_quote!(Python {
323 type $N(name) = ($T(members[0].clone()));
324 })
325 .unwrap();
326 }
327 let mut cb = CodeBlock::builder();
332 cb.add(
333 "type %N = (\n %T",
334 (NameArg(name.to_string()), members[0].clone()),
335 );
336 for member in &members[1..] {
337 cb.add("\n | %T", (member.clone(),));
338 }
339 cb.add("\n)", ());
340 cb.build_unwrap()
341}
342
343fn emit_union(schema: &IrSchema, u: &IrUnion) -> Option<FileSpec> {
348 let name = schema.name.to_pascal_case();
349
350 let mut members: Vec<TypeName> = u.members.iter().map(python_type_name).collect();
351 if u.nullable {
352 members.push(TypeName::primitive("None"));
353 }
354
355 let type_alias = format_type_alias(&name, &members);
356
357 let mut file =
358 FileSpec::builder_with("model.py", Python::new()).header(future_annotations_header());
359 if needs_typing_literal_in_exprs(&u.members) {
360 file = file.add_import(ImportSpec::named("typing", "Literal"));
361 }
362 if let Some(doc) = &schema.description {
363 file = file.add_raw(&format!("# {}\n", escape_docstring(doc)));
364 }
365 file = file.add_code(type_alias);
366 file.build().ok()
367}
368
369fn emit_intersection(schema: &IrSchema, inter: &IrIntersection, ir: &IrSpec) -> Option<FileSpec> {
374 let mut all_props: indexmap::IndexMap<String, IrProperty> = indexmap::IndexMap::new();
375 for member in &inter.members {
376 if let IrTypeExpr::Named(ref_name) = member
377 && let Some(s) = ir.schemas.get(ref_name.as_str())
378 && let IrSchemaKind::Object(obj) = &s.kind
379 {
380 for (k, v) in &obj.properties {
381 all_props.entry(k.clone()).or_insert_with(|| v.clone());
382 }
383 }
384 }
385
386 if all_props.is_empty() {
387 return emit_intersection_as_alias(schema, inter);
388 }
389
390 emit_intersection_as_dataclass(schema, &all_props, ir)
391}
392
393fn emit_intersection_as_alias(schema: &IrSchema, inter: &IrIntersection) -> Option<FileSpec> {
394 let name = schema.name.to_pascal_case();
395 let members: Vec<TypeName> = inter.members.iter().map(python_type_name).collect();
396
397 let type_alias = format_type_alias(&name, &members);
398
399 let mut file =
400 FileSpec::builder_with("model.py", Python::new()).header(future_annotations_header());
401 if needs_typing_literal_in_exprs(&inter.members) {
402 file = file.add_import(ImportSpec::named("typing", "Literal"));
403 }
404 file = file.add_code(type_alias);
405 file.build().ok()
406}
407
408fn emit_intersection_as_dataclass(
409 schema: &IrSchema,
410 all_props: &indexmap::IndexMap<String, IrProperty>,
411 ir: &IrSpec,
412) -> Option<FileSpec> {
413 let name = schema.name.to_pascal_case();
414
415 let mut file =
416 FileSpec::builder_with("model.py", Python::new()).header(future_annotations_header());
417
418 if needs_typing_literal_in_props(all_props) {
419 file = file.add_import(ImportSpec::named("typing", "Literal"));
420 }
421
422 let dataclass_tn = TypeName::importable("dataclasses", "dataclass");
423 let mut cls = TypeSpec::builder(&name, TypeKind::Class)
424 .annotate(AnnotationSpec::importable(dataclass_tn));
425
426 if let Some(doc) = &schema.description {
427 cls = cls.doc(&format!("{}.", escape_docstring(doc)));
428 }
429
430 let mut required: Vec<(&String, &IrProperty)> = Vec::new();
431 let mut optional: Vec<(&String, &IrProperty)> = Vec::new();
432 for (json_name, prop) in all_props {
433 if prop.required && !prop.nullable {
434 required.push((json_name, prop));
435 } else {
436 optional.push((json_name, prop));
437 }
438 }
439
440 if required.is_empty() && optional.is_empty() {
441 cls = cls.extra_member(CodeBlock::of("pass", ()).expect("pass"));
442 } else {
443 for (_json_name, prop) in &required {
444 let field_name = python_field_name(&prop.name);
445 let type_name = python_type_name(&prop.type_expr);
446 cls = cls.add_field(
447 FieldSpec::builder(&field_name, type_name)
448 .build()
449 .expect("required field"),
450 );
451 }
452 for (_json_name, prop) in &optional {
453 let field_name = python_field_name(&prop.name);
454 let type_name = python_type_name(&prop.type_expr);
455 cls = cls.add_field(
456 FieldSpec::builder(&field_name, TypeName::optional(type_name))
457 .initializer(CodeBlock::of("None", ()).expect("None init"))
458 .build()
459 .expect("optional field"),
460 );
461 }
462
463 let all_fields: Vec<(&String, &IrProperty)> =
464 required.iter().chain(optional.iter()).copied().collect();
465 cls = cls.add_method(build_to_dict_method(&all_fields, ir, all_props));
466 cls = cls.add_method(build_from_dict_method(&name, &all_fields, ir, all_props));
467 }
468
469 file = file.add_type(cls.build().ok()?);
470 file.build().ok()
471}
472
473fn emit_tagged_union(schema: &IrSchema, tu: &IrTaggedUnion, ir: &IrSpec) -> Option<FileSpec> {
478 let name = schema.name.to_pascal_case();
479 let snake_name = schema.name.to_snake_case();
480
481 let members: Vec<TypeName> = tu
482 .variants
483 .iter()
484 .map(|v| python_type_name(&v.content_type))
485 .collect();
486
487 let type_alias = format_type_alias(&name, &members);
488
489 let hint = match &tu.tagging {
490 TaggingStyle::Internal => {
491 format!("Discriminator: {} (internal).", tu.discriminator_field)
492 }
493 TaggingStyle::Adjacent { content_field } => format!(
494 "Discriminator: {} / content: {} (adjacent).",
495 tu.discriminator_field, content_field
496 ),
497 TaggingStyle::External => "Discriminator: variant key (external).".to_string(),
498 };
499
500 let doc = match &schema.description {
501 Some(desc) => format!("{desc}\n\n{hint}"),
502 None => hint,
503 };
504
505 let mut file =
506 FileSpec::builder_with("model.py", Python::new()).header(future_annotations_header());
507 let exprs: Vec<&IrTypeExpr> = tu.variants.iter().map(|v| &v.content_type).collect();
508 if exprs.iter().any(|e| needs_typing_literal(e)) {
509 file = file.add_import(ImportSpec::named("typing", "Literal"));
510 }
511 let mut doc_block = String::new();
512 for line in doc.lines() {
513 doc_block.push_str(&format!("# {line}\n"));
514 }
515 file = file.add_raw(&doc_block);
516 file = file.add_code(type_alias);
517
518 if !tu.variants.is_empty() {
519 let helpers = build_tagged_union_helpers(&name, &snake_name, tu, ir);
520 file = file.add_code(helpers);
521 }
522
523 file.build().ok()
524}
525
526fn build_tagged_union_helpers(
527 pascal_name: &str,
528 snake_name: &str,
529 tu: &IrTaggedUnion,
530 ir: &IrSpec,
531) -> CodeBlock {
532 let tag_field = &tu.discriminator_field;
533
534 let resolved_variants: Vec<(&IrTaggedVariant, String)> = tu
536 .variants
537 .iter()
538 .filter_map(|v| {
539 if let IrTypeExpr::Named(ref_name) = &v.content_type
540 && is_object_schema(ref_name, ir)
541 {
542 return Some((v, ref_name.to_pascal_case()));
543 }
544 None
545 })
546 .collect();
547
548 let mut cb = CodeBlock::builder();
549
550 if resolved_variants.is_empty() {
551 return cb.build_unwrap();
552 }
553
554 cb.add_line();
556 cb.begin_control_flow(
557 &format!("def {snake_name}_from_dict(data: dict[str, object]) -> {pascal_name}"),
558 (),
559 );
560 match &tu.tagging {
561 TaggingStyle::Internal => {
562 cb.add_statement(&format!("_tag = data[\"{tag_field}\"]"), ());
563 for (i, (variant, py_class)) in resolved_variants.iter().enumerate() {
564 let cond = format!("_tag == \"{}\"", variant.discriminator_value);
565 emit_elif(&mut cb, i == 0, false, &cond);
566 cb.add_statement(&format!("return {py_class}.from_dict(data)"), ());
567 }
568 cb.end_control_flow_no_newline();
569 }
570 TaggingStyle::Adjacent { content_field } => {
571 cb.add_statement(&format!("_tag = data[\"{tag_field}\"]"), ());
572 cb.add_statement(
573 &format!("_content = data[\"{content_field}\"] # type: ignore[assignment]"),
574 (),
575 );
576 for (i, (variant, py_class)) in resolved_variants.iter().enumerate() {
577 let cond = format!("_tag == \"{}\"", variant.discriminator_value);
578 emit_elif(&mut cb, i == 0, false, &cond);
579 cb.add_statement(
580 &format!("return {py_class}.from_dict(_content) # type: ignore[arg-type]"),
581 (),
582 );
583 }
584 cb.end_control_flow_no_newline();
585 }
586 TaggingStyle::External => {
587 for (i, (variant, py_class)) in resolved_variants.iter().enumerate() {
588 let cond = format!("\"{}\" in data", variant.discriminator_value);
589 emit_elif(&mut cb, i == 0, false, &cond);
590 cb.add_statement(
591 &format!(
592 "return {py_class}.from_dict(data[\"{}\"]) # type: ignore[arg-type]",
593 variant.discriminator_value
594 ),
595 (),
596 );
597 }
598 cb.end_control_flow_no_newline();
599 }
600 }
601 cb.add_statement(
602 "raise ValueError(%V)",
603 VerbatimStrArg(format!(
604 "Unknown discriminator value for {pascal_name}: {{data}}"
605 )),
606 );
607 cb.end_control_flow();
608
609 cb.add_line();
611 cb.begin_control_flow(
612 &format!("def {snake_name}_to_dict(obj: {pascal_name}) -> dict[str, object]"),
613 (),
614 );
615 match &tu.tagging {
616 TaggingStyle::Internal => {
617 for (i, (variant, py_class)) in resolved_variants.iter().enumerate() {
618 let cond = format!("isinstance(obj, {py_class})");
619 emit_elif(&mut cb, i == 0, false, &cond);
620 cb.add_statement("result = obj.to_dict()", ());
621 cb.add_statement(
622 &format!(
623 "result[\"{tag_field}\"] = \"{}\"",
624 variant.discriminator_value
625 ),
626 (),
627 );
628 cb.add_statement("return result", ());
629 }
630 cb.end_control_flow_no_newline();
631 }
632 TaggingStyle::Adjacent { content_field } => {
633 for (i, (variant, py_class)) in resolved_variants.iter().enumerate() {
634 let cond = format!("isinstance(obj, {py_class})");
635 emit_elif(&mut cb, i == 0, false, &cond);
636 cb.add_statement(
637 &format!(
638 "return {{\"{tag_field}\": \"{}\", \"{content_field}\": obj.to_dict()}}",
639 variant.discriminator_value
640 ),
641 (),
642 );
643 }
644 cb.end_control_flow_no_newline();
645 }
646 TaggingStyle::External => {
647 for (i, (variant, py_class)) in resolved_variants.iter().enumerate() {
648 let cond = format!("isinstance(obj, {py_class})");
649 emit_elif(&mut cb, i == 0, false, &cond);
650 cb.add_statement(
651 &format!(
652 "return {{\"{}\": obj.to_dict()}}",
653 variant.discriminator_value
654 ),
655 (),
656 );
657 }
658 cb.end_control_flow_no_newline();
659 }
660 }
661 cb.add_statement(
662 "raise ValueError(%V)",
663 VerbatimStrArg(format!("Unknown variant for {pascal_name}: {{type(obj)}}")),
664 );
665 cb.end_control_flow();
666
667 cb.build_unwrap()
668}
669
670fn emit_elif(
671 cb: &mut sigil_stitch::code_block::CodeBlockBuilder,
672 is_first: bool,
673 is_last: bool,
674 cond: &str,
675) {
676 if !is_first {
677 cb.end_control_flow_no_newline();
678 }
679 if is_last && !is_first {
680 cb.begin_control_flow("else", ());
681 } else {
682 let kw = if is_first { "if" } else { "elif" };
683 cb.begin_control_flow(&format!("{kw} {cond}"), ());
684 }
685}
686
687pub fn python_type_name(expr: &IrTypeExpr) -> TypeName {
693 match expr {
694 IrTypeExpr::Named(name) => {
695 let py_name = name.to_pascal_case();
696 let module = format!(".{}", name.to_snake_case());
697 TypeName::importable(&module, &py_name)
698 }
699 IrTypeExpr::Primitive(p) => python_primitive_type_name(p),
700 IrTypeExpr::StringLiteral(s) => {
701 let lit = format!("Literal[\"{}\"]", escape_python_string(s));
702 TypeName::raw(&lit)
703 }
704 IrTypeExpr::StringEnum(values) => {
705 let members: Vec<String> = values
706 .iter()
707 .map(|v| format!("\"{}\"", escape_python_string(v)))
708 .collect();
709 let lit = format!("Literal[{}]", members.join(", "));
710 TypeName::raw(&lit)
711 }
712 IrTypeExpr::Array(inner) => {
713 TypeName::generic(TypeName::primitive("list"), vec![python_type_name(inner)])
714 }
715 IrTypeExpr::Map(inner) => TypeName::generic(
716 TypeName::primitive("dict"),
717 vec![TypeName::primitive("str"), python_type_name(inner)],
718 ),
719 IrTypeExpr::Union(members) => {
720 if members.is_empty() {
721 TypeName::importable("typing", "Any")
722 } else {
723 TypeName::union(members.iter().map(python_type_name).collect())
724 }
725 }
726 IrTypeExpr::Nullable(inner) => TypeName::optional(python_type_name(inner)),
727 IrTypeExpr::Any => TypeName::importable("typing", "Any"),
728 }
729}
730
731pub fn api_type_name(expr: &IrTypeExpr) -> TypeName {
733 match expr {
734 IrTypeExpr::Named(name) => {
735 let py_name = name.to_pascal_case();
736 let module = format!("..models.{}", name.to_snake_case());
737 TypeName::importable(&module, &py_name)
738 }
739 IrTypeExpr::Array(inner) => {
740 TypeName::generic(TypeName::primitive("list"), vec![api_type_name(inner)])
741 }
742 IrTypeExpr::Map(inner) => TypeName::generic(
743 TypeName::primitive("dict"),
744 vec![TypeName::primitive("str"), api_type_name(inner)],
745 ),
746 IrTypeExpr::Union(members) => {
747 if members.is_empty() {
748 TypeName::importable("typing", "Any")
749 } else {
750 TypeName::union(members.iter().map(api_type_name).collect())
751 }
752 }
753 IrTypeExpr::Nullable(inner) => TypeName::optional(api_type_name(inner)),
754 _ => python_type_name(expr),
755 }
756}
757
758fn python_primitive_type_name(p: &IrPrimitive) -> TypeName {
759 match p {
760 IrPrimitive::String | IrPrimitive::StringWithFormat(_) => TypeName::primitive("str"),
761 IrPrimitive::Integer | IrPrimitive::IntegerWithFormat(_) => TypeName::primitive("int"),
762 IrPrimitive::Number | IrPrimitive::NumberWithFormat(_) => TypeName::primitive("float"),
763 IrPrimitive::Boolean => TypeName::primitive("bool"),
764 IrPrimitive::Binary => TypeName::primitive("bytes"),
765 IrPrimitive::Date => TypeName::importable("datetime", "date"),
766 IrPrimitive::DateTime => TypeName::importable("datetime", "datetime"),
767 IrPrimitive::Uuid => TypeName::importable("uuid", "UUID"),
768 }
769}
770
771pub fn python_type_str(expr: &IrTypeExpr) -> String {
773 match expr {
774 IrTypeExpr::Named(name) => name.to_pascal_case(),
775 IrTypeExpr::Primitive(p) => python_primitive(p).to_string(),
776 IrTypeExpr::StringLiteral(s) => {
777 format!("Literal[\"{}\"]", escape_python_string(s))
778 }
779 IrTypeExpr::StringEnum(values) => {
780 let members: Vec<String> = values
781 .iter()
782 .map(|v| format!("\"{}\"", escape_python_string(v)))
783 .collect();
784 format!("Literal[{}]", members.join(", "))
785 }
786 IrTypeExpr::Array(inner) => {
787 let inner_ty = python_type_str(inner);
788 format!("list[{inner_ty}]")
789 }
790 IrTypeExpr::Map(inner) => {
791 let inner_ty = python_type_str(inner);
792 format!("dict[str, {inner_ty}]")
793 }
794 IrTypeExpr::Union(members) => {
795 let parts: Vec<String> = members.iter().map(python_type_str).collect();
796 if parts.is_empty() {
797 "Any".to_string()
798 } else {
799 parts.join(" | ")
800 }
801 }
802 IrTypeExpr::Nullable(inner) => {
803 let inner_ty = python_type_str(inner);
804 format!("{inner_ty} | None")
805 }
806 IrTypeExpr::Any => "Any".to_string(),
807 }
808}
809
810fn python_primitive(p: &IrPrimitive) -> &'static str {
811 match p {
812 IrPrimitive::String | IrPrimitive::StringWithFormat(_) => "str",
813 IrPrimitive::Integer | IrPrimitive::IntegerWithFormat(_) => "int",
814 IrPrimitive::Number | IrPrimitive::NumberWithFormat(_) => "float",
815 IrPrimitive::Boolean => "bool",
816 IrPrimitive::Binary => "bytes",
817 IrPrimitive::Date => "datetime.date",
818 IrPrimitive::DateTime => "datetime.datetime",
819 IrPrimitive::Uuid => "uuid.UUID",
820 }
821}
822
823fn needs_typing_literal(expr: &IrTypeExpr) -> bool {
824 match expr {
825 IrTypeExpr::StringLiteral(_) | IrTypeExpr::StringEnum(_) => true,
826 IrTypeExpr::Array(inner) | IrTypeExpr::Map(inner) | IrTypeExpr::Nullable(inner) => {
827 needs_typing_literal(inner)
828 }
829 IrTypeExpr::Union(members) => members.iter().any(needs_typing_literal),
830 _ => false,
831 }
832}
833
834fn needs_typing_literal_in_props(props: &indexmap::IndexMap<String, IrProperty>) -> bool {
835 props.values().any(|p| needs_typing_literal(&p.type_expr))
836}
837
838fn needs_typing_literal_in_exprs(exprs: &[IrTypeExpr]) -> bool {
839 exprs.iter().any(needs_typing_literal)
840}
841
842fn render_to_dict_expr(
847 value_expr: &str,
848 json_name: &str,
849 ir: &IrSpec,
850 properties: &indexmap::IndexMap<String, IrProperty>,
851) -> String {
852 let prop = properties.get(json_name);
853 let type_expr = prop.map(|p| &p.type_expr);
854 match type_expr {
855 Some(IrTypeExpr::Named(ref_name)) => {
856 if is_object_schema(ref_name, ir) {
857 format!("{value_expr}.to_dict()")
858 } else if is_tagged_union_schema(ref_name, ir) {
859 let snake = ref_name.to_snake_case();
860 format!("{snake}_to_dict({value_expr})")
861 } else {
862 value_expr.to_string()
863 }
864 }
865 Some(IrTypeExpr::Array(inner)) => {
866 if let IrTypeExpr::Named(ref_name) = inner.as_ref()
867 && is_object_schema(ref_name, ir)
868 {
869 return format!("[item.to_dict() for item in {value_expr}]");
870 }
871 if let IrTypeExpr::Named(ref_name) = inner.as_ref()
872 && is_tagged_union_schema(ref_name, ir)
873 {
874 let snake = ref_name.to_snake_case();
875 return format!("[{snake}_to_dict(item) for item in {value_expr}]");
876 }
877 value_expr.to_string()
878 }
879 Some(IrTypeExpr::Nullable(inner)) => {
880 if let IrTypeExpr::Named(ref_name) = inner.as_ref()
881 && is_object_schema(ref_name, ir)
882 {
883 return format!("{value_expr}.to_dict() if {value_expr} is not None else None");
884 }
885 if let IrTypeExpr::Named(ref_name) = inner.as_ref()
886 && is_tagged_union_schema(ref_name, ir)
887 {
888 let snake = ref_name.to_snake_case();
889 return format!(
890 "{snake}_to_dict({value_expr}) if {value_expr} is not None else None"
891 );
892 }
893 value_expr.to_string()
894 }
895 Some(IrTypeExpr::Map(inner)) => {
896 if let IrTypeExpr::Named(ref_name) = inner.as_ref()
897 && is_object_schema(ref_name, ir)
898 {
899 return format!("{{k: v.to_dict() for k, v in {value_expr}.items()}}");
900 }
901 if let IrTypeExpr::Named(ref_name) = inner.as_ref()
902 && is_tagged_union_schema(ref_name, ir)
903 {
904 let snake = ref_name.to_snake_case();
905 return format!("{{k: {snake}_to_dict(v) for k, v in {value_expr}.items()}}");
906 }
907 value_expr.to_string()
908 }
909 _ => value_expr.to_string(),
910 }
911}
912
913fn render_from_dict_expr(
914 json_name: &str,
915 ir: &IrSpec,
916 properties: &indexmap::IndexMap<String, IrProperty>,
917) -> String {
918 let prop = properties.get(json_name);
919 let type_expr = prop.map(|p| &p.type_expr);
920 let accessor = format!("data[\"{json_name}\"]");
921 match type_expr {
922 Some(IrTypeExpr::Named(ref_name)) => {
923 if is_object_schema(ref_name, ir) {
924 let py_name = ref_name.to_pascal_case();
925 format!("{py_name}.from_dict({accessor}) # type: ignore[arg-type]")
926 } else if is_tagged_union_schema(ref_name, ir) {
927 let snake = ref_name.to_snake_case();
928 format!("{snake}_from_dict({accessor}) # type: ignore[arg-type]")
929 } else {
930 format!("{accessor} # type: ignore[assignment]")
931 }
932 }
933 Some(IrTypeExpr::Array(inner)) => {
934 if let IrTypeExpr::Named(ref_name) = inner.as_ref()
935 && is_object_schema(ref_name, ir)
936 {
937 let py_name = ref_name.to_pascal_case();
938 return format!(
939 "[{py_name}.from_dict(item) for item in {accessor}] # type: ignore[union-attr]"
940 );
941 }
942 if let IrTypeExpr::Named(ref_name) = inner.as_ref()
943 && is_tagged_union_schema(ref_name, ir)
944 {
945 let snake = ref_name.to_snake_case();
946 return format!(
947 "[{snake}_from_dict(item) for item in {accessor}] # type: ignore[union-attr]"
948 );
949 }
950 format!("{accessor} # type: ignore[assignment]")
951 }
952 Some(IrTypeExpr::Map(inner)) => {
953 if let IrTypeExpr::Named(ref_name) = inner.as_ref()
954 && is_object_schema(ref_name, ir)
955 {
956 let py_name = ref_name.to_pascal_case();
957 return format!(
958 "{{k: {py_name}.from_dict(v) for k, v in {accessor}.items()}} # type: ignore[union-attr]"
959 );
960 }
961 if let IrTypeExpr::Named(ref_name) = inner.as_ref()
962 && is_tagged_union_schema(ref_name, ir)
963 {
964 let snake = ref_name.to_snake_case();
965 return format!(
966 "{{k: {snake}_from_dict(v) for k, v in {accessor}.items()}} # type: ignore[union-attr]"
967 );
968 }
969 format!("{accessor} # type: ignore[assignment]")
970 }
971 _ => format!("{accessor} # type: ignore[assignment]"),
972 }
973}
974
975fn render_from_dict_optional_expr(
976 json_name: &str,
977 ir: &IrSpec,
978 properties: &indexmap::IndexMap<String, IrProperty>,
979) -> String {
980 let prop = properties.get(json_name);
981 let type_expr = prop.map(|p| &p.type_expr);
982 let raw_type = type_expr.map(|t| match t {
983 IrTypeExpr::Nullable(inner) => inner.as_ref(),
984 _ => t,
985 });
986 let accessor = format!("data.get(\"{json_name}\")");
987 match raw_type {
988 Some(IrTypeExpr::Named(ref_name)) => {
989 if is_object_schema(ref_name, ir) {
990 let py_name = ref_name.to_pascal_case();
991 format!(
992 "{py_name}.from_dict({accessor}) if {accessor} is not None else None # type: ignore[arg-type]"
993 )
994 } else if is_tagged_union_schema(ref_name, ir) {
995 let snake = ref_name.to_snake_case();
996 format!(
997 "{snake}_from_dict({accessor}) if {accessor} is not None else None # type: ignore[arg-type]"
998 )
999 } else {
1000 format!("{accessor} # type: ignore[assignment]")
1001 }
1002 }
1003 Some(IrTypeExpr::Array(inner)) => {
1004 if let IrTypeExpr::Named(ref_name) = inner.as_ref()
1005 && is_object_schema(ref_name, ir)
1006 {
1007 let py_name = ref_name.to_pascal_case();
1008 return format!(
1009 "[{py_name}.from_dict(item) for item in {accessor}] if {accessor} is not None else None # type: ignore[union-attr]"
1010 );
1011 }
1012 if let IrTypeExpr::Named(ref_name) = inner.as_ref()
1013 && is_tagged_union_schema(ref_name, ir)
1014 {
1015 let snake = ref_name.to_snake_case();
1016 return format!(
1017 "[{snake}_from_dict(item) for item in {accessor}] if {accessor} is not None else None # type: ignore[union-attr]"
1018 );
1019 }
1020 format!("{accessor} # type: ignore[assignment]")
1021 }
1022 Some(IrTypeExpr::Map(inner)) => {
1023 if let IrTypeExpr::Named(ref_name) = inner.as_ref()
1024 && is_object_schema(ref_name, ir)
1025 {
1026 let py_name = ref_name.to_pascal_case();
1027 return format!(
1028 "{{k: {py_name}.from_dict(v) for k, v in {accessor}.items()}} if {accessor} is not None else None # type: ignore[union-attr]"
1029 );
1030 }
1031 if let IrTypeExpr::Named(ref_name) = inner.as_ref()
1032 && is_tagged_union_schema(ref_name, ir)
1033 {
1034 let snake = ref_name.to_snake_case();
1035 return format!(
1036 "{{k: {snake}_from_dict(v) for k, v in {accessor}.items()}} if {accessor} is not None else None # type: ignore[union-attr]"
1037 );
1038 }
1039 format!("{accessor} # type: ignore[assignment]")
1040 }
1041 _ => format!("{accessor} # type: ignore[assignment]"),
1042 }
1043}
1044
1045pub fn is_object_schema(name: &str, ir: &IrSpec) -> bool {
1046 ir.schemas.get(name).is_some_and(|s| match &s.kind {
1047 IrSchemaKind::Object(_) => true,
1048 IrSchemaKind::Intersection(inter) => inter.members.iter().any(|m| {
1049 if let IrTypeExpr::Named(ref_name) = m {
1050 ir.schemas
1051 .get(ref_name.as_str())
1052 .is_some_and(|ms| matches!(ms.kind, IrSchemaKind::Object(_)))
1053 } else {
1054 false
1055 }
1056 }),
1057 _ => false,
1058 })
1059}
1060
1061pub fn is_tagged_union_schema(name: &str, ir: &IrSpec) -> bool {
1062 ir.schemas
1063 .get(name)
1064 .is_some_and(|s| matches!(s.kind, IrSchemaKind::TaggedUnion(_)))
1065}
1066
1067fn collect_tagged_union_refs(expr: &IrTypeExpr, ir: &IrSpec) -> Vec<String> {
1069 let mut refs = Vec::new();
1070 match expr {
1071 IrTypeExpr::Named(name) => {
1072 if is_tagged_union_schema(name, ir) {
1073 refs.push(name.clone());
1074 }
1075 }
1076 IrTypeExpr::Array(inner) | IrTypeExpr::Nullable(inner) => {
1077 refs.extend(collect_tagged_union_refs(inner, ir));
1078 }
1079 IrTypeExpr::Map(inner) => {
1080 refs.extend(collect_tagged_union_refs(inner, ir));
1081 }
1082 _ => {}
1083 }
1084 refs
1085}
1086
1087pub fn python_field_name(name: &str) -> String {
1092 let snake = name.to_snake_case();
1093 if snake.is_empty() {
1094 return "field_".to_string();
1095 }
1096 match snake.as_str() {
1097 "and" | "as" | "assert" | "async" | "await" | "break" | "class" | "continue" | "def"
1098 | "del" | "elif" | "else" | "except" | "finally" | "for" | "from" | "global" | "if"
1099 | "import" | "in" | "is" | "lambda" | "nonlocal" | "not" | "or" | "pass" | "raise"
1100 | "return" | "try" | "while" | "with" | "yield" | "type" => {
1101 format!("{snake}_")
1102 }
1103 _ => snake,
1104 }
1105}
1106
1107fn python_enum_member_name(value: &str) -> String {
1108 let upper = value
1109 .to_uppercase()
1110 .replace(|c: char| !c.is_alphanumeric(), "_");
1111 if upper.is_empty() {
1112 return "EMPTY".to_string();
1113 }
1114 if upper.starts_with(|c: char| c.is_ascii_digit()) {
1115 return format!("N{upper}");
1116 }
1117 upper
1118}
1119
1120fn escape_python_string(s: &str) -> String {
1121 s.replace('\\', "\\\\").replace('"', "\\\"")
1122}
1123
1124fn escape_docstring(s: &str) -> String {
1125 s.replace("\"\"\"", "\\\"\\\"\\\"")
1126 .lines()
1127 .next()
1128 .unwrap_or("")
1129 .to_string()
1130}