Skip to main content

arete_interpreter/
python.rs

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