Skip to main content

weaveffi_gen_python/
lib.rs

1//! Python (`ctypes`) binding generator for WeaveFFI.
2//!
3//! Emits a pip-installable package containing `ctypes`-based bindings and
4//! `.pyi` type stubs over the C ABI. Async functions surface as
5//! `async def` wrappers. Implements [`LanguageBackend`]; the shared driver
6//! bridges it into the generator pipeline.
7
8use camino::Utf8Path;
9use serde::{Deserialize, Serialize};
10use weaveffi_core::abi::{self, CType};
11use weaveffi_core::backend::{LanguageBackend, OutputFile};
12use weaveffi_core::codegen::common::{
13    emit_doc as common_emit_doc, is_c_pointer_type, pascal_case, DocCommentStyle,
14};
15use weaveffi_core::model::{
16    BindingModel, CallShape, EnumBinding, FieldBinding, FnBinding, ModuleBinding, ParamBinding,
17    StructBinding,
18};
19use weaveffi_core::pkg::{self, ResolvedPackage};
20use weaveffi_core::utils::{
21    local_type_name, render_prelude, render_trailer, wrapper_name, CommentStyle,
22};
23use weaveffi_ir::ir::{Api, TypeRef};
24
25/// Per-target configuration for [`PythonGenerator`].
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27#[serde(default)]
28pub struct PythonConfig {
29    /// pip-installable Python package name (default `"weaveffi"`). Also
30    /// determines the on-disk package directory inside `python/`.
31    pub package_name: Option<String>,
32    /// When `true`, strip the IR module name prefix from emitted Python
33    /// function names.
34    pub strip_module_prefix: bool,
35    /// C ABI symbol prefix (default `"weaveffi"`). Normally set once globally
36    /// via `[global] c_prefix`; honored so the ctypes bindings call the same
37    /// exported symbols the producer emits.
38    pub prefix: Option<String>,
39    /// Basename of the IDL the CLI was invoked with.
40    #[serde(skip)]
41    pub input_basename: Option<String>,
42}
43
44impl PythonConfig {
45    pub fn package_name(&self) -> &str {
46        self.package_name.as_deref().unwrap_or("weaveffi")
47    }
48
49    pub fn prefix(&self) -> &str {
50        self.prefix.as_deref().unwrap_or("weaveffi")
51    }
52
53    pub fn input_basename(&self) -> &str {
54        self.input_basename.as_deref().unwrap_or("weaveffi.yml")
55    }
56}
57
58pub struct PythonGenerator;
59
60impl PythonGenerator {
61    /// Render the primary `weaveffi.py` source by composing the shared
62    /// [`LanguageBackend::emit_members`] walk over every module. Shared by the
63    /// [`LanguageBackend::files`] hook and the test-facing
64    /// [`render_python_module`] wrapper so there is one assembly path.
65    fn render_py_source(
66        &self,
67        model: &BindingModel,
68        strip_module_prefix: bool,
69        input_basename: &str,
70    ) -> String {
71        let config = PythonConfig {
72            strip_module_prefix,
73            ..PythonConfig::default()
74        };
75        let mut out = render_prelude(CommentStyle::Hash, input_basename);
76        render_preamble(&mut out);
77        let has_async = model.functions().any(|(_, f)| f.is_async);
78        if has_async {
79            out.push_str("\nimport asyncio\nimport threading\n");
80        }
81        // The model is a flat, pre-order list of modules, each carrying its
82        // joined symbol path — the same traversal order the recursive walk
83        // produced.
84        for m in &model.modules {
85            out.push_str(&format!("\n\n# === Module: {} ===", m.path));
86            self.emit_members(&mut out, m, &config);
87        }
88        out.push('\n');
89        out.push_str(&render_trailer(CommentStyle::Hash, "weaveffi.py"));
90        out
91    }
92}
93
94impl LanguageBackend for PythonGenerator {
95    type Config = PythonConfig;
96
97    fn name(&self) -> &'static str {
98        "python"
99    }
100
101    fn prefix<'a>(&self, config: &'a Self::Config) -> &'a str {
102        config.prefix()
103    }
104
105    fn render_enum(&self, out: &mut String, e: &EnumBinding, _config: &Self::Config) {
106        render_enum(out, e);
107    }
108
109    fn render_struct(
110        &self,
111        out: &mut String,
112        _module: &ModuleBinding,
113        s: &StructBinding,
114        _config: &Self::Config,
115    ) {
116        render_struct(out, s);
117        if s.builder.is_some() {
118            render_builder(out, s);
119        }
120    }
121
122    fn render_function(
123        &self,
124        out: &mut String,
125        module: &ModuleBinding,
126        f: &FnBinding,
127        config: &Self::Config,
128    ) {
129        render_function(out, &module.path, f, config.strip_module_prefix);
130    }
131
132    fn files(
133        &self,
134        api: &Api,
135        model: &BindingModel,
136        out_dir: &Utf8Path,
137        config: &Self::Config,
138    ) -> Vec<OutputFile> {
139        let package = pkg::resolve(
140            api,
141            config.package_name.as_deref(),
142            config.input_basename.as_deref(),
143        );
144        let import_name = package.ident_name();
145        let input_basename = config.input_basename();
146        let dir = out_dir.join("python");
147        let pkg_dir = dir.join(&import_name);
148        let hash = CommentStyle::Hash;
149        vec![
150            OutputFile::new(
151                pkg_dir.join("__init__.py"),
152                format!(
153                    "{}from .weaveffi import *  # noqa: F401,F403\n\n{}",
154                    render_prelude(hash, input_basename),
155                    render_trailer(hash, "__init__.py"),
156                ),
157            ),
158            OutputFile::new(
159                pkg_dir.join("weaveffi.py"),
160                self.render_py_source(model, config.strip_module_prefix, input_basename),
161            ),
162            OutputFile::new(
163                pkg_dir.join("weaveffi.pyi"),
164                render_pyi_module(api, config.strip_module_prefix, input_basename),
165            ),
166            OutputFile::new(
167                dir.join("pyproject.toml"),
168                render_pyproject_toml(&package, &import_name, input_basename),
169            ),
170            OutputFile::new(
171                dir.join("setup.py"),
172                render_setup_py(&package, &import_name, input_basename),
173            ),
174            OutputFile::new(
175                dir.join("README.md"),
176                render_readme(&package, input_basename),
177            ),
178        ]
179    }
180}
181
182weaveffi_core::impl_generator_via_backend!(PythonGenerator);
183
184// ── Type helpers ──
185
186fn py_ctypes_scalar(ty: &TypeRef) -> &'static str {
187    match ty {
188        TypeRef::I32 => "ctypes.c_int32",
189        TypeRef::U32 => "ctypes.c_uint32",
190        TypeRef::I64 => "ctypes.c_int64",
191        TypeRef::F64 => "ctypes.c_double",
192        TypeRef::Bool => "ctypes.c_int32",
193        TypeRef::StringUtf8 | TypeRef::BorrowedStr => "ctypes.c_char_p",
194        TypeRef::Handle => "ctypes.c_uint64",
195        TypeRef::TypedHandle(_) => "ctypes.c_void_p",
196        TypeRef::Bytes | TypeRef::BorrowedBytes => "ctypes.c_uint8",
197        TypeRef::Struct(_) => "ctypes.c_void_p",
198        TypeRef::Enum(_) => "ctypes.c_int32",
199        TypeRef::Optional(_) | TypeRef::List(_) | TypeRef::Map(_, _) | TypeRef::Iterator(_) => {
200            "ctypes.c_void_p"
201        }
202    }
203}
204
205fn py_type_hint(ty: &TypeRef) -> String {
206    match ty {
207        TypeRef::I32 | TypeRef::U32 | TypeRef::I64 | TypeRef::Handle => "int".into(),
208        // Structs, enums, and typed handles all surface as bare local class names
209        // in the generated module. A cross-module reference (e.g. `handle<Store>`
210        // resolved to `kv.Store`) must still annotate the *local* `Store`, not the
211        // qualified IR name, which is not a symbol in this module.
212        TypeRef::TypedHandle(name) => format!("\"{}\"", local_type_name(name)),
213        TypeRef::F64 => "float".into(),
214        TypeRef::Bool => "bool".into(),
215        TypeRef::StringUtf8 | TypeRef::BorrowedStr => "str".into(),
216        TypeRef::Bytes | TypeRef::BorrowedBytes => "bytes".into(),
217        TypeRef::Enum(name) => format!("\"{}\"", local_type_name(name)),
218        TypeRef::Struct(name) => format!("\"{}\"", local_type_name(name)),
219        TypeRef::Optional(inner) => format!("Optional[{}]", py_type_hint(inner)),
220        TypeRef::List(inner) => format!("List[{}]", py_type_hint(inner)),
221        TypeRef::Map(k, v) => format!("Dict[{}, {}]", py_type_hint(k), py_type_hint(v)),
222        TypeRef::Iterator(inner) => format!("Iterator[{}]", py_type_hint(inner)),
223    }
224}
225
226/// Maps a shared ABI [`CType`] onto its `ctypes` spelling. The structural
227/// lowering (which slots exist, in what order) comes from
228/// [`weaveffi_core::abi`]; this is the Python-specific vocabulary applied to
229/// each slot. Opaque handles and structs collapse to `c_void_p`; `char*`
230/// becomes the `c_char_p` convenience type.
231fn py_ctype(ty: &CType) -> String {
232    match ty {
233        CType::Int32 => "ctypes.c_int32".into(),
234        CType::Uint32 => "ctypes.c_uint32".into(),
235        CType::Int64 => "ctypes.c_int64".into(),
236        CType::Uint64 => "ctypes.c_uint64".into(),
237        CType::Double => "ctypes.c_double".into(),
238        CType::Bool => "ctypes.c_int32".into(),
239        CType::Size => "ctypes.c_size_t".into(),
240        CType::Handle => "ctypes.c_uint64".into(),
241        CType::Char => "ctypes.c_char".into(),
242        CType::Uint8 => "ctypes.c_uint8".into(),
243        CType::Void => "None".into(),
244        CType::Enum { .. } => "ctypes.c_int32".into(),
245        CType::CancelToken | CType::Error | CType::StructTag { .. } | CType::Named(_) => {
246            "ctypes.c_void_p".into()
247        }
248        CType::Ptr { pointee, .. } => match pointee.as_ref() {
249            CType::Char => "ctypes.c_char_p".into(),
250            CType::StructTag { .. } | CType::CancelToken | CType::Void | CType::Named(_) => {
251                "ctypes.c_void_p".into()
252            }
253            other => format!("ctypes.POINTER({})", py_ctype(other)),
254        },
255    }
256}
257
258fn py_param_argtypes(ty: &TypeRef) -> Vec<String> {
259    abi::lower_param("_", ty, "", false)
260        .iter()
261        .map(|p| py_ctype(&p.ty))
262        .collect()
263}
264
265/// Returns `(restype, out_param_argtypes)` for a return type.
266fn py_return_info(ty: &TypeRef) -> (String, Vec<String>) {
267    // Map returns marshal via `byref` out-params, which ctypes models with an
268    // extra `POINTER` level beyond the shared C ABI shape. This convention is
269    // Python-specific, so it stays local rather than in the shared model.
270    if let Some((k, v)) = get_map_kv(ty) {
271        return (
272            "None".into(),
273            vec![
274                format!("ctypes.POINTER(ctypes.POINTER({}))", py_ctypes_scalar(k)),
275                format!("ctypes.POINTER(ctypes.POINTER({}))", py_ctypes_scalar(v)),
276                "ctypes.POINTER(ctypes.c_size_t)".into(),
277            ],
278        );
279    }
280    // Iterator constructors return the opaque iterator handle; the `_next`
281    // signature is emitted separately by the iterator code path.
282    if matches!(ty, TypeRef::Iterator(_)) {
283        return ("ctypes.c_void_p".into(), vec![]);
284    }
285    let r = abi::lower_return(ty, "");
286    let out = r.out_params.iter().map(|p| py_ctype(&p.ty)).collect();
287    (py_ctype(&r.ret), out)
288}
289
290fn get_map_kv(ty: &TypeRef) -> Option<(&TypeRef, &TypeRef)> {
291    match ty {
292        TypeRef::Map(k, v) => Some((k, v)),
293        TypeRef::Optional(inner) => get_map_kv(inner),
294        _ => None,
295    }
296}
297
298/// `(param_name, ctypes_type)` pairs for async C callback parameters after `(context, err)`.
299fn py_async_cb_trailing_fields(ret: &Option<TypeRef>) -> Vec<(String, String)> {
300    match ret {
301        None => vec![],
302        // Optional peeling stays local so `Optional<bytes>`/`<list>`/`<map>`
303        // still surface their trailing `result_len`, matching the inner type.
304        Some(TypeRef::Optional(inner)) if is_c_pointer_type(inner) => {
305            py_async_cb_trailing_fields(&Some((**inner).clone()))
306        }
307        Some(ty) => abi::callback_result_params(ty, "")
308            .into_iter()
309            .map(|p| (p.name, py_ctype(&p.ty)))
310            .collect(),
311    }
312}
313
314fn append_async_success_handler(out: &mut String, ret: &Option<TypeRef>, ind: &str) {
315    match ret {
316        None => {
317            out.push_str(&format!("{ind}_state[\"val\"] = None\n"));
318        }
319        Some(TypeRef::I32 | TypeRef::U32 | TypeRef::I64 | TypeRef::F64 | TypeRef::Handle) => {
320            out.push_str(&format!("{ind}_state[\"val\"] = result\n"));
321        }
322        Some(TypeRef::Bool) => {
323            out.push_str(&format!("{ind}_state[\"val\"] = bool(result)\n"));
324        }
325        Some(TypeRef::StringUtf8 | TypeRef::BorrowedStr) => {
326            out.push_str(&format!(
327                "{ind}_s = _bytes_to_string(result) or \"\" if result else \"\"\n"
328            ));
329            out.push_str(&format!("{ind}if result:\n"));
330            out.push_str(&format!("{ind}    _lib.weaveffi_free_string(result)\n"));
331            out.push_str(&format!("{ind}_state[\"val\"] = _s\n"));
332        }
333        Some(TypeRef::Enum(name)) => {
334            let name = local_type_name(name);
335            out.push_str(&format!("{ind}_state[\"val\"] = {name}(result)\n"));
336        }
337        Some(TypeRef::Struct(name)) | Some(TypeRef::TypedHandle(name)) => {
338            let name = local_type_name(name);
339            out.push_str(&format!("{ind}if result is None:\n"));
340            out.push_str(&format!(
341                "{ind}    _state[\"err\"] = WeaveFFIError(-1, \"null pointer\")\n"
342            ));
343            out.push_str(&format!("{ind}else:\n"));
344            out.push_str(&format!("{ind}    _state[\"val\"] = {name}(result)\n"));
345        }
346        Some(TypeRef::Bytes | TypeRef::BorrowedBytes) => {
347            out.push_str(&format!("{ind}if not result:\n"));
348            out.push_str(&format!("{ind}    _state[\"val\"] = b\"\"\n"));
349            out.push_str(&format!("{ind}else:\n"));
350            out.push_str(&format!("{ind}    _n = int(result_len)\n"));
351            out.push_str(&format!("{ind}    _state[\"val\"] = bytes(result[:_n])\n"));
352            out.push_str(&format!(
353                "{ind}    _lib.weaveffi_free_bytes(result, ctypes.c_size_t(_n))\n"
354            ));
355        }
356        Some(TypeRef::List(inner)) => {
357            let elem = py_read_element("result[_i]", inner);
358            out.push_str(&format!("{ind}if not result:\n"));
359            out.push_str(&format!("{ind}    _state[\"val\"] = []\n"));
360            out.push_str(&format!("{ind}else:\n"));
361            out.push_str(&format!("{ind}    _rl = int(result_len)\n"));
362            out.push_str(&format!(
363                "{ind}    _state[\"val\"] = [{elem} for _i in range(_rl)]\n"
364            ));
365        }
366        Some(TypeRef::Map(k, v)) => {
367            let kread = py_read_element("result_keys[_i]", k);
368            let vread = py_read_element("result_values[_i]", v);
369            out.push_str(&format!("{ind}if not result_keys or not result_values:\n"));
370            out.push_str(&format!("{ind}    _state[\"val\"] = {{}}\n"));
371            out.push_str(&format!("{ind}else:\n"));
372            out.push_str(&format!("{ind}    _ml = int(result_len)\n"));
373            out.push_str(&format!(
374                "{ind}    _state[\"val\"] = {{{kread}: {vread} for _i in range(_ml)}}\n"
375            ));
376        }
377        Some(TypeRef::Optional(inner)) => {
378            if is_c_pointer_type(inner) {
379                match inner.as_ref() {
380                    TypeRef::StringUtf8 | TypeRef::BorrowedStr => {
381                        out.push_str(&format!("{ind}if not result:\n"));
382                        out.push_str(&format!("{ind}    _state[\"val\"] = None\n"));
383                        out.push_str(&format!("{ind}else:\n"));
384                        out.push_str(&format!("{ind}    _s = _bytes_to_string(result)\n"));
385                        out.push_str(&format!("{ind}    _lib.weaveffi_free_string(result)\n"));
386                        out.push_str(&format!("{ind}    _state[\"val\"] = _s\n"));
387                    }
388                    TypeRef::Struct(name) | TypeRef::TypedHandle(name) => {
389                        let name = local_type_name(name);
390                        out.push_str(&format!("{ind}if not result:\n"));
391                        out.push_str(&format!("{ind}    _state[\"val\"] = None\n"));
392                        out.push_str(&format!("{ind}else:\n"));
393                        out.push_str(&format!("{ind}    _state[\"val\"] = {name}(result)\n"));
394                    }
395                    TypeRef::Bytes | TypeRef::BorrowedBytes => {
396                        out.push_str(&format!("{ind}if not result:\n"));
397                        out.push_str(&format!("{ind}    _state[\"val\"] = None\n"));
398                        out.push_str(&format!("{ind}else:\n"));
399                        out.push_str(&format!("{ind}    _n = int(result_len)\n"));
400                        out.push_str(&format!("{ind}    _b = bytes(result[:_n])\n"));
401                        out.push_str(&format!(
402                            "{ind}    _lib.weaveffi_free_bytes(result, ctypes.c_size_t(_n))\n"
403                        ));
404                        out.push_str(&format!("{ind}    _state[\"val\"] = _b\n"));
405                    }
406                    TypeRef::List(elem) => {
407                        let read = py_read_element("result[_i]", elem);
408                        out.push_str(&format!("{ind}if not result:\n"));
409                        out.push_str(&format!("{ind}    _state[\"val\"] = None\n"));
410                        out.push_str(&format!("{ind}else:\n"));
411                        out.push_str(&format!("{ind}    _rl = int(result_len)\n"));
412                        out.push_str(&format!(
413                            "{ind}    _state[\"val\"] = [{read} for _i in range(_rl)]\n"
414                        ));
415                    }
416                    TypeRef::Map(k, v) => {
417                        let kread = py_read_element("result_keys[_i]", k);
418                        let vread = py_read_element("result_values[_i]", v);
419                        out.push_str(&format!("{ind}if not result_keys or not result_values:\n"));
420                        out.push_str(&format!("{ind}    _state[\"val\"] = None\n"));
421                        out.push_str(&format!("{ind}else:\n"));
422                        out.push_str(&format!("{ind}    _ml = int(result_len)\n"));
423                        out.push_str(&format!(
424                            "{ind}    _state[\"val\"] = {{{kread}: {vread} for _i in range(_ml)}}\n"
425                        ));
426                    }
427                    _ => append_async_success_handler(out, &Some(*inner.clone()), ind),
428                }
429            } else {
430                match inner.as_ref() {
431                    TypeRef::Bool => {
432                        out.push_str(&format!("{ind}if not result:\n"));
433                        out.push_str(&format!("{ind}    _state[\"val\"] = None\n"));
434                        out.push_str(&format!("{ind}else:\n"));
435                        out.push_str(&format!("{ind}    _state[\"val\"] = bool(result[0])\n"));
436                    }
437                    TypeRef::Enum(name) => {
438                        let name = local_type_name(name);
439                        out.push_str(&format!("{ind}if not result:\n"));
440                        out.push_str(&format!("{ind}    _state[\"val\"] = None\n"));
441                        out.push_str(&format!("{ind}else:\n"));
442                        out.push_str(&format!("{ind}    _state[\"val\"] = {name}(result[0])\n"));
443                    }
444                    _ => {
445                        out.push_str(&format!("{ind}if not result:\n"));
446                        out.push_str(&format!("{ind}    _state[\"val\"] = None\n"));
447                        out.push_str(&format!("{ind}else:\n"));
448                        out.push_str(&format!("{ind}    _state[\"val\"] = result[0]\n"));
449                    }
450                }
451            }
452        }
453        Some(TypeRef::Iterator(_)) => todo!("async iterator return"),
454    }
455}
456
457fn render_async_ffi_call_body(out: &mut String, f: &FnBinding) {
458    let c_async = format!("{}_async", f.c_base);
459    let ind = "    ";
460
461    out.push_str(&format!("{ind}_fn = _lib.{c_async}\n"));
462    out.push_str(&format!("{ind}_ev = threading.Event()\n"));
463    out.push_str(&format!("{ind}_state = {{\"err\": None, \"val\": None}}\n"));
464
465    let trailing = py_async_cb_trailing_fields(&f.ret);
466    let mut cb_param_list: Vec<String> = vec!["context".into(), "err".into()];
467    cb_param_list.extend(trailing.iter().map(|(n, _)| n.clone()));
468    let cb_params_joined = cb_param_list.join(", ");
469
470    out.push_str(&format!("{ind}def _cb_impl({cb_params_joined}):\n"));
471    out.push_str(&format!("{ind}    try:\n"));
472    out.push_str(&format!(
473        "{ind}        if err and err.contents.code != 0:\n"
474    ));
475    out.push_str(&format!("{ind}            _code = err.contents.code\n"));
476    out.push_str(&format!(
477        "{ind}            _msg = err.contents.message.decode(\"utf-8\") if err.contents.message else \"\"\n"
478    ));
479    out.push_str(&format!(
480        "{ind}            _lib.weaveffi_error_clear(ctypes.byref(err.contents))\n"
481    ));
482    out.push_str(&format!(
483        "{ind}            _state[\"err\"] = WeaveFFIError(_code, _msg)\n"
484    ));
485    out.push_str(&format!("{ind}        else:\n"));
486    append_async_success_handler(out, &f.ret, "                ");
487    out.push_str(&format!("{ind}    finally:\n"));
488    out.push_str(&format!("{ind}        _ev.set()\n"));
489
490    let mut cf_parts: Vec<String> = vec![
491        "ctypes.c_void_p".into(),
492        "ctypes.POINTER(_WeaveFFIErrorStruct)".into(),
493    ];
494    cf_parts.extend(trailing.iter().map(|(_, t)| t.clone()));
495    out.push_str(&format!(
496        "{ind}_cb_type = ctypes.CFUNCTYPE(None, {})\n",
497        cf_parts.join(", ")
498    ));
499    out.push_str(&format!("{ind}_cb = _cb_type(_cb_impl)\n"));
500
501    let mut argtypes: Vec<String> = Vec::new();
502    for p in &f.params {
503        argtypes.extend(py_param_argtypes(&p.ty));
504    }
505    if f.cancellable {
506        argtypes.push("ctypes.c_void_p".into());
507    }
508    argtypes.push("_cb_type".into());
509    argtypes.push("ctypes.c_void_p".into());
510
511    out.push_str(&format!("{ind}_fn.argtypes = [{}]\n", argtypes.join(", ")));
512    out.push_str(&format!("{ind}_fn.restype = None\n"));
513
514    for p in &f.params {
515        for line in py_param_conversion(&p.name, &p.ty, ind) {
516            out.push_str(&line);
517            out.push('\n');
518        }
519    }
520
521    let mut call_args: Vec<String> = Vec::new();
522    for p in &f.params {
523        call_args.extend(py_param_call_args(&p.name, &p.ty));
524    }
525    if f.cancellable {
526        call_args.push("None".into());
527    }
528    call_args.push("_cb".into());
529    call_args.push("None".into());
530
531    out.push_str(&format!("{ind}_fn({})\n", call_args.join(", ")));
532    out.push_str(&format!("{ind}_ev.wait()\n"));
533    out.push_str(&format!("{ind}if _state[\"err\"] is not None:\n"));
534    out.push_str(&format!("{ind}    raise _state[\"err\"]\n"));
535    if f.ret.is_some() {
536        out.push_str(&format!("{ind}return _state[\"val\"]\n"));
537    }
538}
539
540// ── Rendering ──
541
542/// Render the `weaveffi.py` module source. Thin wrapper over the shared
543/// [`LanguageBackend::emit_members`] walk (via
544/// [`PythonGenerator::render_py_source`]); retained for direct use in tests.
545#[cfg(test)]
546fn render_python_module(
547    api: &Api,
548    strip_module_prefix: bool,
549    prefix: &str,
550    input_basename: &str,
551) -> String {
552    let model = BindingModel::build(api, prefix);
553    PythonGenerator.render_py_source(&model, strip_module_prefix, input_basename)
554}
555
556/// Emits a Python `# ...` line comment at `indent`. Used above C ABI binding
557/// declarations (`attach_function`-style binds) where docstrings can't live.
558fn emit_doc(out: &mut String, doc: &Option<String>, indent: &str) {
559    common_emit_doc(out, doc, indent, DocCommentStyle::Hash);
560}
561
562/// Emits a Python triple-quoted `"""..."""` docstring as the first statement
563/// of a class or function body, at the given `indent`.
564fn emit_docstring(out: &mut String, doc: &Option<String>, indent: &str) {
565    let Some(doc) = doc else {
566        return;
567    };
568    let doc = doc.trim();
569    if doc.is_empty() {
570        return;
571    }
572    if doc.contains('\n') {
573        out.push_str(indent);
574        out.push_str("\"\"\"\n");
575        for line in doc.lines() {
576            if line.is_empty() {
577                out.push('\n');
578            } else {
579                out.push_str(indent);
580                out.push_str(line);
581                out.push('\n');
582            }
583        }
584        out.push_str(indent);
585        out.push_str("\"\"\"\n");
586    } else {
587        out.push_str(indent);
588        out.push_str("\"\"\"");
589        out.push_str(doc);
590        out.push_str("\"\"\"\n");
591    }
592}
593
594/// Emits a NumPy/Google-style docstring with a `Parameters` section listing
595/// each parameter that has a `doc:` value. Skips entirely when there is
596/// nothing to document.
597fn emit_fn_docstring(
598    out: &mut String,
599    doc: &Option<String>,
600    params: &[ParamBinding],
601    indent: &str,
602) {
603    let trimmed_doc = doc.as_ref().map(|d| d.trim()).filter(|d| !d.is_empty());
604    let documented_params: Vec<&ParamBinding> = params
605        .iter()
606        .filter(|p| {
607            p.doc
608                .as_ref()
609                .map(|d| !d.trim().is_empty())
610                .unwrap_or(false)
611        })
612        .collect();
613    if trimmed_doc.is_none() && documented_params.is_empty() {
614        return;
615    }
616    out.push_str(indent);
617    out.push_str("\"\"\"");
618    if let Some(d) = trimmed_doc {
619        if d.contains('\n') {
620            out.push('\n');
621            for line in d.lines() {
622                if line.is_empty() {
623                    out.push('\n');
624                } else {
625                    out.push_str(indent);
626                    out.push_str(line);
627                    out.push('\n');
628                }
629            }
630        } else {
631            out.push_str(d);
632            out.push('\n');
633        }
634    } else {
635        out.push('\n');
636    }
637    if !documented_params.is_empty() {
638        out.push('\n');
639        out.push_str(indent);
640        out.push_str("Parameters\n");
641        out.push_str(indent);
642        out.push_str("----------\n");
643        for p in documented_params {
644            let pdoc = p.doc.as_ref().unwrap().trim();
645            let mut lines = pdoc.lines();
646            let first = lines.next().unwrap_or("");
647            out.push_str(indent);
648            out.push_str(&format!("{} : {}\n", p.name, first));
649            for line in lines {
650                out.push_str(indent);
651                if line.is_empty() {
652                    out.push('\n');
653                } else {
654                    out.push_str("    ");
655                    out.push_str(line);
656                    out.push('\n');
657                }
658            }
659        }
660    }
661    out.push_str(indent);
662    out.push_str("\"\"\"\n");
663}
664
665fn render_preamble(out: &mut String) {
666    out.push_str(
667        r#""""WeaveFFI Python ctypes bindings (auto-generated)"""
668import contextlib
669import ctypes
670import os
671import platform
672from enum import IntEnum
673from typing import Dict, Iterator, List, Optional
674
675
676class WeaveFFIError(Exception):
677    def __init__(self, code: int, message: str) -> None:
678        self.code = code
679        self.message = message
680        super().__init__(f"({code}) {message}")
681
682
683class _WeaveFFIErrorStruct(ctypes.Structure):
684    _fields_ = [
685        ("code", ctypes.c_int32),
686        ("message", ctypes.c_char_p),
687    ]
688
689
690def _load_library() -> ctypes.CDLL:
691    # An explicit path in WEAVEFFI_LIBRARY wins, so callers can point at a
692    # specific build artifact regardless of its file name or location.
693    override = os.environ.get("WEAVEFFI_LIBRARY")
694    if override:
695        return ctypes.CDLL(override)
696    system = platform.system()
697    if system == "Darwin":
698        name = "libweaveffi.dylib"
699    elif system == "Windows":
700        name = "weaveffi.dll"
701    else:
702        name = "libweaveffi.so"
703    return ctypes.CDLL(name)
704
705
706_lib = _load_library()
707_lib.weaveffi_error_clear.argtypes = [ctypes.POINTER(_WeaveFFIErrorStruct)]
708_lib.weaveffi_error_clear.restype = None
709_lib.weaveffi_free_string.argtypes = [ctypes.c_char_p]
710_lib.weaveffi_free_string.restype = None
711_lib.weaveffi_free_bytes.argtypes = [ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t]
712_lib.weaveffi_free_bytes.restype = None
713
714
715def _check_error(err: _WeaveFFIErrorStruct) -> None:
716    if err.code != 0:
717        code = err.code
718        message = err.message.decode("utf-8") if err.message else ""
719        _lib.weaveffi_error_clear(ctypes.byref(err))
720        raise WeaveFFIError(code, message)
721
722
723class _PointerGuard(contextlib.AbstractContextManager):
724    def __init__(self, ptr, free_fn) -> None:
725        self.ptr = ptr
726        self._free_fn = free_fn
727
728    def __exit__(self, *exc) -> bool:
729        if self.ptr is not None:
730            self._free_fn(self.ptr)
731            self.ptr = None
732        return False
733
734
735def _string_to_bytes(s: Optional[str]) -> Optional[bytes]:
736    if s is None:
737        return None
738    return s.encode("utf-8")
739
740
741def _bytes_to_string(ptr) -> Optional[str]:
742    if ptr is None:
743        return None
744    return ptr.decode("utf-8")
745"#,
746    );
747}
748
749fn render_enum(out: &mut String, e: &EnumBinding) {
750    out.push_str(&format!("\n\nclass {}(IntEnum):\n", e.name));
751    emit_docstring(out, &e.doc, "    ");
752    for v in &e.variants {
753        if let Some(d) = &v.doc {
754            let trimmed = d.trim();
755            if !trimmed.is_empty() {
756                for line in trimmed.lines() {
757                    out.push_str(&format!("    # {}\n", line));
758                }
759            }
760        }
761        out.push_str(&format!("    {} = {}\n", v.name, v.value));
762    }
763}
764
765fn render_struct(out: &mut String, s: &StructBinding) {
766    let destroy = &s.destroy_symbol;
767
768    out.push_str(&format!("\n\nclass {}:\n", s.name));
769    emit_docstring(out, &s.doc, "    ");
770
771    out.push_str("\n    def __init__(self, _ptr: int) -> None:");
772    out.push_str("\n        self._ptr = _ptr");
773
774    out.push_str("\n\n    def __del__(self) -> None:");
775    out.push_str("\n        if self._ptr is not None:");
776    out.push_str(&format!(
777        "\n            _lib.{destroy}.argtypes = [ctypes.c_void_p]"
778    ));
779    out.push_str(&format!("\n            _lib.{destroy}.restype = None"));
780    out.push_str(&format!("\n            _lib.{destroy}(self._ptr)"));
781    out.push_str("\n            self._ptr = None");
782
783    for field in &s.fields {
784        render_getter(out, field);
785    }
786    out.push('\n');
787}
788
789fn render_builder(out: &mut String, s: &StructBinding) {
790    let builder_name = format!("{}Builder", s.name);
791    out.push_str(&format!("\n\nclass {}:\n", builder_name));
792    emit_docstring(out, &s.doc, "    ");
793    out.push_str("    def __init__(self) -> None:");
794    for field in &s.fields {
795        let py_ty = py_type_hint(&field.ty);
796        out.push_str(&format!(
797            "\n        self._{}: Optional[{}] = None",
798            field.name, py_ty
799        ));
800    }
801    for field in &s.fields {
802        let py_ty = py_type_hint(&field.ty);
803        out.push_str(&format!(
804            "\n\n    def with_{}(self, value: {}) -> \"{}\":",
805            field.name, py_ty, builder_name
806        ));
807        if let Some(d) = &field.doc {
808            let trimmed = d.trim();
809            if !trimmed.is_empty() {
810                if trimmed.contains('\n') {
811                    out.push_str("\n        \"\"\"\n");
812                    for line in trimmed.lines() {
813                        if line.is_empty() {
814                            out.push('\n');
815                        } else {
816                            out.push_str("        ");
817                            out.push_str(line);
818                            out.push('\n');
819                        }
820                    }
821                    out.push_str("        \"\"\"");
822                } else {
823                    out.push_str(&format!("\n        \"\"\"{}\"\"\"", trimmed));
824                }
825            }
826        }
827        out.push_str(&format!("\n        self._{} = value", field.name));
828        out.push_str("\n        return self");
829    }
830    let ret_ty = py_type_hint(&TypeRef::Struct(s.name.clone()));
831    out.push_str(&format!("\n\n    def build(self) -> {}:", ret_ty));
832    for field in &s.fields {
833        out.push_str(&format!(
834            "\n        if self._{} is None:\n            raise ValueError(\"missing field: {}\")",
835            field.name, field.name
836        ));
837    }
838    out.push_str(&format!(
839        "\n        raise NotImplementedError(\"{}Builder.build requires FFI backing\")",
840        s.name
841    ));
842    out.push('\n');
843}
844
845fn render_getter(out: &mut String, field: &FieldBinding) {
846    let getter = &field.getter_symbol;
847    let py_ty = py_type_hint(&field.ty);
848    let ind = "        ";
849
850    out.push_str(&format!(
851        "\n\n    @property\n    def {}(self) -> {}:\n",
852        field.name, py_ty
853    ));
854    emit_docstring(out, &field.doc, ind);
855    out.push_str(&format!("{ind}_fn = _lib.{getter}\n"));
856
857    let (restype, out_argtypes) = py_return_info(&field.ty);
858    let mut argtypes = vec!["ctypes.c_void_p".to_string()];
859    argtypes.extend(out_argtypes.iter().cloned());
860
861    out.push_str(&format!("{ind}_fn.argtypes = [{}]\n", argtypes.join(", ")));
862    out.push_str(&format!("{ind}_fn.restype = {restype}\n"));
863
864    if out_argtypes.is_empty() {
865        out.push_str(&format!("{ind}_result = _fn(self._ptr)\n"));
866    } else if let Some((k, v)) = get_map_kv(&field.ty) {
867        out.push_str(&format!(
868            "{ind}_out_keys = ctypes.POINTER({})()\n",
869            py_ctypes_scalar(k)
870        ));
871        out.push_str(&format!(
872            "{ind}_out_values = ctypes.POINTER({})()\n",
873            py_ctypes_scalar(v)
874        ));
875        out.push_str(&format!("{ind}_out_len = ctypes.c_size_t(0)\n"));
876        out.push_str(&format!("{ind}_fn(self._ptr, ctypes.byref(_out_keys), ctypes.byref(_out_values), ctypes.byref(_out_len))\n"));
877    } else {
878        out.push_str(&format!("{ind}_out_len = ctypes.c_size_t(0)\n"));
879        out.push_str(&format!(
880            "{ind}_result = _fn(self._ptr, ctypes.byref(_out_len))\n"
881        ));
882    }
883
884    render_return_value(out, &field.ty, ind);
885}
886
887fn render_function(out: &mut String, module_name: &str, f: &FnBinding, strip_module_prefix: bool) {
888    let func_name = wrapper_name(module_name, &f.name, strip_module_prefix);
889    let params_sig: Vec<String> = f
890        .params
891        .iter()
892        .map(|p| format!("{}: {}", p.name, py_type_hint(&p.ty)))
893        .collect();
894    let ret_hint = f
895        .ret
896        .as_ref()
897        .map(py_type_hint)
898        .unwrap_or_else(|| "None".to_string());
899
900    let def_name = if f.is_async {
901        format!("_{func_name}_sync")
902    } else {
903        func_name.clone()
904    };
905
906    if let (Some(TypeRef::Iterator(inner)), CallShape::Iterator(it)) = (&f.ret, &f.shape) {
907        render_iterator_class(out, &it.iter_tag, &f.name, inner);
908    }
909
910    out.push_str(&format!(
911        "\n\ndef {}({}) -> {}:\n",
912        def_name,
913        params_sig.join(", "),
914        ret_hint
915    ));
916
917    let ind = "    ";
918
919    emit_fn_docstring(out, &f.doc, &f.params, ind);
920
921    if let Some(msg) = &f.deprecated {
922        out.push_str(&format!(
923            "{ind}import warnings\n{ind}warnings.warn(\"{}\", DeprecationWarning, stacklevel=2)\n",
924            msg.replace('"', "\\\"")
925        ));
926    }
927
928    if f.is_async {
929        render_async_ffi_call_body(out, f);
930    } else {
931        out.push_str(&format!("{ind}_fn = _lib.{}\n", f.c_base));
932
933        let mut argtypes: Vec<String> = Vec::new();
934        for p in &f.params {
935            argtypes.extend(py_param_argtypes(&p.ty));
936        }
937        let mut out_ret_argtypes = Vec::new();
938        let restype;
939        if let Some(ret_ty) = &f.ret {
940            let (rt, oat) = py_return_info(ret_ty);
941            argtypes.extend(oat.iter().cloned());
942            restype = rt;
943            out_ret_argtypes = oat;
944        } else {
945            restype = "None".to_string();
946        }
947        argtypes.push("ctypes.POINTER(_WeaveFFIErrorStruct)".into());
948
949        out.push_str(&format!("{ind}_fn.argtypes = [{}]\n", argtypes.join(", ")));
950        out.push_str(&format!("{ind}_fn.restype = {restype}\n"));
951
952        for p in &f.params {
953            for line in py_param_conversion(&p.name, &p.ty, ind) {
954                out.push_str(&line);
955                out.push('\n');
956            }
957        }
958
959        out.push_str(&format!("{ind}_err = _WeaveFFIErrorStruct()\n"));
960
961        let is_map_ret = f.ret.as_ref().and_then(get_map_kv).is_some();
962        let has_out_len = !out_ret_argtypes.is_empty() && !is_map_ret;
963
964        if let Some((k, v)) = f.ret.as_ref().and_then(get_map_kv) {
965            out.push_str(&format!(
966                "{ind}_out_keys = ctypes.POINTER({})()\n",
967                py_ctypes_scalar(k)
968            ));
969            out.push_str(&format!(
970                "{ind}_out_values = ctypes.POINTER({})()\n",
971                py_ctypes_scalar(v)
972            ));
973            out.push_str(&format!("{ind}_out_len = ctypes.c_size_t(0)\n"));
974        } else if has_out_len {
975            out.push_str(&format!("{ind}_out_len = ctypes.c_size_t(0)\n"));
976        }
977
978        let mut call_args: Vec<String> = Vec::new();
979        for p in &f.params {
980            call_args.extend(py_param_call_args(&p.name, &p.ty));
981        }
982        if is_map_ret {
983            call_args.push("ctypes.byref(_out_keys)".into());
984            call_args.push("ctypes.byref(_out_values)".into());
985            call_args.push("ctypes.byref(_out_len)".into());
986        } else if has_out_len {
987            call_args.push("ctypes.byref(_out_len)".into());
988        }
989        call_args.push("ctypes.byref(_err)".into());
990
991        let call_expr = format!("_fn({})", call_args.join(", "));
992        if f.ret.is_some() && !is_map_ret {
993            out.push_str(&format!("{ind}_result = {call_expr}\n"));
994        } else {
995            out.push_str(&format!("{ind}{call_expr}\n"));
996        }
997
998        out.push_str(&format!("{ind}_check_error(_err)\n"));
999
1000        if let Some(ret_ty) = &f.ret {
1001            if let (TypeRef::Iterator(inner), CallShape::Iterator(it)) = (ret_ty, &f.shape) {
1002                render_iterator_return(out, &it.iter_tag, inner, ind);
1003            } else {
1004                render_return_value(out, ret_ty, ind);
1005            }
1006        }
1007    }
1008
1009    if f.is_async {
1010        let params_joined = params_sig.join(", ");
1011        out.push_str(&format!(
1012            "\n\nasync def {}({}) -> {}:\n",
1013            func_name, params_joined, ret_hint
1014        ));
1015        emit_fn_docstring(out, &f.doc, &f.params, ind);
1016        out.push_str("    _loop = asyncio.get_event_loop()\n");
1017        let arg_names: Vec<&str> = f.params.iter().map(|p| p.name.as_str()).collect();
1018        let executor_args = if arg_names.is_empty() {
1019            def_name
1020        } else {
1021            format!("{def_name}, {}", arg_names.join(", "))
1022        };
1023        if f.ret.is_some() {
1024            out.push_str(&format!(
1025                "    return await _loop.run_in_executor(None, {executor_args})\n"
1026            ));
1027        } else {
1028            out.push_str(&format!(
1029                "    await _loop.run_in_executor(None, {executor_args})\n"
1030            ));
1031        }
1032    }
1033}
1034
1035// ── Param helpers ──
1036
1037fn py_list_convert_expr(name: &str, elem: &TypeRef) -> String {
1038    match elem {
1039        TypeRef::StringUtf8 | TypeRef::BorrowedStr => {
1040            format!("*[_string_to_bytes(v) for v in {name}]")
1041        }
1042        TypeRef::Struct(_) | TypeRef::TypedHandle(_) => format!("*[v._ptr for v in {name}]"),
1043        TypeRef::Enum(_) => format!("*[v.value for v in {name}]"),
1044        TypeRef::Bool => format!("*[1 if v else 0 for v in {name}]"),
1045        _ => format!("*{name}"),
1046    }
1047}
1048
1049fn py_map_elem_convert(list_name: &str, ty: &TypeRef, var: &str) -> String {
1050    match ty {
1051        TypeRef::StringUtf8 | TypeRef::BorrowedStr => {
1052            format!("*[_string_to_bytes({var}) for {var} in {list_name}]")
1053        }
1054        TypeRef::Enum(_) => format!("*[{var}.value for {var} in {list_name}]"),
1055        TypeRef::Struct(_) | TypeRef::TypedHandle(_) => {
1056            format!("*[{var}._ptr for {var} in {list_name}]")
1057        }
1058        TypeRef::Bool => format!("*[1 if {var} else 0 for {var} in {list_name}]"),
1059        _ => format!("*{list_name}"),
1060    }
1061}
1062
1063fn py_param_conversion(name: &str, ty: &TypeRef, ind: &str) -> Vec<String> {
1064    match ty {
1065        TypeRef::Bytes | TypeRef::BorrowedBytes => {
1066            let s = py_ctypes_scalar(&TypeRef::Bytes);
1067            vec![format!("{ind}_{name}_arr = ({s} * len({name}))(*{name})")]
1068        }
1069        TypeRef::Optional(inner) => match inner.as_ref() {
1070            TypeRef::I32 | TypeRef::U32 | TypeRef::I64 | TypeRef::F64 | TypeRef::Handle => {
1071                let s = py_ctypes_scalar(inner);
1072                vec![format!(
1073                    "{ind}_{name}_c = ctypes.byref({s}({name})) if {name} is not None else None"
1074                )]
1075            }
1076            TypeRef::Bool => {
1077                vec![format!(
1078                    "{ind}_{name}_c = ctypes.byref(ctypes.c_int32(1 if {name} else 0)) if {name} is not None else None"
1079                )]
1080            }
1081            TypeRef::StringUtf8 | TypeRef::BorrowedStr => {
1082                vec![format!("{ind}_{name}_c = _string_to_bytes({name})")]
1083            }
1084            TypeRef::Enum(_) => {
1085                vec![format!(
1086                    "{ind}_{name}_c = ctypes.byref(ctypes.c_int32({name}.value)) if {name} is not None else None"
1087                )]
1088            }
1089            TypeRef::Bytes | TypeRef::BorrowedBytes => {
1090                let s = py_ctypes_scalar(&TypeRef::Bytes);
1091                vec![
1092                    format!("{ind}if {name} is not None:"),
1093                    format!("{ind}    _{name}_arr = ({s} * len({name}))(*{name})"),
1094                    format!("{ind}    _{name}_len = len({name})"),
1095                    format!("{ind}else:"),
1096                    format!("{ind}    _{name}_arr = None"),
1097                    format!("{ind}    _{name}_len = 0"),
1098                ]
1099            }
1100            TypeRef::List(elem) => {
1101                let s = py_ctypes_scalar(elem);
1102                let convert = py_list_convert_expr(name, elem);
1103                vec![
1104                    format!("{ind}if {name} is not None:"),
1105                    format!("{ind}    _{name}_arr = ({s} * len({name}))({convert})"),
1106                    format!("{ind}    _{name}_len = len({name})"),
1107                    format!("{ind}else:"),
1108                    format!("{ind}    _{name}_arr = None"),
1109                    format!("{ind}    _{name}_len = 0"),
1110                ]
1111            }
1112            _ => vec![],
1113        },
1114        TypeRef::List(inner) => {
1115            let s = py_ctypes_scalar(inner);
1116            let convert = py_list_convert_expr(name, inner);
1117            vec![format!("{ind}_{name}_arr = ({s} * len({name}))({convert})")]
1118        }
1119        TypeRef::Map(k, v) => {
1120            let ks = py_ctypes_scalar(k);
1121            let vs = py_ctypes_scalar(v);
1122            let kconv = py_map_elem_convert(&format!("_{name}_keys"), k, "_k");
1123            let vconv = py_map_elem_convert(&format!("_{name}_vals"), v, "_v");
1124            vec![
1125                format!("{ind}_{name}_keys = list({name}.keys())"),
1126                format!("{ind}_{name}_vals = [{name}[_k] for _k in _{name}_keys]"),
1127                format!("{ind}_{name}_ka = ({ks} * len(_{name}_keys))({kconv})"),
1128                format!("{ind}_{name}_va = ({vs} * len(_{name}_vals))({vconv})"),
1129            ]
1130        }
1131        _ => vec![],
1132    }
1133}
1134
1135fn py_param_call_args(name: &str, ty: &TypeRef) -> Vec<String> {
1136    match ty {
1137        TypeRef::I32 | TypeRef::U32 | TypeRef::I64 | TypeRef::F64 | TypeRef::Handle => {
1138            vec![name.to_string()]
1139        }
1140        TypeRef::Bool => vec![format!("1 if {name} else 0")],
1141        TypeRef::StringUtf8 | TypeRef::BorrowedStr => vec![format!("_string_to_bytes({name})")],
1142        TypeRef::Bytes | TypeRef::BorrowedBytes => {
1143            vec![format!("_{name}_arr"), format!("len({name})")]
1144        }
1145        TypeRef::Struct(_) | TypeRef::TypedHandle(_) => vec![format!("{name}._ptr")],
1146        TypeRef::Enum(_) => vec![format!("{name}.value")],
1147        TypeRef::Optional(inner) => match inner.as_ref() {
1148            TypeRef::StringUtf8 | TypeRef::BorrowedStr => vec![format!("_{name}_c")],
1149            TypeRef::Struct(_) | TypeRef::TypedHandle(_) => {
1150                vec![format!("{name}._ptr if {name} is not None else None")]
1151            }
1152            TypeRef::Bytes | TypeRef::BorrowedBytes | TypeRef::List(_) => {
1153                vec![format!("_{name}_arr"), format!("_{name}_len")]
1154            }
1155            TypeRef::Map(_, _) => vec![
1156                format!("_{name}_ka"),
1157                format!("_{name}_va"),
1158                format!("_{name}_len"),
1159            ],
1160            _ if !is_c_pointer_type(inner) => vec![format!("_{name}_c")],
1161            _ => py_param_call_args(name, inner),
1162        },
1163        TypeRef::List(_) => vec![format!("_{name}_arr"), format!("len({name})")],
1164        TypeRef::Map(_, _) => vec![
1165            format!("_{name}_ka"),
1166            format!("_{name}_va"),
1167            format!("len(_{name}_keys)"),
1168        ],
1169        TypeRef::Iterator(_) => unreachable!("iterator not valid as parameter"),
1170    }
1171}
1172
1173// ── Return helpers ──
1174
1175fn py_read_element(expr: &str, ty: &TypeRef) -> String {
1176    match ty {
1177        TypeRef::StringUtf8 | TypeRef::BorrowedStr => format!("_bytes_to_string({expr})"),
1178        TypeRef::Struct(name) | TypeRef::TypedHandle(name) | TypeRef::Enum(name) => {
1179            let name = local_type_name(name);
1180            format!("{name}({expr})")
1181        }
1182        TypeRef::Bool => format!("bool({expr})"),
1183        _ => expr.to_string(),
1184    }
1185}
1186
1187fn render_return_value(out: &mut String, ty: &TypeRef, ind: &str) {
1188    match ty {
1189        TypeRef::I32 | TypeRef::U32 | TypeRef::I64 | TypeRef::F64 | TypeRef::Handle => {
1190            out.push_str(&format!("{ind}return _result\n"));
1191        }
1192        TypeRef::Bool => {
1193            out.push_str(&format!("{ind}return bool(_result)\n"));
1194        }
1195        TypeRef::StringUtf8 | TypeRef::BorrowedStr => {
1196            out.push_str(&format!("{ind}return _bytes_to_string(_result) or \"\"\n"));
1197        }
1198        TypeRef::Bytes | TypeRef::BorrowedBytes => {
1199            out.push_str(&format!("{ind}if not _result:\n"));
1200            out.push_str(&format!("{ind}    return b\"\"\n"));
1201            out.push_str(&format!("{ind}return bytes(_result[:_out_len.value])\n"));
1202        }
1203        TypeRef::Struct(name) | TypeRef::TypedHandle(name) => {
1204            let name = local_type_name(name);
1205            out.push_str(&format!("{ind}if _result is None:\n"));
1206            out.push_str(&format!(
1207                "{ind}    raise WeaveFFIError(-1, \"null pointer\")\n"
1208            ));
1209            out.push_str(&format!("{ind}return {name}(_result)\n"));
1210        }
1211        TypeRef::Enum(name) => {
1212            let name = local_type_name(name);
1213            out.push_str(&format!("{ind}return {name}(_result)\n"));
1214        }
1215        TypeRef::Optional(inner) => render_optional_return(out, inner, ind),
1216        TypeRef::List(inner) => render_list_return(out, inner, ind),
1217        TypeRef::Map(k, v) => render_map_return(out, k, v, ind),
1218        TypeRef::Iterator(_) => unreachable!("iterator return handled in render_function"),
1219    }
1220}
1221
1222fn render_optional_return(out: &mut String, inner: &TypeRef, ind: &str) {
1223    match inner {
1224        TypeRef::StringUtf8 | TypeRef::BorrowedStr => {
1225            out.push_str(&format!("{ind}return _bytes_to_string(_result)\n"));
1226        }
1227        TypeRef::Bytes | TypeRef::BorrowedBytes => {
1228            out.push_str(&format!("{ind}if not _result:\n"));
1229            out.push_str(&format!("{ind}    return None\n"));
1230            out.push_str(&format!("{ind}return bytes(_result[:_out_len.value])\n"));
1231        }
1232        TypeRef::Struct(name) | TypeRef::TypedHandle(name) => {
1233            let name = local_type_name(name);
1234            out.push_str(&format!("{ind}if _result is None:\n"));
1235            out.push_str(&format!("{ind}    return None\n"));
1236            out.push_str(&format!("{ind}return {name}(_result)\n"));
1237        }
1238        TypeRef::Enum(name) => {
1239            let name = local_type_name(name);
1240            out.push_str(&format!("{ind}if not _result:\n"));
1241            out.push_str(&format!("{ind}    return None\n"));
1242            out.push_str(&format!("{ind}return {name}(_result[0])\n"));
1243        }
1244        TypeRef::Bool => {
1245            out.push_str(&format!("{ind}if not _result:\n"));
1246            out.push_str(&format!("{ind}    return None\n"));
1247            out.push_str(&format!("{ind}return bool(_result[0])\n"));
1248        }
1249        _ if !is_c_pointer_type(inner) => {
1250            out.push_str(&format!("{ind}if not _result:\n"));
1251            out.push_str(&format!("{ind}    return None\n"));
1252            out.push_str(&format!("{ind}return _result[0]\n"));
1253        }
1254        _ => {
1255            out.push_str(&format!("{ind}return _result\n"));
1256        }
1257    }
1258}
1259
1260fn render_list_return(out: &mut String, inner: &TypeRef, ind: &str) {
1261    out.push_str(&format!("{ind}if not _result:\n"));
1262    out.push_str(&format!("{ind}    return []\n"));
1263    let elem = py_read_element("_result[_i]", inner);
1264    out.push_str(&format!(
1265        "{ind}return [{elem} for _i in range(_out_len.value)]\n"
1266    ));
1267}
1268
1269fn render_map_return(out: &mut String, k: &TypeRef, v: &TypeRef, ind: &str) {
1270    out.push_str(&format!("{ind}if not _out_keys or not _out_values:\n"));
1271    out.push_str(&format!("{ind}    return {{}}\n"));
1272    let key_read = py_read_element("_out_keys[_i]", k);
1273    let val_read = py_read_element("_out_values[_i]", v);
1274    out.push_str(&format!(
1275        "{ind}return {{{key_read}: {val_read} for _i in range(_out_len.value)}}\n"
1276    ));
1277}
1278
1279// ── Iterator helpers ──
1280
1281fn py_read_iter_item(inner: &TypeRef) -> String {
1282    match inner {
1283        TypeRef::StringUtf8 | TypeRef::BorrowedStr => "_bytes_to_string(_out_item.value)".into(),
1284        TypeRef::Struct(name) | TypeRef::TypedHandle(name) | TypeRef::Enum(name) => {
1285            let name = local_type_name(name);
1286            format!("{name}(_out_item.value)")
1287        }
1288        TypeRef::Bool => "bool(_out_item.value)".into(),
1289        _ => "_out_item.value".into(),
1290    }
1291}
1292
1293fn render_iterator_class(out: &mut String, iter_tag: &str, func_name: &str, inner: &TypeRef) {
1294    let pascal = pascal_case(func_name);
1295    let class_name = format!("_{pascal}Iterator");
1296    let item_scalar = py_ctypes_scalar(inner);
1297    let read_expr = py_read_iter_item(inner);
1298
1299    out.push_str(&format!("\n\nclass {class_name}:"));
1300    out.push_str("\n    def __init__(self, ptr):");
1301    out.push_str("\n        self._ptr = ptr");
1302    out.push_str("\n        self._done = False");
1303
1304    out.push_str("\n\n    def __iter__(self):");
1305    out.push_str("\n        return self");
1306
1307    out.push_str("\n\n    def __next__(self):");
1308    out.push_str("\n        if self._done:");
1309    out.push_str("\n            raise StopIteration");
1310    out.push_str(&format!("\n        _next_fn = _lib.{iter_tag}_next"));
1311    out.push_str(&format!(
1312        "\n        _next_fn.argtypes = [ctypes.c_void_p, ctypes.POINTER({item_scalar}), ctypes.POINTER(_WeaveFFIErrorStruct)]"
1313    ));
1314    out.push_str("\n        _next_fn.restype = ctypes.c_int32");
1315    out.push_str(&format!("\n        _out_item = {item_scalar}()"));
1316    out.push_str("\n        _err = _WeaveFFIErrorStruct()");
1317    out.push_str(
1318        "\n        _has = _next_fn(self._ptr, ctypes.byref(_out_item), ctypes.byref(_err))",
1319    );
1320    out.push_str("\n        _check_error(_err)");
1321    out.push_str("\n        if not _has:");
1322    out.push_str("\n            self._done = True");
1323    out.push_str("\n            self._destroy()");
1324    out.push_str("\n            raise StopIteration");
1325    out.push_str(&format!("\n        return {read_expr}"));
1326
1327    out.push_str("\n\n    def _destroy(self):");
1328    out.push_str("\n        if self._ptr is not None:");
1329    out.push_str(&format!(
1330        "\n            _destroy_fn = _lib.{iter_tag}_destroy"
1331    ));
1332    out.push_str("\n            _destroy_fn.argtypes = [ctypes.c_void_p]");
1333    out.push_str("\n            _destroy_fn.restype = None");
1334    out.push_str("\n            _destroy_fn(self._ptr)");
1335    out.push_str("\n            self._ptr = None");
1336
1337    out.push_str("\n\n    def __del__(self):");
1338    out.push_str("\n        self._destroy()");
1339    out.push('\n');
1340}
1341
1342fn render_iterator_return(out: &mut String, iter_tag: &str, inner: &TypeRef, ind: &str) {
1343    let item_scalar = py_ctypes_scalar(inner);
1344    let read_expr = py_read_iter_item(inner);
1345
1346    out.push_str(&format!("{ind}_next_fn = _lib.{iter_tag}_next\n"));
1347    out.push_str(&format!(
1348        "{ind}_next_fn.argtypes = [ctypes.c_void_p, ctypes.POINTER({item_scalar}), ctypes.POINTER(_WeaveFFIErrorStruct)]\n"
1349    ));
1350    out.push_str(&format!("{ind}_next_fn.restype = ctypes.c_int32\n"));
1351
1352    out.push_str(&format!("{ind}_destroy_fn = _lib.{iter_tag}_destroy\n"));
1353    out.push_str(&format!("{ind}_destroy_fn.argtypes = [ctypes.c_void_p]\n"));
1354    out.push_str(&format!("{ind}_destroy_fn.restype = None\n"));
1355
1356    out.push_str(&format!("{ind}_items = []\n"));
1357    out.push_str(&format!("{ind}while True:\n"));
1358    out.push_str(&format!("{ind}    _out_item = {item_scalar}()\n"));
1359    out.push_str(&format!("{ind}    _item_err = _WeaveFFIErrorStruct()\n"));
1360    out.push_str(&format!(
1361        "{ind}    _has = _next_fn(_result, ctypes.byref(_out_item), ctypes.byref(_item_err))\n"
1362    ));
1363    out.push_str(&format!("{ind}    _check_error(_item_err)\n"));
1364    out.push_str(&format!("{ind}    if not _has:\n"));
1365    out.push_str(&format!("{ind}        break\n"));
1366    out.push_str(&format!("{ind}    _items.append({read_expr})\n"));
1367
1368    out.push_str(&format!("{ind}_destroy_fn(_result)\n"));
1369    out.push_str(&format!("{ind}return _items\n"));
1370}
1371
1372// ── Packaging ──
1373
1374fn render_pyproject_toml(
1375    package: &ResolvedPackage,
1376    import_name: &str,
1377    input_basename: &str,
1378) -> String {
1379    let prelude = render_prelude(CommentStyle::Hash, input_basename);
1380    let trailer = render_trailer(CommentStyle::Hash, "pyproject.toml");
1381    let name = &package.name;
1382    let version = &package.version;
1383    let description = package.description_or_default();
1384    let mut extra = String::new();
1385    if let Some(license) = &package.license {
1386        extra.push_str(&format!("license = {{ text = \"{license}\" }}\n"));
1387    }
1388    if !package.authors.is_empty() {
1389        let authors = package
1390            .authors
1391            .iter()
1392            .map(|a| format!("{{ name = \"{a}\" }}"))
1393            .collect::<Vec<_>>()
1394            .join(", ");
1395        extra.push_str(&format!("authors = [{authors}]\n"));
1396    }
1397    if let Some(homepage) = &package.homepage {
1398        extra.push_str(&format!("[project.urls]\nHomepage = \"{homepage}\"\n"));
1399    } else if let Some(repository) = &package.repository {
1400        extra.push_str(&format!("[project.urls]\nRepository = \"{repository}\"\n"));
1401    }
1402    format!(
1403        r#"{prelude}[build-system]
1404requires = ["setuptools>=61.0"]
1405build-backend = "setuptools.build_meta"
1406
1407[project]
1408name = "{name}"
1409version = "{version}"
1410description = "{description}"
1411requires-python = ">=3.8"
1412{extra}
1413[tool.setuptools]
1414packages = ["{import_name}"]
1415
1416{trailer}"#,
1417    )
1418}
1419
1420fn render_setup_py(package: &ResolvedPackage, import_name: &str, input_basename: &str) -> String {
1421    let prelude = render_prelude(CommentStyle::Hash, input_basename);
1422    let trailer = render_trailer(CommentStyle::Hash, "setup.py");
1423    let name = &package.name;
1424    let version = &package.version;
1425    format!(
1426        r#"{prelude}from setuptools import setup
1427
1428setup(
1429    name="{name}",
1430    version="{version}",
1431    packages=["{import_name}"],
1432)
1433
1434{trailer}"#,
1435    )
1436}
1437
1438fn render_readme(package: &ResolvedPackage, input_basename: &str) -> String {
1439    let prelude = render_prelude(CommentStyle::Xml, input_basename);
1440    let trailer = render_trailer(CommentStyle::Xml, "README.md");
1441    let name = &package.name;
1442    let import_name = package.ident_name();
1443    format!(
1444        r#"{prelude}# {name} (Python)
1445
1446Auto-generated Python bindings using ctypes.
1447
1448## Prerequisites
1449
1450- Python >= 3.8
1451- The compiled shared library (`libweaveffi.so`, `libweaveffi.dylib`, or `weaveffi.dll`) available on your library search path.
1452
1453## Install
1454
1455```bash
1456pip install .
1457```
1458
1459## Development install
1460
1461```bash
1462pip install -e .
1463```
1464
1465## Usage
1466
1467```python
1468from {import_name} import *
1469```
1470
1471{trailer}"#
1472    )
1473}
1474
1475// ── Type stub (.pyi) rendering ──
1476
1477fn render_pyi_module(api: &Api, strip_module_prefix: bool, input_basename: &str) -> String {
1478    // Type stubs contain no C symbols, so the ABI prefix is irrelevant here; the
1479    // model is used purely for its flattened, path-carrying module traversal.
1480    let model = BindingModel::build(api, "weaveffi");
1481    let mut out = render_prelude(CommentStyle::Hash, input_basename);
1482    out.push_str("from enum import IntEnum\nfrom typing import Dict, Iterator, List, Optional\n");
1483    for m in &model.modules {
1484        for e in &m.enums {
1485            render_pyi_enum(&mut out, e);
1486        }
1487        for s in &m.structs {
1488            render_pyi_struct(&mut out, s);
1489        }
1490        for f in &m.functions {
1491            render_pyi_function(&mut out, &m.path, f, strip_module_prefix);
1492        }
1493    }
1494    out.push('\n');
1495    out.push_str(&render_trailer(CommentStyle::Hash, "weaveffi.pyi"));
1496    out
1497}
1498
1499fn render_pyi_enum(out: &mut String, e: &EnumBinding) {
1500    out.push('\n');
1501    emit_doc(out, &e.doc, "");
1502    out.push_str(&format!("class {}(IntEnum):\n", e.name));
1503    for v in &e.variants {
1504        emit_doc(out, &v.doc, "    ");
1505        out.push_str(&format!("    {}: int\n", v.name));
1506    }
1507}
1508
1509fn render_pyi_struct(out: &mut String, s: &StructBinding) {
1510    out.push('\n');
1511    emit_doc(out, &s.doc, "");
1512    out.push_str(&format!("class {}:\n", s.name));
1513    for field in &s.fields {
1514        let py_ty = py_type_hint(&field.ty);
1515        emit_doc(out, &field.doc, "    ");
1516        out.push_str(&format!(
1517            "    @property\n    def {}(self) -> {}: ...\n",
1518            field.name, py_ty
1519        ));
1520    }
1521}
1522
1523fn render_pyi_function(
1524    out: &mut String,
1525    module_name: &str,
1526    f: &FnBinding,
1527    strip_module_prefix: bool,
1528) {
1529    let func_name = wrapper_name(module_name, &f.name, strip_module_prefix);
1530    let params: Vec<String> = f
1531        .params
1532        .iter()
1533        .map(|p| format!("{}: {}", p.name, py_type_hint(&p.ty)))
1534        .collect();
1535    let ret = f
1536        .ret
1537        .as_ref()
1538        .map(py_type_hint)
1539        .unwrap_or_else(|| "None".into());
1540    let async_kw = if f.is_async { "async " } else { "" };
1541    out.push('\n');
1542    emit_doc(out, &f.doc, "");
1543    out.push_str(&format!(
1544        "{async_kw}def {}({}) -> {}: ...\n",
1545        func_name,
1546        params.join(", "),
1547        ret
1548    ));
1549}
1550
1551#[cfg(test)]
1552mod tests {
1553    use super::*;
1554    use camino::Utf8Path;
1555    use weaveffi_core::codegen::Generator;
1556    use weaveffi_ir::ir::{
1557        Api, EnumDef, EnumVariant, Function, Module, Param, StructDef, StructField, TypeRef,
1558    };
1559
1560    fn make_api(modules: Vec<Module>) -> Api {
1561        Api {
1562            version: "0.1.0".into(),
1563            modules,
1564            generators: None,
1565            package: None,
1566        }
1567    }
1568
1569    fn simple_module(functions: Vec<Function>) -> Module {
1570        Module {
1571            name: "math".into(),
1572            functions,
1573            structs: vec![],
1574            enums: vec![],
1575            callbacks: vec![],
1576            listeners: vec![],
1577            errors: None,
1578            modules: vec![],
1579        }
1580    }
1581
1582    #[test]
1583    fn generator_name_is_python() {
1584        assert_eq!(Generator::name(&PythonGenerator), "python");
1585    }
1586
1587    #[test]
1588    fn generate_creates_output_files() {
1589        let api = make_api(vec![simple_module(vec![Function {
1590            name: "add".into(),
1591            params: vec![
1592                Param {
1593                    name: "a".into(),
1594                    ty: TypeRef::I32,
1595                    mutable: false,
1596                    doc: None,
1597                },
1598                Param {
1599                    name: "b".into(),
1600                    ty: TypeRef::I32,
1601                    mutable: false,
1602                    doc: None,
1603                },
1604            ],
1605            returns: Some(TypeRef::I32),
1606            doc: None,
1607            r#async: false,
1608            cancellable: false,
1609            deprecated: None,
1610            since: None,
1611        }])]);
1612
1613        let tmp = std::env::temp_dir().join("weaveffi_test_python_gen_output");
1614        let _ = std::fs::remove_dir_all(&tmp);
1615        std::fs::create_dir_all(&tmp).unwrap();
1616        let out_dir = Utf8Path::from_path(&tmp).expect("valid UTF-8");
1617
1618        PythonGenerator
1619            .generate(
1620                &api,
1621                out_dir,
1622                &PythonConfig {
1623                    strip_module_prefix: true,
1624                    ..PythonConfig::default()
1625                },
1626            )
1627            .unwrap();
1628
1629        let init = std::fs::read_to_string(tmp.join("python/weaveffi/__init__.py")).unwrap();
1630        assert!(init.contains("from .weaveffi import *"));
1631
1632        let weaveffi = std::fs::read_to_string(tmp.join("python/weaveffi/weaveffi.py")).unwrap();
1633        assert!(weaveffi.contains("WeaveFFI"));
1634        assert!(weaveffi.contains("def add("));
1635
1636        let _ = std::fs::remove_dir_all(&tmp);
1637    }
1638
1639    #[test]
1640    fn output_files_lists_all() {
1641        let api = make_api(vec![]);
1642        let out = Utf8Path::new("/tmp/out");
1643        let files = PythonGenerator.output_files(&api, out, &PythonConfig::default());
1644        assert_eq!(
1645            files,
1646            vec![
1647                format!("{out}/python/README.md"),
1648                format!("{out}/python/pyproject.toml"),
1649                format!("{out}/python/setup.py"),
1650                format!("{out}/python/weaveffi/__init__.py"),
1651                format!("{out}/python/weaveffi/weaveffi.py"),
1652                format!("{out}/python/weaveffi/weaveffi.pyi"),
1653            ]
1654        );
1655    }
1656
1657    #[test]
1658    fn preamble_has_load_library() {
1659        let api = make_api(vec![]);
1660        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
1661        assert!(py.contains("def _load_library()"), "missing _load_library");
1662        assert!(
1663            py.contains("libweaveffi.dylib"),
1664            "missing macOS library name"
1665        );
1666        assert!(py.contains("libweaveffi.so"), "missing Linux library name");
1667        assert!(py.contains("weaveffi.dll"), "missing Windows library name");
1668        assert!(py.contains("ctypes.CDLL(name)"), "missing CDLL call");
1669    }
1670
1671    #[test]
1672    fn preamble_has_error_handling() {
1673        let api = make_api(vec![]);
1674        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
1675        assert!(
1676            py.contains("class WeaveFFIError(Exception):"),
1677            "missing error class"
1678        );
1679        assert!(
1680            py.contains("class _WeaveFFIErrorStruct(ctypes.Structure):"),
1681            "missing error struct"
1682        );
1683        assert!(py.contains("def _check_error("), "missing _check_error");
1684        assert!(
1685            py.contains("weaveffi_error_clear"),
1686            "missing error_clear setup"
1687        );
1688    }
1689
1690    #[test]
1691    fn simple_i32_function() {
1692        let api = make_api(vec![simple_module(vec![Function {
1693            name: "add".into(),
1694            params: vec![
1695                Param {
1696                    name: "a".into(),
1697                    ty: TypeRef::I32,
1698                    mutable: false,
1699                    doc: None,
1700                },
1701                Param {
1702                    name: "b".into(),
1703                    ty: TypeRef::I32,
1704                    mutable: false,
1705                    doc: None,
1706                },
1707            ],
1708            returns: Some(TypeRef::I32),
1709            doc: None,
1710            r#async: false,
1711            cancellable: false,
1712            deprecated: None,
1713            since: None,
1714        }])]);
1715
1716        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
1717        assert!(
1718            py.contains("def add(a: int, b: int) -> int:"),
1719            "missing function signature: {py}"
1720        );
1721        assert!(
1722            py.contains("_lib.weaveffi_math_add"),
1723            "missing C symbol: {py}"
1724        );
1725        assert!(
1726            py.contains("ctypes.c_int32, ctypes.c_int32"),
1727            "missing argtypes: {py}"
1728        );
1729        assert!(
1730            py.contains("_fn.restype = ctypes.c_int32"),
1731            "missing restype: {py}"
1732        );
1733        assert!(
1734            py.contains("_check_error(_err)"),
1735            "missing error check: {py}"
1736        );
1737        assert!(py.contains("return _result"), "missing return: {py}");
1738    }
1739
1740    #[test]
1741    fn string_function_encode_decode() {
1742        let api = make_api(vec![Module {
1743            name: "text".into(),
1744            functions: vec![Function {
1745                name: "echo".into(),
1746                params: vec![Param {
1747                    name: "msg".into(),
1748                    ty: TypeRef::StringUtf8,
1749                    mutable: false,
1750                    doc: None,
1751                }],
1752                returns: Some(TypeRef::StringUtf8),
1753                doc: None,
1754                r#async: false,
1755                cancellable: false,
1756                deprecated: None,
1757                since: None,
1758            }],
1759            structs: vec![],
1760            enums: vec![],
1761            callbacks: vec![],
1762            listeners: vec![],
1763            errors: None,
1764            modules: vec![],
1765        }]);
1766
1767        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
1768        assert!(
1769            py.contains("def echo(msg: str) -> str:"),
1770            "missing signature: {py}"
1771        );
1772        assert!(py.contains("ctypes.c_char_p"), "missing c_char_p: {py}");
1773        assert!(
1774            py.contains("_string_to_bytes(msg)"),
1775            "missing _string_to_bytes call: {py}"
1776        );
1777        assert!(
1778            py.contains("_bytes_to_string(_result)"),
1779            "missing _bytes_to_string call: {py}"
1780        );
1781    }
1782
1783    #[test]
1784    fn void_function() {
1785        let api = make_api(vec![simple_module(vec![Function {
1786            name: "reset".into(),
1787            params: vec![],
1788            returns: None,
1789            doc: None,
1790            r#async: false,
1791            cancellable: false,
1792            deprecated: None,
1793            since: None,
1794        }])]);
1795
1796        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
1797        assert!(
1798            py.contains("def reset() -> None:"),
1799            "missing void signature: {py}"
1800        );
1801        assert!(
1802            py.contains("_fn.restype = None"),
1803            "missing None restype: {py}"
1804        );
1805        assert!(
1806            !py.contains("_result ="),
1807            "void function should not assign _result: {py}"
1808        );
1809    }
1810
1811    #[test]
1812    fn enum_intenum_class() {
1813        let api = make_api(vec![Module {
1814            name: "paint".into(),
1815            functions: vec![],
1816            structs: vec![],
1817            enums: vec![EnumDef {
1818                name: "Color".into(),
1819                doc: Some("Primary colors".into()),
1820                variants: vec![
1821                    EnumVariant {
1822                        name: "Red".into(),
1823                        value: 0,
1824                        doc: None,
1825                    },
1826                    EnumVariant {
1827                        name: "Green".into(),
1828                        value: 1,
1829                        doc: None,
1830                    },
1831                    EnumVariant {
1832                        name: "Blue".into(),
1833                        value: 2,
1834                        doc: None,
1835                    },
1836                ],
1837            }],
1838            callbacks: vec![],
1839            listeners: vec![],
1840            errors: None,
1841            modules: vec![],
1842        }]);
1843
1844        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
1845        assert!(
1846            py.contains("class Color(IntEnum):"),
1847            "missing IntEnum class: {py}"
1848        );
1849        assert!(
1850            py.contains("\"\"\"Primary colors\"\"\""),
1851            "missing doc: {py}"
1852        );
1853        assert!(py.contains("Red = 0"), "missing Red: {py}");
1854        assert!(py.contains("Green = 1"), "missing Green: {py}");
1855        assert!(py.contains("Blue = 2"), "missing Blue: {py}");
1856    }
1857
1858    #[test]
1859    fn enum_param_and_return() {
1860        let api = make_api(vec![Module {
1861            name: "paint".into(),
1862            functions: vec![Function {
1863                name: "mix".into(),
1864                params: vec![Param {
1865                    name: "a".into(),
1866                    ty: TypeRef::Enum("Color".into()),
1867                    mutable: false,
1868                    doc: None,
1869                }],
1870                returns: Some(TypeRef::Enum("Color".into())),
1871                doc: None,
1872                r#async: false,
1873                cancellable: false,
1874                deprecated: None,
1875                since: None,
1876            }],
1877            structs: vec![],
1878            enums: vec![],
1879            callbacks: vec![],
1880            listeners: vec![],
1881            errors: None,
1882            modules: vec![],
1883        }]);
1884
1885        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
1886        assert!(py.contains("a: \"Color\""), "missing enum param hint: {py}");
1887        assert!(
1888            py.contains("-> \"Color\":"),
1889            "missing enum return hint: {py}"
1890        );
1891        assert!(py.contains("a.value"), "missing .value conversion: {py}");
1892        assert!(
1893            py.contains("return Color(_result)"),
1894            "missing enum return wrap: {py}"
1895        );
1896    }
1897
1898    #[test]
1899    fn struct_class_with_getters() {
1900        let api = make_api(vec![Module {
1901            name: "contacts".into(),
1902            functions: vec![],
1903            structs: vec![StructDef {
1904                name: "Contact".into(),
1905                doc: None,
1906                fields: vec![
1907                    StructField {
1908                        name: "name".into(),
1909                        ty: TypeRef::StringUtf8,
1910                        doc: None,
1911                        default: None,
1912                    },
1913                    StructField {
1914                        name: "age".into(),
1915                        ty: TypeRef::I32,
1916                        doc: None,
1917                        default: None,
1918                    },
1919                ],
1920                builder: false,
1921            }],
1922            enums: vec![],
1923            callbacks: vec![],
1924            listeners: vec![],
1925            errors: None,
1926            modules: vec![],
1927        }]);
1928
1929        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
1930        assert!(py.contains("class Contact:"), "missing class: {py}");
1931        assert!(
1932            py.contains("def __init__(self, _ptr: int)"),
1933            "missing __init__: {py}"
1934        );
1935        assert!(
1936            py.contains("self._ptr = _ptr"),
1937            "missing _ptr assignment: {py}"
1938        );
1939        assert!(py.contains("def __del__(self)"), "missing __del__: {py}");
1940        assert!(
1941            py.contains("weaveffi_contacts_Contact_destroy"),
1942            "missing destroy call: {py}"
1943        );
1944        assert!(
1945            py.contains("def name(self) -> str:"),
1946            "missing name getter: {py}"
1947        );
1948        assert!(
1949            py.contains("weaveffi_contacts_Contact_get_name"),
1950            "missing name getter C call: {py}"
1951        );
1952        assert!(
1953            py.contains("_bytes_to_string(_result)"),
1954            "missing _bytes_to_string in getter: {py}"
1955        );
1956        assert!(
1957            py.contains("def age(self) -> int:"),
1958            "missing age getter: {py}"
1959        );
1960        assert!(
1961            py.contains("weaveffi_contacts_Contact_get_age"),
1962            "missing age getter C call: {py}"
1963        );
1964    }
1965
1966    #[test]
1967    fn python_builder_generated() {
1968        let api = Api {
1969            version: "0.1.0".into(),
1970            modules: vec![Module {
1971                name: "contacts".into(),
1972                functions: vec![],
1973                structs: vec![StructDef {
1974                    name: "Contact".into(),
1975                    doc: None,
1976                    fields: vec![
1977                        StructField {
1978                            name: "name".into(),
1979                            ty: TypeRef::StringUtf8,
1980                            doc: None,
1981                            default: None,
1982                        },
1983                        StructField {
1984                            name: "age".into(),
1985                            ty: TypeRef::I32,
1986                            doc: None,
1987                            default: None,
1988                        },
1989                    ],
1990                    builder: true,
1991                }],
1992                enums: vec![],
1993                callbacks: vec![],
1994                listeners: vec![],
1995                errors: None,
1996                modules: vec![],
1997            }],
1998            generators: None,
1999            package: None,
2000        };
2001        let dir = tempfile::tempdir().unwrap();
2002        let out = Utf8Path::from_path(dir.path()).unwrap();
2003        PythonGenerator
2004            .generate(&api, out, &PythonConfig::default())
2005            .unwrap();
2006        let py = std::fs::read_to_string(out.join("python/weaveffi/weaveffi.py")).unwrap();
2007        assert!(
2008            py.contains("class ContactBuilder"),
2009            "missing builder class: {py}"
2010        );
2011        assert!(py.contains("def with_name("), "missing with_name: {py}");
2012        assert!(py.contains("def with_age("), "missing with_age: {py}");
2013        assert!(py.contains("def build("), "missing build: {py}");
2014    }
2015
2016    #[test]
2017    fn struct_return() {
2018        let api = make_api(vec![Module {
2019            name: "contacts".into(),
2020            functions: vec![Function {
2021                name: "get_contact".into(),
2022                params: vec![Param {
2023                    name: "id".into(),
2024                    ty: TypeRef::Handle,
2025                    mutable: false,
2026                    doc: None,
2027                }],
2028                returns: Some(TypeRef::Struct("Contact".into())),
2029                doc: None,
2030                r#async: false,
2031                cancellable: false,
2032                deprecated: None,
2033                since: None,
2034            }],
2035            structs: vec![],
2036            enums: vec![],
2037            callbacks: vec![],
2038            listeners: vec![],
2039            errors: None,
2040            modules: vec![],
2041        }]);
2042
2043        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
2044        assert!(
2045            py.contains("-> \"Contact\":"),
2046            "missing struct return hint: {py}"
2047        );
2048        assert!(
2049            py.contains("ctypes.c_void_p"),
2050            "missing void_p for struct: {py}"
2051        );
2052        assert!(
2053            py.contains("return Contact(_result)"),
2054            "missing struct wrapping: {py}"
2055        );
2056    }
2057
2058    #[test]
2059    fn bool_uses_c_int32() {
2060        let api = make_api(vec![simple_module(vec![Function {
2061            name: "is_valid".into(),
2062            params: vec![Param {
2063                name: "flag".into(),
2064                ty: TypeRef::Bool,
2065                mutable: false,
2066                doc: None,
2067            }],
2068            returns: Some(TypeRef::Bool),
2069            doc: None,
2070            r#async: false,
2071            cancellable: false,
2072            deprecated: None,
2073            since: None,
2074        }])]);
2075
2076        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
2077        assert!(py.contains("flag: bool"), "missing bool param: {py}");
2078        assert!(py.contains("-> bool:"), "missing bool return: {py}");
2079        assert!(
2080            py.contains("ctypes.c_int32"),
2081            "missing c_int32 for Bool: {py}"
2082        );
2083        assert!(
2084            py.contains("1 if flag else 0"),
2085            "missing bool-to-int conversion: {py}"
2086        );
2087        assert!(
2088            py.contains("return bool(_result)"),
2089            "missing int-to-bool conversion: {py}"
2090        );
2091    }
2092
2093    #[test]
2094    fn handle_uses_c_uint64() {
2095        let api = make_api(vec![simple_module(vec![Function {
2096            name: "create".into(),
2097            params: vec![],
2098            returns: Some(TypeRef::Handle),
2099            doc: None,
2100            r#async: false,
2101            cancellable: false,
2102            deprecated: None,
2103            since: None,
2104        }])]);
2105
2106        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
2107        assert!(
2108            py.contains("ctypes.c_uint64"),
2109            "missing c_uint64 for Handle: {py}"
2110        );
2111    }
2112
2113    #[test]
2114    fn bytes_param_and_return() {
2115        let api = make_api(vec![Module {
2116            name: "store".into(),
2117            functions: vec![Function {
2118                name: "process".into(),
2119                params: vec![Param {
2120                    name: "data".into(),
2121                    ty: TypeRef::Bytes,
2122                    mutable: false,
2123                    doc: None,
2124                }],
2125                returns: Some(TypeRef::Bytes),
2126                doc: None,
2127                r#async: false,
2128                cancellable: false,
2129                deprecated: None,
2130                since: None,
2131            }],
2132            structs: vec![],
2133            enums: vec![],
2134            callbacks: vec![],
2135            listeners: vec![],
2136            errors: None,
2137            modules: vec![],
2138        }]);
2139
2140        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
2141        assert!(py.contains("data: bytes"), "missing bytes param: {py}");
2142        assert!(py.contains("-> bytes:"), "missing bytes return: {py}");
2143        assert!(
2144            py.contains("ctypes.POINTER(ctypes.c_uint8)"),
2145            "missing uint8 pointer: {py}"
2146        );
2147        assert!(py.contains("ctypes.c_size_t"), "missing size_t: {py}");
2148        assert!(py.contains("_out_len"), "missing out_len: {py}");
2149    }
2150
2151    #[test]
2152    fn optional_value_param_and_return() {
2153        let api = make_api(vec![Module {
2154            name: "store".into(),
2155            functions: vec![Function {
2156                name: "find".into(),
2157                params: vec![Param {
2158                    name: "id".into(),
2159                    ty: TypeRef::Optional(Box::new(TypeRef::I32)),
2160                    mutable: false,
2161                    doc: None,
2162                }],
2163                returns: Some(TypeRef::Optional(Box::new(TypeRef::I32))),
2164                doc: None,
2165                r#async: false,
2166                cancellable: false,
2167                deprecated: None,
2168                since: None,
2169            }],
2170            structs: vec![],
2171            enums: vec![],
2172            callbacks: vec![],
2173            listeners: vec![],
2174            errors: None,
2175            modules: vec![],
2176        }]);
2177
2178        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
2179        assert!(
2180            py.contains("id: Optional[int]"),
2181            "missing optional param: {py}"
2182        );
2183        assert!(
2184            py.contains("-> Optional[int]:"),
2185            "missing optional return: {py}"
2186        );
2187        assert!(
2188            py.contains("ctypes.POINTER(ctypes.c_int32)"),
2189            "missing POINTER for optional: {py}"
2190        );
2191        assert!(
2192            py.contains("ctypes.byref(ctypes.c_int32(id)) if id is not None else None"),
2193            "missing optional param conversion: {py}"
2194        );
2195        assert!(py.contains("return None"), "missing None return path: {py}");
2196        assert!(
2197            py.contains("return _result[0]"),
2198            "missing pointer deref: {py}"
2199        );
2200    }
2201
2202    #[test]
2203    fn optional_string_return() {
2204        let api = make_api(vec![Module {
2205            name: "store".into(),
2206            functions: vec![Function {
2207                name: "get_name".into(),
2208                params: vec![],
2209                returns: Some(TypeRef::Optional(Box::new(TypeRef::StringUtf8))),
2210                doc: None,
2211                r#async: false,
2212                cancellable: false,
2213                deprecated: None,
2214                since: None,
2215            }],
2216            structs: vec![],
2217            enums: vec![],
2218            callbacks: vec![],
2219            listeners: vec![],
2220            errors: None,
2221            modules: vec![],
2222        }]);
2223
2224        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
2225        assert!(
2226            py.contains("-> Optional[str]:"),
2227            "missing optional str return: {py}"
2228        );
2229        assert!(
2230            py.contains("return _bytes_to_string(_result)"),
2231            "missing _bytes_to_string for optional string: {py}"
2232        );
2233    }
2234
2235    #[test]
2236    fn list_param_and_return() {
2237        let api = make_api(vec![Module {
2238            name: "batch".into(),
2239            functions: vec![
2240                Function {
2241                    name: "process".into(),
2242                    params: vec![Param {
2243                        name: "ids".into(),
2244                        ty: TypeRef::List(Box::new(TypeRef::I32)),
2245                        mutable: false,
2246                        doc: None,
2247                    }],
2248                    returns: None,
2249                    doc: None,
2250                    r#async: false,
2251                    cancellable: false,
2252                    deprecated: None,
2253                    since: None,
2254                },
2255                Function {
2256                    name: "get_ids".into(),
2257                    params: vec![],
2258                    returns: Some(TypeRef::List(Box::new(TypeRef::I32))),
2259                    doc: None,
2260                    r#async: false,
2261                    cancellable: false,
2262                    deprecated: None,
2263                    since: None,
2264                },
2265            ],
2266            structs: vec![],
2267            enums: vec![],
2268            callbacks: vec![],
2269            listeners: vec![],
2270            errors: None,
2271            modules: vec![],
2272        }]);
2273
2274        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
2275        assert!(py.contains("ids: List[int]"), "missing list param: {py}");
2276        assert!(py.contains("-> List[int]:"), "missing list return: {py}");
2277        assert!(
2278            py.contains("ctypes.c_int32 * len(ids)"),
2279            "missing ctypes array creation: {py}"
2280        );
2281        assert!(
2282            py.contains("_out_len"),
2283            "missing out_len for list return: {py}"
2284        );
2285        assert!(
2286            py.contains("for _i in range(_out_len.value)"),
2287            "missing list iteration: {py}"
2288        );
2289    }
2290
2291    #[test]
2292    fn map_param_and_return() {
2293        let api = make_api(vec![Module {
2294            name: "store".into(),
2295            functions: vec![
2296                Function {
2297                    name: "update".into(),
2298                    params: vec![Param {
2299                        name: "scores".into(),
2300                        ty: TypeRef::Map(Box::new(TypeRef::StringUtf8), Box::new(TypeRef::I32)),
2301                        mutable: false,
2302                        doc: None,
2303                    }],
2304                    returns: None,
2305                    doc: None,
2306                    r#async: false,
2307                    cancellable: false,
2308                    deprecated: None,
2309                    since: None,
2310                },
2311                Function {
2312                    name: "get_scores".into(),
2313                    params: vec![],
2314                    returns: Some(TypeRef::Map(
2315                        Box::new(TypeRef::StringUtf8),
2316                        Box::new(TypeRef::I32),
2317                    )),
2318                    doc: None,
2319                    r#async: false,
2320                    cancellable: false,
2321                    deprecated: None,
2322                    since: None,
2323                },
2324            ],
2325            structs: vec![],
2326            enums: vec![],
2327            callbacks: vec![],
2328            listeners: vec![],
2329            errors: None,
2330            modules: vec![],
2331        }]);
2332
2333        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
2334        assert!(
2335            py.contains("scores: Dict[str, int]"),
2336            "missing map param: {py}"
2337        );
2338        assert!(
2339            py.contains("-> Dict[str, int]:"),
2340            "missing map return: {py}"
2341        );
2342        assert!(
2343            py.contains("list(scores.keys())"),
2344            "missing keys extraction: {py}"
2345        );
2346        assert!(py.contains("_out_keys"), "missing out_keys: {py}");
2347        assert!(py.contains("_out_values"), "missing out_values: {py}");
2348    }
2349
2350    #[test]
2351    fn struct_optional_string_getter() {
2352        let api = make_api(vec![Module {
2353            name: "contacts".into(),
2354            functions: vec![],
2355            structs: vec![StructDef {
2356                name: "Contact".into(),
2357                doc: None,
2358                fields: vec![StructField {
2359                    name: "email".into(),
2360                    ty: TypeRef::Optional(Box::new(TypeRef::StringUtf8)),
2361                    doc: None,
2362                    default: None,
2363                }],
2364                builder: false,
2365            }],
2366            enums: vec![],
2367            callbacks: vec![],
2368            listeners: vec![],
2369            errors: None,
2370            modules: vec![],
2371        }]);
2372
2373        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
2374        assert!(
2375            py.contains("def email(self) -> Optional[str]:"),
2376            "missing optional getter: {py}"
2377        );
2378        assert!(
2379            py.contains("_bytes_to_string(_result)"),
2380            "missing _bytes_to_string in optional getter: {py}"
2381        );
2382    }
2383
2384    #[test]
2385    fn struct_enum_field_getter() {
2386        let api = make_api(vec![Module {
2387            name: "contacts".into(),
2388            functions: vec![],
2389            structs: vec![StructDef {
2390                name: "Contact".into(),
2391                doc: None,
2392                fields: vec![StructField {
2393                    name: "role".into(),
2394                    ty: TypeRef::Enum("Role".into()),
2395                    doc: None,
2396                    default: None,
2397                }],
2398                builder: false,
2399            }],
2400            enums: vec![],
2401            callbacks: vec![],
2402            listeners: vec![],
2403            errors: None,
2404            modules: vec![],
2405        }]);
2406
2407        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
2408        assert!(
2409            py.contains("def role(self) -> \"Role\":"),
2410            "missing enum getter: {py}"
2411        );
2412        assert!(
2413            py.contains("return Role(_result)"),
2414            "missing enum wrapping in getter: {py}"
2415        );
2416    }
2417
2418    #[test]
2419    fn comprehensive_contacts_api() {
2420        let api = make_api(vec![Module {
2421            name: "contacts".into(),
2422            enums: vec![EnumDef {
2423                name: "ContactType".into(),
2424                doc: None,
2425                variants: vec![
2426                    EnumVariant {
2427                        name: "Personal".into(),
2428                        value: 0,
2429                        doc: None,
2430                    },
2431                    EnumVariant {
2432                        name: "Work".into(),
2433                        value: 1,
2434                        doc: None,
2435                    },
2436                ],
2437            }],
2438            callbacks: vec![],
2439            listeners: vec![],
2440            structs: vec![StructDef {
2441                name: "Contact".into(),
2442                doc: None,
2443                fields: vec![
2444                    StructField {
2445                        name: "id".into(),
2446                        ty: TypeRef::I64,
2447                        doc: None,
2448                        default: None,
2449                    },
2450                    StructField {
2451                        name: "first_name".into(),
2452                        ty: TypeRef::StringUtf8,
2453                        doc: None,
2454                        default: None,
2455                    },
2456                    StructField {
2457                        name: "email".into(),
2458                        ty: TypeRef::Optional(Box::new(TypeRef::StringUtf8)),
2459                        doc: None,
2460                        default: None,
2461                    },
2462                    StructField {
2463                        name: "contact_type".into(),
2464                        ty: TypeRef::Enum("ContactType".into()),
2465                        doc: None,
2466                        default: None,
2467                    },
2468                ],
2469                builder: false,
2470            }],
2471            functions: vec![
2472                Function {
2473                    name: "create_contact".into(),
2474                    params: vec![
2475                        Param {
2476                            name: "first_name".into(),
2477                            ty: TypeRef::StringUtf8,
2478                            mutable: false,
2479                            doc: None,
2480                        },
2481                        Param {
2482                            name: "email".into(),
2483                            ty: TypeRef::Optional(Box::new(TypeRef::StringUtf8)),
2484                            mutable: false,
2485                            doc: None,
2486                        },
2487                        Param {
2488                            name: "contact_type".into(),
2489                            ty: TypeRef::Enum("ContactType".into()),
2490                            mutable: false,
2491                            doc: None,
2492                        },
2493                    ],
2494                    returns: Some(TypeRef::Handle),
2495                    doc: None,
2496                    r#async: false,
2497                    cancellable: false,
2498                    deprecated: None,
2499                    since: None,
2500                },
2501                Function {
2502                    name: "get_contact".into(),
2503                    params: vec![Param {
2504                        name: "id".into(),
2505                        ty: TypeRef::Handle,
2506                        mutable: false,
2507                        doc: None,
2508                    }],
2509                    returns: Some(TypeRef::Struct("Contact".into())),
2510                    doc: None,
2511                    r#async: false,
2512                    cancellable: false,
2513                    deprecated: None,
2514                    since: None,
2515                },
2516                Function {
2517                    name: "list_contacts".into(),
2518                    params: vec![],
2519                    returns: Some(TypeRef::List(Box::new(TypeRef::Struct("Contact".into())))),
2520                    doc: None,
2521                    r#async: false,
2522                    cancellable: false,
2523                    deprecated: None,
2524                    since: None,
2525                },
2526                Function {
2527                    name: "count_contacts".into(),
2528                    params: vec![],
2529                    returns: Some(TypeRef::I32),
2530                    doc: None,
2531                    r#async: false,
2532                    cancellable: false,
2533                    deprecated: None,
2534                    since: None,
2535                },
2536            ],
2537            errors: None,
2538            modules: vec![],
2539        }]);
2540
2541        let tmp = std::env::temp_dir().join("weaveffi_test_python_gen_contacts");
2542        let _ = std::fs::remove_dir_all(&tmp);
2543        std::fs::create_dir_all(&tmp).unwrap();
2544        let out_dir = Utf8Path::from_path(&tmp).expect("valid UTF-8");
2545
2546        PythonGenerator
2547            .generate(
2548                &api,
2549                out_dir,
2550                &PythonConfig {
2551                    strip_module_prefix: true,
2552                    ..PythonConfig::default()
2553                },
2554            )
2555            .unwrap();
2556
2557        let py = std::fs::read_to_string(tmp.join("python/weaveffi/weaveffi.py")).unwrap();
2558
2559        assert!(py.contains("class ContactType(IntEnum):"));
2560        assert!(py.contains("Personal = 0"));
2561        assert!(py.contains("Work = 1"));
2562
2563        assert!(py.contains("class Contact:"));
2564        assert!(py.contains("weaveffi_contacts_Contact_destroy"));
2565        assert!(py.contains("def id(self) -> int:"));
2566        assert!(py.contains("weaveffi_contacts_Contact_get_id"));
2567        assert!(py.contains("def first_name(self) -> str:"));
2568        assert!(py.contains("def email(self) -> Optional[str]:"));
2569        assert!(py.contains("def contact_type(self) -> \"ContactType\":"));
2570
2571        assert!(py.contains("def create_contact("));
2572        assert!(py.contains("weaveffi_contacts_create_contact"));
2573        assert!(py.contains("def get_contact(id: int) -> \"Contact\":"));
2574        assert!(py.contains("def list_contacts() -> List[\"Contact\"]:"));
2575        assert!(py.contains("def count_contacts() -> int:"));
2576
2577        let _ = std::fs::remove_dir_all(&tmp);
2578    }
2579
2580    #[test]
2581    fn type_hint_mapping() {
2582        assert_eq!(py_type_hint(&TypeRef::I32), "int");
2583        assert_eq!(py_type_hint(&TypeRef::U32), "int");
2584        assert_eq!(py_type_hint(&TypeRef::I64), "int");
2585        assert_eq!(py_type_hint(&TypeRef::F64), "float");
2586        assert_eq!(py_type_hint(&TypeRef::Bool), "bool");
2587        assert_eq!(py_type_hint(&TypeRef::StringUtf8), "str");
2588        assert_eq!(py_type_hint(&TypeRef::Bytes), "bytes");
2589        assert_eq!(py_type_hint(&TypeRef::Handle), "int");
2590        assert_eq!(py_type_hint(&TypeRef::Struct("Foo".into())), "\"Foo\"");
2591        assert_eq!(py_type_hint(&TypeRef::Enum("Bar".into())), "\"Bar\"");
2592        assert_eq!(py_type_hint(&TypeRef::TypedHandle("Foo".into())), "\"Foo\"");
2593        // Cross-module references (resolved to a qualified IR name) must still
2594        // annotate the bare *local* class, which is the only symbol that exists
2595        // in the generated module.
2596        assert_eq!(
2597            py_type_hint(&TypeRef::TypedHandle("kv.Store".into())),
2598            "\"Store\"",
2599            "qualified typed handle must annotate the local class name"
2600        );
2601        assert_eq!(
2602            py_type_hint(&TypeRef::Struct("kv.Store".into())),
2603            "\"Store\""
2604        );
2605        assert_eq!(py_type_hint(&TypeRef::Enum("kv.Kind".into())), "\"Kind\"");
2606        assert_eq!(
2607            py_type_hint(&TypeRef::Optional(Box::new(TypeRef::I32))),
2608            "Optional[int]"
2609        );
2610        assert_eq!(
2611            py_type_hint(&TypeRef::List(Box::new(TypeRef::I32))),
2612            "List[int]"
2613        );
2614        assert_eq!(
2615            py_type_hint(&TypeRef::Map(
2616                Box::new(TypeRef::StringUtf8),
2617                Box::new(TypeRef::I32)
2618            )),
2619            "Dict[str, int]"
2620        );
2621    }
2622
2623    #[test]
2624    fn ctypes_scalar_mapping() {
2625        assert_eq!(py_ctypes_scalar(&TypeRef::I32), "ctypes.c_int32");
2626        assert_eq!(py_ctypes_scalar(&TypeRef::U32), "ctypes.c_uint32");
2627        assert_eq!(py_ctypes_scalar(&TypeRef::I64), "ctypes.c_int64");
2628        assert_eq!(py_ctypes_scalar(&TypeRef::F64), "ctypes.c_double");
2629        assert_eq!(py_ctypes_scalar(&TypeRef::Bool), "ctypes.c_int32");
2630        assert_eq!(py_ctypes_scalar(&TypeRef::StringUtf8), "ctypes.c_char_p");
2631        assert_eq!(py_ctypes_scalar(&TypeRef::Handle), "ctypes.c_uint64");
2632        assert_eq!(py_ctypes_scalar(&TypeRef::Bytes), "ctypes.c_uint8");
2633        assert_eq!(
2634            py_ctypes_scalar(&TypeRef::Struct("X".into())),
2635            "ctypes.c_void_p"
2636        );
2637        assert_eq!(
2638            py_ctypes_scalar(&TypeRef::Enum("X".into())),
2639            "ctypes.c_int32"
2640        );
2641    }
2642
2643    #[test]
2644    fn list_struct_return() {
2645        let api = make_api(vec![Module {
2646            name: "store".into(),
2647            functions: vec![Function {
2648                name: "list_items".into(),
2649                params: vec![],
2650                returns: Some(TypeRef::List(Box::new(TypeRef::Struct("Item".into())))),
2651                doc: None,
2652                r#async: false,
2653                cancellable: false,
2654                deprecated: None,
2655                since: None,
2656            }],
2657            structs: vec![],
2658            enums: vec![],
2659            callbacks: vec![],
2660            listeners: vec![],
2661            errors: None,
2662            modules: vec![],
2663        }]);
2664
2665        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
2666        assert!(
2667            py.contains("-> List[\"Item\"]:"),
2668            "missing list struct return: {py}"
2669        );
2670        assert!(
2671            py.contains("Item(_result[_i])"),
2672            "missing struct wrapping in list: {py}"
2673        );
2674    }
2675
2676    #[test]
2677    fn struct_bytes_field_getter() {
2678        let api = make_api(vec![Module {
2679            name: "storage".into(),
2680            functions: vec![],
2681            structs: vec![StructDef {
2682                name: "Blob".into(),
2683                doc: None,
2684                fields: vec![StructField {
2685                    name: "data".into(),
2686                    ty: TypeRef::Bytes,
2687                    doc: None,
2688                    default: None,
2689                }],
2690                builder: false,
2691            }],
2692            enums: vec![],
2693            callbacks: vec![],
2694            listeners: vec![],
2695            errors: None,
2696            modules: vec![],
2697        }]);
2698
2699        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
2700        assert!(
2701            py.contains("def data(self) -> bytes:"),
2702            "missing bytes getter: {py}"
2703        );
2704        assert!(
2705            py.contains("_out_len = ctypes.c_size_t(0)"),
2706            "missing out_len in bytes getter: {py}"
2707        );
2708        assert!(
2709            py.contains("_result[:_out_len.value]"),
2710            "missing bytes slice: {py}"
2711        );
2712    }
2713
2714    #[test]
2715    fn python_generates_type_stubs() {
2716        let api = make_api(vec![Module {
2717            name: "contacts".into(),
2718            enums: vec![EnumDef {
2719                name: "ContactType".into(),
2720                doc: None,
2721                variants: vec![
2722                    EnumVariant {
2723                        name: "Personal".into(),
2724                        value: 0,
2725                        doc: None,
2726                    },
2727                    EnumVariant {
2728                        name: "Work".into(),
2729                        value: 1,
2730                        doc: None,
2731                    },
2732                ],
2733            }],
2734            callbacks: vec![],
2735            listeners: vec![],
2736            structs: vec![StructDef {
2737                name: "Contact".into(),
2738                doc: None,
2739                fields: vec![
2740                    StructField {
2741                        name: "id".into(),
2742                        ty: TypeRef::I64,
2743                        doc: None,
2744                        default: None,
2745                    },
2746                    StructField {
2747                        name: "name".into(),
2748                        ty: TypeRef::StringUtf8,
2749                        doc: None,
2750                        default: None,
2751                    },
2752                    StructField {
2753                        name: "email".into(),
2754                        ty: TypeRef::Optional(Box::new(TypeRef::StringUtf8)),
2755                        doc: None,
2756                        default: None,
2757                    },
2758                    StructField {
2759                        name: "tags".into(),
2760                        ty: TypeRef::List(Box::new(TypeRef::StringUtf8)),
2761                        doc: None,
2762                        default: None,
2763                    },
2764                    StructField {
2765                        name: "metadata".into(),
2766                        ty: TypeRef::Map(Box::new(TypeRef::StringUtf8), Box::new(TypeRef::I32)),
2767                        doc: None,
2768                        default: None,
2769                    },
2770                ],
2771                builder: false,
2772            }],
2773            functions: vec![
2774                Function {
2775                    name: "create_contact".into(),
2776                    params: vec![
2777                        Param {
2778                            name: "name".into(),
2779                            ty: TypeRef::StringUtf8,
2780                            mutable: false,
2781                            doc: None,
2782                        },
2783                        Param {
2784                            name: "email".into(),
2785                            ty: TypeRef::Optional(Box::new(TypeRef::StringUtf8)),
2786                            mutable: false,
2787                            doc: None,
2788                        },
2789                    ],
2790                    returns: Some(TypeRef::Handle),
2791                    doc: None,
2792                    r#async: false,
2793                    cancellable: false,
2794                    deprecated: None,
2795                    since: None,
2796                },
2797                Function {
2798                    name: "get_contact".into(),
2799                    params: vec![Param {
2800                        name: "id".into(),
2801                        ty: TypeRef::Handle,
2802                        mutable: false,
2803                        doc: None,
2804                    }],
2805                    returns: Some(TypeRef::Struct("Contact".into())),
2806                    doc: None,
2807                    r#async: false,
2808                    cancellable: false,
2809                    deprecated: None,
2810                    since: None,
2811                },
2812                Function {
2813                    name: "delete_contact".into(),
2814                    params: vec![Param {
2815                        name: "id".into(),
2816                        ty: TypeRef::Handle,
2817                        mutable: false,
2818                        doc: None,
2819                    }],
2820                    returns: None,
2821                    doc: None,
2822                    r#async: false,
2823                    cancellable: false,
2824                    deprecated: None,
2825                    since: None,
2826                },
2827            ],
2828            errors: None,
2829            modules: vec![],
2830        }]);
2831
2832        let tmp = std::env::temp_dir().join("weaveffi_test_python_pyi");
2833        let _ = std::fs::remove_dir_all(&tmp);
2834        std::fs::create_dir_all(&tmp).unwrap();
2835        let out_dir = Utf8Path::from_path(&tmp).expect("valid UTF-8");
2836
2837        PythonGenerator
2838            .generate(
2839                &api,
2840                out_dir,
2841                &PythonConfig {
2842                    strip_module_prefix: true,
2843                    ..PythonConfig::default()
2844                },
2845            )
2846            .unwrap();
2847
2848        let pyi_path = tmp.join("python/weaveffi/weaveffi.pyi");
2849        assert!(pyi_path.exists(), ".pyi file must exist");
2850
2851        let pyi = std::fs::read_to_string(&pyi_path).unwrap();
2852
2853        assert!(
2854            pyi.contains("from enum import IntEnum"),
2855            "missing IntEnum import"
2856        );
2857        assert!(
2858            pyi.contains("from typing import Dict, Iterator, List, Optional"),
2859            "missing typing imports"
2860        );
2861
2862        assert!(
2863            pyi.contains("class ContactType(IntEnum):"),
2864            "missing enum stub"
2865        );
2866        assert!(
2867            pyi.contains("    Personal: int"),
2868            "missing enum variant Personal"
2869        );
2870        assert!(pyi.contains("    Work: int"), "missing enum variant Work");
2871
2872        assert!(pyi.contains("class Contact:"), "missing struct stub");
2873        assert!(
2874            pyi.contains("    def id(self) -> int: ..."),
2875            "missing id property: {pyi}"
2876        );
2877        assert!(
2878            pyi.contains("    def name(self) -> str: ..."),
2879            "missing name property: {pyi}"
2880        );
2881        assert!(
2882            pyi.contains("    def email(self) -> Optional[str]: ..."),
2883            "missing email property: {pyi}"
2884        );
2885        assert!(
2886            pyi.contains("    def tags(self) -> List[str]: ..."),
2887            "missing tags property: {pyi}"
2888        );
2889        assert!(
2890            pyi.contains("    def metadata(self) -> Dict[str, int]: ..."),
2891            "missing metadata property: {pyi}"
2892        );
2893
2894        assert!(
2895            pyi.contains("def create_contact(name: str, email: Optional[str]) -> int: ..."),
2896            "missing create_contact stub: {pyi}"
2897        );
2898        assert!(
2899            pyi.contains("def get_contact(id: int) -> \"Contact\": ..."),
2900            "missing get_contact stub: {pyi}"
2901        );
2902        assert!(
2903            pyi.contains("def delete_contact(id: int) -> None: ..."),
2904            "missing delete_contact stub: {pyi}"
2905        );
2906
2907        let _ = std::fs::remove_dir_all(&tmp);
2908    }
2909
2910    #[test]
2911    fn generate_python_basic() {
2912        let api = make_api(vec![simple_module(vec![Function {
2913            name: "add".into(),
2914            params: vec![
2915                Param {
2916                    name: "a".into(),
2917                    ty: TypeRef::I32,
2918                    mutable: false,
2919                    doc: None,
2920                },
2921                Param {
2922                    name: "b".into(),
2923                    ty: TypeRef::I32,
2924                    mutable: false,
2925                    doc: None,
2926                },
2927            ],
2928            returns: Some(TypeRef::I32),
2929            doc: None,
2930            r#async: false,
2931            cancellable: false,
2932            deprecated: None,
2933            since: None,
2934        }])]);
2935
2936        let tmp = std::env::temp_dir().join("weaveffi_test_py_basic");
2937        let _ = std::fs::remove_dir_all(&tmp);
2938        std::fs::create_dir_all(&tmp).unwrap();
2939        let out_dir = Utf8Path::from_path(&tmp).expect("valid UTF-8");
2940
2941        PythonGenerator
2942            .generate(
2943                &api,
2944                out_dir,
2945                &PythonConfig {
2946                    strip_module_prefix: true,
2947                    ..PythonConfig::default()
2948                },
2949            )
2950            .unwrap();
2951
2952        let py = std::fs::read_to_string(tmp.join("python/weaveffi/weaveffi.py")).unwrap();
2953
2954        assert!(py.contains("def add(a: int, b: int) -> int:"));
2955        assert!(py.contains("_fn = _lib.weaveffi_math_add"));
2956        assert!(py.contains("ctypes.c_int32, ctypes.c_int32"));
2957        assert!(py.contains("_fn.restype = ctypes.c_int32"));
2958        assert!(py.contains("_err = _WeaveFFIErrorStruct()"));
2959        assert!(py.contains("_check_error(_err)"));
2960        assert!(py.contains("return _result"));
2961
2962        assert!(py.contains("import ctypes"));
2963        assert!(py.contains("from enum import IntEnum"));
2964        assert!(py.contains("from typing import Dict, Iterator, List, Optional"));
2965        assert!(py.contains("class WeaveFFIError(Exception):"));
2966        assert!(py.contains("def _load_library()"));
2967        assert!(py.contains("_lib = _load_library()"));
2968
2969        let _ = std::fs::remove_dir_all(&tmp);
2970    }
2971
2972    #[test]
2973    fn generate_python_with_structs() {
2974        let api = make_api(vec![Module {
2975            name: "contacts".into(),
2976            functions: vec![],
2977            structs: vec![StructDef {
2978                name: "Contact".into(),
2979                doc: Some("A contact record".into()),
2980                fields: vec![
2981                    StructField {
2982                        name: "id".into(),
2983                        ty: TypeRef::I64,
2984                        doc: None,
2985                        default: None,
2986                    },
2987                    StructField {
2988                        name: "first_name".into(),
2989                        ty: TypeRef::StringUtf8,
2990                        doc: None,
2991                        default: None,
2992                    },
2993                    StructField {
2994                        name: "last_name".into(),
2995                        ty: TypeRef::StringUtf8,
2996                        doc: None,
2997                        default: None,
2998                    },
2999                    StructField {
3000                        name: "email".into(),
3001                        ty: TypeRef::Optional(Box::new(TypeRef::StringUtf8)),
3002                        doc: None,
3003                        default: None,
3004                    },
3005                ],
3006                builder: false,
3007            }],
3008            enums: vec![],
3009            callbacks: vec![],
3010            listeners: vec![],
3011            errors: None,
3012            modules: vec![],
3013        }]);
3014
3015        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
3016
3017        assert!(py.contains("class Contact:"), "missing class decl");
3018        assert!(
3019            py.contains("\"\"\"A contact record\"\"\""),
3020            "missing doc: {py}"
3021        );
3022        assert!(py.contains("def __init__(self, _ptr: int) -> None:"));
3023        assert!(py.contains("self._ptr = _ptr"));
3024        assert!(py.contains("def __del__(self) -> None:"));
3025        assert!(py.contains("weaveffi_contacts_Contact_destroy"));
3026
3027        assert!(py.contains("@property\n    def id(self) -> int:"));
3028        assert!(py.contains("weaveffi_contacts_Contact_get_id"));
3029        assert!(py.contains("_fn.restype = ctypes.c_int64"));
3030
3031        assert!(py.contains("@property\n    def first_name(self) -> str:"));
3032        assert!(py.contains("weaveffi_contacts_Contact_get_first_name"));
3033
3034        assert!(py.contains("@property\n    def last_name(self) -> str:"));
3035        assert!(py.contains("weaveffi_contacts_Contact_get_last_name"));
3036
3037        assert!(py.contains("@property\n    def email(self) -> Optional[str]:"));
3038        assert!(py.contains("weaveffi_contacts_Contact_get_email"));
3039    }
3040
3041    #[test]
3042    fn generate_python_with_enums() {
3043        let api = make_api(vec![Module {
3044            name: "contacts".into(),
3045            functions: vec![Function {
3046                name: "get_type".into(),
3047                params: vec![Param {
3048                    name: "ct".into(),
3049                    ty: TypeRef::Enum("ContactType".into()),
3050                    mutable: false,
3051                    doc: None,
3052                }],
3053                returns: Some(TypeRef::Enum("ContactType".into())),
3054                doc: None,
3055                r#async: false,
3056                cancellable: false,
3057                deprecated: None,
3058                since: None,
3059            }],
3060            structs: vec![],
3061            enums: vec![EnumDef {
3062                name: "ContactType".into(),
3063                doc: Some("Type of contact".into()),
3064                variants: vec![
3065                    EnumVariant {
3066                        name: "Personal".into(),
3067                        value: 0,
3068                        doc: None,
3069                    },
3070                    EnumVariant {
3071                        name: "Work".into(),
3072                        value: 1,
3073                        doc: None,
3074                    },
3075                    EnumVariant {
3076                        name: "Other".into(),
3077                        value: 2,
3078                        doc: None,
3079                    },
3080                ],
3081            }],
3082            callbacks: vec![],
3083            listeners: vec![],
3084            errors: None,
3085            modules: vec![],
3086        }]);
3087
3088        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
3089
3090        assert!(py.contains("class ContactType(IntEnum):"));
3091        assert!(py.contains("\"\"\"Type of contact\"\"\""));
3092        assert!(py.contains("Personal = 0"));
3093        assert!(py.contains("Work = 1"));
3094        assert!(py.contains("Other = 2"));
3095
3096        assert!(
3097            py.contains("ct: \"ContactType\""),
3098            "missing enum param hint"
3099        );
3100        assert!(
3101            py.contains("-> \"ContactType\":"),
3102            "missing enum return hint"
3103        );
3104        assert!(py.contains("ct.value"), "missing .value for enum param");
3105        assert!(
3106            py.contains("return ContactType(_result)"),
3107            "missing enum return wrap"
3108        );
3109        assert!(py.contains("ctypes.c_int32"), "enum should use c_int32 ABI");
3110    }
3111
3112    #[test]
3113    fn generate_python_with_optionals() {
3114        let api = make_api(vec![Module {
3115            name: "store".into(),
3116            functions: vec![
3117                Function {
3118                    name: "find_int".into(),
3119                    params: vec![Param {
3120                        name: "key".into(),
3121                        ty: TypeRef::Optional(Box::new(TypeRef::I32)),
3122                        mutable: false,
3123                        doc: None,
3124                    }],
3125                    returns: Some(TypeRef::Optional(Box::new(TypeRef::I32))),
3126                    doc: None,
3127                    r#async: false,
3128                    cancellable: false,
3129                    deprecated: None,
3130                    since: None,
3131                },
3132                Function {
3133                    name: "find_name".into(),
3134                    params: vec![Param {
3135                        name: "prefix".into(),
3136                        ty: TypeRef::Optional(Box::new(TypeRef::StringUtf8)),
3137                        mutable: false,
3138                        doc: None,
3139                    }],
3140                    returns: Some(TypeRef::Optional(Box::new(TypeRef::StringUtf8))),
3141                    doc: None,
3142                    r#async: false,
3143                    cancellable: false,
3144                    deprecated: None,
3145                    since: None,
3146                },
3147                Function {
3148                    name: "find_contact".into(),
3149                    params: vec![],
3150                    returns: Some(TypeRef::Optional(Box::new(TypeRef::Struct(
3151                        "Contact".into(),
3152                    )))),
3153                    doc: None,
3154                    r#async: false,
3155                    cancellable: false,
3156                    deprecated: None,
3157                    since: None,
3158                },
3159                Function {
3160                    name: "find_flag".into(),
3161                    params: vec![],
3162                    returns: Some(TypeRef::Optional(Box::new(TypeRef::Bool))),
3163                    doc: None,
3164                    r#async: false,
3165                    cancellable: false,
3166                    deprecated: None,
3167                    since: None,
3168                },
3169            ],
3170            structs: vec![],
3171            enums: vec![],
3172            callbacks: vec![],
3173            listeners: vec![],
3174            errors: None,
3175            modules: vec![],
3176        }]);
3177
3178        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
3179
3180        assert!(
3181            py.contains("key: Optional[int]"),
3182            "missing Optional[int] param"
3183        );
3184        assert!(
3185            py.contains("-> Optional[int]:"),
3186            "missing Optional[int] return"
3187        );
3188        assert!(
3189            py.contains("ctypes.byref(ctypes.c_int32(key)) if key is not None else None"),
3190            "missing optional i32 conversion"
3191        );
3192        assert!(
3193            py.contains("ctypes.POINTER(ctypes.c_int32)"),
3194            "missing POINTER for optional i32"
3195        );
3196
3197        assert!(
3198            py.contains("prefix: Optional[str]"),
3199            "missing Optional[str] param"
3200        );
3201        assert!(
3202            py.contains("-> Optional[str]:"),
3203            "missing Optional[str] return"
3204        );
3205        assert!(
3206            py.contains("_string_to_bytes(prefix)"),
3207            "missing optional _string_to_bytes"
3208        );
3209
3210        assert!(
3211            py.contains("-> Optional[\"Contact\"]:"),
3212            "missing Optional struct return"
3213        );
3214        assert!(
3215            py.contains("if _result is None:\n        return None\n    return Contact(_result)"),
3216            "missing optional struct None check"
3217        );
3218
3219        assert!(
3220            py.contains("-> Optional[bool]:"),
3221            "missing Optional[bool] return"
3222        );
3223        assert!(
3224            py.contains("return bool(_result[0])"),
3225            "missing optional bool deref"
3226        );
3227    }
3228
3229    #[test]
3230    fn generate_python_with_lists() {
3231        let api = make_api(vec![Module {
3232            name: "batch".into(),
3233            functions: vec![
3234                Function {
3235                    name: "process_ids".into(),
3236                    params: vec![Param {
3237                        name: "ids".into(),
3238                        ty: TypeRef::List(Box::new(TypeRef::I32)),
3239                        mutable: false,
3240                        doc: None,
3241                    }],
3242                    returns: None,
3243                    doc: None,
3244                    r#async: false,
3245                    cancellable: false,
3246                    deprecated: None,
3247                    since: None,
3248                },
3249                Function {
3250                    name: "get_names".into(),
3251                    params: vec![],
3252                    returns: Some(TypeRef::List(Box::new(TypeRef::StringUtf8))),
3253                    doc: None,
3254                    r#async: false,
3255                    cancellable: false,
3256                    deprecated: None,
3257                    since: None,
3258                },
3259                Function {
3260                    name: "get_items".into(),
3261                    params: vec![],
3262                    returns: Some(TypeRef::List(Box::new(TypeRef::Struct("Item".into())))),
3263                    doc: None,
3264                    r#async: false,
3265                    cancellable: false,
3266                    deprecated: None,
3267                    since: None,
3268                },
3269            ],
3270            structs: vec![],
3271            enums: vec![],
3272            callbacks: vec![],
3273            listeners: vec![],
3274            errors: None,
3275            modules: vec![],
3276        }]);
3277
3278        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
3279
3280        assert!(py.contains("ids: List[int]"), "missing List[int] param");
3281        assert!(
3282            py.contains("(ctypes.c_int32 * len(ids))(*ids)"),
3283            "missing list-to-array conversion"
3284        );
3285        assert!(
3286            py.contains("ctypes.POINTER(ctypes.c_int32)"),
3287            "missing POINTER for list param"
3288        );
3289        assert!(py.contains("ctypes.c_size_t"), "missing size_t for length");
3290
3291        assert!(
3292            py.contains("-> List[str]:"),
3293            "missing List[str] return: {py}"
3294        );
3295        assert!(
3296            py.contains("_bytes_to_string(_result[_i]) for _i in range(_out_len.value)"),
3297            "missing string list _bytes_to_string: {py}"
3298        );
3299
3300        assert!(
3301            py.contains("-> List[\"Item\"]:"),
3302            "missing List struct return"
3303        );
3304        assert!(
3305            py.contains("Item(_result[_i]) for _i in range(_out_len.value)"),
3306            "missing struct wrapping in list"
3307        );
3308    }
3309
3310    #[test]
3311    fn generate_python_with_maps() {
3312        let api = make_api(vec![Module {
3313            name: "config".into(),
3314            functions: vec![
3315                Function {
3316                    name: "set_config".into(),
3317                    params: vec![Param {
3318                        name: "settings".into(),
3319                        ty: TypeRef::Map(Box::new(TypeRef::StringUtf8), Box::new(TypeRef::I32)),
3320                        mutable: false,
3321                        doc: None,
3322                    }],
3323                    returns: None,
3324                    doc: None,
3325                    r#async: false,
3326                    cancellable: false,
3327                    deprecated: None,
3328                    since: None,
3329                },
3330                Function {
3331                    name: "get_config".into(),
3332                    params: vec![],
3333                    returns: Some(TypeRef::Map(
3334                        Box::new(TypeRef::StringUtf8),
3335                        Box::new(TypeRef::I32),
3336                    )),
3337                    doc: None,
3338                    r#async: false,
3339                    cancellable: false,
3340                    deprecated: None,
3341                    since: None,
3342                },
3343            ],
3344            structs: vec![],
3345            enums: vec![],
3346            callbacks: vec![],
3347            listeners: vec![],
3348            errors: None,
3349            modules: vec![],
3350        }]);
3351
3352        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
3353
3354        assert!(
3355            py.contains("settings: Dict[str, int]"),
3356            "missing Dict param hint"
3357        );
3358        assert!(
3359            py.contains("list(settings.keys())"),
3360            "missing keys extraction"
3361        );
3362        assert!(
3363            py.contains("_settings_vals = [settings[_k] for _k in _settings_keys]"),
3364            "missing values extraction"
3365        );
3366        assert!(
3367            py.contains("ctypes.c_char_p * len(_settings_keys)"),
3368            "missing key array creation"
3369        );
3370        assert!(
3371            py.contains("ctypes.c_int32 * len(_settings_vals)"),
3372            "missing value array creation"
3373        );
3374
3375        assert!(
3376            py.contains("-> Dict[str, int]:"),
3377            "missing Dict return hint"
3378        );
3379        assert!(
3380            py.contains("_out_keys = ctypes.POINTER(ctypes.c_char_p)()"),
3381            "missing out_keys init"
3382        );
3383        assert!(
3384            py.contains("_out_values = ctypes.POINTER(ctypes.c_int32)()"),
3385            "missing out_values init"
3386        );
3387        assert!(
3388            py.contains("_out_len = ctypes.c_size_t(0)"),
3389            "missing out_len init"
3390        );
3391        assert!(
3392            py.contains("if not _out_keys or not _out_values:"),
3393            "missing empty map check"
3394        );
3395        assert!(
3396            py.contains("_bytes_to_string(_out_keys[_i]): _out_values[_i]"),
3397            "missing map comprehension"
3398        );
3399    }
3400
3401    #[test]
3402    fn generate_python_pyi_types() {
3403        let api = make_api(vec![Module {
3404            name: "contacts".into(),
3405            enums: vec![EnumDef {
3406                name: "ContactType".into(),
3407                doc: None,
3408                variants: vec![
3409                    EnumVariant {
3410                        name: "Personal".into(),
3411                        value: 0,
3412                        doc: None,
3413                    },
3414                    EnumVariant {
3415                        name: "Work".into(),
3416                        value: 1,
3417                        doc: None,
3418                    },
3419                    EnumVariant {
3420                        name: "Other".into(),
3421                        value: 2,
3422                        doc: None,
3423                    },
3424                ],
3425            }],
3426            callbacks: vec![],
3427            listeners: vec![],
3428            structs: vec![StructDef {
3429                name: "Contact".into(),
3430                doc: None,
3431                fields: vec![
3432                    StructField {
3433                        name: "id".into(),
3434                        ty: TypeRef::I64,
3435                        doc: None,
3436                        default: None,
3437                    },
3438                    StructField {
3439                        name: "first_name".into(),
3440                        ty: TypeRef::StringUtf8,
3441                        doc: None,
3442                        default: None,
3443                    },
3444                    StructField {
3445                        name: "email".into(),
3446                        ty: TypeRef::Optional(Box::new(TypeRef::StringUtf8)),
3447                        doc: None,
3448                        default: None,
3449                    },
3450                    StructField {
3451                        name: "tags".into(),
3452                        ty: TypeRef::List(Box::new(TypeRef::StringUtf8)),
3453                        doc: None,
3454                        default: None,
3455                    },
3456                    StructField {
3457                        name: "scores".into(),
3458                        ty: TypeRef::Map(Box::new(TypeRef::StringUtf8), Box::new(TypeRef::I32)),
3459                        doc: None,
3460                        default: None,
3461                    },
3462                ],
3463                builder: false,
3464            }],
3465            functions: vec![
3466                Function {
3467                    name: "create_contact".into(),
3468                    params: vec![
3469                        Param {
3470                            name: "name".into(),
3471                            ty: TypeRef::StringUtf8,
3472                            mutable: false,
3473                            doc: None,
3474                        },
3475                        Param {
3476                            name: "email".into(),
3477                            ty: TypeRef::Optional(Box::new(TypeRef::StringUtf8)),
3478                            mutable: false,
3479                            doc: None,
3480                        },
3481                    ],
3482                    returns: Some(TypeRef::Handle),
3483                    doc: None,
3484                    r#async: false,
3485                    cancellable: false,
3486                    deprecated: None,
3487                    since: None,
3488                },
3489                Function {
3490                    name: "get_contact".into(),
3491                    params: vec![Param {
3492                        name: "id".into(),
3493                        ty: TypeRef::Handle,
3494                        mutable: false,
3495                        doc: None,
3496                    }],
3497                    returns: Some(TypeRef::Struct("Contact".into())),
3498                    doc: None,
3499                    r#async: false,
3500                    cancellable: false,
3501                    deprecated: None,
3502                    since: None,
3503                },
3504                Function {
3505                    name: "list_contacts".into(),
3506                    params: vec![],
3507                    returns: Some(TypeRef::List(Box::new(TypeRef::Struct("Contact".into())))),
3508                    doc: None,
3509                    r#async: false,
3510                    cancellable: false,
3511                    deprecated: None,
3512                    since: None,
3513                },
3514                Function {
3515                    name: "delete_contact".into(),
3516                    params: vec![Param {
3517                        name: "id".into(),
3518                        ty: TypeRef::Handle,
3519                        mutable: false,
3520                        doc: None,
3521                    }],
3522                    returns: None,
3523                    doc: None,
3524                    r#async: false,
3525                    cancellable: false,
3526                    deprecated: None,
3527                    since: None,
3528                },
3529            ],
3530            errors: None,
3531            modules: vec![],
3532        }]);
3533
3534        let pyi = render_pyi_module(&api, true, "weaveffi.yml");
3535
3536        assert!(pyi.contains("from enum import IntEnum"));
3537        assert!(pyi.contains("from typing import Dict, Iterator, List, Optional"));
3538
3539        assert!(pyi.contains("class ContactType(IntEnum):"));
3540        assert!(pyi.contains("    Personal: int"));
3541        assert!(pyi.contains("    Work: int"));
3542        assert!(pyi.contains("    Other: int"));
3543
3544        assert!(pyi.contains("class Contact:"));
3545        assert!(pyi.contains("    def id(self) -> int: ..."));
3546        assert!(pyi.contains("    def first_name(self) -> str: ..."));
3547        assert!(pyi.contains("    def email(self) -> Optional[str]: ..."));
3548        assert!(pyi.contains("    def tags(self) -> List[str]: ..."));
3549        assert!(pyi.contains("    def scores(self) -> Dict[str, int]: ..."));
3550
3551        assert!(pyi.contains("def create_contact(name: str, email: Optional[str]) -> int: ..."));
3552        assert!(pyi.contains("def get_contact(id: int) -> \"Contact\": ..."));
3553        assert!(pyi.contains("def list_contacts() -> List[\"Contact\"]: ..."));
3554        assert!(pyi.contains("def delete_contact(id: int) -> None: ..."));
3555    }
3556
3557    #[test]
3558    fn generate_python_full_contacts() {
3559        let api = make_api(vec![Module {
3560            name: "contacts".into(),
3561            enums: vec![EnumDef {
3562                name: "ContactType".into(),
3563                doc: None,
3564                variants: vec![
3565                    EnumVariant {
3566                        name: "Personal".into(),
3567                        value: 0,
3568                        doc: None,
3569                    },
3570                    EnumVariant {
3571                        name: "Work".into(),
3572                        value: 1,
3573                        doc: None,
3574                    },
3575                    EnumVariant {
3576                        name: "Other".into(),
3577                        value: 2,
3578                        doc: None,
3579                    },
3580                ],
3581            }],
3582            callbacks: vec![],
3583            listeners: vec![],
3584            structs: vec![StructDef {
3585                name: "Contact".into(),
3586                doc: None,
3587                fields: vec![
3588                    StructField {
3589                        name: "id".into(),
3590                        ty: TypeRef::I64,
3591                        doc: None,
3592                        default: None,
3593                    },
3594                    StructField {
3595                        name: "first_name".into(),
3596                        ty: TypeRef::StringUtf8,
3597                        doc: None,
3598                        default: None,
3599                    },
3600                    StructField {
3601                        name: "last_name".into(),
3602                        ty: TypeRef::StringUtf8,
3603                        doc: None,
3604                        default: None,
3605                    },
3606                    StructField {
3607                        name: "email".into(),
3608                        ty: TypeRef::Optional(Box::new(TypeRef::StringUtf8)),
3609                        doc: None,
3610                        default: None,
3611                    },
3612                    StructField {
3613                        name: "contact_type".into(),
3614                        ty: TypeRef::Enum("ContactType".into()),
3615                        doc: None,
3616                        default: None,
3617                    },
3618                ],
3619                builder: false,
3620            }],
3621            functions: vec![
3622                Function {
3623                    name: "create_contact".into(),
3624                    params: vec![
3625                        Param {
3626                            name: "first_name".into(),
3627                            ty: TypeRef::StringUtf8,
3628                            mutable: false,
3629                            doc: None,
3630                        },
3631                        Param {
3632                            name: "last_name".into(),
3633                            ty: TypeRef::StringUtf8,
3634                            mutable: false,
3635                            doc: None,
3636                        },
3637                        Param {
3638                            name: "email".into(),
3639                            ty: TypeRef::Optional(Box::new(TypeRef::StringUtf8)),
3640                            mutable: false,
3641                            doc: None,
3642                        },
3643                        Param {
3644                            name: "contact_type".into(),
3645                            ty: TypeRef::Enum("ContactType".into()),
3646                            mutable: false,
3647                            doc: None,
3648                        },
3649                    ],
3650                    returns: Some(TypeRef::Handle),
3651                    doc: None,
3652                    r#async: false,
3653                    cancellable: false,
3654                    deprecated: None,
3655                    since: None,
3656                },
3657                Function {
3658                    name: "get_contact".into(),
3659                    params: vec![Param {
3660                        name: "id".into(),
3661                        ty: TypeRef::Handle,
3662                        mutable: false,
3663                        doc: None,
3664                    }],
3665                    returns: Some(TypeRef::Struct("Contact".into())),
3666                    doc: None,
3667                    r#async: false,
3668                    cancellable: false,
3669                    deprecated: None,
3670                    since: None,
3671                },
3672                Function {
3673                    name: "list_contacts".into(),
3674                    params: vec![],
3675                    returns: Some(TypeRef::List(Box::new(TypeRef::Struct("Contact".into())))),
3676                    doc: None,
3677                    r#async: false,
3678                    cancellable: false,
3679                    deprecated: None,
3680                    since: None,
3681                },
3682                Function {
3683                    name: "delete_contact".into(),
3684                    params: vec![Param {
3685                        name: "id".into(),
3686                        ty: TypeRef::Handle,
3687                        mutable: false,
3688                        doc: None,
3689                    }],
3690                    returns: Some(TypeRef::Bool),
3691                    doc: None,
3692                    r#async: false,
3693                    cancellable: false,
3694                    deprecated: None,
3695                    since: None,
3696                },
3697                Function {
3698                    name: "count_contacts".into(),
3699                    params: vec![],
3700                    returns: Some(TypeRef::I32),
3701                    doc: None,
3702                    r#async: false,
3703                    cancellable: false,
3704                    deprecated: None,
3705                    since: None,
3706                },
3707            ],
3708            errors: None,
3709            modules: vec![],
3710        }]);
3711
3712        let tmp = std::env::temp_dir().join("weaveffi_test_py_full_contacts");
3713        let _ = std::fs::remove_dir_all(&tmp);
3714        std::fs::create_dir_all(&tmp).unwrap();
3715        let out_dir = Utf8Path::from_path(&tmp).expect("valid UTF-8");
3716
3717        PythonGenerator
3718            .generate(
3719                &api,
3720                out_dir,
3721                &PythonConfig {
3722                    strip_module_prefix: true,
3723                    ..PythonConfig::default()
3724                },
3725            )
3726            .unwrap();
3727
3728        let py = std::fs::read_to_string(tmp.join("python/weaveffi/weaveffi.py")).unwrap();
3729        let pyi = std::fs::read_to_string(tmp.join("python/weaveffi/weaveffi.pyi")).unwrap();
3730
3731        assert!(py.contains("class ContactType(IntEnum):"));
3732        assert!(py.contains("Personal = 0"));
3733        assert!(py.contains("Work = 1"));
3734        assert!(py.contains("Other = 2"));
3735
3736        assert!(py.contains("class Contact:"));
3737        assert!(py.contains("weaveffi_contacts_Contact_destroy"));
3738        assert!(py.contains("@property\n    def id(self) -> int:"));
3739        assert!(py.contains("weaveffi_contacts_Contact_get_id"));
3740        assert!(py.contains("@property\n    def first_name(self) -> str:"));
3741        assert!(py.contains("weaveffi_contacts_Contact_get_first_name"));
3742        assert!(py.contains("@property\n    def last_name(self) -> str:"));
3743        assert!(py.contains("weaveffi_contacts_Contact_get_last_name"));
3744        assert!(py.contains("@property\n    def email(self) -> Optional[str]:"));
3745        assert!(py.contains("weaveffi_contacts_Contact_get_email"));
3746        assert!(py.contains("@property\n    def contact_type(self) -> \"ContactType\":"));
3747        assert!(py.contains("weaveffi_contacts_Contact_get_contact_type"));
3748        assert!(py.contains("return ContactType(_result)"));
3749
3750        assert!(py.contains("def create_contact("));
3751        assert!(py.contains("first_name: str"));
3752        assert!(py.contains("last_name: str"));
3753        assert!(py.contains("email: Optional[str]"));
3754        assert!(py.contains("contact_type: \"ContactType\""));
3755        assert!(py.contains("-> int:"));
3756        assert!(py.contains("weaveffi_contacts_create_contact"));
3757        assert!(py.contains("_string_to_bytes(first_name)"));
3758        assert!(py.contains("contact_type.value"));
3759
3760        assert!(py.contains("def get_contact(id: int) -> \"Contact\":"));
3761        assert!(py.contains("weaveffi_contacts_get_contact"));
3762        assert!(py.contains("return Contact(_result)"));
3763
3764        assert!(py.contains("def list_contacts() -> List[\"Contact\"]:"));
3765        assert!(py.contains("weaveffi_contacts_list_contacts"));
3766        assert!(py.contains("Contact(_result[_i]) for _i in range(_out_len.value)"));
3767
3768        assert!(py.contains("def delete_contact(id: int) -> bool:"));
3769        assert!(py.contains("weaveffi_contacts_delete_contact"));
3770        assert!(py.contains("return bool(_result)"));
3771
3772        assert!(py.contains("def count_contacts() -> int:"));
3773        assert!(py.contains("weaveffi_contacts_count_contacts"));
3774
3775        assert!(pyi.contains("class ContactType(IntEnum):"));
3776        assert!(pyi.contains("    Personal: int"));
3777        assert!(pyi.contains("    Work: int"));
3778        assert!(pyi.contains("    Other: int"));
3779        assert!(pyi.contains("class Contact:"));
3780        assert!(pyi.contains("def create_contact("));
3781        assert!(pyi.contains("def get_contact(id: int) -> \"Contact\": ..."));
3782        assert!(pyi.contains("def list_contacts() -> List[\"Contact\"]: ..."));
3783        assert!(pyi.contains("def delete_contact(id: int) -> bool: ..."));
3784        assert!(pyi.contains("def count_contacts() -> int: ..."));
3785
3786        let _ = std::fs::remove_dir_all(&tmp);
3787    }
3788
3789    #[test]
3790    fn python_generates_packaging() {
3791        let api = make_api(vec![simple_module(vec![Function {
3792            name: "add".into(),
3793            params: vec![
3794                Param {
3795                    name: "a".into(),
3796                    ty: TypeRef::I32,
3797                    mutable: false,
3798                    doc: None,
3799                },
3800                Param {
3801                    name: "b".into(),
3802                    ty: TypeRef::I32,
3803                    mutable: false,
3804                    doc: None,
3805                },
3806            ],
3807            returns: Some(TypeRef::I32),
3808            doc: None,
3809            r#async: false,
3810            cancellable: false,
3811            deprecated: None,
3812            since: None,
3813        }])]);
3814
3815        let tmp = std::env::temp_dir().join("weaveffi_test_python_packaging");
3816        let _ = std::fs::remove_dir_all(&tmp);
3817        std::fs::create_dir_all(&tmp).unwrap();
3818        let out_dir = Utf8Path::from_path(&tmp).expect("valid UTF-8");
3819
3820        PythonGenerator
3821            .generate(&api, out_dir, &PythonConfig::default())
3822            .unwrap();
3823
3824        let pyproject = std::fs::read_to_string(tmp.join("python/pyproject.toml")).unwrap();
3825        assert!(
3826            pyproject.contains("[build-system]"),
3827            "missing build-system: {pyproject}"
3828        );
3829        assert!(
3830            pyproject.contains("setuptools"),
3831            "missing setuptools: {pyproject}"
3832        );
3833        assert!(
3834            pyproject.contains("[project]"),
3835            "missing project section: {pyproject}"
3836        );
3837        assert!(
3838            pyproject.contains("name = \"weaveffi\""),
3839            "missing project name: {pyproject}"
3840        );
3841        assert!(
3842            pyproject.contains("version = \"0.1.0\""),
3843            "missing version: {pyproject}"
3844        );
3845        assert!(
3846            pyproject.contains("[tool.setuptools]"),
3847            "missing tool.setuptools: {pyproject}"
3848        );
3849        assert!(
3850            pyproject.contains("packages = [\"weaveffi\"]"),
3851            "missing packages list: {pyproject}"
3852        );
3853
3854        let setup = std::fs::read_to_string(tmp.join("python/setup.py")).unwrap();
3855        assert!(
3856            setup.contains("from setuptools import setup"),
3857            "missing setuptools import: {setup}"
3858        );
3859        assert!(
3860            setup.contains("name=\"weaveffi\""),
3861            "missing package name: {setup}"
3862        );
3863
3864        let readme = std::fs::read_to_string(tmp.join("python/README.md")).unwrap();
3865        assert!(
3866            readme.contains("pip install"),
3867            "missing install instructions: {readme}"
3868        );
3869
3870        let _ = std::fs::remove_dir_all(&tmp);
3871    }
3872
3873    #[test]
3874    fn python_has_memory_helpers() {
3875        let api = make_api(vec![]);
3876        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
3877        assert!(
3878            py.contains("import contextlib"),
3879            "missing contextlib import"
3880        );
3881        assert!(
3882            py.contains("class _PointerGuard(contextlib.AbstractContextManager):"),
3883            "missing _PointerGuard class"
3884        );
3885        assert!(
3886            py.contains("def __exit__(self, *exc)"),
3887            "missing _PointerGuard.__exit__"
3888        );
3889        assert!(
3890            py.contains("def _string_to_bytes("),
3891            "missing _string_to_bytes helper"
3892        );
3893        assert!(
3894            py.contains("def _bytes_to_string("),
3895            "missing _bytes_to_string helper"
3896        );
3897    }
3898
3899    #[test]
3900    fn python_custom_package_name() {
3901        let api = make_api(vec![simple_module(vec![Function {
3902            name: "add".into(),
3903            params: vec![
3904                Param {
3905                    name: "a".into(),
3906                    ty: TypeRef::I32,
3907                    mutable: false,
3908                    doc: None,
3909                },
3910                Param {
3911                    name: "b".into(),
3912                    ty: TypeRef::I32,
3913                    mutable: false,
3914                    doc: None,
3915                },
3916            ],
3917            returns: Some(TypeRef::I32),
3918            doc: None,
3919            r#async: false,
3920            cancellable: false,
3921            deprecated: None,
3922            since: None,
3923        }])]);
3924
3925        let config = PythonConfig {
3926            package_name: Some("my_bindings".into()),
3927            ..PythonConfig::default()
3928        };
3929
3930        let tmp = std::env::temp_dir().join("weaveffi_test_py_custom_pkg");
3931        let _ = std::fs::remove_dir_all(&tmp);
3932        std::fs::create_dir_all(&tmp).unwrap();
3933        let out_dir = Utf8Path::from_path(&tmp).expect("valid UTF-8");
3934
3935        PythonGenerator.generate(&api, out_dir, &config).unwrap();
3936
3937        assert!(
3938            tmp.join("python/my_bindings/__init__.py").exists(),
3939            "package dir should use custom name"
3940        );
3941        assert!(
3942            tmp.join("python/my_bindings/weaveffi.py").exists(),
3943            "module file should be inside custom package dir"
3944        );
3945
3946        let pyproject = std::fs::read_to_string(tmp.join("python/pyproject.toml")).unwrap();
3947        assert!(
3948            pyproject.contains("name = \"my_bindings\""),
3949            "pyproject.toml should use custom name: {pyproject}"
3950        );
3951        assert!(
3952            pyproject.contains("packages = [\"my_bindings\"]"),
3953            "pyproject.toml packages should use custom name: {pyproject}"
3954        );
3955
3956        let setup = std::fs::read_to_string(tmp.join("python/setup.py")).unwrap();
3957        assert!(
3958            setup.contains("name=\"my_bindings\""),
3959            "setup.py should use custom name: {setup}"
3960        );
3961
3962        let _ = std::fs::remove_dir_all(&tmp);
3963    }
3964
3965    #[test]
3966    fn python_strip_module_prefix() {
3967        let api = make_api(vec![Module {
3968            name: "contacts".into(),
3969            functions: vec![Function {
3970                name: "create_contact".into(),
3971                params: vec![Param {
3972                    name: "name".into(),
3973                    ty: TypeRef::StringUtf8,
3974                    mutable: false,
3975                    doc: None,
3976                }],
3977                returns: Some(TypeRef::I32),
3978                doc: None,
3979                r#async: false,
3980                cancellable: false,
3981                deprecated: None,
3982                since: None,
3983            }],
3984            structs: vec![],
3985            enums: vec![],
3986            callbacks: vec![],
3987            listeners: vec![],
3988            errors: None,
3989            modules: vec![],
3990        }]);
3991
3992        let config = PythonConfig {
3993            strip_module_prefix: true,
3994            ..PythonConfig::default()
3995        };
3996
3997        let tmp = std::env::temp_dir().join("weaveffi_test_python_strip_prefix");
3998        let _ = std::fs::remove_dir_all(&tmp);
3999        std::fs::create_dir_all(&tmp).unwrap();
4000        let out_dir = Utf8Path::from_path(&tmp).expect("valid UTF-8");
4001
4002        PythonGenerator.generate(&api, out_dir, &config).unwrap();
4003
4004        let py = std::fs::read_to_string(tmp.join("python/weaveffi/weaveffi.py")).unwrap();
4005        assert!(
4006            py.contains("def create_contact("),
4007            "stripped name should be create_contact: {py}"
4008        );
4009        assert!(
4010            !py.contains("def contacts_create_contact("),
4011            "should not contain module-prefixed name: {py}"
4012        );
4013        assert!(
4014            py.contains("weaveffi_contacts_create_contact"),
4015            "C ABI call should still use full name: {py}"
4016        );
4017
4018        let pyi = std::fs::read_to_string(tmp.join("python/weaveffi/weaveffi.pyi")).unwrap();
4019        assert!(
4020            pyi.contains("def create_contact("),
4021            "pyi stripped name should be create_contact: {pyi}"
4022        );
4023
4024        let no_strip = PythonConfig::default();
4025        let tmp2 = std::env::temp_dir().join("weaveffi_test_python_no_strip_prefix");
4026        let _ = std::fs::remove_dir_all(&tmp2);
4027        std::fs::create_dir_all(&tmp2).unwrap();
4028        let out_dir2 = Utf8Path::from_path(&tmp2).expect("valid UTF-8");
4029
4030        PythonGenerator.generate(&api, out_dir2, &no_strip).unwrap();
4031
4032        let py2 = std::fs::read_to_string(tmp2.join("python/weaveffi/weaveffi.py")).unwrap();
4033        assert!(
4034            py2.contains("def contacts_create_contact("),
4035            "default should use module-prefixed name: {py2}"
4036        );
4037
4038        let pyi2 = std::fs::read_to_string(tmp2.join("python/weaveffi/weaveffi.pyi")).unwrap();
4039        assert!(
4040            pyi2.contains("def contacts_create_contact("),
4041            "pyi default should use module-prefixed name: {pyi2}"
4042        );
4043
4044        let _ = std::fs::remove_dir_all(&tmp);
4045        let _ = std::fs::remove_dir_all(&tmp2);
4046    }
4047
4048    #[test]
4049    fn python_deeply_nested_optional() {
4050        let api = make_api(vec![Module {
4051            name: "edge".into(),
4052            functions: vec![Function {
4053                name: "process".into(),
4054                params: vec![Param {
4055                    name: "data".into(),
4056                    ty: TypeRef::Optional(Box::new(TypeRef::List(Box::new(TypeRef::Optional(
4057                        Box::new(TypeRef::Struct("Contact".into())),
4058                    ))))),
4059                    mutable: false,
4060                    doc: None,
4061                }],
4062                returns: None,
4063                doc: None,
4064                r#async: false,
4065                cancellable: false,
4066                deprecated: None,
4067                since: None,
4068            }],
4069            structs: vec![StructDef {
4070                name: "Contact".into(),
4071                doc: None,
4072                fields: vec![StructField {
4073                    name: "name".into(),
4074                    ty: TypeRef::StringUtf8,
4075                    doc: None,
4076                    default: None,
4077                }],
4078                builder: false,
4079            }],
4080            enums: vec![],
4081            callbacks: vec![],
4082            listeners: vec![],
4083            errors: None,
4084            modules: vec![],
4085        }]);
4086        let pyi = render_pyi_module(&api, true, "weaveffi.yml");
4087        assert!(
4088            pyi.contains("Optional[List[Optional["),
4089            "should contain deeply nested optional type: {pyi}"
4090        );
4091    }
4092
4093    #[test]
4094    fn python_map_of_lists() {
4095        let api = make_api(vec![Module {
4096            name: "edge".into(),
4097            functions: vec![Function {
4098                name: "process".into(),
4099                params: vec![Param {
4100                    name: "scores".into(),
4101                    ty: TypeRef::Map(
4102                        Box::new(TypeRef::StringUtf8),
4103                        Box::new(TypeRef::List(Box::new(TypeRef::I32))),
4104                    ),
4105                    mutable: false,
4106                    doc: None,
4107                }],
4108                returns: None,
4109                doc: None,
4110                r#async: false,
4111                cancellable: false,
4112                deprecated: None,
4113                since: None,
4114            }],
4115            structs: vec![],
4116            enums: vec![],
4117            callbacks: vec![],
4118            listeners: vec![],
4119            errors: None,
4120            modules: vec![],
4121        }]);
4122        let pyi = render_pyi_module(&api, true, "weaveffi.yml");
4123        assert!(
4124            pyi.contains("Dict[str, List[int]]"),
4125            "should contain map of lists type: {pyi}"
4126        );
4127    }
4128
4129    #[test]
4130    fn python_enum_keyed_map() {
4131        let api = make_api(vec![Module {
4132            name: "edge".into(),
4133            functions: vec![Function {
4134                name: "process".into(),
4135                params: vec![Param {
4136                    name: "contacts".into(),
4137                    ty: TypeRef::Map(
4138                        Box::new(TypeRef::Enum("Color".into())),
4139                        Box::new(TypeRef::Struct("Contact".into())),
4140                    ),
4141                    mutable: false,
4142                    doc: None,
4143                }],
4144                returns: None,
4145                doc: None,
4146                r#async: false,
4147                cancellable: false,
4148                deprecated: None,
4149                since: None,
4150            }],
4151            structs: vec![StructDef {
4152                name: "Contact".into(),
4153                doc: None,
4154                fields: vec![StructField {
4155                    name: "name".into(),
4156                    ty: TypeRef::StringUtf8,
4157                    doc: None,
4158                    default: None,
4159                }],
4160                builder: false,
4161            }],
4162            enums: vec![EnumDef {
4163                name: "Color".into(),
4164                doc: None,
4165                variants: vec![
4166                    EnumVariant {
4167                        name: "Red".into(),
4168                        value: 0,
4169                        doc: None,
4170                    },
4171                    EnumVariant {
4172                        name: "Green".into(),
4173                        value: 1,
4174                        doc: None,
4175                    },
4176                    EnumVariant {
4177                        name: "Blue".into(),
4178                        value: 2,
4179                        doc: None,
4180                    },
4181                ],
4182            }],
4183            callbacks: vec![],
4184            listeners: vec![],
4185            errors: None,
4186            modules: vec![],
4187        }]);
4188        let pyi = render_pyi_module(&api, true, "weaveffi.yml");
4189        assert!(
4190            pyi.contains("Dict[\"Color\", \"Contact\"]"),
4191            "should contain enum-keyed map type: {pyi}"
4192        );
4193    }
4194
4195    #[test]
4196    fn python_typed_handle_type() {
4197        let api = Api {
4198            version: "0.1.0".into(),
4199            modules: vec![Module {
4200                name: "contacts".into(),
4201                functions: vec![Function {
4202                    name: "get_info".into(),
4203                    params: vec![Param {
4204                        name: "contact".into(),
4205                        ty: TypeRef::TypedHandle("Contact".into()),
4206                        mutable: false,
4207                        doc: None,
4208                    }],
4209                    returns: None,
4210                    doc: None,
4211                    r#async: false,
4212                    cancellable: false,
4213                    deprecated: None,
4214                    since: None,
4215                }],
4216                structs: vec![StructDef {
4217                    name: "Contact".into(),
4218                    doc: None,
4219                    fields: vec![StructField {
4220                        name: "name".into(),
4221                        ty: TypeRef::StringUtf8,
4222                        doc: None,
4223                        default: None,
4224                    }],
4225                    builder: false,
4226                }],
4227                enums: vec![],
4228                callbacks: vec![],
4229                listeners: vec![],
4230                errors: None,
4231                modules: vec![],
4232            }],
4233            generators: None,
4234            package: None,
4235        };
4236        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
4237        assert!(
4238            py.contains("contact: \"Contact\""),
4239            "TypedHandle should use class type not int: {py}"
4240        );
4241        assert!(
4242            py.contains("contact._ptr"),
4243            "TypedHandle call arg should extract ._ptr: {py}"
4244        );
4245        assert!(
4246            py.contains("ctypes.c_void_p"),
4247            "TypedHandle ctypes type should be c_void_p: {py}"
4248        );
4249    }
4250
4251    #[test]
4252    fn python_no_double_free_on_error() {
4253        let api = make_api(vec![Module {
4254            name: "contacts".into(),
4255            functions: vec![Function {
4256                name: "find_contact".into(),
4257                params: vec![Param {
4258                    name: "name".into(),
4259                    ty: TypeRef::StringUtf8,
4260                    mutable: false,
4261                    doc: None,
4262                }],
4263                returns: Some(TypeRef::Struct("Contact".into())),
4264                doc: None,
4265                r#async: false,
4266                cancellable: false,
4267                deprecated: None,
4268                since: None,
4269            }],
4270            structs: vec![StructDef {
4271                name: "Contact".into(),
4272                doc: None,
4273                fields: vec![StructField {
4274                    name: "name".into(),
4275                    ty: TypeRef::StringUtf8,
4276                    doc: None,
4277                    default: None,
4278                }],
4279                builder: false,
4280            }],
4281            enums: vec![],
4282            callbacks: vec![],
4283            listeners: vec![],
4284            errors: None,
4285            modules: vec![],
4286        }]);
4287
4288        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
4289
4290        assert!(
4291            py.contains("_string_to_bytes(name)"),
4292            "string param should use _string_to_bytes(name): {py}"
4293        );
4294        assert!(
4295            !py.contains("weaveffi_free_string(name"),
4296            "input string param must not be freed with weaveffi_free_string(name): {py}"
4297        );
4298        assert!(
4299            !py.contains("free(name"),
4300            "input string param must not be passed to free(name: {py}"
4301        );
4302
4303        let fn_sig = "def find_contact(name: str) -> \"Contact\":";
4304        let start = py
4305            .find(fn_sig)
4306            .unwrap_or_else(|| panic!("missing find_contact signature: {py}"));
4307        let rest = &py[start..];
4308        let end_offset = rest[1..]
4309            .find("\n\ndef ")
4310            .or_else(|| rest[1..].find("\n\nclass "))
4311            .map(|i| i + 1)
4312            .unwrap_or(rest.len());
4313        let body = &rest[..end_offset];
4314        let err_pos = body
4315            .find("_check_error(_err)")
4316            .expect("_check_error should appear in find_contact");
4317        let contact_pos = body
4318            .find("return Contact(_result)")
4319            .expect("return Contact(_result) should appear in find_contact");
4320        assert!(
4321            err_pos < contact_pos,
4322            "_check_error(_err) should precede return Contact(_result): {body}"
4323        );
4324
4325        let class_start = py
4326            .find("class Contact:")
4327            .expect("Contact class should be defined");
4328        let after_class = &py[class_start..];
4329        let class_end = after_class[1..]
4330            .find("\n\nclass ")
4331            .or_else(|| after_class[1..].find("\n\ndef "))
4332            .map(|i| i + 1)
4333            .unwrap_or(after_class.len());
4334        let contact_class = &after_class[..class_end];
4335        assert!(
4336            contact_class.contains("def __del__(self)"),
4337            "Contact should define __del__: {contact_class}"
4338        );
4339        assert!(
4340            contact_class.contains("_destroy"),
4341            "Contact.__del__ should call _destroy: {contact_class}"
4342        );
4343    }
4344
4345    #[test]
4346    fn python_null_check_on_optional_return() {
4347        let api = make_api(vec![Module {
4348            name: "contacts".into(),
4349            functions: vec![Function {
4350                name: "find_contact".into(),
4351                params: vec![Param {
4352                    name: "id".into(),
4353                    ty: TypeRef::I32,
4354                    mutable: false,
4355                    doc: None,
4356                }],
4357                returns: Some(TypeRef::Optional(Box::new(TypeRef::Struct(
4358                    "Contact".into(),
4359                )))),
4360                doc: None,
4361                r#async: false,
4362                cancellable: false,
4363                deprecated: None,
4364                since: None,
4365            }],
4366            structs: vec![StructDef {
4367                name: "Contact".into(),
4368                doc: None,
4369                fields: vec![StructField {
4370                    name: "name".into(),
4371                    ty: TypeRef::StringUtf8,
4372                    doc: None,
4373                    default: None,
4374                }],
4375                builder: false,
4376            }],
4377            enums: vec![],
4378            callbacks: vec![],
4379            listeners: vec![],
4380            errors: None,
4381            modules: vec![],
4382        }]);
4383
4384        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
4385
4386        assert!(
4387            py.contains("if _result is None:\n        return None"),
4388            "optional struct return should null-check before wrap: {py}"
4389        );
4390        let none_check = py
4391            .find("if _result is None:\n        return None")
4392            .expect("null-check block");
4393        let wrap = py
4394            .find("return Contact(_result)")
4395            .expect("Contact(_result) wrap");
4396        assert!(
4397            wrap > none_check,
4398            "Contact(_result) should appear after null check: {py}"
4399        );
4400    }
4401
4402    #[test]
4403    fn python_async_function_is_async_def() {
4404        let api = make_api(vec![simple_module(vec![Function {
4405            name: "fetch_data".into(),
4406            params: vec![Param {
4407                name: "id".into(),
4408                ty: TypeRef::I32,
4409                mutable: false,
4410                doc: None,
4411            }],
4412            returns: Some(TypeRef::StringUtf8),
4413            doc: None,
4414            r#async: true,
4415            cancellable: false,
4416            deprecated: None,
4417            since: None,
4418        }])]);
4419        let code = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
4420        assert!(
4421            code.contains("import asyncio"),
4422            "should import asyncio: {code}"
4423        );
4424        assert!(
4425            code.contains("def _fetch_data_sync(id: int) -> str:"),
4426            "should have sync helper: {code}"
4427        );
4428        assert!(
4429            code.contains("async def fetch_data(id: int) -> str:"),
4430            "should have async wrapper: {code}"
4431        );
4432        assert!(
4433            code.contains("asyncio.get_event_loop()"),
4434            "should use get_event_loop: {code}"
4435        );
4436        assert!(
4437            code.contains("run_in_executor(None, _fetch_data_sync, id)"),
4438            "should use run_in_executor with sync fn and args: {code}"
4439        );
4440    }
4441
4442    /// `ctypes.CFUNCTYPE` instances pin the C trampoline; `_cb` is held alive
4443    /// in the local frame for the lifetime of the synchronous helper, which
4444    /// blocks on `_ev.wait()` until the C callback fires. The `try/finally`
4445    /// around `_state` mutation ensures `_ev.set()` always runs, releasing
4446    /// the wait and letting `_cb` drop together with the helper frame.
4447    #[test]
4448    fn python_async_pins_callback_for_lifetime() {
4449        let api = make_api(vec![simple_module(vec![Function {
4450            name: "fetch_data".into(),
4451            params: vec![Param {
4452                name: "id".into(),
4453                ty: TypeRef::I32,
4454                mutable: false,
4455                doc: None,
4456            }],
4457            returns: Some(TypeRef::StringUtf8),
4458            doc: None,
4459            r#async: true,
4460            cancellable: false,
4461            deprecated: None,
4462            since: None,
4463        }])]);
4464        let code = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
4465        let pin_count = code.matches("_cb = _cb_type(_cb_impl)").count();
4466        let wait_count = code.matches("_ev.wait()").count();
4467        let set_count = code.matches("_ev.set()").count();
4468        assert_eq!(
4469            pin_count, 1,
4470            "expected one `_cb = _cb_type(_cb_impl)` per async fn, got {pin_count}: {code}"
4471        );
4472        assert_eq!(
4473            wait_count, set_count,
4474            "every `_ev.wait()` must be matched by an `_ev.set()` in finally: wait={wait_count} set={set_count}: {code}"
4475        );
4476        assert!(
4477            code.contains("finally:\n            _ev.set()"),
4478            "_ev.set() must be in a finally block: {code}"
4479        );
4480    }
4481
4482    #[test]
4483    fn python_pyi_async_function() {
4484        let api = make_api(vec![simple_module(vec![Function {
4485            name: "fetch_data".into(),
4486            params: vec![Param {
4487                name: "id".into(),
4488                ty: TypeRef::I32,
4489                mutable: false,
4490                doc: None,
4491            }],
4492            returns: Some(TypeRef::StringUtf8),
4493            doc: None,
4494            r#async: true,
4495            cancellable: false,
4496            deprecated: None,
4497            since: None,
4498        }])]);
4499        let stubs = render_pyi_module(&api, true, "weaveffi.yml");
4500        assert!(
4501            stubs.contains("async def fetch_data(id: int) -> str: ..."),
4502            "pyi should declare async def: {stubs}"
4503        );
4504    }
4505
4506    #[test]
4507    fn python_cross_module_struct() {
4508        let api = make_api(vec![
4509            Module {
4510                name: "types".into(),
4511                functions: vec![],
4512                structs: vec![StructDef {
4513                    name: "Name".into(),
4514                    doc: None,
4515                    fields: vec![StructField {
4516                        name: "value".into(),
4517                        ty: TypeRef::StringUtf8,
4518                        doc: None,
4519                        default: None,
4520                    }],
4521                    builder: false,
4522                }],
4523                enums: vec![],
4524                callbacks: vec![],
4525                listeners: vec![],
4526                errors: None,
4527                modules: vec![],
4528            },
4529            Module {
4530                name: "ops".into(),
4531                functions: vec![Function {
4532                    name: "get_name".into(),
4533                    params: vec![Param {
4534                        name: "id".into(),
4535                        ty: TypeRef::I32,
4536                        mutable: false,
4537                        doc: None,
4538                    }],
4539                    returns: Some(TypeRef::Struct("types.Name".into())),
4540                    doc: None,
4541                    r#async: false,
4542                    cancellable: false,
4543                    deprecated: None,
4544                    since: None,
4545                }],
4546                structs: vec![],
4547                enums: vec![],
4548                callbacks: vec![],
4549                listeners: vec![],
4550                errors: None,
4551                modules: vec![],
4552            },
4553        ]);
4554
4555        let code = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
4556        let stubs = render_pyi_module(&api, true, "weaveffi.yml");
4557
4558        assert!(
4559            code.contains("Name(_result)"),
4560            "cross-module return should construct Name, not types.Name: {code}"
4561        );
4562        assert!(
4563            !code.contains("types.Name"),
4564            "dot-qualified name should not appear in generated Python code: {code}"
4565        );
4566        assert!(
4567            stubs.contains("\"Name\""),
4568            "pyi should use local type name: {stubs}"
4569        );
4570        assert!(
4571            !stubs.contains("types.Name"),
4572            "dot-qualified name should not appear in pyi stubs: {stubs}"
4573        );
4574    }
4575
4576    #[test]
4577    fn python_nested_module_output() {
4578        let api = make_api(vec![Module {
4579            name: "parent".to_string(),
4580            functions: vec![Function {
4581                name: "outer_fn".to_string(),
4582                params: vec![],
4583                returns: Some(TypeRef::I32),
4584                doc: None,
4585                r#async: false,
4586                cancellable: false,
4587                deprecated: None,
4588                since: None,
4589            }],
4590            structs: vec![],
4591            enums: vec![],
4592            callbacks: vec![],
4593            listeners: vec![],
4594            errors: None,
4595            modules: vec![Module {
4596                name: "child".to_string(),
4597                functions: vec![Function {
4598                    name: "inner_fn".to_string(),
4599                    params: vec![],
4600                    returns: Some(TypeRef::I32),
4601                    doc: None,
4602                    r#async: false,
4603                    cancellable: false,
4604                    deprecated: None,
4605                    since: None,
4606                }],
4607                structs: vec![],
4608                enums: vec![],
4609                callbacks: vec![],
4610                listeners: vec![],
4611                errors: None,
4612                modules: vec![],
4613            }],
4614        }]);
4615        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
4616        assert!(
4617            py.contains("# === Module: parent ==="),
4618            "parent module section missing: {py}"
4619        );
4620        assert!(
4621            py.contains("# === Module: parent_child ==="),
4622            "nested child module section missing: {py}"
4623        );
4624        assert!(
4625            py.contains("weaveffi_parent_outer_fn"),
4626            "parent C function missing: {py}"
4627        );
4628        assert!(
4629            py.contains("weaveffi_parent_child_inner_fn"),
4630            "nested child C function missing: {py}"
4631        );
4632        let pyi = render_pyi_module(&api, true, "weaveffi.yml");
4633        assert!(
4634            pyi.contains("def inner_fn"),
4635            "nested child function missing from pyi: {pyi}"
4636        );
4637    }
4638
4639    #[test]
4640    fn python_type_hint_iterator() {
4641        assert_eq!(
4642            py_type_hint(&TypeRef::Iterator(Box::new(TypeRef::I32))),
4643            "Iterator[int]"
4644        );
4645        assert_eq!(
4646            py_type_hint(&TypeRef::Iterator(Box::new(TypeRef::Struct(
4647                "Contact".into()
4648            )))),
4649            "Iterator[\"Contact\"]"
4650        );
4651    }
4652
4653    #[test]
4654    fn python_iterator_return() {
4655        let api = make_api(vec![Module {
4656            name: "data".to_string(),
4657            functions: vec![Function {
4658                name: "list_items".to_string(),
4659                params: vec![],
4660                returns: Some(TypeRef::Iterator(Box::new(TypeRef::I32))),
4661                doc: None,
4662                r#async: false,
4663                cancellable: false,
4664                deprecated: None,
4665                since: None,
4666            }],
4667            structs: vec![],
4668            enums: vec![],
4669            callbacks: vec![],
4670            listeners: vec![],
4671            errors: None,
4672            modules: vec![],
4673        }]);
4674        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
4675        assert!(
4676            py.contains("ListItemsIterator"),
4677            "should reference iterator type name: {py}"
4678        );
4679        assert!(
4680            py.contains("_next"),
4681            "should call _next for iteration: {py}"
4682        );
4683        assert!(
4684            py.contains("_destroy"),
4685            "should call _destroy for cleanup: {py}"
4686        );
4687    }
4688
4689    #[test]
4690    fn deprecated_function_generates_annotation() {
4691        let api = make_api(vec![simple_module(vec![Function {
4692            name: "add_old".into(),
4693            params: vec![
4694                Param {
4695                    name: "a".into(),
4696                    ty: TypeRef::I32,
4697                    mutable: false,
4698                    doc: None,
4699                },
4700                Param {
4701                    name: "b".into(),
4702                    ty: TypeRef::I32,
4703                    mutable: false,
4704                    doc: None,
4705                },
4706            ],
4707            returns: Some(TypeRef::I32),
4708            doc: None,
4709            r#async: false,
4710            cancellable: false,
4711            deprecated: Some("Use add_v2 instead".into()),
4712            since: Some("0.1.0".into()),
4713        }])]);
4714        let py = render_python_module(&api, true, "weaveffi", "weaveffi.yml");
4715        assert!(
4716            py.contains("warnings.warn(\"Use add_v2 instead\", DeprecationWarning, stacklevel=2)"),
4717            "missing deprecation warning: {py}"
4718        );
4719    }
4720
4721    fn doc_api() -> Api {
4722        make_api(vec![Module {
4723            name: "docs".into(),
4724            functions: vec![Function {
4725                name: "do_thing".into(),
4726                params: vec![Param {
4727                    name: "x".into(),
4728                    ty: TypeRef::I32,
4729                    mutable: false,
4730                    doc: Some("the input value".into()),
4731                }],
4732                returns: Some(TypeRef::I32),
4733                doc: Some("Performs a thing.".into()),
4734                r#async: false,
4735                cancellable: false,
4736                deprecated: None,
4737                since: None,
4738            }],
4739            structs: vec![StructDef {
4740                name: "Item".into(),
4741                doc: Some("An item we track.".into()),
4742                fields: vec![StructField {
4743                    name: "id".into(),
4744                    ty: TypeRef::I64,
4745                    doc: Some("Stable id".into()),
4746                    default: None,
4747                }],
4748                builder: false,
4749            }],
4750            enums: vec![EnumDef {
4751                name: "Kind".into(),
4752                doc: Some("Kind of item.".into()),
4753                variants: vec![EnumVariant {
4754                    name: "Small".into(),
4755                    value: 0,
4756                    doc: Some("A small one".into()),
4757                }],
4758            }],
4759            callbacks: vec![],
4760            listeners: vec![],
4761            errors: None,
4762            modules: vec![],
4763        }])
4764    }
4765
4766    #[test]
4767    fn python_emits_doc_on_function() {
4768        let py = render_python_module(&doc_api(), true, "weaveffi", "weaveffi.yml");
4769        assert!(py.contains("\"\"\"Performs a thing."), "{py}");
4770    }
4771
4772    #[test]
4773    fn python_emits_doc_on_struct() {
4774        let py = render_python_module(&doc_api(), true, "weaveffi", "weaveffi.yml");
4775        assert!(py.contains("\"\"\"An item we track.\"\"\""), "{py}");
4776    }
4777
4778    #[test]
4779    fn python_emits_doc_on_enum_variant() {
4780        let py = render_python_module(&doc_api(), true, "weaveffi", "weaveffi.yml");
4781        assert!(py.contains("\"\"\"Kind of item.\"\"\""), "{py}");
4782        assert!(py.contains("# A small one"), "{py}");
4783    }
4784
4785    #[test]
4786    fn python_emits_doc_on_field() {
4787        let py = render_python_module(&doc_api(), true, "weaveffi", "weaveffi.yml");
4788        assert!(py.contains("\"\"\"Stable id\"\"\""), "{py}");
4789    }
4790
4791    #[test]
4792    fn python_emits_doc_on_param() {
4793        let py = render_python_module(&doc_api(), true, "weaveffi", "weaveffi.yml");
4794        assert!(py.contains("Parameters"), "{py}");
4795        assert!(py.contains("x : the input value"), "{py}");
4796    }
4797
4798    #[test]
4799    fn python_custom_prefix_threads_to_user_symbols() {
4800        let api = make_api(vec![simple_module(vec![Function {
4801            name: "add".into(),
4802            params: vec![
4803                Param {
4804                    name: "a".into(),
4805                    ty: TypeRef::I32,
4806                    mutable: false,
4807                    doc: None,
4808                },
4809                Param {
4810                    name: "b".into(),
4811                    ty: TypeRef::I32,
4812                    mutable: false,
4813                    doc: None,
4814                },
4815            ],
4816            returns: Some(TypeRef::I32),
4817            doc: None,
4818            r#async: false,
4819            cancellable: false,
4820            deprecated: None,
4821            since: None,
4822        }])]);
4823
4824        let py = render_python_module(&api, true, "myffi", "weaveffi.yml");
4825
4826        // User symbols honor the configured ABI prefix.
4827        assert!(
4828            py.contains("_lib.myffi_math_add"),
4829            "user symbol should use the custom prefix: {py}"
4830        );
4831        assert!(
4832            !py.contains("weaveffi_math_add"),
4833            "user symbol must not hard-code the weaveffi_ prefix: {py}"
4834        );
4835
4836        // Runtime ABI helpers stay literal regardless of the user prefix.
4837        assert!(
4838            py.contains("weaveffi_error_clear"),
4839            "runtime ABI helper must remain literal: {py}"
4840        );
4841        assert!(
4842            !py.contains("myffi_error_clear"),
4843            "runtime ABI helper must not be prefixed: {py}"
4844        );
4845    }
4846}