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