wasmer_pack/py/
mod.rs

1use std::path::Path;
2
3use anyhow::Error;
4use heck::{ToPascalCase, ToSnakeCase};
5use minijinja::Environment;
6use once_cell::sync::Lazy;
7use wai_bindgen_gen_core::Generator;
8use wai_bindgen_gen_wasmer_py::WasmerPy;
9
10use crate::{
11    types::{Interface, Package},
12    Files, Metadata, Module, SourceFile,
13};
14
15static TEMPLATES: Lazy<Environment> = Lazy::new(|| {
16    let mut env = Environment::new();
17    env.add_template(
18        "bindings.__init__.py",
19        include_str!("bindings.__init__.py.j2"),
20    )
21    .unwrap();
22    env.add_template(
23        "top_level.__init__.py",
24        include_str!("top_level.__init__.py.j2"),
25    )
26    .unwrap();
27    env.add_template("MANIFEST.in", include_str!("MANIFEST.in.j2"))
28        .unwrap();
29    env.add_template(
30        "commands.__init__.py",
31        include_str!("commands.__init__.py.j2"),
32    )
33    .unwrap();
34
35    env
36});
37
38/// Generate Python bindings.
39pub fn generate_python(package: &Package) -> Result<Files, Error> {
40    let metadata = package.metadata();
41    let package_name = metadata.package_name.python_name();
42
43    let mut files = Files::new();
44
45    let ctx = Context::for_package(package);
46
47    if !ctx.libraries.is_empty() {
48        files.insert_child_directory(
49            Path::new(&package_name).join("bindings"),
50            library_bindings(&ctx)?,
51        );
52    }
53
54    if !ctx.commands.is_empty() {
55        files.insert_child_directory(
56            Path::new(&package_name).join("commands"),
57            command_bindings(&ctx)?,
58        );
59    }
60
61    files.insert(
62        Path::new(&package_name).join("__init__.py"),
63        top_level_dunder_init(package)?,
64    );
65    // Indicate that we use type hints
66    files.insert(
67        Path::new(&package_name).join("py.typed"),
68        SourceFile::empty(),
69    );
70
71    files.insert(
72        "pyproject.toml",
73        generate_pyproject_toml(metadata, &package_name)?,
74    );
75
76    files.insert("MANIFEST.in", generate_manifest(package, &package_name)?);
77
78    Ok(files)
79}
80
81#[derive(Debug, serde::Serialize)]
82struct Context {
83    commands: Vec<CommandContext>,
84    libraries: Vec<LibraryContext>,
85}
86
87impl Context {
88    fn for_package(pkg: &Package) -> Self {
89        let commands = pkg
90            .commands()
91            .iter()
92            .cloned()
93            .map(CommandContext::from)
94            .collect();
95
96        let libraries = pkg
97            .libraries()
98            .iter()
99            .cloned()
100            .map(LibraryContext::from)
101            .collect();
102
103        Context {
104            commands,
105            libraries,
106        }
107    }
108}
109
110#[derive(Debug, serde::Serialize)]
111struct LibraryContext {
112    ident: String,
113    class_name: String,
114    module_filename: String,
115    wasi: bool,
116    exports: InterfaceContext,
117    imports: Vec<InterfaceContext>,
118    #[serde(skip)]
119    module: Module,
120}
121
122impl From<crate::Library> for LibraryContext {
123    fn from(lib: crate::Library) -> Self {
124        let module_filename = Path::new(lib.module_filename()).with_extension("wasm");
125        let ident = lib.interface_name().to_snake_case();
126        let class_name = lib.class_name();
127
128        LibraryContext {
129            ident,
130            class_name,
131            module_filename: module_filename.display().to_string(),
132            wasi: lib.requires_wasi(),
133            exports: lib.exports.into(),
134            imports: lib
135                .imports
136                .into_iter()
137                .map(InterfaceContext::from)
138                .collect(),
139            module: lib.module,
140        }
141    }
142}
143
144#[derive(Debug, serde::Serialize)]
145struct InterfaceContext {
146    /// The name used when you need to refer to this interface as a variable.
147    ident: String,
148    /// The name of the interface (i.e. the `wasmer-pack` in
149    /// `wasmer-pack.exports.wit`).
150    interface_name: String,
151    /// The name of the class generated by `wai-bindgen` (i.e. `WasmerPack`).
152    class_name: String,
153    #[serde(skip)]
154    interface: Interface,
155}
156
157impl From<Interface> for InterfaceContext {
158    fn from(interface: Interface) -> Self {
159        InterfaceContext {
160            ident: interface.name().to_snake_case(),
161            interface_name: interface.name().to_string(),
162            class_name: interface.name().to_pascal_case(),
163            interface,
164        }
165    }
166}
167
168#[derive(Debug, serde::Serialize)]
169struct CommandContext {
170    ident: String,
171    module_filename: String,
172    #[serde(skip)]
173    wasm: Vec<u8>,
174}
175
176impl From<crate::Command> for CommandContext {
177    fn from(cmd: crate::Command) -> CommandContext {
178        let ident = cmd.name.replace('-', "_");
179        let module_filename = format!("{ident}.wasm");
180        CommandContext {
181            ident,
182            module_filename,
183            wasm: cmd.wasm,
184        }
185    }
186}
187
188fn command_bindings(ctx: &Context) -> Result<Files, Error> {
189    let mut files = Files::new();
190
191    for cmd in &ctx.commands {
192        files.insert(&cmd.module_filename, SourceFile::from(&cmd.wasm));
193    }
194
195    files.insert(
196        "__init__.py",
197        TEMPLATES
198            .get_template("commands.__init__.py")
199            .unwrap()
200            .render(ctx)?
201            .into(),
202    );
203
204    Ok(files)
205}
206
207fn library_bindings(ctx: &Context) -> Result<Files, Error> {
208    let mut files = Files::new();
209
210    for lib in &ctx.libraries {
211        let mut bindings = generate_bindings(lib);
212        bindings.insert(&lib.module_filename, lib.module.wasm.clone().into());
213        files.insert_child_directory(&lib.ident, bindings);
214    }
215
216    let dunder_init = TEMPLATES
217        .get_template("bindings.__init__.py")
218        .unwrap()
219        .render(ctx)?;
220    files.insert("__init__.py", dunder_init.into());
221
222    Ok(files)
223}
224
225fn generate_manifest(package: &Package, package_name: &str) -> Result<SourceFile, Error> {
226    let ctx = minijinja::context! {
227        package_name,
228        libraries => package.libraries()
229        .iter()
230            .map(|lib| lib.interface_name())
231            .collect::<Vec<_>>(),
232        commands => package.commands()
233            .iter()
234            .map(|cmd| cmd.name.as_str())
235            .collect::<Vec<_>>(),
236    };
237    let rendered = TEMPLATES
238        .get_template("MANIFEST.in")
239        .unwrap()
240        .render(&ctx)?;
241
242    Ok(rendered.into())
243}
244
245fn generate_pyproject_toml(metadata: &Metadata, package_name: &str) -> Result<SourceFile, Error> {
246    let Metadata {
247        version,
248        description,
249        ..
250    } = metadata;
251
252    let project = PyProject {
253        project: Project {
254            name: package_name,
255            version,
256            description: description.as_deref(),
257            readme: None,
258            keywords: Vec::new(),
259            dependencies: vec!["wasmer", "wasmer_compiler_cranelift"],
260        },
261        build_system: BuildSystem {
262            requires: &["setuptools", "setuptools-scm"],
263            build_backend: "setuptools.build_meta",
264        },
265    };
266
267    let serialized = toml::to_string(&project)?;
268
269    Ok(serialized.into())
270}
271
272#[derive(Debug, Clone, PartialEq, serde::Serialize)]
273#[serde(rename_all = "kebab-case")]
274struct PyProject<'a> {
275    project: Project<'a>,
276    build_system: BuildSystem<'a>,
277}
278
279#[derive(Debug, Clone, PartialEq, serde::Serialize)]
280#[serde(rename_all = "kebab-case")]
281struct BuildSystem<'a> {
282    requires: &'a [&'a str],
283    build_backend: &'a str,
284}
285
286#[derive(Debug, Clone, PartialEq, serde::Serialize)]
287struct Project<'a> {
288    name: &'a str,
289    version: &'a str,
290    description: Option<&'a str>,
291    readme: Option<&'a Path>,
292    keywords: Vec<&'a str>,
293    dependencies: Vec<&'a str>,
294}
295
296fn top_level_dunder_init(package: &Package) -> Result<SourceFile, Error> {
297    let Metadata {
298        version,
299        description,
300        package_name,
301    } = package.metadata();
302
303    let ctx = minijinja::context! {
304        version,
305        description,
306        generator => crate::GENERATOR,
307        package_name => package_name.to_string(),
308        ident => package_name.name().to_pascal_case(),
309        commands => !package.commands().is_empty(),
310        libraries => !package.libraries().is_empty(),
311    };
312
313    let rendered = TEMPLATES
314        .get_template("top_level.__init__.py")
315        .unwrap()
316        .render(ctx)?;
317
318    Ok(rendered.into())
319}
320
321fn generate_bindings(lib: &LibraryContext) -> Files {
322    // Note: imports and exports were reported from the perspective of the
323    // guest, but we're generating bindings from the perspective of the host.
324    // Hence the "host_imports = guest_exports" thing.
325
326    let imports = std::slice::from_ref(&lib.exports.interface.0);
327    let exports: Vec<_> = lib
328        .imports
329        .iter()
330        .map(|ctx| ctx.interface.0.clone())
331        .collect();
332
333    let mut generated = wai_bindgen_gen_core::Files::default();
334
335    WasmerPy::default().generate_all(imports, &exports, &mut generated);
336
337    let mut files = Files::from(generated);
338    files.insert("__init__.py", "from .bindings import *".into());
339
340    files
341}
342
343#[cfg(test)]
344mod tests {
345    use insta::Settings;
346
347    use super::*;
348    use crate::{Command, Library, Module};
349    use std::collections::BTreeSet;
350
351    const WASMER_PACK_EXPORTS: &str = include_str!(concat!(
352        env!("CARGO_MANIFEST_DIR"),
353        "/../wasm/wasmer-pack.exports.wai"
354    ));
355
356    #[test]
357    fn generated_files() {
358        let expected: BTreeSet<&Path> = [
359            "MANIFEST.in",
360            "pyproject.toml",
361            "wasmer_pack/__init__.py",
362            "wasmer_pack/py.typed",
363            "wasmer_pack/commands/__init__.py",
364            "wasmer_pack/commands/first.wasm",
365            "wasmer_pack/commands/second_with_dashes.wasm",
366            "wasmer_pack/bindings/__init__.py",
367            "wasmer_pack/bindings/wasmer_pack/__init__.py",
368            "wasmer_pack/bindings/wasmer_pack/bindings.py",
369            "wasmer_pack/bindings/wasmer_pack/wasmer_pack_wasm.wasm",
370        ]
371        .iter()
372        .map(Path::new)
373        .collect();
374        let metadata = Metadata::new("wasmer/wasmer-pack".parse().unwrap(), "1.2.3");
375        let module = Module {
376            name: "wasmer_pack_wasm.wasm".to_string(),
377            abi: crate::Abi::None,
378            wasm: Vec::new(),
379        };
380        let exports =
381            crate::Interface::from_wit("wasmer-pack.exports.wit", WASMER_PACK_EXPORTS).unwrap();
382        let commands = vec![
383            Command::new("first", []),
384            Command::new("second-with-dashes", []),
385        ];
386        let browser =
387            crate::Interface::from_wit("browser.wit", "greet: func(who: string) -> string")
388                .unwrap();
389        let libraries = vec![Library {
390            module,
391            exports,
392            imports: vec![browser],
393        }];
394        let package = Package::new(metadata, libraries, commands);
395
396        let files = generate_python(&package).unwrap();
397
398        let actual_files: BTreeSet<_> = files.iter().map(|(p, _)| p).collect();
399        assert_eq!(actual_files, expected);
400
401        let mut settings = Settings::clone_current();
402        settings.add_filter(
403            r"Generated by wasmer-pack v\d+\.\d+\.\d+(-\w+(\.\d+)?)?",
404            "Generated by XXX",
405        );
406        settings.bind(|| {
407            insta::assert_display_snapshot!(files["pyproject.toml"].utf8_contents().unwrap());
408            insta::assert_display_snapshot!(files["MANIFEST.in"].utf8_contents().unwrap());
409            insta::assert_display_snapshot!(files["wasmer_pack/__init__.py"]
410                .utf8_contents()
411                .unwrap()
412                .replace(crate::GENERATOR, "XXX"));
413            insta::assert_display_snapshot!(files["wasmer_pack/bindings/__init__.py"]
414                .utf8_contents()
415                .unwrap());
416            insta::assert_display_snapshot!(files["wasmer_pack/commands/__init__.py"]
417                .utf8_contents()
418                .unwrap());
419        });
420        insta::assert_display_snapshot!(files["wasmer_pack/py.typed"].utf8_contents().unwrap());
421
422        let actual_files: BTreeSet<_> = files.iter().map(|(p, _)| p).collect();
423        assert_eq!(actual_files, expected);
424    }
425}