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