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