Skip to main content

openapi_nexus/generators/python/requests/
emit_models.rs

1//! Model emission for IR schemas (Python dataclasses/enums/aliases).
2//!
3//! Uses sigil-stitch high-level APIs (TypeSpec, FieldSpec, FunSpec, TypeName,
4//! sigil_quote!) for structured code generation with automatic import tracking.
5//! Each schema produces one `.py` file via `FileSpec`.
6
7use 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
20/// Generate every model file from the IR.
21pub 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
116// ---------------------------------------------------------------------------
117// Object -> @dataclass
118// ---------------------------------------------------------------------------
119
120fn 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    // Import tagged-union helper functions for fields referencing TaggedUnion schemas
131    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
275// ---------------------------------------------------------------------------
276// Enum -> class(str, Enum) or class(int, Enum)
277// ---------------------------------------------------------------------------
278
279fn 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
331// ---------------------------------------------------------------------------
332// Alias -> type X = Y (PEP 695)
333// ---------------------------------------------------------------------------
334
335fn 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
373// ---------------------------------------------------------------------------
374// Type alias builder with proper multi-line formatting
375// ---------------------------------------------------------------------------
376
377fn 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
398// ---------------------------------------------------------------------------
399// Union -> type X = A | B | C
400// ---------------------------------------------------------------------------
401
402fn 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
424// ---------------------------------------------------------------------------
425// Intersection -> merged @dataclass
426// ---------------------------------------------------------------------------
427
428fn 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
528// ---------------------------------------------------------------------------
529// TaggedUnion -> type X = A | B | C
530// ---------------------------------------------------------------------------
531
532fn 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    // Only generate helpers for variants that resolve to Object schemas
590    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    // from_dict
610    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    // to_dict
665    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
742// ---------------------------------------------------------------------------
743// Type mapping
744// ---------------------------------------------------------------------------
745
746/// Map an IR type expression to a sigil-stitch TypeName with auto-import tracking.
747pub 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
786/// Like `python_type_name` but Named types import from `..models.{snake}` (for API files).
787pub 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
826/// Map an IR type expression to a Python type string (for serialization helpers).
827pub 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
934// ---------------------------------------------------------------------------
935// Serialization helpers
936// ---------------------------------------------------------------------------
937
938fn 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
1159/// Collect all tagged-union schema names referenced (directly or nested) in a type expression.
1160fn 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
1179// ---------------------------------------------------------------------------
1180// Helpers
1181// ---------------------------------------------------------------------------
1182
1183pub 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}