1use crate::ast::*;
21use crate::typescript_instructions::{
22 dedupe_errors_by_code, normalize_seed_arg_type, split_generic,
23};
24use std::collections::{BTreeMap, HashMap, HashSet};
25
26#[derive(Debug, Clone)]
31pub struct PythonOutput {
32 pub module_name: String,
34 pub pyproject_toml: String,
35 pub init_py: String,
36 pub models_py: String,
37 pub views_py: String,
38 pub programs_py: Option<String>,
41}
42
43#[derive(Debug, Clone)]
44pub struct PythonStackConfig {
45 pub package_name: String,
48 pub sdk_version: String,
49 pub module_mode: bool,
53 pub url: Option<String>,
55 pub http_url: Option<String>,
57 pub extension_modules: Vec<String>,
62 pub extension_entry: Option<String>,
66}
67
68impl Default for PythonStackConfig {
69 fn default() -> Self {
70 Self {
71 package_name: "generated-stack".to_string(),
72 sdk_version: "0.4".to_string(),
73 module_mode: false,
74 url: None,
75 http_url: None,
76 extension_modules: Vec::new(),
77 extension_entry: None,
78 }
79 }
80}
81
82#[derive(Debug, Clone, Default)]
83pub struct PythonCompositionConfig {
84 pub stack: PythonStackConfig,
85 pub live_urls: BTreeMap<String, String>,
86}
87
88#[derive(Debug, Clone)]
89pub struct PythonAliasedStackOutput {
90 pub alias: String,
91 pub module_name: String,
92 pub output: PythonOutput,
93}
94
95#[derive(Debug, Clone)]
96pub struct PythonCompositionOutput {
97 pub name: String,
98 pub module_name: String,
100 pub pyproject_toml: String,
101 pub init_py: String,
102 pub live_stacks: Vec<PythonAliasedStackOutput>,
103}
104
105pub fn compile_stack_spec(
111 stack_spec: SerializableStackSpec,
112 config: Option<PythonStackConfig>,
113) -> Result<PythonOutput, String> {
114 compile_stack_spec_with_view_selection(stack_spec, config, false)
115}
116
117pub fn compile_stack_spec_with_exact_views(
120 stack_spec: SerializableStackSpec,
121 config: Option<PythonStackConfig>,
122) -> Result<PythonOutput, String> {
123 compile_stack_spec_with_view_selection(stack_spec, config, true)
124}
125
126pub fn compile_public_artifacts(
129 programs: &[arete_artifacts::ProgramSpecArtifact],
130 live_spec: &arete_artifacts::LiveSpecArtifact,
131 manifest: &arete_artifacts::StackManifestArtifact,
132 config: Option<PythonStackConfig>,
133) -> Result<PythonOutput, String> {
134 let stack_spec =
135 crate::public_artifacts::stack_spec_from_artifacts(programs, live_spec, manifest)?;
136 compile_stack_spec(stack_spec, config)
137}
138
139pub fn compile_public_artifacts_v2(
141 programs: &[arete_artifacts::ProgramSpecArtifact],
142 live_spec: &arete_artifacts::LiveSpecArtifactV2,
143 manifest: &arete_artifacts::StackManifestArtifactV2,
144 config: Option<PythonStackConfig>,
145) -> Result<PythonOutput, String> {
146 let stack_spec =
147 crate::public_artifacts::stack_spec_from_artifacts_v2(programs, live_spec, manifest)?;
148 compile_stack_spec_with_view_selection(stack_spec, config, true)
149}
150
151pub fn compile_composed_public_artifacts_v2(
155 programs: &[arete_artifacts::ProgramSpecArtifact],
156 live_specs: &[(String, arete_artifacts::LiveSpecArtifactV2)],
157 manifest: &arete_artifacts::StackManifestArtifactV2,
158 config: Option<PythonCompositionConfig>,
159) -> Result<PythonCompositionOutput, String> {
160 let composed =
161 crate::public_artifacts::stack_specs_from_artifacts_v2(programs, live_specs, manifest)?;
162 if composed.live_specs.is_empty() {
163 return Err(
164 "Python composition generation requires at least one aliased LiveSpec".to_string(),
165 );
166 }
167 let config = config.unwrap_or_default();
168 if !config.stack.extension_modules.is_empty() || config.stack.extension_entry.is_some() {
169 return Err(
170 "Python composition SDKs do not support stack extensions; extensions attach to a single-live stack module".to_string(),
171 );
172 }
173 let mut live_stacks = Vec::with_capacity(composed.live_specs.len());
174 for live in composed.live_specs {
175 let module_name = python_module_name(&live.alias);
176 let mut live_config = config.stack.clone();
177 live_config.module_mode = true;
178 live_config.url = config.live_urls.get(&live.alias).cloned();
179 let mut output =
180 compile_stack_spec_with_view_selection(live.stack_spec, Some(live_config), true)?;
181 output.module_name = module_name.clone();
182 live_stacks.push(PythonAliasedStackOutput {
183 alias: live.alias,
184 module_name,
185 output,
186 });
187 }
188 let mut init_py = format!(
189 "\"\"\"Generated Arete composition binding for `{}`. Do not edit.\"\"\"\n\n",
190 composed.name
191 );
192 for live in &live_stacks {
193 init_py.push_str(&format!(
194 "from . import {} # noqa: F401\n",
195 live.module_name
196 ));
197 }
198 init_py.push_str(&format!(
199 "\n__all__ = [{}]\n",
200 live_stacks
201 .iter()
202 .map(|live| py_string_literal(&live.module_name))
203 .collect::<Vec<_>>()
204 .join(", ")
205 ));
206 Ok(PythonCompositionOutput {
207 name: composed.name,
208 module_name: python_module_name(&config.stack.package_name),
209 pyproject_toml: generate_stack_pyproject(&config.stack),
210 init_py,
211 live_stacks,
212 })
213}
214
215pub fn write_python_module(
218 output: &PythonOutput,
219 module_dir: &std::path::Path,
220) -> Result<(), std::io::Error> {
221 std::fs::create_dir_all(module_dir)?;
222 std::fs::write(module_dir.join("__init__.py"), &output.init_py)?;
223 std::fs::write(module_dir.join("models.py"), &output.models_py)?;
224 std::fs::write(module_dir.join("views.py"), &output.views_py)?;
225 if let Some(programs) = &output.programs_py {
226 std::fs::write(module_dir.join("programs.py"), programs)?;
227 }
228 Ok(())
229}
230
231pub fn write_python_package(
234 output: &PythonOutput,
235 package_dir: &std::path::Path,
236) -> Result<(), std::io::Error> {
237 std::fs::create_dir_all(package_dir)?;
238 std::fs::write(package_dir.join("pyproject.toml"), &output.pyproject_toml)?;
239 write_python_module(output, &package_dir.join(&output.module_name))
240}
241
242pub fn write_python_composition_module(
243 output: &PythonCompositionOutput,
244 module_dir: &std::path::Path,
245) -> Result<(), std::io::Error> {
246 std::fs::create_dir_all(module_dir)?;
247 std::fs::write(module_dir.join("__init__.py"), &output.init_py)?;
248 for live in &output.live_stacks {
249 write_python_module(&live.output, &module_dir.join(&live.module_name))?;
250 }
251 Ok(())
252}
253
254pub fn write_python_composition_package(
255 output: &PythonCompositionOutput,
256 package_dir: &std::path::Path,
257) -> Result<(), std::io::Error> {
258 std::fs::create_dir_all(package_dir)?;
259 std::fs::write(package_dir.join("pyproject.toml"), &output.pyproject_toml)?;
260 write_python_composition_module(output, &package_dir.join(&output.module_name))
261}
262
263fn compile_stack_spec_with_view_selection(
264 stack_spec: SerializableStackSpec,
265 config: Option<PythonStackConfig>,
266 exact_views: bool,
267) -> Result<PythonOutput, String> {
268 let config = config.unwrap_or_default();
269 let stack_name = stack_spec.stack_name.clone();
270 let stack_kebab = to_kebab_case(&stack_name);
271
272 let mut entity_names: Vec<String> = Vec::new();
273 let mut entity_specs: Vec<SerializableStreamSpec> = Vec::new();
274 for spec in stack_spec.entities {
275 entity_names.push(spec.state_name.clone());
276 entity_specs.push(spec);
277 }
278
279 let (models_py, model_exports, account_structs) =
280 generate_stack_models_py(&stack_name, &entity_specs, &entity_names);
281
282 let programs = generate_stack_programs_py(
283 &stack_name,
284 &stack_spec.instructions,
285 &stack_spec.idls,
286 &stack_spec.pdas,
287 &stack_spec.program_ids,
288 &stack_spec.program_specs,
289 &account_structs,
290 );
291
292 let views_py = generate_stack_views_py(&stack_name, &entity_specs, &entity_names, exact_views);
293
294 validate_extension_modules(&config, programs.is_some())?;
295
296 let init_py = generate_stack_init_py(
297 &stack_name,
298 &stack_kebab,
299 &config,
300 programs.is_some(),
301 &model_exports,
302 );
303 let pyproject_toml = generate_stack_pyproject(&config);
304
305 Ok(PythonOutput {
306 module_name: python_module_name(&config.package_name),
307 pyproject_toml,
308 init_py,
309 models_py,
310 views_py,
311 programs_py: programs.map(|codegen| codegen.code),
312 })
313}
314
315fn generate_stack_pyproject(config: &PythonStackConfig) -> String {
320 format!(
321 r#"[project]
322name = "{name}"
323version = "0.1.0"
324requires-python = ">=3.9"
325dependencies = ["arete-sdk>={sdk}"]
326
327[build-system]
328requires = ["setuptools>=61.0"]
329build-backend = "setuptools.build_meta"
330
331[tool.setuptools.packages.find]
332include = ["{module}*"]
333"#,
334 name = config.package_name,
335 sdk = config.sdk_version,
336 module = python_module_name(&config.package_name),
337 )
338}
339
340fn validate_extension_modules(
344 config: &PythonStackConfig,
345 has_programs: bool,
346) -> Result<(), String> {
347 if config.extension_modules.is_empty() && config.extension_entry.is_none() {
348 return Ok(());
349 }
350 if config.extension_entry.is_none() {
351 return Err("extension modules were configured without an extension entry".to_string());
352 }
353 let mut seen = HashSet::new();
354 for stem in &config.extension_modules {
355 let reserved = matches!(stem.as_str(), "models" | "views" | "__init__")
356 || (stem == "programs" && has_programs);
357 if reserved {
358 return Err(format!(
359 "extension file '{stem}.py' collides with the generated '{stem}' module; rename the extension file"
360 ));
361 }
362 if !seen.insert(stem.as_str()) {
363 return Err(format!(
364 "extension file '{stem}.py' resolves to the same module name as another staged extension file"
365 ));
366 }
367 }
368 match &config.extension_entry {
369 Some(entry) if config.extension_modules.last() == Some(entry) => Ok(()),
370 Some(entry) => Err(format!(
371 "extension entry module '{entry}' must be the last configured extension module"
372 )),
373 None => unreachable!("checked above"),
374 }
375}
376
377fn generate_stack_init_py(
378 stack_name: &str,
379 stack_kebab: &str,
380 config: &PythonStackConfig,
381 has_programs: bool,
382 model_exports: &[String],
383) -> String {
384 let _ = model_exports;
385 let stack_const = format!("{}_STACK", to_screaming_snake(stack_name));
386
387 let submodules = if has_programs {
388 "models, programs, views"
389 } else {
390 "models, views"
391 };
392 let mut star_imports = String::new();
393 star_imports.push_str("from .models import * # noqa: F401,F403\n");
394 if has_programs {
395 star_imports.push_str("from .programs import * # noqa: F401,F403\n");
396 }
397 star_imports.push_str("from .views import * # noqa: F401,F403\n");
398
399 let ws_line = match config.url.as_deref() {
400 Some(url) if !url.is_empty() => format!(" ws={},", py_string_literal(url)),
401 _ => " ws=\"\", # TODO: Set URL after first deployment in arete.toml".to_string(),
402 };
403 let http_line = match config.http_url.as_deref() {
404 Some(http_url) if !http_url.is_empty() => {
405 format!("\n http={},", py_string_literal(http_url))
406 }
407 _ => String::new(),
408 };
409
410 let programs_kwargs = if has_programs {
411 "\n programs=programs.PROGRAMS,\n program_reads=programs.PROGRAM_READS,"
412 } else {
413 ""
414 };
415
416 let mut all_items = vec![py_string_literal(&stack_const)];
417 all_items.push("*models.__all__".to_string());
418 if has_programs {
419 all_items.push("*programs.__all__".to_string());
420 }
421 all_items.push("*views.__all__".to_string());
422
423 let mut output = format!(
424 r#""""Generated Arete stack binding for `{stack_name}`.
425
426Generated by the Arete interpreter (arete-sdk {sdk}); do not edit.
427"""
428
429from __future__ import annotations
430
431from arete.stack import StackDef, StackEndpoints
432
433from . import {submodules}
434{star_imports}
435{stack_const}: StackDef = StackDef(
436 name={kebab},
437 endpoints=StackEndpoints(
438{ws_line}{http_line}
439 ),
440 views=views.VIEWS,{programs_kwargs}
441)
442
443__all__ = [
444 {all_items},
445]
446"#,
447 stack_name = stack_name,
448 sdk = config.sdk_version,
449 submodules = submodules,
450 star_imports = star_imports,
451 stack_const = stack_const,
452 kebab = py_string_literal(stack_kebab),
453 ws_line = ws_line,
454 http_line = http_line,
455 programs_kwargs = programs_kwargs,
456 all_items = all_items.join(",\n "),
457 );
458
459 if let Some(entry) = &config.extension_entry {
460 output.push_str(
461 "\n# Hand-authored devex extensions (staged from extensions.json; not generated).\n",
462 );
463 for stem in &config.extension_modules {
464 if stem == entry {
465 continue;
466 }
467 output.push_str(&format!("from . import {stem} # noqa: F401\n"));
468 }
469 output.push_str(&format!("from .{entry} import * # noqa: F401,F403\n"));
470 }
471
472 output
473}
474
475#[derive(Debug, Clone)]
481enum WireConversion {
482 PassThrough,
483 Int,
484 IntList,
485 Nested(String),
486 NestedList(String),
487 CaptureWrapped(String),
489 CaptureWrappedList(String),
490 EventWrapped(String),
492 EventWrappedList(String),
493}
494
495impl WireConversion {
496 fn render(&self, accessor: &str) -> String {
497 match self {
498 WireConversion::PassThrough => accessor.to_string(),
499 WireConversion::Int => format!("_to_int({accessor})"),
500 WireConversion::IntList => format!("_to_int_list({accessor})"),
501 WireConversion::Nested(converter) => format!("_convert({accessor}, {converter})"),
502 WireConversion::NestedList(converter) => {
503 format!("_convert_list({accessor}, {converter})")
504 }
505 WireConversion::CaptureWrapped(converter) => {
506 format!("_convert_capture({accessor}, {converter})")
507 }
508 WireConversion::CaptureWrappedList(converter) => {
509 format!("_convert_capture_list({accessor}, {converter})")
510 }
511 WireConversion::EventWrapped(converter) => {
512 format!("_convert_event({accessor}, {converter})")
513 }
514 WireConversion::EventWrappedList(converter) => {
515 format!("_convert_event_list({accessor}, {converter})")
516 }
517 }
518 }
519}
520
521#[derive(Debug, Clone, Copy, PartialEq, Eq)]
525enum WrapperKind {
526 None,
527 Capture,
528 Event,
529}
530
531#[derive(Debug, Clone)]
532struct PyField {
533 name: String,
535 wire: String,
537 annotation: String,
538 conversion: WireConversion,
539 required: bool,
543}
544
545fn py_base_annotation(base_type: &BaseType) -> &'static str {
546 match base_type {
547 BaseType::Integer | BaseType::Timestamp => "int",
548 BaseType::Float => "float",
549 BaseType::String => "str",
550 BaseType::Boolean => "bool",
551 BaseType::Binary => "List[int]",
552 BaseType::Pubkey => "str",
553 BaseType::Array => "List[Any]",
554 BaseType::Object | BaseType::Any => "Any",
555 }
556}
557
558fn wrap_optional(annotation: &str) -> String {
559 if annotation == "Any" {
560 "Any".to_string()
561 } else {
562 format!("Optional[{annotation}]")
563 }
564}
565
566fn py_scalar_array_element(inner_type: &str) -> Option<&'static str> {
570 let trimmed = inner_type.trim();
571 let element = trimmed
572 .strip_prefix("Vec <")
573 .and_then(|rest| rest.strip_suffix('>'))
574 .or_else(|| {
575 trimmed
576 .strip_prefix("Vec<")
577 .and_then(|rest| rest.strip_suffix('>'))
578 })
579 .map(str::trim)
580 .unwrap_or(trimmed);
581 match element {
582 "f32" | "f64" => Some("float"),
583 "bool" => Some("bool"),
584 "String" | "&str" | "str" => Some("str"),
585 _ => None,
586 }
587}
588
589fn py_scalar_field_shape(
597 base_type: &BaseType,
598 integer_kind: Option<IntegerKind>,
599 is_array: bool,
600 inner_type: Option<&str>,
601) -> (String, WireConversion) {
602 let integer_array = is_array && matches!(base_type, BaseType::Array) && integer_kind.is_some();
603 if integer_array {
604 return (wrap_optional("List[int]"), WireConversion::IntList);
605 }
606 if matches!(base_type, BaseType::Integer | BaseType::Timestamp) {
607 return if is_array {
608 (wrap_optional("List[int]"), WireConversion::IntList)
609 } else {
610 (wrap_optional("int"), WireConversion::Int)
611 };
612 }
613 if is_array && matches!(base_type, BaseType::Array) {
614 if let Some(element) = inner_type.and_then(py_scalar_array_element) {
615 return (
616 wrap_optional(&format!("List[{element}]")),
617 WireConversion::PassThrough,
618 );
619 }
620 }
621 let base = py_base_annotation(base_type);
622 let annotation = if is_array && !matches!(base_type, BaseType::Array) {
623 wrap_optional(&format!("List[{base}]"))
624 } else {
625 wrap_optional(base)
626 };
627 (annotation, WireConversion::PassThrough)
628}
629
630fn wrapper_kind_for(
635 field: &FieldTypeInfo,
636 resolved: &ResolvedStructType,
637 capture_fields: &HashSet<String>,
638) -> WrapperKind {
639 if resolved.is_event || (resolved.is_instruction && field.is_array) {
640 return WrapperKind::Event;
641 }
642 if resolved.is_account
643 && (capture_fields.contains(&field.field_name)
644 || capture_fields.contains(field.raw_field_name()))
645 {
646 return WrapperKind::Capture;
647 }
648 WrapperKind::None
649}
650
651fn py_field_for(
653 field: &FieldTypeInfo,
654 resolved_name_map: &HashMap<String, String>,
655 capture_fields: &HashSet<String>,
656) -> PyField {
657 let name = to_snake_case(&field.field_name);
658 let wire = name.clone();
659
660 if let Some(resolved) = &field.resolved_type {
661 let emitted = resolved_name_map
662 .get(&resolved.type_name)
663 .cloned()
664 .unwrap_or_else(|| to_pascal_case(&resolved.type_name));
665 if resolved.is_enum {
666 let base = emitted;
667 let annotation = if field.is_array {
668 wrap_optional(&format!("List[{base}]"))
669 } else {
670 wrap_optional(&base)
671 };
672 return PyField {
673 name,
674 wire,
675 annotation,
676 conversion: WireConversion::PassThrough,
677 required: false,
678 };
679 }
680 let converter = format!("{}_from_wire", to_snake_case(&emitted));
681 let wrapper = wrapper_kind_for(field, resolved, capture_fields);
682 let element = match wrapper {
683 WrapperKind::None => emitted,
684 WrapperKind::Capture => format!("CaptureWrapper[{emitted}]"),
685 WrapperKind::Event => format!("EventWrapper[{emitted}]"),
686 };
687 let annotation = if field.is_array {
688 wrap_optional(&format!("List[{element}]"))
689 } else {
690 wrap_optional(&element)
691 };
692 let conversion = match (wrapper, field.is_array) {
693 (WrapperKind::None, false) => WireConversion::Nested(converter),
694 (WrapperKind::None, true) => WireConversion::NestedList(converter),
695 (WrapperKind::Capture, false) => WireConversion::CaptureWrapped(converter),
696 (WrapperKind::Capture, true) => WireConversion::CaptureWrappedList(converter),
697 (WrapperKind::Event, false) => WireConversion::EventWrapped(converter),
698 (WrapperKind::Event, true) => WireConversion::EventWrappedList(converter),
699 };
700 return PyField {
701 name,
702 wire,
703 annotation,
704 conversion,
705 required: false,
706 };
707 }
708
709 let (annotation, conversion) = py_scalar_field_shape(
710 &field.base_type,
711 field.effective_integer_kind(),
712 field.is_array,
713 field
714 .inner_type
715 .as_deref()
716 .or(Some(field.rust_type_name.as_str())),
717 );
718 PyField {
719 name,
720 wire,
721 annotation,
722 conversion,
723 required: false,
724 }
725}
726
727fn py_field_for_resolved(field: &ResolvedField) -> PyField {
728 let name = to_snake_case(&field.field_name);
729 let wire = name.clone();
730 let (annotation, conversion) = py_scalar_field_shape(
731 &field.base_type,
732 field.effective_integer_kind(),
733 field.is_array,
734 Some(field.field_type.as_str()),
735 );
736 PyField {
737 name,
738 wire,
739 annotation,
740 conversion,
741 required: !field.is_optional,
742 }
743}
744
745fn render_dataclass(name: &str, doc: &str, fields: &[PyField]) -> String {
746 let mut out = format!("@dataclass\nclass {name}:\n \"\"\"{doc}\"\"\"\n");
747 if !fields.is_empty() {
748 out.push('\n');
749 for field in fields {
750 out.push_str(&format!(
751 " {}: {} = None\n",
752 field.name, field.annotation
753 ));
754 }
755 }
756 out
757}
758
759fn render_from_wire(name: &str, fn_name: &str, fields: &[PyField], strict: bool) -> String {
768 let doc = if strict {
769 format!("Converts a wire payload into :class:`{name}`.\n\n Raises ``ValueError`` when a required field is absent.")
770 } else {
771 format!("Converts a wire payload into :class:`{name}`.")
772 };
773 let mut out = format!("def {fn_name}(value: Any) -> {name}:\n \"\"\"{doc}\"\"\"\n");
774 if fields.is_empty() {
775 out.push_str(&format!(
776 " _mapping(value, {})\n return {name}()\n",
777 py_string_literal(name)
778 ));
779 return out;
780 }
781 out.push_str(&format!(
782 " data = _mapping(value, {})\n return {name}(\n",
783 py_string_literal(name)
784 ));
785 for field in fields {
786 let accessor = if strict && field.required {
787 format!(
788 "_require(data, {}, {})",
789 py_string_literal(&field.wire),
790 py_string_literal(name)
791 )
792 } else {
793 format!("data.get({})", py_string_literal(&field.wire))
794 };
795 out.push_str(&format!(
796 " {}={},\n",
797 field.name,
798 field.conversion.render(&accessor)
799 ));
800 }
801 out.push_str(" )\n");
802 out
803}
804
805fn render_patch_from_wire(name: &str, fn_name: &str, fields: &[PyField]) -> String {
806 let mut out = format!(
807 "def {fn_name}(value: Any) -> Dict[str, Any]:\n \"\"\"Converts a partial `{name}` patch; only present keys appear.\"\"\"\n"
808 );
809 out.push_str(&format!(
810 " data = _mapping(value, {})\n out: Dict[str, Any] = {{}}\n",
811 py_string_literal(&format!("{name} patch"))
812 ));
813 for field in fields {
814 let accessor = format!("data[{}]", py_string_literal(&field.wire));
815 out.push_str(&format!(
816 " if {} in data:\n out[{}] = {}\n",
817 py_string_literal(&field.wire),
818 py_string_literal(&field.wire),
819 field.conversion.render(&accessor)
820 ));
821 }
822 out.push_str(" return out\n");
823 out
824}
825
826fn build_resolved_type_name_map(
828 spec: &SerializableStreamSpec,
829 entity_name: &str,
830) -> HashMap<String, String> {
831 let mut reserved_names = HashSet::from([
832 entity_name.to_string(),
833 "EventWrapper".to_string(),
834 "CaptureWrapper".to_string(),
835 ]);
836 for section in &spec.sections {
837 if !is_root_section(§ion.name) && section.fields.iter().any(|field| field.emit) {
838 reserved_names.insert(format!("{}{}", entity_name, to_pascal_case(§ion.name)));
839 }
840 }
841
842 let mut resolved_name_map = HashMap::new();
843 for section in &spec.sections {
844 for field in §ion.fields {
845 if !field.emit {
846 continue;
847 }
848 let Some(resolved) = &field.resolved_type else {
849 continue;
850 };
851 if resolved_name_map.contains_key(&resolved.type_name) {
852 continue;
853 }
854 let emitted_name = unique_resolved_type_name(resolved, &mut reserved_names);
855 resolved_name_map.insert(resolved.type_name.clone(), emitted_name);
856 }
857 }
858 resolved_name_map
859}
860
861fn unique_resolved_type_name(
862 resolved: &ResolvedStructType,
863 reserved_names: &mut HashSet<String>,
864) -> String {
865 let base_name = to_pascal_case(&resolved.type_name);
866 if reserved_names.insert(base_name.clone()) {
867 return base_name;
868 }
869 let suffix = if resolved.is_account {
870 "Account"
871 } else if resolved.is_event {
872 "Event"
873 } else if resolved.is_instruction {
874 "Instruction"
875 } else {
876 "Type"
877 };
878 let preferred = format!("{}{}", base_name, suffix);
879 if reserved_names.insert(preferred.clone()) {
880 return preferred;
881 }
882 let mut index = 2;
883 loop {
884 let candidate = format!("{}{}{}", base_name, suffix, index);
885 if reserved_names.insert(candidate.clone()) {
886 return candidate;
887 }
888 index += 1;
889 }
890}
891
892fn is_root_section(name: &str) -> bool {
893 name.eq_ignore_ascii_case("root")
894}
895
896fn capture_field_targets(spec: &SerializableStreamSpec) -> HashSet<String> {
900 let mut targets = HashSet::new();
901 for handler in &spec.handlers {
902 for mapping in &handler.mappings {
903 if matches!(&mapping.source, MappingSource::AsCapture { .. }) {
904 targets.insert(mapping.target_path.clone());
905 }
906 }
907 }
908 targets
909}
910
911const MODELS_HELPERS: &str = r#"
912def _snake_key(key: str) -> str:
913 out = []
914 for index, ch in enumerate(key):
915 if ch.isascii() and ch.isupper():
916 if index != 0:
917 out.append("_")
918 out.append(ch.lower())
919 else:
920 out.append(ch)
921 return "".join(out)
922
923
924def _mapping(value: Any, context: str) -> Dict[str, Any]:
925 if not isinstance(value, Mapping):
926 raise TypeError(
927 f"{context} payload must be a mapping, got {type(value).__name__}"
928 )
929 return {_snake_key(key): item for key, item in value.items()}
930
931
932def _to_int(value: Any) -> Optional[int]:
933 if value is None:
934 return None
935 if isinstance(value, bool):
936 return int(value)
937 if isinstance(value, int):
938 return value
939 if isinstance(value, (str, float)):
940 return int(value)
941 raise TypeError(f"Cannot convert {type(value).__name__} to int")
942
943
944def _to_int_list(value: Any) -> Optional[List[Optional[int]]]:
945 if value is None:
946 return None
947 return [_to_int(item) for item in value]
948
949
950def _convert(value: Any, converter: Any) -> Any:
951 if value is None:
952 return None
953 return converter(value)
954
955
956def _convert_list(value: Any, converter: Any) -> Any:
957 if value is None:
958 return None
959 return [converter(item) for item in value]
960
961
962def _convert_capture(value: Any, converter: Any) -> Any:
963 if value is None:
964 return None
965 return capture_wrapper_from_wire(value, converter)
966
967
968def _convert_capture_list(value: Any, converter: Any) -> Any:
969 if value is None:
970 return None
971 return [capture_wrapper_from_wire(item, converter) for item in value]
972
973
974def _convert_event(value: Any, converter: Any) -> Any:
975 if value is None:
976 return None
977 return event_wrapper_from_wire(value, converter)
978
979
980def _convert_event_list(value: Any, converter: Any) -> Any:
981 if value is None:
982 return None
983 return [event_wrapper_from_wire(item, converter) for item in value]
984
985
986def _require(data: Mapping[str, Any], key: str, context: str) -> Any:
987 if key not in data:
988 raise ValueError(f"{context} payload is missing required field '{key}'")
989 return data[key]
990"#;
991
992const MODELS_WRAPPERS: &str = r#"@dataclass
999class EventWrapper(Generic[_T]):
1000 """Wrapper for captured events (timestamp + data + provenance)."""
1001
1002 timestamp: int = 0
1003 data: Optional[_T] = None
1004 slot: Optional[int] = None
1005 signature: Optional[str] = None
1006
1007
1008def event_wrapper_from_wire(value: Any, converter: Any = None) -> EventWrapper:
1009 """Converts a wire event wrapper into :class:`EventWrapper`.
1010
1011 ``converter`` parses the inner ``data`` payload; omit it to pass the raw
1012 payload through.
1013 """
1014 data = _mapping(value, "EventWrapper")
1015 inner = data.get("data")
1016 return EventWrapper(
1017 timestamp=_to_int(data.get("timestamp")) or 0,
1018 data=_convert(inner, converter) if converter is not None else inner,
1019 slot=_to_int(data.get("slot")),
1020 signature=data.get("signature"),
1021 )
1022
1023
1024@dataclass
1025class CaptureWrapper(Generic[_T]):
1026 """Wrapper for captured accounts (timestamp + address + data + provenance)."""
1027
1028 timestamp: int = 0
1029 account_address: Optional[str] = None
1030 data: Optional[_T] = None
1031 slot: Optional[int] = None
1032 signature: Optional[str] = None
1033
1034
1035def capture_wrapper_from_wire(value: Any, converter: Any = None) -> CaptureWrapper:
1036 """Converts a wire account-capture wrapper into :class:`CaptureWrapper`.
1037
1038 ``converter`` parses the inner ``data`` payload; omit it to pass the raw
1039 payload through.
1040 """
1041 data = _mapping(value, "CaptureWrapper")
1042 inner = data.get("data")
1043 return CaptureWrapper(
1044 timestamp=_to_int(data.get("timestamp")) or 0,
1045 account_address=data.get("account_address"),
1046 data=_convert(inner, converter) if converter is not None else inner,
1047 slot=_to_int(data.get("slot")),
1048 signature=data.get("signature"),
1049 )
1050"#;
1051
1052fn generate_stack_models_py(
1057 stack_name: &str,
1058 entity_specs: &[SerializableStreamSpec],
1059 entity_names: &[String],
1060) -> (String, Vec<String>, BTreeMap<String, String>) {
1061 let mut exports: Vec<String> = Vec::new();
1062 let mut blocks: Vec<String> = Vec::new();
1063 let mut generated: HashSet<String> = HashSet::new();
1064 let mut account_structs: BTreeMap<String, String> = BTreeMap::new();
1065
1066 blocks.push(MODELS_WRAPPERS.to_string());
1068 exports.extend([
1069 "EventWrapper".to_string(),
1070 "event_wrapper_from_wire".to_string(),
1071 "CaptureWrapper".to_string(),
1072 "capture_wrapper_from_wire".to_string(),
1073 ]);
1074
1075 for (index, spec) in entity_specs.iter().enumerate() {
1076 let entity_name = &entity_names[index];
1077 let resolved_name_map = build_resolved_type_name_map(spec, entity_name);
1078 let capture_fields = capture_field_targets(spec);
1079
1080 for section in &spec.sections {
1082 for field in §ion.fields {
1083 if !field.emit {
1084 continue;
1085 }
1086 let Some(resolved) = &field.resolved_type else {
1087 continue;
1088 };
1089 let emitted_name = resolved_name_map
1090 .get(&resolved.type_name)
1091 .cloned()
1092 .unwrap_or_else(|| to_pascal_case(&resolved.type_name));
1093 if !generated.insert(emitted_name.clone()) {
1094 continue;
1095 }
1096 if resolved.is_account && !resolved.is_enum {
1097 account_structs
1098 .entry(resolved.type_name.clone())
1099 .or_insert_with(|| emitted_name.clone());
1100 }
1101 if resolved.is_enum {
1102 let variants = resolved.enum_variants.join(", ");
1103 blocks.push(format!(
1104 "# Enum `{}` (variants: {}) passed through as strings.\n{} = str\n",
1105 resolved.type_name, variants, emitted_name
1106 ));
1107 exports.push(emitted_name);
1108 continue;
1109 }
1110 let fields: Vec<PyField> =
1111 resolved.fields.iter().map(py_field_for_resolved).collect();
1112 let snake = to_snake_case(&emitted_name);
1113 let from_wire = format!("{snake}_from_wire");
1114 let patch = format!("{snake}_patch_from_wire");
1115 blocks.push(render_dataclass(
1116 &emitted_name,
1117 &format!("Resolved type `{}`.", resolved.type_name),
1118 &fields,
1119 ));
1120 blocks.push(render_from_wire(&emitted_name, &from_wire, &fields, true));
1121 blocks.push(render_patch_from_wire(&emitted_name, &patch, &fields));
1122 exports.extend([emitted_name.clone(), from_wire, patch]);
1123 }
1124 }
1125
1126 let mut section_fields: Vec<(String, String)> = Vec::new(); for section in &spec.sections {
1129 if is_root_section(§ion.name) || !section.fields.iter().any(|field| field.emit) {
1130 continue;
1131 }
1132 let class_name = format!("{}{}", entity_name, to_pascal_case(§ion.name));
1133 section_fields.push((to_snake_case(§ion.name), class_name.clone()));
1134 if !generated.insert(class_name.clone()) {
1135 continue;
1136 }
1137 let fields: Vec<PyField> = section
1138 .fields
1139 .iter()
1140 .filter(|field| field.emit)
1141 .map(|field| py_field_for(field, &resolved_name_map, &capture_fields))
1142 .collect();
1143 let snake = to_snake_case(&class_name);
1144 let from_wire = format!("{snake}_from_wire");
1145 let patch = format!("{snake}_patch_from_wire");
1146 blocks.push(render_dataclass(
1147 &class_name,
1148 &format!("`{}` section of `{}`.", section.name, entity_name),
1149 &fields,
1150 ));
1151 blocks.push(render_from_wire(&class_name, &from_wire, &fields, false));
1152 blocks.push(render_patch_from_wire(&class_name, &patch, &fields));
1153 exports.extend([class_name, from_wire, patch]);
1154 }
1155
1156 if generated.insert(entity_name.clone()) {
1158 let root_fields: Vec<PyField> = spec
1159 .sections
1160 .iter()
1161 .filter(|section| is_root_section(§ion.name))
1162 .flat_map(|section| {
1163 section
1164 .fields
1165 .iter()
1166 .filter(|field| field.emit)
1167 .map(|field| py_field_for(field, &resolved_name_map, &capture_fields))
1168 .collect::<Vec<_>>()
1169 })
1170 .collect();
1171
1172 let mut out = format!(
1173 "@dataclass\nclass {entity_name}:\n \"\"\"Entity `{entity_name}`.\"\"\"\n"
1174 );
1175 if !section_fields.is_empty() || !root_fields.is_empty() {
1176 out.push('\n');
1177 }
1178 for (field_name, class_name) in §ion_fields {
1179 out.push_str(&format!(
1180 " {field_name}: {class_name} = field(default_factory={class_name})\n"
1181 ));
1182 }
1183 for field in &root_fields {
1184 out.push_str(&format!(
1185 " {}: {} = None\n",
1186 field.name, field.annotation
1187 ));
1188 }
1189 blocks.push(out);
1190
1191 let entity_snake = to_snake_case(entity_name);
1192 let from_wire_name = format!("{entity_snake}_from_wire");
1193 let patch_name = format!("{entity_snake}_patch_from_wire");
1194
1195 let mut from_wire = format!(
1196 "def {from_wire_name}(value: Any) -> {entity_name}:\n \"\"\"Converts a merged wire entity into :class:`{entity_name}`.\"\"\"\n"
1197 );
1198 if section_fields.is_empty() && root_fields.is_empty() {
1199 from_wire.push_str(&format!(
1200 " _mapping(value, {})\n return {entity_name}()\n",
1201 py_string_literal(entity_name)
1202 ));
1203 } else {
1204 from_wire.push_str(&format!(
1205 " data = _mapping(value, {})\n return {entity_name}(\n",
1206 py_string_literal(entity_name)
1207 ));
1208 for (field_name, class_name) in §ion_fields {
1209 from_wire.push_str(&format!(
1210 " {field_name}={converter}(data.get({wire}) or {{}}),\n",
1211 field_name = field_name,
1212 converter = format_args!("{}_from_wire", to_snake_case(class_name)),
1213 wire = py_string_literal(field_name),
1214 ));
1215 }
1216 for field in &root_fields {
1217 let accessor = format!("data.get({})", py_string_literal(&field.wire));
1218 from_wire.push_str(&format!(
1219 " {}={},\n",
1220 field.name,
1221 field.conversion.render(&accessor)
1222 ));
1223 }
1224 from_wire.push_str(" )\n");
1225 }
1226 blocks.push(from_wire);
1227
1228 let mut patch = format!(
1229 "def {patch_name}(value: Any) -> Dict[str, Any]:\n \"\"\"Converts a partial `{entity_name}` patch; only present keys appear.\"\"\"\n"
1230 );
1231 patch.push_str(&format!(
1232 " data = _mapping(value, {})\n out: Dict[str, Any] = {{}}\n",
1233 py_string_literal(&format!("{entity_name} patch"))
1234 ));
1235 for (field_name, class_name) in §ion_fields {
1236 patch.push_str(&format!(
1237 " if {wire} in data:\n out[{wire}] = {converter}(data[{wire}] or {{}})\n",
1238 wire = py_string_literal(field_name),
1239 converter = format_args!(
1240 "{}_patch_from_wire",
1241 to_snake_case(class_name)
1242 ),
1243 ));
1244 }
1245 for field in &root_fields {
1246 let accessor = format!("data[{}]", py_string_literal(&field.wire));
1247 patch.push_str(&format!(
1248 " if {wire} in data:\n out[{wire}] = {expr}\n",
1249 wire = py_string_literal(&field.wire),
1250 expr = field.conversion.render(&accessor)
1251 ));
1252 }
1253 patch.push_str(" return out\n");
1254 blocks.push(patch);
1255
1256 exports.extend([entity_name.clone(), from_wire_name, patch_name]);
1257 }
1258 }
1259
1260 let all_list = exports
1261 .iter()
1262 .map(|name| format!(" {},", py_string_literal(name)))
1263 .collect::<Vec<_>>()
1264 .join("\n");
1265
1266 let mut output = format!(
1267 r#""""Generated entity models for the `{stack_name}` stack. Do not edit.
1268
1269Wire payloads are snake_case and pass through untransformed; u64/u128 decimal
1270strings convert to `int`. `*_from_wire` builds full dataclasses (IDL struct
1271converters reject payloads missing a required key; entity converters leave
1272missing keys None); `*_patch_from_wire` converts only the keys present in a
1273patch. Fields fed by `#[capture]` mappings or event handlers arrive inside a
1274`CaptureWrapper` / `EventWrapper` envelope and are typed as such.
1275"""
1276
1277from __future__ import annotations
1278
1279from dataclasses import dataclass, field
1280from typing import Any, Dict, Generic, List, Mapping, Optional, TypeVar
1281
1282_T = TypeVar("_T")
1283
1284__all__ = [
1285{all_list}
1286]
1287
1288{helpers}
1289
1290"#,
1291 stack_name = stack_name,
1292 all_list = all_list,
1293 helpers = MODELS_HELPERS.trim_start_matches('\n'),
1294 );
1295 output.push_str(&blocks.join("\n\n"));
1296 (output, exports, account_structs)
1297}
1298
1299fn state_view_key_fields(
1307 entity_name: &str,
1308 spec: &SerializableStreamSpec,
1309) -> Result<String, String> {
1310 let mut seen = HashSet::new();
1311 let distinct_keys: Vec<&str> = spec
1312 .identity
1313 .primary_keys
1314 .iter()
1315 .map(String::as_str)
1316 .filter(|key| seen.insert(*key))
1317 .collect();
1318
1319 if distinct_keys.len() > 1 {
1320 return Err(format!(
1321 "composite state key [{}] is not supported; the view takes one positional scalar key",
1322 distinct_keys.join(", ")
1323 ));
1324 }
1325 let Some(key_path) = distinct_keys.first().copied() else {
1326 return Err(
1327 "no primary key recorded; the view takes one positional scalar key".to_string(),
1328 );
1329 };
1330 let key_leaf = key_path.rsplit('.').next().unwrap_or(key_path);
1331 let field_info = spec.field_mappings.get(key_path).or_else(|| {
1332 spec.sections.iter().find_map(|section| {
1333 section.fields.iter().find(|field| {
1334 field.raw_field_name() == key_path
1335 || field.raw_field_name() == key_leaf
1336 || field.field_name == key_path
1337 || field.field_name == key_leaf
1338 })
1339 })
1340 });
1341 if let Some(field) = field_info {
1342 if field.is_array {
1343 return Err(format!(
1344 "array state key '{key_path}' is not supported; the view takes one positional scalar key"
1345 ));
1346 }
1347 match field.base_type {
1348 BaseType::String
1349 | BaseType::Binary
1350 | BaseType::Pubkey
1351 | BaseType::Integer
1352 | BaseType::Timestamp => {}
1353 _ => {
1354 return Err(format!(
1355 "state key '{}' with type '{}' is not supported for entity '{}'; the view takes one positional scalar key",
1356 key_path, field.rust_type_name, entity_name
1357 ));
1358 }
1359 }
1360 }
1361 Ok(to_snake_case(key_leaf))
1362}
1363
1364fn generate_stack_views_py(
1365 stack_name: &str,
1366 entity_specs: &[SerializableStreamSpec],
1367 entity_names: &[String],
1368 exact_views: bool,
1369) -> String {
1370 let mut exports: Vec<String> = Vec::new();
1371 let mut class_blocks: Vec<String> = Vec::new();
1372 let mut group_entries: Vec<String> = Vec::new();
1373
1374 for (index, entity_name) in entity_names.iter().enumerate() {
1375 let spec = &entity_specs[index];
1376 if exact_views && spec.views.is_empty() {
1377 continue;
1378 }
1379 let parser = format!("models.{}_from_wire", to_snake_case(entity_name));
1380 let class_name = format!("{entity_name}Views");
1381 let mut attrs: Vec<(String, String)> = Vec::new();
1382
1383 let has_state = !exact_views
1384 || spec
1385 .views
1386 .iter()
1387 .any(|view| view.id == format!("{entity_name}/state"));
1388 if has_state {
1389 let (key_fields_expr, key_note) = match state_view_key_fields(entity_name, spec) {
1390 Ok(field_name) => (format!("({},)", py_string_literal(&field_name)), None),
1391 Err(reason) => ("()".to_string(), Some(reason)),
1392 };
1393 let mut code = String::new();
1394 if let Some(note) = key_note {
1395 code.push_str(&format!(" # [arete codegen] {note}\n"));
1396 }
1397 code.push_str(&format!(
1398 " state = ViewDef(\n mode=\"state\",\n view={view},\n key_fields={key_fields},\n parser={parser},\n )",
1399 view = py_string_literal(&format!("{entity_name}/state")),
1400 key_fields = key_fields_expr,
1401 parser = parser,
1402 ));
1403 attrs.push(("state".to_string(), code));
1404 }
1405
1406 let has_list = !exact_views
1407 || spec
1408 .views
1409 .iter()
1410 .any(|view| view.id == format!("{entity_name}/list"));
1411 if has_list {
1412 attrs.push((
1413 "list".to_string(),
1414 format!(
1415 " list = ViewDef(mode=\"list\", view={}, parser={})",
1416 py_string_literal(&format!("{entity_name}/list")),
1417 parser
1418 ),
1419 ));
1420 }
1421
1422 for view in spec.views.iter().filter(|view| {
1423 !view.id.ends_with("/state")
1424 && !view.id.ends_with("/list")
1425 && view.id.starts_with(entity_name.as_str())
1426 }) {
1427 let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
1428 let attr = to_snake_case(view_name);
1429 attrs.push((
1430 attr.clone(),
1431 format!(
1432 " {} = ViewDef(mode=\"list\", view={}, parser={})",
1433 attr,
1434 py_string_literal(&view.id),
1435 parser
1436 ),
1437 ));
1438 }
1439
1440 let body = if attrs.is_empty() {
1441 " pass".to_string()
1442 } else {
1443 attrs
1444 .iter()
1445 .map(|(_, code)| code.clone())
1446 .collect::<Vec<_>>()
1447 .join("\n")
1448 };
1449 class_blocks.push(format!(
1450 "class {class_name}:\n \"\"\"Typed views of the `{entity_name}` entity.\"\"\"\n\n{body}\n",
1451 ));
1452 exports.push(class_name.clone());
1453
1454 let group_key = to_snake_case(entity_name);
1455 let entries = attrs
1456 .iter()
1457 .map(|(attr, _)| {
1458 format!(
1459 " {}: {}.{},",
1460 py_string_literal(attr),
1461 class_name,
1462 attr
1463 )
1464 })
1465 .collect::<Vec<_>>()
1466 .join("\n");
1467 group_entries.push(format!(
1468 " {}: {{\n{}\n }},",
1469 py_string_literal(&group_key),
1470 entries
1471 ));
1472 }
1473
1474 exports.push("VIEWS".to_string());
1475 let all_list = exports
1476 .iter()
1477 .map(|name| format!(" {},", py_string_literal(name)))
1478 .collect::<Vec<_>>()
1479 .join("\n");
1480
1481 let views_map = if group_entries.is_empty() {
1482 "VIEWS: Dict[str, Dict[str, ViewDef]] = {}\n".to_string()
1483 } else {
1484 format!(
1485 "VIEWS: Dict[str, Dict[str, ViewDef]] = {{\n{}\n}}\n",
1486 group_entries.join("\n")
1487 )
1488 };
1489
1490 format!(
1491 r#""""Generated typed views for the `{stack_name}` stack. Do not edit.
1492
1493`VIEWS` feeds `StackDef.views`; group keys are snake_case entity names
1494(`a4.views.<entity>.<view>`). State views declare typed key fields consumed
1495as keyword arguments (`.use(<key_field>=...)`).
1496"""
1497
1498from __future__ import annotations
1499
1500from typing import Dict
1501
1502from arete.views import ViewDef
1503
1504from . import models
1505
1506__all__ = [
1507{all_list}
1508]
1509
1510
1511{classes}
1512
1513{views_map}"#,
1514 stack_name = stack_name,
1515 all_list = all_list,
1516 classes = class_blocks.join("\n\n"),
1517 views_map = views_map,
1518 )
1519}
1520
1521#[derive(Debug, Clone)]
1527pub(crate) struct PythonProgramsCodegen {
1528 code: String,
1529}
1530
1531#[derive(Debug, Default)]
1533struct PythonProgramImports {
1534 account_meta: bool,
1535 arg_schema: bool,
1536 pda: bool,
1537 pda_factory: bool,
1538 error_metadata: bool,
1539 builders: bool,
1540 reads: bool,
1541}
1542
1543#[derive(Debug, Clone)]
1545struct PyParsedArg {
1546 schema: String,
1548 param_type: String,
1550 supported: bool,
1552}
1553
1554fn py_unsupported() -> PyParsedArg {
1555 PyParsedArg {
1556 schema: "\"u8\"".to_string(),
1557 param_type: "Any".to_string(),
1558 supported: false,
1559 }
1560}
1561
1562fn py_prim(schema: &str, param_type: &str) -> PyParsedArg {
1563 PyParsedArg {
1564 schema: py_string_literal(schema),
1565 param_type: param_type.to_string(),
1566 supported: true,
1567 }
1568}
1569
1570struct PythonDefinedTypes<'a> {
1575 defs: BTreeMap<String, &'a IdlTypeDefSnapshot>,
1576 lower: BTreeMap<String, String>,
1577 resolved: BTreeMap<String, Option<PyParsedArg>>,
1578 visiting: HashSet<String>,
1579}
1580
1581impl<'a> PythonDefinedTypes<'a> {
1582 fn new(idls: &'a [IdlSnapshot]) -> Self {
1583 let mut defs: BTreeMap<String, &'a IdlTypeDefSnapshot> = BTreeMap::new();
1584 let mut lower: BTreeMap<String, String> = BTreeMap::new();
1585 for idl in idls {
1586 for def in &idl.types {
1587 if !defs.contains_key(def.name.as_str()) {
1588 defs.insert(def.name.clone(), def);
1589 lower.insert(def.name.to_lowercase(), def.name.clone());
1590 }
1591 }
1592 }
1593 PythonDefinedTypes {
1594 defs,
1595 lower,
1596 resolved: BTreeMap::new(),
1597 visiting: HashSet::new(),
1598 }
1599 }
1600
1601 fn parse_arg_type(&mut self, raw: &str) -> PyParsedArg {
1604 let t = raw.trim().trim_start_matches('&').trim();
1605
1606 if let Some((name, inner)) = split_generic(t) {
1607 match name {
1608 "Option" => {
1609 let inner = self.parse_arg_type(inner);
1610 return PyParsedArg {
1611 schema: format!("{{\"option\": {}}}", inner.schema),
1612 param_type: wrap_optional(&inner.param_type),
1613 supported: inner.supported,
1614 };
1615 }
1616 "Vec" => {
1617 let inner = self.parse_arg_type(inner);
1618 return PyParsedArg {
1619 schema: format!("{{\"vec\": {}}}", inner.schema),
1620 param_type: format!("Sequence[{}]", inner.param_type),
1621 supported: inner.supported,
1622 };
1623 }
1624 _ => return py_unsupported(),
1625 }
1626 }
1627
1628 if let Some(stripped) = t.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
1630 if let Some((ty, n)) = stripped.rsplit_once(';') {
1631 let inner = self.parse_arg_type(ty.trim());
1632 let n = n.trim();
1633 if n.parse::<usize>().is_ok() {
1634 return PyParsedArg {
1635 schema: format!("{{\"array\": ({}, {})}}", inner.schema, n),
1636 param_type: format!("Sequence[{}]", inner.param_type),
1637 supported: inner.supported,
1638 };
1639 }
1640 }
1641 }
1642
1643 let last = t.rsplit("::").next().unwrap_or(t);
1645 match last {
1646 "u8" => py_prim("u8", "int"),
1647 "u16" => py_prim("u16", "int"),
1648 "u32" => py_prim("u32", "int"),
1649 "u64" => py_prim("u64", "int"),
1650 "u128" => py_prim("u128", "int"),
1651 "i8" => py_prim("i8", "int"),
1652 "i16" => py_prim("i16", "int"),
1653 "i32" => py_prim("i32", "int"),
1654 "i64" => py_prim("i64", "int"),
1655 "i128" => py_prim("i128", "int"),
1656 "f32" => py_prim("f32", "float"),
1657 "f64" => py_prim("f64", "float"),
1658 "bool" => py_prim("bool", "bool"),
1659 "String" | "string" | "str" => py_prim("string", "str"),
1660 "Pubkey" | "pubkey" | "PublicKey" | "publicKey" => py_prim("pubkey", "str"),
1661 "bytes" => py_prim("bytes", "bytes"),
1662 _ => self.resolve_defined(last).unwrap_or_else(py_unsupported),
1663 }
1664 }
1665
1666 fn parse_snapshot_type(&mut self, t: &IdlTypeSnapshot) -> PyParsedArg {
1667 match t {
1668 IdlTypeSnapshot::Simple(s) => self.parse_arg_type(s),
1669 IdlTypeSnapshot::Option(o) => {
1670 let inner = self.parse_snapshot_type(&o.option);
1671 PyParsedArg {
1672 schema: format!("{{\"option\": {}}}", inner.schema),
1673 param_type: wrap_optional(&inner.param_type),
1674 supported: inner.supported,
1675 }
1676 }
1677 IdlTypeSnapshot::Vec(v) => {
1678 let inner = self.parse_snapshot_type(&v.vec);
1679 PyParsedArg {
1680 schema: format!("{{\"vec\": {}}}", inner.schema),
1681 param_type: format!("Sequence[{}]", inner.param_type),
1682 supported: inner.supported,
1683 }
1684 }
1685 IdlTypeSnapshot::Array(arr) => {
1686 let mut element: Option<PyParsedArg> = None;
1687 let mut size: Option<u32> = None;
1688 for part in &arr.array {
1689 match part {
1690 IdlArrayElementSnapshot::Type(inner) => {
1691 element = Some(self.parse_snapshot_type(inner))
1692 }
1693 IdlArrayElementSnapshot::TypeName(name) => {
1694 element = Some(self.parse_arg_type(name))
1695 }
1696 IdlArrayElementSnapshot::Size(n) => size = Some(*n),
1697 }
1698 }
1699 match (element, size) {
1700 (Some(inner), Some(n)) => PyParsedArg {
1701 schema: format!("{{\"array\": ({}, {})}}", inner.schema, n),
1702 param_type: format!("Sequence[{}]", inner.param_type),
1703 supported: inner.supported,
1704 },
1705 _ => py_unsupported(),
1706 }
1707 }
1708 IdlTypeSnapshot::HashMap(map) => {
1709 let key = self.parse_snapshot_type(&map.hash_map.0);
1710 let value = self.parse_snapshot_type(&map.hash_map.1);
1711 if !key.supported || key.schema != "\"string\"" || !value.supported {
1712 py_unsupported()
1713 } else {
1714 PyParsedArg {
1715 schema: format!("{{\"hashMap\": ({}, {})}}", key.schema, value.schema),
1716 param_type: "Any".to_string(),
1717 supported: true,
1718 }
1719 }
1720 }
1721 IdlTypeSnapshot::Defined(d) => {
1722 let name = match &d.defined {
1723 IdlDefinedInnerSnapshot::Named { name } => name.as_str(),
1724 IdlDefinedInnerSnapshot::Simple(s) => s.as_str(),
1725 };
1726 self.resolve_defined(name).unwrap_or_else(py_unsupported)
1727 }
1728 }
1729 }
1730
1731 fn resolve_defined(&mut self, name: &str) -> Option<PyParsedArg> {
1732 if let Some(cached) = self.resolved.get(name) {
1733 return cached.clone();
1734 }
1735 if self.visiting.contains(name) {
1736 return None;
1738 }
1739 let key = if self.defs.contains_key(name) {
1740 name.to_string()
1741 } else {
1742 match self.lower.get(&name.to_lowercase()) {
1743 Some(canonical) => canonical.clone(),
1744 None => {
1745 self.resolved.insert(name.to_string(), None);
1746 return None;
1747 }
1748 }
1749 };
1750 self.visiting.insert(key.clone());
1751 let def = self.defs[&key];
1752 let result = match &def.type_def {
1753 IdlTypeDefKindSnapshot::Struct { fields, .. } => {
1754 let fields = fields.clone();
1755 self.resolve_struct(&fields)
1756 }
1757 IdlTypeDefKindSnapshot::TupleStruct { .. } => None,
1758 IdlTypeDefKindSnapshot::Enum { variants, .. } => {
1759 let variants = variants.clone();
1760 self.resolve_enum(&variants)
1761 }
1762 };
1763 self.visiting.remove(&key);
1764 self.resolved.insert(name.to_string(), result.clone());
1765 if name != key {
1766 self.resolved.insert(key, result.clone());
1767 }
1768 result
1769 }
1770
1771 fn resolve_struct(&mut self, fields: &[IdlFieldSnapshot]) -> Option<PyParsedArg> {
1772 let mut field_exprs: Vec<String> = Vec::new();
1773 for field in fields {
1774 let parsed = self.parse_snapshot_type(&field.type_);
1775 if !parsed.supported {
1776 return None;
1777 }
1778 field_exprs.push(format!(
1779 "{{\"name\": {}, \"type\": {}}}",
1780 py_string_literal(&field.name),
1781 parsed.schema
1782 ));
1783 }
1784 Some(PyParsedArg {
1785 schema: format!("{{\"struct\": [{}]}}", field_exprs.join(", ")),
1786 param_type: "Any".to_string(),
1787 supported: true,
1788 })
1789 }
1790
1791 fn resolve_enum(&mut self, variants: &[IdlEnumVariantSnapshot]) -> Option<PyParsedArg> {
1792 let mut variant_exprs: Vec<String> = Vec::new();
1793 for variant in variants {
1794 let name_literal = py_string_literal(&variant.name);
1795 if variant.fields.is_empty() {
1796 variant_exprs.push(name_literal);
1797 continue;
1798 }
1799 let named: Vec<_> = variant
1800 .fields
1801 .iter()
1802 .filter_map(|field| match field {
1803 IdlEnumVariantFieldSnapshot::Named(field) => Some(field),
1804 IdlEnumVariantFieldSnapshot::Tuple(_) => None,
1805 })
1806 .collect();
1807 if named.len() == variant.fields.len() {
1808 let mut field_exprs: Vec<String> = Vec::new();
1809 for field in named {
1810 let parsed = self.parse_snapshot_type(&field.type_);
1811 if !parsed.supported {
1812 return None;
1813 }
1814 field_exprs.push(format!(
1815 "{{\"name\": {}, \"type\": {}}}",
1816 py_string_literal(&field.name),
1817 parsed.schema
1818 ));
1819 }
1820 variant_exprs.push(format!(
1821 "{{\"name\": {}, \"fields\": [{}]}}",
1822 name_literal,
1823 field_exprs.join(", ")
1824 ));
1825 } else if named.is_empty() {
1826 let mut element_exprs: Vec<String> = Vec::new();
1827 for field in &variant.fields {
1828 let IdlEnumVariantFieldSnapshot::Tuple(ty) = field else {
1829 unreachable!("named.is_empty() guarantees tuple fields");
1830 };
1831 let parsed = self.parse_snapshot_type(ty);
1832 if !parsed.supported {
1833 return None;
1834 }
1835 element_exprs.push(parsed.schema);
1836 }
1837 variant_exprs.push(format!(
1838 "{{\"name\": {}, \"tuple\": [{}]}}",
1839 name_literal,
1840 element_exprs.join(", ")
1841 ));
1842 } else {
1843 return None;
1845 }
1846 }
1847 Some(PyParsedArg {
1848 schema: format!("{{\"enum\": [{}]}}", variant_exprs.join(", ")),
1849 param_type: "Any".to_string(),
1850 supported: true,
1851 })
1852 }
1853}
1854
1855#[derive(Debug, Clone, Copy, PartialEq)]
1857enum PyAccountFieldKind {
1858 Signer,
1860 Required,
1862 Optional,
1864}
1865
1866struct MappedPyAccount {
1868 literal: String,
1870 field: Option<(String, PyAccountFieldKind)>,
1872 notes: Vec<String>,
1874 uses_pda: bool,
1876}
1877
1878fn py_account_meta_literal(
1879 acc: &InstructionAccountDef,
1880 resolution: &str,
1881 comment: Option<&str>,
1882) -> String {
1883 let mut out = String::new();
1884 if let Some(comment) = comment {
1885 out.push_str(&format!(" # [arete codegen] {}\n", comment));
1886 }
1887 out.push_str(&format!(
1888 " AccountMeta(\n name={name},\n is_signer={is_signer},\n is_writable={is_writable},\n resolution={resolution},\n is_optional={is_optional},\n ),",
1889 name = py_string_literal(&acc.name),
1890 is_signer = py_bool(acc.is_signer),
1891 is_writable = py_bool(acc.is_writable),
1892 resolution = resolution,
1893 is_optional = py_bool(acc.is_optional),
1894 ));
1895 out
1896}
1897
1898fn map_py_account(
1899 acc: &InstructionAccountDef,
1900 pda_lookup: &BTreeMap<&str, &PdaDefinition>,
1901 account_names: &HashSet<&str>,
1902 arg_types: &BTreeMap<&str, &str>,
1903) -> MappedPyAccount {
1904 let user_field_kind = if acc.is_optional {
1905 PyAccountFieldKind::Optional
1906 } else {
1907 PyAccountFieldKind::Required
1908 };
1909 let degraded = |reason: String| -> MappedPyAccount {
1910 let note = format!(
1911 "account `{}` degraded to user-provided ({})",
1912 acc.name, reason
1913 );
1914 MappedPyAccount {
1915 literal: py_account_meta_literal(acc, "UserProvided()", Some(¬e)),
1916 field: Some((acc.name.clone(), user_field_kind)),
1917 notes: vec![note],
1918 uses_pda: false,
1919 }
1920 };
1921
1922 match &acc.resolution {
1923 AccountResolution::Signer => MappedPyAccount {
1924 literal: py_account_meta_literal(acc, "Signer()", None),
1925 field: Some((acc.name.clone(), PyAccountFieldKind::Signer)),
1926 notes: Vec::new(),
1927 uses_pda: false,
1928 },
1929 AccountResolution::Known { address } => MappedPyAccount {
1930 literal: py_account_meta_literal(
1931 acc,
1932 &format!("Known({})", py_string_literal(address)),
1933 None,
1934 ),
1935 field: None,
1936 notes: Vec::new(),
1937 uses_pda: false,
1938 },
1939 AccountResolution::UserProvided => MappedPyAccount {
1940 literal: py_account_meta_literal(acc, "UserProvided()", None),
1941 field: Some((acc.name.clone(), user_field_kind)),
1942 notes: Vec::new(),
1943 uses_pda: false,
1944 },
1945 AccountResolution::PdaInline { seeds, program_id } => {
1946 match build_py_pda_config(seeds, program_id.as_deref(), account_names, arg_types) {
1947 Ok((config, notes)) => MappedPyAccount {
1948 literal: py_account_meta_literal(acc, &format!("Pda({config})"), None),
1949 field: None,
1950 notes,
1951 uses_pda: true,
1952 },
1953 Err(reason) => degraded(reason),
1954 }
1955 }
1956 AccountResolution::PdaRef { pda_name } => match pda_lookup.get(pda_name.as_str()) {
1957 Some(def) => {
1958 match build_py_pda_config(
1959 &def.seeds,
1960 def.program_id.as_deref(),
1961 account_names,
1962 arg_types,
1963 ) {
1964 Ok((config, notes)) => MappedPyAccount {
1965 literal: py_account_meta_literal(acc, &format!("Pda({config})"), None),
1966 field: None,
1967 notes,
1968 uses_pda: true,
1969 },
1970 Err(reason) => degraded(format!("PDA '{}': {}", pda_name, reason)),
1971 }
1972 }
1973 None => degraded(format!("references unknown PDA '{}'", pda_name)),
1974 },
1975 }
1976}
1977
1978fn py_seed_expr(seed: &PdaSeedDef) -> String {
1979 match seed {
1980 PdaSeedDef::Literal { value } => format!("LiteralSeed({})", py_string_literal(value)),
1981 PdaSeedDef::Bytes { value } => {
1982 let bytes: Vec<String> = value.iter().map(|b| b.to_string()).collect();
1983 format!("BytesSeed(bytes([{}]))", bytes.join(", "))
1984 }
1985 PdaSeedDef::AccountRef { account_name } => {
1986 format!("AccountRefSeed({})", py_string_literal(account_name))
1987 }
1988 PdaSeedDef::ArgRef { arg_name, arg_type } => {
1989 match arg_type.as_deref().and_then(normalize_seed_arg_type) {
1990 Some(canonical) => format!(
1991 "ArgRefSeed({}, {})",
1992 py_string_literal(arg_name),
1993 py_string_literal(&canonical)
1994 ),
1995 None => format!("ArgRefSeed({})", py_string_literal(arg_name)),
1996 }
1997 }
1998 }
1999}
2000
2001fn py_seed_tuple(seed_exprs: &[String]) -> String {
2002 if seed_exprs.len() == 1 {
2003 format!("({},)", seed_exprs[0])
2004 } else {
2005 format!("({})", seed_exprs.join(", "))
2006 }
2007}
2008
2009fn build_py_pda_config(
2013 seeds: &[PdaSeedDef],
2014 program_id: Option<&str>,
2015 account_names: &HashSet<&str>,
2016 arg_types: &BTreeMap<&str, &str>,
2017) -> Result<(String, Vec<String>), String> {
2018 let mut seed_exprs: Vec<String> = Vec::new();
2019 let mut notes: Vec<String> = Vec::new();
2020 for seed in seeds {
2021 match seed {
2022 PdaSeedDef::Literal { .. } | PdaSeedDef::Bytes { .. } => {
2023 seed_exprs.push(py_seed_expr(seed));
2024 }
2025 PdaSeedDef::AccountRef { account_name } => {
2026 if account_name.contains('.') {
2027 return Err(format!(
2028 "seed references account field '{}' which is not supported for auto-resolution",
2029 account_name
2030 ));
2031 }
2032 if !account_names.contains(account_name.as_str()) {
2033 return Err(format!(
2034 "seed references account '{}' not present in this instruction",
2035 account_name
2036 ));
2037 }
2038 seed_exprs.push(py_seed_expr(seed));
2039 }
2040 PdaSeedDef::ArgRef { arg_name, arg_type } => {
2041 let arg_root = arg_name.split('.').next().unwrap_or(arg_name.as_str());
2042 let present =
2043 arg_types.contains_key(arg_name.as_str()) || arg_types.contains_key(arg_root);
2044 let raw_type = arg_type
2047 .as_deref()
2048 .or_else(|| arg_types.get(arg_name.as_str()).copied())
2049 .or_else(|| arg_types.get(arg_root).copied());
2050 let canonical = raw_type.and_then(normalize_seed_arg_type);
2051 if !present {
2052 if canonical.is_none() {
2053 return Err(format!(
2054 "seed helper arg '{}' is not present in this instruction and has no primitive type information",
2055 arg_name
2056 ));
2057 }
2058 notes.push(format!(
2059 "seed input `{}` must be supplied via the `resolve` key when building through the raw handler",
2060 arg_name
2061 ));
2062 }
2063 match canonical {
2064 Some(canonical) => seed_exprs.push(format!(
2065 "ArgRefSeed({}, {})",
2066 py_string_literal(arg_name),
2067 py_string_literal(&canonical)
2068 )),
2069 None => {
2070 notes.push(format!(
2071 "seed arg `{}` has non-primitive type '{}'; the runtime will use heuristic encoding",
2072 arg_name,
2073 raw_type.unwrap_or("<unknown>")
2074 ));
2075 seed_exprs.push(format!("ArgRefSeed({})", py_string_literal(arg_name)));
2076 }
2077 }
2078 }
2079 }
2080 }
2081
2082 let config = match program_id {
2083 Some(pid) => format!(
2084 "PdaConfig(seeds={}, program_id={})",
2085 py_seed_tuple(&seed_exprs),
2086 py_string_literal(pid)
2087 ),
2088 None => format!("PdaConfig(seeds={})", py_seed_tuple(&seed_exprs)),
2089 };
2090 Ok((config, notes))
2091}
2092
2093struct PyInstructionBlock {
2095 code: String,
2096 raw_key: String,
2098 handler_fn: String,
2099 exports: Vec<String>,
2100}
2101
2102#[allow(clippy::too_many_arguments)]
2103fn generate_py_instruction_block(
2104 instr: &InstructionDef,
2105 errors_expr: &str,
2106 has_program_errors: bool,
2107 pda_lookup: &BTreeMap<&str, &PdaDefinition>,
2108 parser: &mut PythonDefinedTypes<'_>,
2109 needs: &mut PythonProgramImports,
2110 module_name: &str,
2111 pascal_prefix: &str,
2112) -> Result<PyInstructionBlock, String> {
2113 let mut parsed_args: Vec<(&InstructionArgDef, PyParsedArg)> = Vec::new();
2115 for arg in &instr.args {
2116 let parsed = parser.parse_arg_type(&arg.arg_type);
2117 if !parsed.supported {
2118 return Err(format!(
2119 "arg '{}' has unsupported type '{}'",
2120 arg.name, arg.arg_type
2121 ));
2122 }
2123 parsed_args.push((arg, parsed));
2124 }
2125
2126 let account_names: HashSet<&str> = instr.accounts.iter().map(|a| a.name.as_str()).collect();
2128 let arg_types: BTreeMap<&str, &str> = instr
2129 .args
2130 .iter()
2131 .map(|a| (a.name.as_str(), a.arg_type.as_str()))
2132 .collect();
2133
2134 let mut account_literals: Vec<String> = Vec::new();
2135 let mut account_fields: Vec<(String, PyAccountFieldKind)> = Vec::new();
2136 let mut notes: Vec<String> = Vec::new();
2137 for acc in &instr.accounts {
2138 let mapped = map_py_account(acc, pda_lookup, &account_names, &arg_types);
2139 account_literals.push(mapped.literal);
2140 if let Some(field) = mapped.field {
2141 account_fields.push(field);
2142 }
2143 notes.extend(mapped.notes);
2144 if mapped.uses_pda {
2145 needs.pda = true;
2146 }
2147 }
2148 if !instr.accounts.is_empty() {
2149 needs.account_meta = true;
2150 }
2151 if !instr.args.is_empty() {
2152 needs.arg_schema = true;
2153 }
2154 if has_program_errors {
2155 needs.error_metadata = true;
2156 }
2157 needs.builders = true;
2158
2159 let snake_name = to_snake_case(&instr.name);
2160 let fn_name = format!("{module_name}_{snake_name}");
2161 let handler_fn = format!("{fn_name}_handler");
2162 let params_name = format!("{}{}Params", pascal_prefix, to_pascal_case(&instr.name));
2163
2164 let arg_name_set: HashSet<&str> = instr.args.iter().map(|a| a.name.as_str()).collect();
2169 let mut used_keys: HashSet<String> = HashSet::new();
2170 let mut param_entries: Vec<String> = Vec::new();
2171 for (arg, parsed) in &parsed_args {
2172 used_keys.insert(arg.name.clone());
2173 param_entries.push(format!(
2174 " # arg `{}` (`{}`)\n {}: {},",
2175 arg.name,
2176 arg.arg_type,
2177 py_string_literal(&arg.name),
2178 parsed.param_type
2179 ));
2180 }
2181 for (name, kind) in &account_fields {
2182 if arg_name_set.contains(name.as_str()) {
2183 notes.push(format!(
2184 "account `{}` shares its name with an instruction arg and has no typed override field",
2185 name
2186 ));
2187 continue;
2188 }
2189 if !used_keys.insert(name.clone()) {
2190 notes.push(format!(
2191 "account `{}` collides with another params field and has no typed override field",
2192 name
2193 ));
2194 continue;
2195 }
2196 let comment = match kind {
2197 PyAccountFieldKind::Signer => format!(
2198 "Optional address override for the `{}` signer (defaults to the payer).",
2199 name
2200 ),
2201 PyAccountFieldKind::Required => format!("Address of the `{}` account.", name),
2202 PyAccountFieldKind::Optional => {
2203 format!("Optional address of the `{}` account.", name)
2204 }
2205 };
2206 param_entries.push(format!(
2207 " # {}\n {}: str,",
2208 comment,
2209 py_string_literal(name)
2210 ));
2211 }
2212
2213 let params_typed_dict = if param_entries.is_empty() {
2214 format!(
2215 "# Typed params for `{name}` (no args or caller-supplied accounts).\n{params_name} = TypedDict(\"{params_name}\", {{}}, total=False)",
2216 name = instr.name,
2217 params_name = params_name
2218 )
2219 } else {
2220 format!(
2221 "# Typed params for `{name}`: instruction args plus overridable accounts\n# (wire-name keys; required/optional noted per key).\n{params_name} = TypedDict(\n \"{params_name}\",\n {{\n{fields}\n }},\n total=False,\n)",
2222 name = instr.name,
2223 params_name = params_name,
2224 fields = param_entries.join("\n")
2225 )
2226 };
2227
2228 let mut doc_lines: Vec<String> = instr
2230 .docs
2231 .iter()
2232 .map(|line| line.trim().to_string())
2233 .collect();
2234 if doc_lines.is_empty() {
2235 doc_lines.push(format!("Builds the `{}` instruction.", instr.name));
2236 }
2237 doc_lines.push(String::new());
2238 doc_lines.push(format!(
2239 "Pure (no network). Params are IDL wire shape (see `{params_name}`);"
2240 ));
2241 doc_lines.push("unknown params fail closed.".to_string());
2242 doc_lines.push(String::new());
2243 doc_lines
2244 .push("Reserved keyword-only options: `wallet` (signer fallback address),".to_string());
2245 doc_lines.push(
2246 "`accounts` (unvalidated overrides), `remaining_accounts`. Account names".to_string(),
2247 );
2248 doc_lines.push("(including `payer`) stay available as params.".to_string());
2249 if !notes.is_empty() {
2250 doc_lines.push(String::new());
2251 doc_lines.push("Codegen notes:".to_string());
2252 for note in ¬es {
2253 doc_lines.push(format!("- {}", note));
2254 }
2255 }
2256 let docstring = py_docstring(&doc_lines, " ");
2257
2258 let builder = format!(
2259 "def {fn_name}(\n *,\n wallet: Optional[str] = None,\n accounts: Optional[Mapping[str, str]] = None,\n remaining_accounts: Optional[Sequence[BuiltAccountMeta]] = None,\n **params: Any,\n) -> BuiltInstruction:\n{docstring}\n return {handler_fn}().build(\n dict(params),\n payer=wallet,\n accounts=accounts,\n remaining_accounts=remaining_accounts,\n )",
2263 fn_name = fn_name,
2264 docstring = docstring,
2265 handler_fn = handler_fn,
2266 );
2267
2268 let discriminator = instr
2270 .discriminator
2271 .iter()
2272 .map(|b| b.to_string())
2273 .collect::<Vec<_>>()
2274 .join(", ");
2275 let accounts_literal = if account_literals.is_empty() {
2276 "[]".to_string()
2277 } else {
2278 format!("[\n{}\n ]", account_literals.join("\n"))
2279 };
2280 let args_literal = if parsed_args.is_empty() {
2281 "[]".to_string()
2282 } else {
2283 let entries: Vec<String> = parsed_args
2284 .iter()
2285 .map(|(arg, parsed)| {
2286 format!(
2287 " ArgSchema(name={}, type={}),",
2288 py_string_literal(&arg.name),
2289 parsed.schema
2290 )
2291 })
2292 .collect();
2293 format!("[\n{}\n ]", entries.join("\n"))
2294 };
2295
2296 let handler = format!(
2297 "def {handler_fn}() -> InstructionHandler:\n \"\"\"Raw instruction handler for `{name}` (escape hatch).\"\"\"\n return InstructionHandler(\n program_id={program_id_const},\n discriminator=bytes([{discriminator}]),\n accounts={accounts},\n args={args},\n errors={errors},\n )",
2298 handler_fn = handler_fn,
2299 name = instr.name,
2300 program_id_const = format_args!("{}_PROGRAM_ID", module_name.to_uppercase()),
2301 discriminator = discriminator,
2302 accounts = accounts_literal,
2303 args = args_literal,
2304 errors = errors_expr,
2305 );
2306
2307 Ok(PyInstructionBlock {
2308 code: format!("{}\n\n\n{}\n\n\n{}", params_typed_dict, builder, handler),
2309 raw_key: snake_name,
2310 handler_fn: handler_fn.clone(),
2311 exports: vec![params_name, fn_name, handler_fn],
2312 })
2313}
2314
2315fn generate_py_pdas(
2318 pdas: &BTreeMap<String, PdaDefinition>,
2319 module_name: &str,
2320 pascal_prefix: &str,
2321 needs: &mut PythonProgramImports,
2322) -> Option<(String, String, String)> {
2323 if pdas.is_empty() {
2324 return None;
2325 }
2326 needs.pda = true;
2327 needs.pda_factory = true;
2328
2329 let const_prefix = module_name.to_uppercase();
2330 let dict_name = format!("_{const_prefix}_PDAS");
2331 let class_name = format!("{pascal_prefix}Pdas");
2332 let program_id_const = format!("{const_prefix}_PROGRAM_ID");
2333
2334 let mut dict_entries: Vec<String> = Vec::new();
2335 let mut class_attrs: Vec<String> = Vec::new();
2336 for def in pdas.values() {
2337 let key = to_snake_case(&def.name);
2338 let seed_exprs: Vec<String> = def.seeds.iter().map(py_seed_expr).collect();
2339 let config = match &def.program_id {
2340 Some(pid) => format!(
2341 "PdaConfig(seeds={}, program_id={})",
2342 py_seed_tuple(&seed_exprs),
2343 py_string_literal(pid)
2344 ),
2345 None => format!("PdaConfig(seeds={})", py_seed_tuple(&seed_exprs)),
2346 };
2347 dict_entries.push(format!(" {}: {},", py_string_literal(&key), config));
2348 class_attrs.push(format!(
2349 " {key} = PdaFactory({name}, {dict_name}[{name}], {program_id_const})",
2350 key = key,
2351 name = py_string_literal(&key),
2352 dict_name = dict_name,
2353 program_id_const = program_id_const,
2354 ));
2355 }
2356
2357 let code = format!(
2358 "{dict_name}: Dict[str, PdaConfig] = {{\n{entries}\n}}\n\n\nclass {class_name}:\n \"\"\"PDA factories for program `{module_name}`: `.derive(**seeds)` returns\n `(address, bump)`; unknown seed kwargs fail closed.\"\"\"\n\n{attrs}",
2359 dict_name = dict_name,
2360 entries = dict_entries.join("\n"),
2361 class_name = class_name,
2362 module_name = module_name,
2363 attrs = class_attrs.join("\n"),
2364 );
2365 Some((code, dict_name, class_name))
2366}
2367
2368type ProgramReadLayer = Result<(String, String), String>;
2371
2372fn resolve_program_read_layer(
2373 program_specs: &[arete_hash::ProgramSpecV1],
2374 program_id: &str,
2375) -> ProgramReadLayer {
2376 let Some(spec) = program_specs
2377 .iter()
2378 .find(|spec| spec.program_id == program_id)
2379 else {
2380 return Err("no program specification was recorded for this program".to_string());
2381 };
2382 let spec_hash = spec
2383 .hash()
2384 .map_err(|error| format!("failed to compute the program spec hash ({error})"))?;
2385 let release_hash = spec
2386 .oss_release_hash()
2387 .map_err(|error| format!("failed to compute the release hash ({error})"))?;
2388 Ok((spec_hash.to_string(), release_hash.to_string()))
2389}
2390
2391#[allow(clippy::too_many_arguments)]
2396fn generate_stack_programs_py(
2397 stack_name: &str,
2398 instructions: &[InstructionDef],
2399 idls: &[IdlSnapshot],
2400 pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
2401 program_ids: &[String],
2402 program_specs: &[arete_hash::ProgramSpecV1],
2403 account_structs: &BTreeMap<String, String>,
2404) -> Option<PythonProgramsCodegen> {
2405 if instructions.is_empty() {
2406 return None;
2407 }
2408
2409 let default_program_id = program_ids.first().cloned().unwrap_or_default();
2410
2411 let mut groups: Vec<(String, Vec<&InstructionDef>)> = Vec::new();
2413 for instr in instructions {
2414 let pid = instr
2415 .program_id
2416 .clone()
2417 .unwrap_or_else(|| default_program_id.clone());
2418 match groups.iter_mut().find(|(existing, _)| *existing == pid) {
2419 Some((_, list)) => list.push(instr),
2420 None => groups.push((pid, vec![instr])),
2421 }
2422 }
2423
2424 let mut parser = PythonDefinedTypes::new(idls);
2425 let mut needs = PythonProgramImports::default();
2426 let mut used_module_names: HashSet<String> = HashSet::new();
2427 let mut sections: Vec<String> = Vec::new();
2428 let mut exports: Vec<String> = Vec::new();
2429 let mut program_entries: Vec<String> = Vec::new();
2430 let mut read_entries: Vec<(String, String)> = Vec::new(); let mut omitted_reads: Vec<(String, String)> = Vec::new(); for (index, (program_id, group)) in groups.iter().enumerate() {
2434 let idl = idls
2435 .iter()
2436 .find(|idl| idl.program_id.as_deref() == Some(program_id.as_str()));
2437 let raw_name = match idl {
2438 Some(idl) => idl.name.clone(),
2439 None if index == 0 => stack_name.to_string(),
2440 None => format!("program{}", index),
2441 };
2442 let mut module_name = python_module_name(&raw_name);
2443 if module_name.is_empty() {
2444 module_name = format!("program{}", index);
2445 }
2446 while !used_module_names.insert(module_name.clone()) {
2447 module_name.push('_');
2448 }
2449 let const_prefix = module_name.to_uppercase();
2450 let pascal_prefix = to_pascal_case(&module_name);
2451
2452 let own_pdas = idl.and_then(|idl| pdas.get(idl.name.as_str()));
2454 let mut pda_lookup: BTreeMap<&str, &PdaDefinition> = BTreeMap::new();
2455 if let Some(own) = own_pdas {
2456 for (name, def) in own {
2457 pda_lookup.insert(name.as_str(), def);
2458 }
2459 }
2460 for group_pdas in pdas.values() {
2461 for (name, def) in group_pdas {
2462 pda_lookup.entry(name.as_str()).or_insert(def);
2463 }
2464 }
2465
2466 let program_errors = idl
2467 .map(|idl| dedupe_errors_by_code(&idl.errors))
2468 .unwrap_or_default();
2469 if !program_errors.is_empty() {
2470 needs.error_metadata = true;
2471 }
2472 let errors_const = format!("{const_prefix}_ERRORS");
2473
2474 let mut blocks: Vec<String> = Vec::new();
2475 let mut raw_entries: Vec<(String, String)> = Vec::new();
2476 let mut skipped: Vec<(String, String)> = Vec::new();
2477 for instr in group {
2478 let errors_expr = if instr.errors.is_empty() {
2479 if program_errors.is_empty() {
2480 "[]".to_string()
2481 } else {
2482 format!("list({errors_const})")
2483 }
2484 } else {
2485 let deduped = dedupe_errors_by_code(&instr.errors);
2486 needs.error_metadata = true;
2487 format!(
2488 "[\n{}\n ]",
2489 deduped
2490 .iter()
2491 .map(|error| format!(
2492 " ErrorMetadata(code={}, name={}, msg={}),",
2493 error.code,
2494 py_string_literal(&error.name),
2495 py_string_literal(error.msg.as_deref().unwrap_or(""))
2496 ))
2497 .collect::<Vec<_>>()
2498 .join("\n")
2499 )
2500 };
2501 match generate_py_instruction_block(
2502 instr,
2503 &errors_expr,
2504 !program_errors.is_empty(),
2505 &pda_lookup,
2506 &mut parser,
2507 &mut needs,
2508 &module_name,
2509 &pascal_prefix,
2510 ) {
2511 Ok(block) => {
2512 raw_entries.push((block.raw_key.clone(), block.handler_fn.clone()));
2513 exports.extend(block.exports.clone());
2514 blocks.push(block.code);
2515 }
2516 Err(reason) => skipped.push((instr.name.clone(), reason)),
2517 }
2518 }
2519
2520 let read_layer = resolve_program_read_layer(program_specs, program_id);
2522 let mut account_read_entries: Vec<String> = Vec::new();
2523 if read_layer.is_ok() {
2524 needs.reads = true;
2525 let accounts = idl.map(|idl| idl.accounts.as_slice()).unwrap_or_default();
2526 for account in accounts {
2527 let Some(struct_name) = account_structs.get(&account.name).or_else(|| {
2528 account_structs
2529 .iter()
2530 .find(|(name, _)| name.eq_ignore_ascii_case(&account.name))
2531 .map(|(_, emitted)| emitted)
2532 }) else {
2533 continue;
2535 };
2536 account_read_entries.push(format!(
2537 " {}: ProgramAccountReadDef(account={}, parser=models.{}_from_wire),",
2538 py_string_literal(&to_snake_case(&account.name)),
2539 py_string_literal(&account.name),
2540 to_snake_case(struct_name),
2541 ));
2542 }
2543 }
2544
2545 let mut section = format!(
2547 "# {rule}\n# Program `{raw_name}` (program ID `{program_id}`)\n# {rule}\n",
2548 rule = "=".repeat(74),
2549 raw_name = raw_name,
2550 program_id = program_id,
2551 );
2552 if let Err(reason) = &read_layer {
2553 section.push_str(&format!("# Program read layer omitted: {}.\n", reason));
2554 }
2555 if !skipped.is_empty() {
2556 section.push_str("# Skipped instructions (unsupported by instruction codegen):\n");
2557 for (name, reason) in &skipped {
2558 section.push_str(&format!("# - `{}`: {}\n", name, reason));
2559 }
2560 }
2561 section.push('\n');
2562 section.push_str(&format!(
2563 "{const_prefix}_PROGRAM_ID = {}\n",
2564 py_string_literal(program_id)
2565 ));
2566 exports.push(format!("{const_prefix}_PROGRAM_ID"));
2567
2568 let mut spec_hash_expr = "None".to_string();
2569 if let Ok((spec_hash, release_hash)) = &read_layer {
2570 section.push_str(&format!(
2571 "\n#: Content hash of the exact program specification captured at generation time.\n{const_prefix}_PROGRAM_SPEC_HASH = {spec}\n\n#: Release identity addressing hosted account reads for this program.\n{const_prefix}_PROGRAM_RELEASE_HASH = {release}\n\n\ndef {module_name}_read_descriptor() -> ProgramReadDescriptor:\n \"\"\"Release-addressed read descriptor for program `{module_name}` (HTTP reads\n over the client's HTTP base URL).\"\"\"\n return ProgramReadDescriptor(\n release=ProgramReleaseReference(\n program_release_hash={const_prefix}_PROGRAM_RELEASE_HASH,\n program_spec_hash={const_prefix}_PROGRAM_SPEC_HASH,\n ),\n transport=LocalHttpTransportDef(),\n )\n",
2572 spec = py_string_literal(spec_hash),
2573 release = py_string_literal(release_hash),
2574 ));
2575 exports.extend([
2576 format!("{const_prefix}_PROGRAM_SPEC_HASH"),
2577 format!("{const_prefix}_PROGRAM_RELEASE_HASH"),
2578 format!("{module_name}_read_descriptor"),
2579 ]);
2580 spec_hash_expr = format!("{const_prefix}_PROGRAM_SPEC_HASH");
2581 }
2582
2583 let errors_literal = if program_errors.is_empty() {
2584 "()".to_string()
2585 } else {
2586 format!(
2587 "(\n{}\n)",
2588 program_errors
2589 .iter()
2590 .map(|error| format!(
2591 " ErrorMetadata(code={}, name={}, msg={}),",
2592 error.code,
2593 py_string_literal(&error.name),
2594 py_string_literal(error.msg.as_deref().unwrap_or(""))
2595 ))
2596 .collect::<Vec<_>>()
2597 .join("\n")
2598 )
2599 };
2600 section.push_str(&format!(
2601 "\n#: IDL error metadata for program `{module_name}`.\n{errors_const}: Tuple[ErrorMetadata, ...] = {errors_literal}\n",
2602 ));
2603 exports.push(errors_const.clone());
2604
2605 let pdas_generated = generate_py_pdas(
2606 own_pdas.unwrap_or(&BTreeMap::new()),
2607 &module_name,
2608 &pascal_prefix,
2609 &mut needs,
2610 );
2611 let pdas_dict_expr = match &pdas_generated {
2612 Some((code, dict_name, class_name)) => {
2613 section.push('\n');
2614 section.push_str(code);
2615 section.push('\n');
2616 exports.push(class_name.clone());
2617 format!("dict({dict_name})")
2618 }
2619 None => "{}".to_string(),
2620 };
2621
2622 for block in &blocks {
2623 section.push('\n');
2624 section.push_str(block);
2625 section.push('\n');
2626 }
2627
2628 let accounts_expr = if account_read_entries.is_empty() {
2629 "{}".to_string()
2630 } else {
2631 let dict_name = format!("_{const_prefix}_ACCOUNTS");
2632 section.push_str(&format!(
2633 "\n{dict_name}: Dict[str, ProgramAccountReadDef] = {{\n{entries}\n}}\n",
2634 entries = account_read_entries.join("\n"),
2635 ));
2636 format!("dict({dict_name})")
2637 };
2638
2639 let raw_dict = if raw_entries.is_empty() {
2640 "{}".to_string()
2641 } else {
2642 format!(
2643 "{{\n{}\n }}",
2644 raw_entries
2645 .iter()
2646 .map(|(key, handler)| format!(
2647 " {}: {}(),",
2648 py_string_literal(key),
2649 handler
2650 ))
2651 .collect::<Vec<_>>()
2652 .join("\n")
2653 )
2654 };
2655
2656 let program_const = format!("{const_prefix}_PROGRAM");
2657 section.push_str(&format!(
2658 "\n#: Portable program SDK definition consumed by `arete.stack`.\n{program_const} = ProgramDef(\n name={name},\n program_id={const_prefix}_PROGRAM_ID,\n raw_instructions={raw_dict},\n pdas={pdas_dict},\n accounts={accounts},\n errors={errors_const},\n program_spec_hash={spec_hash},\n)\n",
2659 name = py_string_literal(&raw_name),
2660 raw_dict = raw_dict,
2661 pdas_dict = pdas_dict_expr,
2662 accounts = accounts_expr,
2663 spec_hash = spec_hash_expr,
2664 ));
2665 exports.push(program_const.clone());
2666
2667 program_entries.push(format!(
2668 " {}: {},",
2669 py_string_literal(&module_name),
2670 program_const
2671 ));
2672 match &read_layer {
2673 Ok(_) => read_entries.push((
2674 module_name.clone(),
2675 format!("{module_name}_read_descriptor()"),
2676 )),
2677 Err(reason) => omitted_reads.push((module_name.clone(), reason.clone())),
2678 }
2679
2680 sections.push(section);
2681 }
2682
2683 let mut instruction_imports: Vec<&str> = Vec::new();
2685 if needs.account_meta {
2686 instruction_imports.extend(["AccountMeta", "Known", "Signer", "UserProvided"]);
2687 }
2688 if needs.pda {
2689 instruction_imports.extend([
2690 "AccountRefSeed",
2691 "ArgRefSeed",
2692 "BytesSeed",
2693 "LiteralSeed",
2694 "PdaConfig",
2695 ]);
2696 if needs.account_meta {
2697 instruction_imports.push("Pda");
2698 }
2699 }
2700 if needs.arg_schema {
2701 instruction_imports.push("ArgSchema");
2702 }
2703 if needs.builders {
2704 instruction_imports.extend(["BuiltAccountMeta", "BuiltInstruction", "InstructionHandler"]);
2705 }
2706 if needs.error_metadata {
2707 instruction_imports.push("ErrorMetadata");
2708 }
2709 instruction_imports.sort_unstable();
2710 instruction_imports.dedup();
2711
2712 let mut import_lines: Vec<String> = Vec::new();
2713 let mut typing_names: Vec<&str> = vec!["Dict", "Tuple"];
2714 if needs.builders {
2715 typing_names.extend(["Any", "Mapping", "Optional", "Sequence", "TypedDict"]);
2716 }
2717 typing_names.sort_unstable();
2718 typing_names.dedup();
2719 import_lines.push(format!("from typing import {}", typing_names.join(", ")));
2720 import_lines.push(String::new());
2721 if !instruction_imports.is_empty() {
2722 import_lines.push(format!(
2723 "from arete.instructions import (\n{}\n)",
2724 instruction_imports
2725 .iter()
2726 .map(|name| format!(" {},", name))
2727 .collect::<Vec<_>>()
2728 .join("\n")
2729 ));
2730 }
2731 if needs.reads {
2732 import_lines.push(
2733 "from arete.program_read_transport import (\n LocalHttpTransportDef,\n ProgramReadDescriptor,\n ProgramReleaseReference,\n)"
2734 .to_string(),
2735 );
2736 import_lines.push("from arete.read import ProgramAccountReadDef".to_string());
2737 }
2738 let mut stack_imports = vec!["ProgramDef"];
2739 if needs.pda_factory {
2740 stack_imports.push("PdaFactory");
2741 }
2742 stack_imports.sort_unstable();
2743 import_lines.push(format!(
2744 "from arete.stack import {}",
2745 stack_imports.join(", ")
2746 ));
2747 if needs.reads {
2748 import_lines.push(String::new());
2749 import_lines.push("from . import models".to_string());
2750 }
2751
2752 exports.extend(["PROGRAMS".to_string(), "PROGRAM_READS".to_string()]);
2754 let programs_map = format!(
2755 "PROGRAMS: Dict[str, ProgramDef] = {{\n{}\n}}\n",
2756 program_entries.join("\n")
2757 );
2758 let reads_map = if omitted_reads.is_empty() && !read_entries.is_empty() {
2762 format!(
2763 "PROGRAM_READS: Dict[str, ProgramReadDescriptor] = {{\n{}\n}}\n",
2764 read_entries
2765 .iter()
2766 .map(|(key, expr)| format!(" {}: {},", py_string_literal(key), expr))
2767 .collect::<Vec<_>>()
2768 .join("\n")
2769 )
2770 } else {
2771 let mut comment = String::new();
2772 for (key, reason) in &omitted_reads {
2773 comment.push_str(&format!("# - `{}`: {}\n", key, reason));
2774 }
2775 let header = if omitted_reads.is_empty() {
2776 String::new()
2777 } else {
2778 format!(
2779 "# Program reads omitted (program_reads keys must exactly match programs):\n{comment}"
2780 )
2781 };
2782 let annotation = if needs.reads {
2783 "Dict[str, ProgramReadDescriptor]"
2784 } else {
2785 "Dict[str, object]"
2786 };
2787 format!("{header}PROGRAM_READS: {annotation} = {{}}\n")
2788 };
2789
2790 let all_list = exports
2791 .iter()
2792 .map(|name| format!(" {},", py_string_literal(name)))
2793 .collect::<Vec<_>>()
2794 .join("\n");
2795
2796 let code = format!(
2797 r#""""Generated program SDKs for the `{stack_name}` stack. Do not edit.
2798
2799Instruction building is pure (no network access). Each program section
2800exposes `<PROG>_PROGRAM_ID`, `<Ix>Params` TypedDicts, `<prog>_<ix>(**params)`
2801builders returning `BuiltInstruction`, raw `<prog>_<ix>_handler()` escape
2802hatches, and a `<Prog>Pdas` namespace of PDA factories. Programs with a
2803recorded program spec additionally expose `<PROG>_PROGRAM_SPEC_HASH` /
2804`<PROG>_PROGRAM_RELEASE_HASH` plus `<prog>_read_descriptor()` for
2805release-addressed HTTP reads. `PROGRAMS` / `PROGRAM_READS` feed the stack
2806binding in `__init__.py`.
2807"""
2808
2809from __future__ import annotations
2810
2811{imports}
2812
2813__all__ = [
2814{all_list}
2815]
2816
2817
2818{sections}
2819
2820{programs_map}
2821{reads_map}"#,
2822 stack_name = stack_name,
2823 imports = import_lines.join("\n"),
2824 all_list = all_list,
2825 sections = sections.join("\n\n"),
2826 programs_map = programs_map,
2827 reads_map = reads_map,
2828 );
2829
2830 Some(PythonProgramsCodegen { code })
2831}
2832
2833fn py_bool(value: bool) -> &'static str {
2838 if value {
2839 "True"
2840 } else {
2841 "False"
2842 }
2843}
2844
2845fn py_string_literal(value: &str) -> String {
2847 let mut out = String::with_capacity(value.len() + 2);
2848 out.push('"');
2849 for ch in value.chars() {
2850 match ch {
2851 '\\' => out.push_str("\\\\"),
2852 '"' => out.push_str("\\\""),
2853 '\n' => out.push_str("\\n"),
2854 '\r' => out.push_str("\\r"),
2855 '\t' => out.push_str("\\t"),
2856 c if (c as u32) < 0x20 => out.push_str(&format!("\\x{:02x}", c as u32)),
2857 c => out.push(c),
2858 }
2859 }
2860 out.push('"');
2861 out
2862}
2863
2864fn py_docstring(lines: &[String], indent: &str) -> String {
2867 let mut out = format!("{indent}\"\"\"");
2868 for (index, line) in lines.iter().enumerate() {
2869 let sanitized = line.replace('\\', "\\\\").replace("\"\"\"", "\\\"\\\"\\\"");
2870 if index == 0 {
2871 out.push_str(&sanitized);
2872 } else if sanitized.is_empty() {
2873 out.push('\n');
2874 } else {
2875 out.push_str(&format!("\n{indent}{sanitized}"));
2876 }
2877 }
2878 out.push_str(&format!("\n{indent}\"\"\""));
2879 out
2880}
2881
2882fn to_kebab_case(s: &str) -> String {
2883 let mut result = String::new();
2884 for (i, c) in s.chars().enumerate() {
2885 if c.is_uppercase() {
2886 if i > 0 {
2887 result.push('-');
2888 }
2889 result.push(c.to_lowercase().next().unwrap());
2890 } else {
2891 result.push(c);
2892 }
2893 }
2894 result
2895}
2896
2897fn to_pascal_case(s: &str) -> String {
2898 s.split(['_', '-', '.'])
2899 .map(|word| {
2900 let mut chars = word.chars();
2901 match chars.next() {
2902 None => String::new(),
2903 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
2904 }
2905 })
2906 .collect()
2907}
2908
2909fn to_snake_case(s: &str) -> String {
2910 let mut result = String::new();
2911 let mut separator = false;
2912 for ch in s.chars() {
2913 if ch.is_ascii_alphanumeric() {
2914 if separator && !result.is_empty() {
2915 result.push('_');
2916 }
2917 separator = false;
2918 if ch.is_ascii_uppercase() {
2919 if !result.is_empty() && !result.ends_with('_') {
2920 result.push('_');
2921 }
2922 result.push(ch.to_ascii_lowercase());
2923 } else {
2924 result.push(ch.to_ascii_lowercase());
2925 }
2926 } else {
2927 separator = true;
2928 }
2929 }
2930 if result
2931 .chars()
2932 .next()
2933 .is_some_and(|character| character.is_ascii_digit())
2934 {
2935 result.insert_str(0, "value_");
2936 }
2937 if is_python_keyword(&result) {
2938 result.push('_');
2939 }
2940 result
2941}
2942
2943fn to_screaming_snake(s: &str) -> String {
2944 to_snake_case(s).to_uppercase()
2945}
2946
2947pub fn python_module_name(value: &str) -> String {
2953 let mut output = String::new();
2954 let mut separator = false;
2955 for character in value.chars() {
2956 if character.is_ascii_alphanumeric() {
2957 if separator && !output.is_empty() {
2958 output.push('_');
2959 }
2960 separator = false;
2961 output.push(character.to_ascii_lowercase());
2962 } else {
2963 separator = true;
2964 }
2965 }
2966 if output
2967 .chars()
2968 .next()
2969 .is_some_and(|character| character.is_ascii_digit())
2970 {
2971 output.insert_str(0, "live_");
2972 }
2973 if is_python_keyword(&output) {
2974 output.push_str("_live");
2975 }
2976 output
2977}
2978
2979#[cfg(test)]
2980mod tests {
2981 use super::*;
2982 use std::collections::BTreeMap;
2983
2984 fn identity_spec() -> IdentitySpec {
2985 IdentitySpec {
2986 primary_keys: vec!["id.address".to_string()],
2987 lookup_indexes: vec![],
2988 }
2989 }
2990
2991 const TEST_PROGRAM_ID: &str = "Prog111111111111111111111111111111111111111";
2992
2993 fn minimal_entity(name: &str) -> SerializableStreamSpec {
2994 SerializableStreamSpec {
2995 ast_version: CURRENT_AST_VERSION.to_string(),
2996 state_name: name.to_string(),
2997 program_id: None,
2998 idl: None,
2999 identity: identity_spec(),
3000 handlers: vec![],
3001 sections: vec![EntitySection {
3002 name: "id".to_string(),
3003 fields: vec![FieldTypeInfo::new(
3004 "address".to_string(),
3005 "String".to_string(),
3006 )],
3007 is_nested_struct: false,
3008 parent_field: None,
3009 }],
3010 field_mappings: BTreeMap::new(),
3011 resolver_hooks: vec![],
3012 instruction_hooks: vec![],
3013 resolver_specs: vec![],
3014 computed_fields: vec![],
3015 computed_field_specs: vec![],
3016 content_hash: None,
3017 views: vec![],
3018 }
3019 }
3020
3021 fn test_idl() -> IdlSnapshot {
3022 IdlSnapshot {
3023 name: "demo".to_string(),
3024 program_id: Some(TEST_PROGRAM_ID.to_string()),
3025 version: "0.1.0".to_string(),
3026 accounts: vec![],
3027 instructions: vec![],
3028 types: vec![],
3029 events: vec![],
3030 errors: vec![IdlErrorSnapshot {
3031 code: 6000,
3032 name: "SlippageExceeded".to_string(),
3033 msg: Some("Slippage exceeded".to_string()),
3034 }],
3035 discriminant_size: 8,
3036 }
3037 }
3038
3039 fn instruction_account(name: &str, resolution: AccountResolution) -> InstructionAccountDef {
3040 InstructionAccountDef {
3041 name: name.to_string(),
3042 is_signer: matches!(resolution, AccountResolution::Signer),
3043 is_writable: true,
3044 resolution,
3045 is_optional: false,
3046 docs: vec![],
3047 }
3048 }
3049
3050 fn instruction_arg(name: &str, arg_type: &str) -> InstructionArgDef {
3051 InstructionArgDef {
3052 name: name.to_string(),
3053 arg_type: arg_type.to_string(),
3054 docs: vec![],
3055 amount_hint: None,
3056 }
3057 }
3058
3059 fn programs_stack_spec() -> SerializableStackSpec {
3060 let mut demo_pdas = BTreeMap::new();
3061 demo_pdas.insert(
3062 "counter".to_string(),
3063 PdaDefinition {
3064 name: "counter".to_string(),
3065 seeds: vec![
3066 PdaSeedDef::Literal {
3067 value: "counter".to_string(),
3068 },
3069 PdaSeedDef::AccountRef {
3070 account_name: "authority".to_string(),
3071 },
3072 ],
3073 program_id: None,
3074 },
3075 );
3076 let mut pdas = BTreeMap::new();
3077 pdas.insert("demo".to_string(), demo_pdas);
3078
3079 SerializableStackSpec {
3080 ast_version: CURRENT_AST_VERSION.to_string(),
3081 stack_name: "Demo".to_string(),
3082 program_ids: vec![TEST_PROGRAM_ID.to_string()],
3083 idls: vec![test_idl()],
3084 program_specs: vec![],
3085 entities: vec![minimal_entity("DemoThing")],
3086 pdas,
3087 instructions: vec![InstructionDef {
3088 name: "doThing".to_string(),
3089 discriminator: vec![12, 34],
3090 discriminator_size: 2,
3091 accounts: vec![
3092 instruction_account("signer", AccountResolution::Signer),
3093 instruction_account("authority", AccountResolution::UserProvided),
3094 instruction_account(
3095 "counter",
3096 AccountResolution::PdaRef {
3097 pda_name: "counter".to_string(),
3098 },
3099 ),
3100 instruction_account(
3101 "systemProgram",
3102 AccountResolution::Known {
3103 address: "11111111111111111111111111111111".to_string(),
3104 },
3105 ),
3106 ],
3107 args: vec![
3108 instruction_arg("roundId", "u64"),
3109 instruction_arg("admin", "solana_pubkey::Pubkey"),
3110 instruction_arg("tip", "Option<u64>"),
3111 ],
3112 errors: vec![],
3113 program_id: Some(TEST_PROGRAM_ID.to_string()),
3114 docs: vec!["Does the thing.".to_string()],
3115 }],
3116 content_hash: None,
3117 }
3118 }
3119
3120 #[test]
3121 fn python_generator_renames_account_types_on_collision() {
3122 let plan_field = FieldTypeInfo {
3123 field_name: "plan".to_string(),
3124 raw_name: Some("plan".to_string()),
3125 canonical_name: Some("plan".to_string()),
3126 rust_type_name: "Option<serde_json::Value>".to_string(),
3127 base_type: BaseType::Object,
3128 integer_kind: None,
3129 is_optional: false,
3130 is_array: false,
3131 inner_type: Some("Value".to_string()),
3132 source_path: None,
3133 resolved_type: Some(ResolvedStructType {
3134 type_name: "plan".to_string(),
3135 fields: vec![],
3136 is_instruction: false,
3137 is_account: true,
3138 is_event: false,
3139 is_enum: false,
3140 enum_variants: vec![],
3141 }),
3142 emit: true,
3143 };
3144
3145 let mut spec = minimal_entity("Plan");
3146 spec.sections.push(EntitySection {
3147 name: "plan".to_string(),
3148 fields: vec![plan_field],
3149 is_nested_struct: false,
3150 parent_field: None,
3151 });
3152 let stack = SerializableStackSpec {
3153 ast_version: CURRENT_AST_VERSION.to_string(),
3154 stack_name: "Plan".to_string(),
3155 program_ids: vec![],
3156 idls: vec![],
3157 program_specs: vec![],
3158 entities: vec![spec],
3159 pdas: BTreeMap::new(),
3160 instructions: vec![],
3161 content_hash: None,
3162 };
3163
3164 let output = compile_stack_spec(stack, None).expect("python generation should succeed");
3165
3166 assert!(output.models_py.contains("class PlanAccount:"));
3169 assert!(output
3170 .models_py
3171 .contains("plan: Optional[PlanAccount] = None"));
3172 assert!(output
3173 .models_py
3174 .contains("plan=_convert(data.get(\"plan\"), plan_account_from_wire),"));
3175 assert!(output.models_py.contains("class PlanPlan:"));
3177 }
3178
3179 #[test]
3180 fn python_generator_converts_integer_fields() {
3181 let mut entity = minimal_entity("Plan");
3182 entity.sections.push(EntitySection {
3183 name: "state".to_string(),
3184 fields: vec![
3185 FieldTypeInfo::new("status".to_string(), "Option<u8>".to_string()),
3186 FieldTypeInfo::new("supply".to_string(), "u64".to_string()),
3187 ],
3188 is_nested_struct: false,
3189 parent_field: None,
3190 });
3191 let stack = SerializableStackSpec {
3192 ast_version: CURRENT_AST_VERSION.to_string(),
3193 stack_name: "Plan".to_string(),
3194 program_ids: vec![],
3195 idls: vec![],
3196 program_specs: vec![],
3197 entities: vec![entity],
3198 pdas: BTreeMap::new(),
3199 instructions: vec![],
3200 content_hash: None,
3201 };
3202
3203 let output = compile_stack_spec(stack, None).expect("python generation should succeed");
3204
3205 assert!(output.models_py.contains("status: Optional[int] = None"));
3207 assert!(output.models_py.contains("supply: Optional[int] = None"));
3208 assert!(output
3209 .models_py
3210 .contains("supply=_to_int(data.get(\"supply\")),"));
3211 assert!(output
3212 .models_py
3213 .contains(" out[\"supply\"] = _to_int(data[\"supply\"])"));
3214 assert!(output.models_py.contains("address=data.get(\"address\"),"));
3216 }
3217
3218 fn stack_of(name: &str, entity: SerializableStreamSpec) -> SerializableStackSpec {
3220 SerializableStackSpec {
3221 ast_version: CURRENT_AST_VERSION.to_string(),
3222 stack_name: name.to_string(),
3223 program_ids: vec![],
3224 idls: vec![],
3225 program_specs: vec![],
3226 entities: vec![entity],
3227 pdas: BTreeMap::new(),
3228 instructions: vec![],
3229 content_hash: None,
3230 }
3231 }
3232
3233 fn resolved_field(name: &str, field_type: &str, base_type: BaseType) -> ResolvedField {
3234 ResolvedField {
3235 field_name: name.to_string(),
3236 raw_name: Some(name.to_string()),
3237 canonical_name: None,
3238 field_type: field_type.to_string(),
3239 base_type,
3240 integer_kind: IntegerKind::from_rust_type(field_type),
3241 is_optional: false,
3242 is_array: field_type.starts_with('['),
3243 }
3244 }
3245
3246 fn snapshot_field(
3249 field_name: &str,
3250 type_name: &str,
3251 is_account: bool,
3252 is_event: bool,
3253 ) -> FieldTypeInfo {
3254 FieldTypeInfo {
3255 field_name: field_name.to_string(),
3256 raw_name: Some(field_name.to_string()),
3257 canonical_name: None,
3258 rust_type_name: "Option<serde_json::Value>".to_string(),
3259 base_type: BaseType::Object,
3260 integer_kind: None,
3261 is_optional: true,
3262 is_array: false,
3263 inner_type: Some("Value".to_string()),
3264 source_path: None,
3265 resolved_type: Some(ResolvedStructType {
3266 type_name: type_name.to_string(),
3267 fields: vec![
3268 resolved_field("motherlode", "u64", BaseType::Integer),
3269 resolved_field("owner", "publicKey", BaseType::Pubkey),
3270 ],
3271 is_instruction: false,
3272 is_account,
3273 is_event,
3274 is_enum: false,
3275 enum_variants: vec![],
3276 }),
3277 emit: true,
3278 }
3279 }
3280
3281 fn capture_handler(target_path: &str) -> SerializableHandlerSpec {
3282 SerializableHandlerSpec {
3283 source: SourceSpec::Source {
3284 program_id: None,
3285 discriminator: None,
3286 type_name: "Treasury".to_string(),
3287 serialization: None,
3288 is_account: true,
3289 },
3290 key_resolution: KeyResolutionStrategy::Embedded {
3291 primary_field: FieldPath::new(&["id", "address"]),
3292 },
3293 mappings: vec![SerializableFieldMapping {
3294 target_path: target_path.to_string(),
3295 source: MappingSource::AsCapture {
3296 field_transforms: BTreeMap::new(),
3297 },
3298 transform: None,
3299 population: PopulationStrategy::LastWrite,
3300 condition: None,
3301 when: None,
3302 stop: None,
3303 emit: true,
3304 }],
3305 conditions: vec![],
3306 emit: true,
3307 }
3308 }
3309
3310 #[test]
3314 fn python_generator_wraps_capture_and_event_fields() {
3315 let mut entity = minimal_entity("OreTreasury");
3316 entity.handlers.push(capture_handler("treasury_snapshot"));
3317 entity.sections.push(EntitySection {
3318 name: "root".to_string(),
3319 fields: vec![
3320 snapshot_field("treasury_snapshot", "Treasury", true, false),
3321 snapshot_field("plain_account", "Vault", true, false),
3323 snapshot_field("deposit_event", "DepositEvent", false, true),
3324 ],
3325 is_nested_struct: false,
3326 parent_field: None,
3327 });
3328
3329 let output = compile_stack_spec(stack_of("OreTreasury", entity), None)
3330 .expect("python generation should succeed");
3331 let models = &output.models_py;
3332
3333 assert!(models.contains("class CaptureWrapper(Generic[_T]):"));
3335 assert!(models.contains("class EventWrapper(Generic[_T]):"));
3336 assert!(models.contains("def capture_wrapper_from_wire(value: Any, converter: Any = None)"));
3337 assert!(models.contains(" \"CaptureWrapper\","));
3338 assert!(models.contains(" \"capture_wrapper_from_wire\","));
3339
3340 assert!(models.contains("treasury_snapshot: Optional[CaptureWrapper[Treasury]] = None"));
3342 assert!(models.contains(
3343 "treasury_snapshot=_convert_capture(data.get(\"treasury_snapshot\"), treasury_from_wire),"
3344 ));
3345 assert!(models.contains(
3346 "out[\"treasury_snapshot\"] = _convert_capture(data[\"treasury_snapshot\"], treasury_from_wire)"
3347 ));
3348
3349 assert!(models.contains("deposit_event: Optional[EventWrapper[DepositEvent]] = None"));
3351 assert!(models.contains(
3352 "deposit_event=_convert_event(data.get(\"deposit_event\"), deposit_event_from_wire),"
3353 ));
3354
3355 assert!(models.contains("plain_account: Optional[Vault] = None"));
3357 assert!(models
3358 .contains("plain_account=_convert(data.get(\"plain_account\"), vault_from_wire),"));
3359 assert!(!models.contains("_convert_capture(data.get(\"plain_account\")"));
3360 }
3361
3362 #[test]
3366 fn python_generator_converts_u64_arrays() {
3367 let mut entity = minimal_entity("OreRound");
3368 entity.sections.push(EntitySection {
3369 name: "state".to_string(),
3370 fields: vec![
3371 FieldTypeInfo::new(
3372 "deployed_per_square".to_string(),
3373 "Option<Vec<u64>>".to_string(),
3374 ),
3375 FieldTypeInfo::new(
3376 "deployed_per_square_ui".to_string(),
3377 "Option<Vec<f64>>".to_string(),
3378 ),
3379 FieldTypeInfo::new("labels".to_string(), "Vec<String>".to_string()),
3380 ],
3381 is_nested_struct: false,
3382 parent_field: None,
3383 });
3384 for field in &mut entity.sections[1].fields {
3386 if field.field_name == "deployed_per_square" {
3387 field.base_type = BaseType::Array;
3388 field.integer_kind = Some(IntegerKind::U64);
3389 field.is_array = true;
3390 field.inner_type = Some("Vec < u64 >".to_string());
3391 }
3392 }
3393
3394 let output = compile_stack_spec(stack_of("OreRound", entity), None)
3395 .expect("python generation should succeed");
3396 let models = &output.models_py;
3397
3398 assert!(models.contains("deployed_per_square: Optional[List[int]] = None"));
3399 assert!(
3400 models.contains("deployed_per_square=_to_int_list(data.get(\"deployed_per_square\")),")
3401 );
3402 assert!(models.contains(
3403 "out[\"deployed_per_square\"] = _to_int_list(data[\"deployed_per_square\"])"
3404 ));
3405 assert!(!models.contains("deployed_per_square: Optional[List[Any]] = None"));
3406
3407 assert!(models.contains("deployed_per_square_ui: Optional[List[float]] = None"));
3409 assert!(models.contains("deployed_per_square_ui=data.get(\"deployed_per_square_ui\"),"));
3410 assert!(models.contains("labels: Optional[List[str]] = None"));
3411 }
3412
3413 #[test]
3417 fn python_generator_emits_strict_idl_struct_converters() {
3418 let mut entity = minimal_entity("OreTreasury");
3419 entity.handlers.push(capture_handler("treasury_snapshot"));
3420 entity.sections.push(EntitySection {
3421 name: "root".to_string(),
3422 fields: vec![snapshot_field("treasury_snapshot", "Treasury", true, false)],
3423 is_nested_struct: false,
3424 parent_field: None,
3425 });
3426
3427 let output = compile_stack_spec(stack_of("OreTreasury", entity), None)
3428 .expect("python generation should succeed");
3429 let models = &output.models_py;
3430
3431 assert!(models.contains("def _require(data: Mapping[str, Any], key: str, context: str)"));
3432 assert!(
3433 models.contains("motherlode=_to_int(_require(data, \"motherlode\", \"Treasury\")),")
3434 );
3435 assert!(models.contains("owner=_require(data, \"owner\", \"Treasury\"),"));
3436 assert!(models.contains(" out[\"motherlode\"] = _to_int(data[\"motherlode\"])"));
3438 assert!(models.contains("address=data.get(\"address\"),"));
3440 }
3441
3442 #[test]
3446 fn python_generator_reserves_wallet_not_payer_on_builders() {
3447 let mut stack = programs_stack_spec();
3448 stack.instructions[0]
3449 .accounts
3450 .push(instruction_account("payer", AccountResolution::Signer));
3451
3452 let output = compile_stack_spec(stack, None).expect("python generation should succeed");
3453 let programs = output
3454 .programs_py
3455 .expect("stack with instructions should emit programs.py");
3456
3457 assert!(programs.contains(" wallet: Optional[str] = None,"));
3458 assert!(programs.contains(" payer=wallet,"));
3459 assert!(!programs.contains(" payer: Optional[str] = None,"));
3461 assert!(programs.contains("\"payer\": str,"));
3463 assert!(programs.contains(" accounts: Optional[Mapping[str, str]] = None,"));
3465 assert!(programs
3466 .contains(" remaining_accounts: Optional[Sequence[BuiltAccountMeta]] = None,"));
3467 }
3468
3469 #[test]
3470 fn generated_pyproject_uses_published_arete_sdk_package() {
3471 let output = compile_stack_spec(programs_stack_spec(), None)
3472 .expect("python generation should succeed");
3473
3474 assert!(output
3475 .pyproject_toml
3476 .contains("dependencies = [\"arete-sdk>=0.4\"]"));
3477 assert!(output.pyproject_toml.contains("name = \"generated-stack\""));
3478 assert_eq!(output.module_name, "generated_stack");
3479 }
3480
3481 #[test]
3482 fn python_generator_emits_program_sdk_module() {
3483 let output = compile_stack_spec(programs_stack_spec(), None)
3484 .expect("python stack generation should succeed");
3485 let programs = output
3486 .programs_py
3487 .expect("programs.py should be generated for stacks with instructions");
3488
3489 assert!(programs.contains(&format!("DEMO_PROGRAM_ID = \"{}\"", TEST_PROGRAM_ID)));
3490
3491 assert!(programs.contains("DemoDoThingParams = TypedDict("));
3493 assert!(programs.contains(" # arg `roundId` (`u64`)\n \"roundId\": int,"));
3494 assert!(programs.contains("\"admin\": str,"));
3495 assert!(programs.contains("\"tip\": Optional[int],"));
3496 assert!(programs.contains(
3497 " # Optional address override for the `signer` signer (defaults to the payer).\n \"signer\": str,"
3498 ));
3499 assert!(programs.contains(
3500 " # Address of the `authority` account.\n \"authority\": str,"
3501 ));
3502 assert!(programs.contains("total=False,"));
3503
3504 assert!(programs.contains("discriminator=bytes([12, 34])"));
3506 assert!(programs.contains("resolution=Signer(),"));
3507 assert!(programs.contains("resolution=Known(\"11111111111111111111111111111111\"),"));
3508 assert!(programs.contains(
3509 "resolution=Pda(PdaConfig(seeds=(LiteralSeed(\"counter\"), AccountRefSeed(\"authority\")))),"
3510 ));
3511 assert!(programs.contains("ArgSchema(name=\"roundId\", type=\"u64\"),"));
3512 assert!(programs.contains("ArgSchema(name=\"tip\", type={\"option\": \"u64\"}),"));
3513 assert!(programs.contains("ArgSchema(name=\"admin\", type=\"pubkey\"),"));
3514 assert!(programs.contains(
3515 "ErrorMetadata(code=6000, name=\"SlippageExceeded\", msg=\"Slippage exceeded\"),"
3516 ));
3517
3518 assert!(programs.contains("def demo_do_thing("));
3520 assert!(programs.contains("def demo_do_thing_handler() -> InstructionHandler:"));
3521 assert!(programs.contains("return demo_do_thing_handler().build("));
3522
3523 assert!(programs.contains("class DemoPdas:"));
3525 assert!(programs.contains(
3526 "counter = PdaFactory(\"counter\", _DEMO_PDAS[\"counter\"], DEMO_PROGRAM_ID)"
3527 ));
3528
3529 assert!(programs.contains("DEMO_PROGRAM = ProgramDef("));
3531 assert!(programs.contains(
3532 " raw_instructions={\n \"do_thing\": demo_do_thing_handler(),\n },"
3533 ));
3534 assert!(programs
3535 .contains("PROGRAMS: Dict[str, ProgramDef] = {\n \"demo\": DEMO_PROGRAM,\n}"));
3536
3537 assert!(programs.contains(
3539 "# Program read layer omitted: no program specification was recorded for this program."
3540 ));
3541 assert!(!programs.contains("DEMO_PROGRAM_SPEC_HASH ="));
3542 assert!(!programs.contains("def demo_read_descriptor"));
3543 assert!(programs.contains("PROGRAM_READS: Dict[str, object] = {}"));
3544
3545 assert!(output
3547 .init_py
3548 .contains("from . import models, programs, views"));
3549 assert!(output.init_py.contains("programs=programs.PROGRAMS,"));
3550 assert!(output
3551 .init_py
3552 .contains("program_reads=programs.PROGRAM_READS,"));
3553 assert!(output.init_py.contains("DEMO_STACK: StackDef = StackDef("));
3554 assert!(output.init_py.contains("name=\"demo\","));
3555 }
3556
3557 #[test]
3558 fn python_generator_without_instructions_omits_programs() {
3559 let mut spec = programs_stack_spec();
3560 spec.instructions.clear();
3561
3562 let output = compile_stack_spec(spec, None).expect("python generation should succeed");
3563
3564 assert!(output.programs_py.is_none());
3565 assert!(output.init_py.contains("from . import models, views"));
3566 assert!(!output.init_py.contains("programs.PROGRAMS"));
3567 assert!(!output.init_py.contains("program_reads"));
3568 }
3569
3570 #[test]
3571 fn python_generator_notes_skipped_instructions() {
3572 let mut spec = programs_stack_spec();
3573 spec.instructions.push(InstructionDef {
3574 name: "badThing".to_string(),
3575 discriminator: vec![9],
3576 discriminator_size: 1,
3577 accounts: vec![],
3578 args: vec![instruction_arg("payload", "MysteryType")],
3579 errors: vec![],
3580 program_id: Some(TEST_PROGRAM_ID.to_string()),
3581 docs: vec![],
3582 });
3583
3584 let output =
3585 compile_stack_spec(spec, None).expect("python stack generation should succeed");
3586 let programs = output.programs_py.expect("programs.py should be generated");
3587
3588 assert!(programs.contains("# Skipped instructions (unsupported by instruction codegen):"));
3589 assert!(
3590 programs.contains("# - `badThing`: arg 'payload' has unsupported type 'MysteryType'")
3591 );
3592 assert!(!programs.contains("BadThingParams"));
3593 assert!(!programs.contains("demo_bad_thing"));
3594 assert!(programs.contains("DemoDoThingParams = TypedDict("));
3596 }
3597
3598 #[test]
3599 fn python_generator_emits_program_read_layer() {
3600 let idl_json = format!(
3603 r#"{{
3604 "address": "{TEST_PROGRAM_ID}",
3605 "version": "0.1.0",
3606 "name": "demo",
3607 "instructions": [
3608 {{
3609 "name": "doThing",
3610 "accounts": [{{ "name": "payer", "isMut": true, "isSigner": true }}],
3611 "args": [{{ "name": "amount", "type": "u64" }}],
3612 "discriminant": {{ "type": "u8", "value": 1 }}
3613 }}
3614 ],
3615 "accounts": [
3616 {{
3617 "name": "Counter",
3618 "type": {{
3619 "kind": "struct",
3620 "fields": [{{ "name": "count", "type": "u64" }}]
3621 }}
3622 }}
3623 ],
3624 "types": [],
3625 "events": [],
3626 "errors": []
3627 }}"#
3628 );
3629 let mut spec = crate::program_sdk::build_program_only_stack_spec_from_idl_bytes(
3630 idl_json.as_bytes(),
3631 None,
3632 "Demo",
3633 )
3634 .expect("program-only stack spec should build");
3635 let expected_spec_hash = spec.program_specs[0].hash().unwrap().to_string();
3636 let expected_release_hash = spec.program_specs[0]
3637 .oss_release_hash()
3638 .unwrap()
3639 .to_string();
3640
3641 let mut entity = minimal_entity("DemoThing");
3644 entity.sections.push(EntitySection {
3645 name: "state".to_string(),
3646 fields: vec![FieldTypeInfo {
3647 field_name: "counter".to_string(),
3648 raw_name: Some("counter".to_string()),
3649 canonical_name: Some("counter".to_string()),
3650 rust_type_name: "Option<serde_json::Value>".to_string(),
3651 base_type: BaseType::Object,
3652 integer_kind: None,
3653 is_optional: false,
3654 is_array: false,
3655 inner_type: Some("Value".to_string()),
3656 source_path: None,
3657 resolved_type: Some(ResolvedStructType {
3658 type_name: "Counter".to_string(),
3659 fields: vec![],
3660 is_instruction: false,
3661 is_account: true,
3662 is_event: false,
3663 is_enum: false,
3664 enum_variants: vec![],
3665 }),
3666 emit: true,
3667 }],
3668 is_nested_struct: false,
3669 parent_field: None,
3670 });
3671 spec.entities.push(entity);
3672
3673 let output =
3674 compile_stack_spec(spec, None).expect("python stack generation should succeed");
3675 let programs = output.programs_py.expect("programs.py should be generated");
3676
3677 assert!(programs.contains(&format!(
3679 "DEMO_PROGRAM_SPEC_HASH = \"{expected_spec_hash}\""
3680 )));
3681 assert!(programs.contains(&format!(
3682 "DEMO_PROGRAM_RELEASE_HASH = \"{expected_release_hash}\""
3683 )));
3684 assert!(programs.contains("def demo_read_descriptor() -> ProgramReadDescriptor:"));
3685 assert!(programs.contains("transport=LocalHttpTransportDef(),"));
3686 assert!(!programs.contains("Program read layer omitted"));
3687
3688 assert!(output.models_py.contains("class Counter:"));
3690 assert!(programs.contains(
3691 "\"counter\": ProgramAccountReadDef(account=\"Counter\", parser=models.counter_from_wire),"
3692 ));
3693 assert!(programs.contains("accounts=dict(_DEMO_ACCOUNTS),"));
3694 assert!(programs.contains("program_spec_hash=DEMO_PROGRAM_SPEC_HASH,"));
3695
3696 assert!(programs.contains(
3698 "PROGRAM_READS: Dict[str, ProgramReadDescriptor] = {\n \"demo\": demo_read_descriptor(),\n}"
3699 ));
3700 }
3701
3702 #[test]
3703 fn python_generator_emits_stack_urls() {
3704 let output = compile_stack_spec(programs_stack_spec(), None)
3705 .expect("python stack generation should succeed");
3706 assert!(output
3707 .init_py
3708 .contains("ws=\"\", # TODO: Set URL after first deployment in arete.toml"));
3709 assert!(!output.init_py.contains("http="));
3710
3711 let config = PythonStackConfig {
3712 url: Some("wss://demo.stack.example".to_string()),
3713 http_url: Some("https://demo.stack.example".to_string()),
3714 ..Default::default()
3715 };
3716 let output = compile_stack_spec(programs_stack_spec(), Some(config))
3717 .expect("python stack generation should succeed");
3718 assert!(output.init_py.contains("ws=\"wss://demo.stack.example\","));
3719 assert!(output
3720 .init_py
3721 .contains("http=\"https://demo.stack.example\","));
3722 }
3723
3724 #[test]
3725 fn python_generator_emits_typed_views() {
3726 let mut spec = programs_stack_spec();
3727 spec.entities[0].views = vec![
3728 crate::ast::ViewDef::state("DemoThing", &["id.address"]),
3729 crate::ast::ViewDef::list("DemoThing"),
3730 crate::ast::ViewDef {
3731 id: "DemoThing/latest".to_string(),
3732 source: ViewSource::Entity {
3733 name: "DemoThing".to_string(),
3734 },
3735 pipeline: vec![],
3736 output: ViewOutput::Collection,
3737 },
3738 ];
3739
3740 let output =
3741 compile_stack_spec(spec, None).expect("python stack generation should succeed");
3742
3743 assert!(output.views_py.contains("class DemoThingViews:"));
3744 assert!(output.views_py.contains("view=\"DemoThing/state\","));
3745 assert!(output.views_py.contains("key_fields=(\"address\",),"));
3746 assert!(output
3747 .views_py
3748 .contains("parser=models.demo_thing_from_wire,"));
3749 assert!(output.views_py.contains(
3750 "list = ViewDef(mode=\"list\", view=\"DemoThing/list\", parser=models.demo_thing_from_wire)"
3751 ));
3752 assert!(output.views_py.contains(
3753 "latest = ViewDef(mode=\"list\", view=\"DemoThing/latest\", parser=models.demo_thing_from_wire)"
3754 ));
3755 assert!(output
3756 .views_py
3757 .contains("VIEWS: Dict[str, Dict[str, ViewDef]] = {"));
3758 assert!(output.views_py.contains(" \"demo_thing\": {"));
3759 assert!(output
3760 .views_py
3761 .contains(" \"state\": DemoThingViews.state,"));
3762 assert!(output
3763 .views_py
3764 .contains(" \"latest\": DemoThingViews.latest,"));
3765 }
3766
3767 #[test]
3768 fn python_generator_selects_exact_views() {
3769 let mut spec = programs_stack_spec();
3770 spec.entities.push(minimal_entity("HiddenThing"));
3772 spec.entities[0].views = vec![crate::ast::ViewDef {
3773 id: "DemoThing/latest".to_string(),
3774 source: ViewSource::Entity {
3775 name: "DemoThing".to_string(),
3776 },
3777 pipeline: vec![],
3778 output: ViewOutput::Collection,
3779 }];
3780
3781 let output = compile_stack_spec_with_exact_views(spec, None)
3782 .expect("python stack generation should succeed");
3783
3784 assert!(output.views_py.contains("class DemoThingViews:"));
3785 assert!(output.views_py.contains("latest = ViewDef("));
3786 assert!(!output.views_py.contains("view=\"DemoThing/state\""));
3788 assert!(!output.views_py.contains("view=\"DemoThing/list\""));
3789 assert!(!output.views_py.contains("HiddenThingViews"));
3790 assert!(!output.views_py.contains("\"hidden_thing\""));
3791 assert!(output.models_py.contains("class HiddenThing:"));
3793 }
3794
3795 #[test]
3796 fn python_generator_degrades_composite_state_keys() {
3797 let mut spec = programs_stack_spec();
3798 spec.entities[0].identity = IdentitySpec {
3799 primary_keys: vec!["id.address".to_string(), "id.slot".to_string()],
3800 lookup_indexes: vec![],
3801 };
3802
3803 let output =
3804 compile_stack_spec(spec, None).expect("python stack generation should succeed");
3805
3806 assert!(output
3807 .views_py
3808 .contains("# [arete codegen] composite state key"));
3809 assert!(output.views_py.contains("key_fields=(),"));
3810 }
3811
3812 #[test]
3813 fn python_generator_wires_extension_modules_after_generated_decls() {
3814 let config = PythonStackConfig {
3815 module_mode: true,
3816 extension_modules: vec!["devex".to_string(), "extensions".to_string()],
3817 extension_entry: Some("extensions".to_string()),
3818 ..Default::default()
3819 };
3820 let output = compile_stack_spec(programs_stack_spec(), Some(config))
3821 .expect("python stack generation should succeed");
3822 let init = &output.init_py;
3823
3824 assert!(init.contains(
3825 "# Hand-authored devex extensions (staged from extensions.json; not generated)."
3826 ));
3827 let stack_def = init.find("DEMO_STACK: StackDef").expect("stack def");
3828 let devex = init
3829 .find("from . import devex # noqa: F401")
3830 .expect("devex import");
3831 let entry = init
3832 .find("from .extensions import * # noqa: F401,F403")
3833 .expect("entry star import");
3834 assert!(stack_def < devex);
3835 assert!(devex < entry);
3836 assert!(!init.contains("from .devex import *"));
3837 }
3838
3839 #[test]
3840 fn python_generator_omits_extension_wiring_without_entry() {
3841 let output = compile_stack_spec(programs_stack_spec(), None)
3842 .expect("python stack generation should succeed");
3843
3844 assert!(!output.init_py.contains("Hand-authored devex extensions"));
3845 assert!(!output.init_py.contains("from .extensions import *"));
3846 }
3847
3848 #[test]
3849 fn python_generator_rejects_extension_module_collisions() {
3850 for reserved in ["models", "views", "programs"] {
3851 let config = PythonStackConfig {
3852 extension_modules: vec![reserved.to_string(), "extensions".to_string()],
3853 extension_entry: Some("extensions".to_string()),
3854 ..Default::default()
3855 };
3856 let error = compile_stack_spec(programs_stack_spec(), Some(config))
3857 .expect_err("collision with a generated module must fail");
3858 assert!(
3859 error.contains(&format!("'{reserved}.py'")),
3860 "collision error should name the file: {error}"
3861 );
3862 }
3863
3864 let mut no_instructions = programs_stack_spec();
3866 no_instructions.instructions.clear();
3867 let config = PythonStackConfig {
3868 extension_modules: vec!["programs".to_string(), "extensions".to_string()],
3869 extension_entry: Some("extensions".to_string()),
3870 ..Default::default()
3871 };
3872 assert!(compile_stack_spec(no_instructions, Some(config)).is_ok());
3873
3874 let duplicate = PythonStackConfig {
3875 extension_modules: vec![
3876 "devex".to_string(),
3877 "devex".to_string(),
3878 "extensions".to_string(),
3879 ],
3880 extension_entry: Some("extensions".to_string()),
3881 ..Default::default()
3882 };
3883 assert!(compile_stack_spec(programs_stack_spec(), Some(duplicate)).is_err());
3884
3885 let entry_not_last = PythonStackConfig {
3886 extension_modules: vec!["extensions".to_string(), "devex".to_string()],
3887 extension_entry: Some("extensions".to_string()),
3888 ..Default::default()
3889 };
3890 assert!(compile_stack_spec(programs_stack_spec(), Some(entry_not_last)).is_err());
3891 }
3892
3893 #[test]
3894 fn python_writer_emits_module_and_package_layouts() {
3895 let output = compile_stack_spec(
3896 programs_stack_spec(),
3897 Some(PythonStackConfig {
3898 package_name: "demo-stack".to_string(),
3899 ..Default::default()
3900 }),
3901 )
3902 .expect("python stack generation should succeed");
3903
3904 let base = std::env::temp_dir().join(format!(
3905 "arete-python-codegen-{}-{:?}",
3906 std::process::id(),
3907 std::thread::current().id()
3908 ));
3909 let _ = std::fs::remove_dir_all(&base);
3910
3911 let module_dir = base.join("module");
3913 write_python_module(&output, &module_dir).expect("module layout should write");
3914 for file in ["__init__.py", "models.py", "views.py", "programs.py"] {
3915 assert!(
3916 module_dir.join(file).is_file(),
3917 "missing module file {file}"
3918 );
3919 }
3920 assert!(!module_dir.join("pyproject.toml").exists());
3921
3922 let package_dir = base.join("package");
3924 write_python_package(&output, &package_dir).expect("package layout should write");
3925 assert!(package_dir.join("pyproject.toml").is_file());
3926 for file in ["__init__.py", "models.py", "views.py", "programs.py"] {
3927 assert!(
3928 package_dir.join("demo_stack").join(file).is_file(),
3929 "missing package file {file}"
3930 );
3931 }
3932
3933 let _ = std::fs::remove_dir_all(&base);
3934 }
3935
3936 #[test]
3937 fn python_module_name_escapes_keywords_and_digits() {
3938 assert_eq!(python_module_name("Ore-Stack"), "ore_stack");
3939 assert_eq!(python_module_name("1live"), "live_1live");
3940 assert_eq!(python_module_name("import"), "import_live");
3941 assert_eq!(to_snake_case("lambda"), "lambda_");
3942 assert_eq!(to_screaming_snake("OreStream"), "ORE_STREAM");
3943 }
3944
3945 #[test]
3957 #[ignore = "writes into examples/ore-python; run explicitly to regenerate"]
3958 fn regenerate_ore_example_python() {
3959 let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3960 .parent()
3961 .expect("interpreter crate lives in the repo root")
3962 .to_path_buf();
3963 let spec_json =
3964 std::fs::read_to_string(repo_root.join("stacks/ore/.arete/OreStream.stack.json"))
3965 .expect("ore stack spec should exist");
3966 let spec = crate::versioned::load_stack_spec(&spec_json)
3967 .expect("ore stack spec should deserialize");
3968
3969 let out_dir = repo_root.join("examples/ore-python/ore_stack");
3970 let (extension_modules, extension_entry) =
3971 match std::fs::read_to_string(out_dir.join("extensions.json")) {
3972 Ok(manifest_json) => {
3973 let manifest: serde_json::Value = serde_json::from_str(&manifest_json)
3974 .expect("staged extensions.json should parse");
3975 assert_eq!(
3976 manifest["language"].as_str(),
3977 Some("python"),
3978 "staged ore extensions must be a Python bundle"
3979 );
3980 let entry_stem = python_module_name(
3981 manifest["entry"]
3982 .as_str()
3983 .and_then(|entry| entry.strip_suffix(".py"))
3984 .expect("extensions entry should be a .py file"),
3985 );
3986 let mut stems: Vec<String> = manifest["files"]
3987 .as_array()
3988 .expect("extensions files should be an array")
3989 .iter()
3990 .map(|file| {
3991 python_module_name(
3992 file.as_str()
3993 .and_then(|file| file.strip_suffix(".py"))
3994 .expect("extension files should be .py files"),
3995 )
3996 })
3997 .filter(|stem| stem != &entry_stem)
3998 .collect();
3999 stems.sort();
4000 stems.dedup();
4001 stems.push(entry_stem.clone());
4002 (stems, Some(entry_stem))
4003 }
4004 Err(_) => (Vec::new(), None),
4005 };
4006
4007 let config = PythonStackConfig {
4008 package_name: "ore-stack".to_string(),
4009 sdk_version: "0.4".to_string(),
4010 module_mode: true,
4011 url: Some("wss://ore.stack.arete.run".to_string()),
4012 http_url: Some("https://ore.stack.arete.run".to_string()),
4013 extension_modules,
4014 extension_entry,
4015 };
4016 let output =
4017 compile_stack_spec(spec, Some(config)).expect("ore stack should compile to Python");
4018
4019 write_python_module(&output, &out_dir).expect("ore example should write");
4020 }
4021}
4022
4023fn is_python_keyword(value: &str) -> bool {
4024 matches!(
4025 value,
4026 "False"
4027 | "None"
4028 | "True"
4029 | "and"
4030 | "as"
4031 | "assert"
4032 | "async"
4033 | "await"
4034 | "break"
4035 | "class"
4036 | "continue"
4037 | "def"
4038 | "del"
4039 | "elif"
4040 | "else"
4041 | "except"
4042 | "finally"
4043 | "for"
4044 | "from"
4045 | "global"
4046 | "if"
4047 | "import"
4048 | "in"
4049 | "is"
4050 | "lambda"
4051 | "nonlocal"
4052 | "not"
4053 | "or"
4054 | "pass"
4055 | "raise"
4056 | "return"
4057 | "try"
4058 | "while"
4059 | "with"
4060 | "yield"
4061 )
4062}