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