Skip to main content

openapi_nexus/generators/python/httpx/
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::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
17/// Generate every model file from the IR.
18pub 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
53// ---------------------------------------------------------------------------
54// Object -> @dataclass
55// ---------------------------------------------------------------------------
56
57fn 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    // Import tagged-union helper functions for fields referencing TaggedUnion schemas
68    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
212// ---------------------------------------------------------------------------
213// Enum -> class(str, Enum) or class(int, Enum)
214// ---------------------------------------------------------------------------
215
216fn 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
268// ---------------------------------------------------------------------------
269// Alias -> type X = Y (PEP 695)
270// ---------------------------------------------------------------------------
271
272fn 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
310// ---------------------------------------------------------------------------
311// Type alias builder with proper multi-line formatting
312// ---------------------------------------------------------------------------
313
314fn 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    // Multi-member: CodeBlock::builder() for precise line-break control.
328    // Python sigil_quote! $for merges output inline (no leading \n), unlike
329    // Go mode which preserves template newlines. This is the only case where
330    // we deviate from sigil_quote! — see sigil-stitch issue.
331    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
343// ---------------------------------------------------------------------------
344// Union -> type X = A | B | C
345// ---------------------------------------------------------------------------
346
347fn 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
369// ---------------------------------------------------------------------------
370// Intersection -> merged @dataclass
371// ---------------------------------------------------------------------------
372
373fn 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
473// ---------------------------------------------------------------------------
474// TaggedUnion -> type X = A | B | C
475// ---------------------------------------------------------------------------
476
477fn 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    // Only generate helpers for variants that resolve to Object schemas
535    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    // from_dict
555    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    // to_dict
610    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
687// ---------------------------------------------------------------------------
688// Type mapping
689// ---------------------------------------------------------------------------
690
691/// Map an IR type expression to a sigil-stitch TypeName with auto-import tracking.
692pub 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
731/// Like `python_type_name` but Named types import from `..models.{snake}` (for API files).
732pub 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
771/// Map an IR type expression to a Python type string (for serialization helpers).
772pub 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
842// ---------------------------------------------------------------------------
843// Serialization helpers
844// ---------------------------------------------------------------------------
845
846fn 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
1067/// Collect all tagged-union schema names referenced (directly or nested) in a type expression.
1068fn 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
1087// ---------------------------------------------------------------------------
1088// Helpers
1089// ---------------------------------------------------------------------------
1090
1091pub 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}