Skip to main content

arete_interpreter/
python.rs

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