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