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