1use 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
17pub 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
53fn 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
202fn 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
258fn 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
300fn 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
334fn 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
446fn 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
518 .variants
519 .iter()
520 .filter_map(|v| {
521 if let IrTypeExpr::Named(ref_name) = &v.content_type
522 && is_object_schema(ref_name, ir)
523 {
524 return Some((v, ref_name.to_pascal_case()));
525 }
526 None
527 })
528 .collect();
529
530 let mut cb = CodeBlock::builder();
531
532 if resolved_variants.is_empty() {
533 return cb.build_unwrap();
534 }
535
536 cb.add_line();
538 cb.begin_control_flow_with_open(
539 &format!("def {snake_name}_from_dict(data: dict[str, object]) -> {pascal_name}"),
540 (),
541 ":",
542 );
543 match &tu.tagging {
544 TaggingStyle::Internal => {
545 cb.add_statement(&format!("_tag = data[\"{tag_field}\"]"), ());
546 for (i, (variant, py_class)) in resolved_variants.iter().enumerate() {
547 let cond = format!("_tag == \"{}\"", variant.discriminator_value);
548 emit_elif(&mut cb, i == 0, &cond, "");
549 cb.add_statement(&format!("return {py_class}.from_dict(data)"), ());
550 }
551 cb.end_control_flow();
552 }
553 TaggingStyle::Adjacent { content_field } => {
554 cb.add_statement(&format!("_tag = data[\"{tag_field}\"]"), ());
555 cb.add_statement(
556 &format!("_content = data[\"{content_field}\"] # type: ignore[assignment]"),
557 (),
558 );
559 for (i, (variant, py_class)) in resolved_variants.iter().enumerate() {
560 let cond = format!("_tag == \"{}\"", variant.discriminator_value);
561 emit_elif(&mut cb, i == 0, &cond, "");
562 cb.add_statement(
563 &format!("return {py_class}.from_dict(_content) # type: ignore[arg-type]"),
564 (),
565 );
566 }
567 cb.end_control_flow();
568 }
569 TaggingStyle::External => {
570 for (i, (variant, py_class)) in resolved_variants.iter().enumerate() {
571 let cond = format!("\"{}\" in data", variant.discriminator_value);
572 emit_elif(&mut cb, i == 0, &cond, "");
573 cb.add_statement(
574 &format!(
575 "return {py_class}.from_dict(data[\"{}\"]) # type: ignore[arg-type]",
576 variant.discriminator_value
577 ),
578 (),
579 );
580 }
581 cb.end_control_flow();
582 }
583 }
584 cb.add_statement(
585 &format!("raise ValueError(f\"Unknown discriminator value for {pascal_name}: {{data}}\")"),
586 (),
587 );
588 cb.end_control_flow();
589
590 cb.add_line();
592 cb.begin_control_flow_with_open(
593 &format!("def {snake_name}_to_dict(obj: {pascal_name}) -> dict[str, object]"),
594 (),
595 ":",
596 );
597 let last_idx = resolved_variants.len() - 1;
598 match &tu.tagging {
599 TaggingStyle::Internal => {
600 for (i, (variant, py_class)) in resolved_variants.iter().enumerate() {
601 let suffix = if i == last_idx {
602 " # type: ignore[reportUnnecessaryIsInstance]"
603 } else {
604 ""
605 };
606 let cond = format!("isinstance(obj, {py_class})");
607 emit_elif(&mut cb, i == 0, &cond, suffix);
608 cb.add_statement("result = obj.to_dict()", ());
609 cb.add_statement(
610 &format!(
611 "result[\"{tag_field}\"] = \"{}\"",
612 variant.discriminator_value
613 ),
614 (),
615 );
616 cb.add_statement("return result", ());
617 }
618 cb.end_control_flow();
619 }
620 TaggingStyle::Adjacent { content_field } => {
621 for (i, (variant, py_class)) in resolved_variants.iter().enumerate() {
622 let suffix = if i == last_idx {
623 " # type: ignore[reportUnnecessaryIsInstance]"
624 } else {
625 ""
626 };
627 let cond = format!("isinstance(obj, {py_class})");
628 emit_elif(&mut cb, i == 0, &cond, suffix);
629 cb.add_statement(
630 &format!(
631 "return {{\"{tag_field}\": \"{}\", \"{content_field}\": obj.to_dict()}}",
632 variant.discriminator_value
633 ),
634 (),
635 );
636 }
637 cb.end_control_flow();
638 }
639 TaggingStyle::External => {
640 for (i, (variant, py_class)) in resolved_variants.iter().enumerate() {
641 let suffix = if i == last_idx {
642 " # type: ignore[reportUnnecessaryIsInstance]"
643 } else {
644 ""
645 };
646 let cond = format!("isinstance(obj, {py_class})");
647 emit_elif(&mut cb, i == 0, &cond, suffix);
648 cb.add_statement(
649 &format!(
650 "return {{\"{}\": obj.to_dict()}}",
651 variant.discriminator_value
652 ),
653 (),
654 );
655 }
656 cb.end_control_flow();
657 }
658 }
659 cb.add_statement(
660 &format!("raise ValueError(f\"Unknown variant for {pascal_name}: {{type(obj)}}\")"),
661 (),
662 );
663 cb.end_control_flow();
664
665 cb.build_unwrap()
666}
667
668fn emit_elif(
675 cb: &mut sigil_stitch::code_block::CodeBlockBuilder,
676 is_first: bool,
677 cond: &str,
678 suffix: &str,
679) {
680 let kw = if is_first { "if" } else { "elif" };
681 if !is_first {
682 cb.end_control_flow();
683 }
684 cb.begin_control_flow_with_open(&format!("{kw} {cond}:{suffix}"), (), "");
685}
686
687pub 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
731pub 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
771pub 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
842fn 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 {
859 value_expr.to_string()
860 }
861 }
862 Some(IrTypeExpr::Array(inner)) => {
863 if let IrTypeExpr::Named(ref_name) = inner.as_ref()
864 && is_object_schema(ref_name, ir)
865 {
866 return format!("[item.to_dict() for item in {value_expr}]");
867 }
868 value_expr.to_string()
869 }
870 Some(IrTypeExpr::Nullable(inner)) => {
871 if let IrTypeExpr::Named(ref_name) = inner.as_ref()
872 && is_object_schema(ref_name, ir)
873 {
874 return format!("{value_expr}.to_dict() if {value_expr} is not None else None");
875 }
876 value_expr.to_string()
877 }
878 Some(IrTypeExpr::Map(inner)) => {
879 if let IrTypeExpr::Named(ref_name) = inner.as_ref()
880 && is_object_schema(ref_name, ir)
881 {
882 return format!("{{k: v.to_dict() for k, v in {value_expr}.items()}}");
883 }
884 value_expr.to_string()
885 }
886 _ => value_expr.to_string(),
887 }
888}
889
890fn render_from_dict_expr(
891 json_name: &str,
892 ir: &IrSpec,
893 properties: &indexmap::IndexMap<String, IrProperty>,
894) -> String {
895 let prop = properties.get(json_name);
896 let type_expr = prop.map(|p| &p.type_expr);
897 let accessor = format!("data[\"{json_name}\"]");
898 match type_expr {
899 Some(IrTypeExpr::Named(ref_name)) => {
900 if is_object_schema(ref_name, ir) {
901 let py_name = ref_name.to_pascal_case();
902 format!("{py_name}.from_dict({accessor}) # type: ignore[arg-type]")
903 } else {
904 format!("{accessor} # type: ignore[assignment]")
905 }
906 }
907 Some(IrTypeExpr::Array(inner)) => {
908 if let IrTypeExpr::Named(ref_name) = inner.as_ref()
909 && is_object_schema(ref_name, ir)
910 {
911 let py_name = ref_name.to_pascal_case();
912 return format!(
913 "[{py_name}.from_dict(item) for item in {accessor}] # type: ignore[union-attr]"
914 );
915 }
916 format!("{accessor} # type: ignore[assignment]")
917 }
918 Some(IrTypeExpr::Map(inner)) => {
919 if let IrTypeExpr::Named(ref_name) = inner.as_ref()
920 && is_object_schema(ref_name, ir)
921 {
922 let py_name = ref_name.to_pascal_case();
923 return format!(
924 "{{k: {py_name}.from_dict(v) for k, v in {accessor}.items()}} # type: ignore[union-attr]"
925 );
926 }
927 format!("{accessor} # type: ignore[assignment]")
928 }
929 _ => format!("{accessor} # type: ignore[assignment]"),
930 }
931}
932
933fn render_from_dict_optional_expr(
934 json_name: &str,
935 ir: &IrSpec,
936 properties: &indexmap::IndexMap<String, IrProperty>,
937) -> String {
938 let prop = properties.get(json_name);
939 let type_expr = prop.map(|p| &p.type_expr);
940 let raw_type = type_expr.map(|t| match t {
941 IrTypeExpr::Nullable(inner) => inner.as_ref(),
942 _ => t,
943 });
944 let accessor = format!("data.get(\"{json_name}\")");
945 match raw_type {
946 Some(IrTypeExpr::Named(ref_name)) => {
947 if is_object_schema(ref_name, ir) {
948 let py_name = ref_name.to_pascal_case();
949 format!(
950 "{py_name}.from_dict({accessor}) if {accessor} is not None else None # type: ignore[arg-type]"
951 )
952 } else {
953 format!("{accessor} # type: ignore[assignment]")
954 }
955 }
956 Some(IrTypeExpr::Array(inner)) => {
957 if let IrTypeExpr::Named(ref_name) = inner.as_ref()
958 && is_object_schema(ref_name, ir)
959 {
960 let py_name = ref_name.to_pascal_case();
961 return format!(
962 "[{py_name}.from_dict(item) for item in {accessor}] if {accessor} is not None else None # type: ignore[union-attr]"
963 );
964 }
965 format!("{accessor} # type: ignore[assignment]")
966 }
967 Some(IrTypeExpr::Map(inner)) => {
968 if let IrTypeExpr::Named(ref_name) = inner.as_ref()
969 && is_object_schema(ref_name, ir)
970 {
971 let py_name = ref_name.to_pascal_case();
972 return format!(
973 "{{k: {py_name}.from_dict(v) for k, v in {accessor}.items()}} if {accessor} is not None else None # type: ignore[union-attr]"
974 );
975 }
976 format!("{accessor} # type: ignore[assignment]")
977 }
978 _ => format!("{accessor} # type: ignore[assignment]"),
979 }
980}
981
982pub fn is_object_schema(name: &str, ir: &IrSpec) -> bool {
983 ir.schemas.get(name).is_some_and(|s| match &s.kind {
984 IrSchemaKind::Object(_) => true,
985 IrSchemaKind::Intersection(inter) => inter.members.iter().any(|m| {
986 if let IrTypeExpr::Named(ref_name) = m {
987 ir.schemas
988 .get(ref_name.as_str())
989 .is_some_and(|ms| matches!(ms.kind, IrSchemaKind::Object(_)))
990 } else {
991 false
992 }
993 }),
994 _ => false,
995 })
996}
997
998pub fn python_field_name(name: &str) -> String {
1003 let snake = name.to_snake_case();
1004 if snake.is_empty() {
1005 return "field_".to_string();
1006 }
1007 match snake.as_str() {
1008 "and" | "as" | "assert" | "async" | "await" | "break" | "class" | "continue" | "def"
1009 | "del" | "elif" | "else" | "except" | "finally" | "for" | "from" | "global" | "if"
1010 | "import" | "in" | "is" | "lambda" | "nonlocal" | "not" | "or" | "pass" | "raise"
1011 | "return" | "try" | "while" | "with" | "yield" | "type" => {
1012 format!("{snake}_")
1013 }
1014 _ => snake,
1015 }
1016}
1017
1018fn python_enum_member_name(value: &str) -> String {
1019 let upper = value
1020 .to_uppercase()
1021 .replace(|c: char| !c.is_alphanumeric(), "_");
1022 if upper.is_empty() {
1023 return "EMPTY".to_string();
1024 }
1025 if upper.starts_with(|c: char| c.is_ascii_digit()) {
1026 return format!("N{upper}");
1027 }
1028 upper
1029}
1030
1031fn escape_python_string(s: &str) -> String {
1032 s.replace('\\', "\\\\").replace('"', "\\\"")
1033}
1034
1035fn escape_docstring(s: &str) -> String {
1036 s.replace("\"\"\"", "\\\"\\\"\\\"")
1037 .lines()
1038 .next()
1039 .unwrap_or("")
1040 .to_string()
1041}