diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 76d2e637..02d6df25 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -53,17 +53,6 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - uses: astral-sh/setup-uv@v3 - # The Python codegen shells out to `ruff format`; different ruff - # versions wrap long lines differently, so the snapshot-drift - # check below is meaningless unless CI and local devs use the - # same ruff version. Pin it explicitly. - - uses: astral-sh/ruff-action@v3 - with: - version: "0.15.8" - # `args` short-circuits the action's default `ruff check` — - # we only want ruff on PATH so the codegen can shell out to - # `ruff format`, not for the action itself to lint the repo. - args: "--version" - name: Regenerate demo Python client from schema run: | cargo run -p reflectapi-cli --quiet -- codegen \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 009172b9..d16dc006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Python package codegen now emits sibling submodule imports in dependency order, so Python 3.14/Pydantic can import generated packages where one sibling model annotation references another sibling namespace. +- Python codegen now formats with a bundled Ruff wasm formatter, so generated Python output is deterministic even when `ruff` is not installed locally. - Rust codegen snapshot typechecking now handles macOS proc-macro library names instead of assuming Linux-style `.so` files. ## 0.17.5 diff --git a/Cargo.lock b/Cargo.lock index f1776beb..b6cbcff2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -262,6 +268,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "borsh" version = "1.6.1" @@ -322,6 +337,9 @@ name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +dependencies = [ + "allocator-api2", +] [[package]] name = "bytecheck" @@ -509,6 +527,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -518,6 +545,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "datatest-stable" version = "0.2.10" @@ -549,6 +586,16 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -569,6 +616,22 @@ dependencies = [ "litrs", ] +[[package]] +name = "dprint-core" +version = "0.67.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c1d827947704a9495f705d6aeed270fa21a67f825f22902c28f38dc3af7a9ae" +dependencies = [ + "anyhow", + "bumpalo", + "hashbrown 0.15.5", + "indexmap", + "rustc-hash", + "serde", + "serde_json", + "unicode-width", +] + [[package]] name = "either" version = "1.15.0" @@ -782,6 +845,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -855,6 +928,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -1234,6 +1309,12 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.16" @@ -1701,6 +1782,7 @@ dependencies = [ "chrono", "chrono-tz", "document-features", + "dprint-core", "futures", "futures-util", "http 1.4.0", @@ -1714,9 +1796,11 @@ dependencies = [ "rust_decimal", "serde", "serde_json", + "sha2", "sseer", "url", "uuid", + "wasmi", ] [[package]] @@ -2002,6 +2086,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustix" version = "1.1.4" @@ -2172,6 +2262,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -2217,6 +2308,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2273,6 +2375,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "sseer" version = "0.1.8" @@ -2297,6 +2405,16 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "string-interner" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0" +dependencies = [ + "hashbrown 0.15.5", + "serde", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2726,6 +2844,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicase" version = "2.9.0" @@ -2738,6 +2862,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -2904,7 +3034,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", - "wasmparser", + "wasmparser 0.244.0", ] [[package]] @@ -2916,7 +3046,7 @@ dependencies = [ "anyhow", "indexmap", "wasm-encoder", - "wasmparser", + "wasmparser 0.244.0", ] [[package]] @@ -2932,6 +3062,56 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmi" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22bf475363d09d960b48275c4ea9403051add498a9d80c64dbc91edabab9d1d0" +dependencies = [ + "spin", + "wasmi_collections", + "wasmi_core", + "wasmi_ir", + "wasmparser 0.228.0", +] + +[[package]] +name = "wasmi_collections" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85851acbdffd675a9b699b3590406a1d37fc1e1fd073743c7c9cf47c59caacba" +dependencies = [ + "string-interner", +] + +[[package]] +name = "wasmi_core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef64cf60195d1f937dbaed592a5afce3e6d86868fb8070c5255bc41539d68f9d" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmi_ir" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dcb572ce4400e06b5475819f3d6b9048513efbca785f0b9ef3a41747f944fd8" +dependencies = [ + "wasmi_core", +] + +[[package]] +name = "wasmparser" +version = "0.228.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3" +dependencies = [ + "bitflags", + "indexmap", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -3190,7 +3370,7 @@ dependencies = [ "serde_json", "wasm-encoder", "wasm-metadata", - "wasmparser", + "wasmparser 0.244.0", "wit-parser", ] @@ -3209,7 +3389,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser", + "wasmparser 0.244.0", ] [[package]] diff --git a/README.md b/README.md index 8fdbf2e9..28200f16 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ ### Building and running -Ensure that you have `prettier`, `rustfmt`, and `ruff` available in your PATH for consistently formatted generated code. +Ensure that you have `prettier` and `rustfmt` available in your PATH for consistently formatted generated TypeScript and Rust code. Python codegen uses a bundled Ruff formatter. To run the demo server: diff --git a/reflectapi-cli/tests/output_paths.rs b/reflectapi-cli/tests/output_paths.rs index 906a3edd..4421af6c 100644 --- a/reflectapi-cli/tests/output_paths.rs +++ b/reflectapi-cli/tests/output_paths.rs @@ -244,7 +244,7 @@ fn python_legacy_cleanup_skips_symlinked_directories() { #[cfg(unix)] #[test] -fn python_format_falls_back_when_ruff_is_missing() { +fn python_format_uses_bundled_formatter_when_ruff_is_missing() { let tmp = tempfile::tempdir().unwrap(); let target = tmp.path().join("python-client"); let empty_path = tmp.path().join("bin"); @@ -307,7 +307,7 @@ fn python_format_false_does_not_require_ruff() { #[cfg(unix)] #[test] -fn python_format_reports_ruff_failures() { +fn python_format_ignores_broken_external_ruff() { use std::os::unix::fs::PermissionsExt; let tmp = tempfile::tempdir().unwrap(); @@ -337,13 +337,11 @@ fn python_format_reports_ruff_failures() { &bin, ); assert!( - !out.status.success(), - "expected ruff failure to fail codegen" + out.status.success(), + "stderr:\n{}", + String::from_utf8_lossy(&out.stderr) ); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("failed to format generated Python code with `ruff format`")); - assert!(stderr.contains("command failed with exit code")); - assert!(stderr.contains("Fix Ruff or pass `--format=false`")); + assert!(target.join("current/__init__.py").is_file()); } #[test] diff --git a/reflectapi/Cargo.toml b/reflectapi/Cargo.toml index 6cdb2572..853f0a6d 100644 --- a/reflectapi/Cargo.toml +++ b/reflectapi/Cargo.toml @@ -51,6 +51,9 @@ axum = { version = "0.8.1", optional = true } anyhow = { version = "1.0.81", optional = true } indexmap = { version = "2.2.6", optional = true, features = ["serde"] } check_keyword = { version = "0.2.0", optional = true } +dprint-core = { version = "0.67.4", optional = true, features = ["wasm"] } +sha2 = { version = "0.10", optional = true } +wasmi = { version = "1.0.9", optional = true, default-features = false, features = ["std"] } # optional 3rd party dependencies for client runtime reqwest = { version = "0.12", optional = true, features = ["stream"] } @@ -85,6 +88,9 @@ codegen = [ "dep:indexmap", "dep:check_keyword", "dep:serde_json", + "dep:dprint-core", + "dep:sha2", + "dep:wasmi", ] rt = ["dep:http", "dep:serde_json", "dep:bytes", "dep:url"] glob = ["reflectapi-schema/glob"] diff --git a/reflectapi/src/codegen/mod.rs b/reflectapi/src/codegen/mod.rs index 5bde3005..79859082 100644 --- a/reflectapi/src/codegen/mod.rs +++ b/reflectapi/src/codegen/mod.rs @@ -1,6 +1,7 @@ mod format; pub mod openapi; pub mod python; +mod python_formatter; pub mod rust; // Compiler-only helpers (symbol identity, schema-ID assignment, // normalization, semantic IR). Used by Python codegen today, available diff --git a/reflectapi/src/codegen/python.rs b/reflectapi/src/codegen/python.rs index 9100294d..0ff914a9 100644 --- a/reflectapi/src/codegen/python.rs +++ b/reflectapi/src/codegen/python.rs @@ -5818,16 +5818,7 @@ fn build_implemented_types() -> BTreeMap { // Helper functions for templates fn format_python_code(code: &str) -> anyhow::Result { - let mut ruff = std::process::Command::new("ruff"); - ruff.args(["format", "--stdin-filename", "generated.py", "-"]); - match super::format_with([&mut ruff], code.to_string()) { - Ok(formatted) => Ok(ensure_final_newline(formatted)), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(basic_python_format(code)), - Err(err) => Err(anyhow::anyhow!( - "failed to format generated Python code with `ruff format`: {err}\n\ - Fix Ruff or pass `--format=false` to skip external formatting." - )), - } + super::python_formatter::format_python_code(code) } fn basic_python_format(code: &str) -> String { diff --git a/reflectapi/src/codegen/python/ruff-0.7.15.wasm b/reflectapi/src/codegen/python/ruff-0.7.15.wasm new file mode 100644 index 00000000..0cb20ff1 Binary files /dev/null and b/reflectapi/src/codegen/python/ruff-0.7.15.wasm differ diff --git a/reflectapi/src/codegen/python_formatter.rs b/reflectapi/src/codegen/python_formatter.rs new file mode 100644 index 00000000..6ad990fd --- /dev/null +++ b/reflectapi/src/codegen/python_formatter.rs @@ -0,0 +1,242 @@ +use anyhow::{anyhow, bail, Context as _, Result}; +use dprint_core::configuration::{ConfigKeyMap, ConfigKeyValue, GlobalConfiguration}; +use dprint_core::plugins::FormatConfigId; +use sha2::{Digest as _, Sha256}; +use wasmi::{Engine, Instance, Memory, Module, Store, TypedFunc}; + +const RUFF_WASM: &[u8] = include_bytes!("python/ruff-0.7.15.wasm"); +const RUFF_WASM_SHA256: &str = "889d8dc7e8ef0d03437c164b9ac95c5fcdbb67bb277322092d50154027d90a8b"; +const GENERATED_FILENAME: &str = "generated.py"; + +pub fn format_python_code(code: &str) -> Result { + let mut formatter = RuffWasmFormatter::new()?; + formatter.format(GENERATED_FILENAME, code) +} + +struct RuffWasmFormatter { + store: Store<()>, + instance: Instance, + memory: Memory, + current_config_id: Option, + buffer_size: usize, +} + +impl RuffWasmFormatter { + fn new() -> Result { + verify_wasm_artifact()?; + + let engine = Engine::default(); + let module = + Module::new(&engine, RUFF_WASM).context("failed to compile bundled Ruff wasm")?; + let mut store = Store::new(&engine, ()); + let linker = wasmi::Linker::new(&engine); + let instance = linker + .instantiate_and_start(&mut store, &module) + .context("failed to instantiate bundled Ruff wasm")?; + let memory = instance + .get_memory(&store, "memory") + .context("bundled Ruff wasm does not export memory")?; + let mut formatter = Self { + store, + instance, + memory, + current_config_id: None, + buffer_size: 0, + }; + formatter.ensure_v3_plugin()?; + formatter.buffer_size = formatter.call0::("get_wasm_memory_buffer_size")? as usize; + if formatter.buffer_size == 0 { + bail!("bundled Ruff wasm reported a zero-sized transfer buffer"); + } + Ok(formatter) + } + + fn format(&mut self, file_path: &str, code: &str) -> Result { + let config = FormatConfig::default_python(); + self.ensure_config(&config)?; + + self.send_string(file_path)?; + self.call_void0("set_file_path")?; + + self.send_bytes(code.as_bytes())?; + match self.call0::("format")? { + 0 => Ok(ensure_final_newline(code.to_string())), + 1 => { + let len = self.call0::("get_formatted_text")? as usize; + Ok(ensure_final_newline(String::from_utf8( + self.receive_bytes(len)?, + )?)) + } + 2 => { + let len = self.call0::("get_error_text")? as usize; + let error_text = String::from_utf8(self.receive_bytes(len)?)?; + Err(anyhow!( + "failed to format generated Python code with bundled Ruff: {error_text}" + )) + } + response => bail!("bundled Ruff wasm returned unknown format response {response}"), + } + } + + fn ensure_v3_plugin(&mut self) -> Result<()> { + let schema_version = self.call0::("get_plugin_schema_version")?; + if schema_version != 3 { + bail!("bundled Ruff wasm uses unsupported dprint plugin schema {schema_version}"); + } + Ok(()) + } + + fn ensure_config(&mut self, config: &FormatConfig) -> Result<()> { + if self.current_config_id == Some(config.id) { + return Ok(()); + } + + self.current_config_id = None; + self.send_string(&serde_json::to_string(&config.global)?)?; + self.call_void0("set_global_config")?; + self.send_string(&serde_json::to_string(&config.plugin)?)?; + self.call_void0("set_plugin_config")?; + self.current_config_id = Some(config.id); + Ok(()) + } + + fn send_string(&mut self, text: &str) -> Result<()> { + self.send_bytes(text.as_bytes()) + } + + fn send_bytes(&mut self, bytes: &[u8]) -> Result<()> { + self.call_void1("clear_shared_bytes", bytes.len() as u32)?; + + let mut offset = 0; + while offset < bytes.len() { + let len = std::cmp::min(bytes.len() - offset, self.buffer_size); + self.write_to_buffer(&bytes[offset..offset + len])?; + self.call_void1("add_to_shared_bytes_from_buffer", len as u32)?; + offset += len; + } + Ok(()) + } + + fn receive_bytes(&mut self, len: usize) -> Result> { + let mut bytes = vec![0; len]; + let mut offset = 0; + while offset < len { + let read_len = std::cmp::min(len - offset, self.buffer_size); + self.call_void2( + "set_buffer_with_shared_bytes", + offset as u32, + read_len as u32, + )?; + self.read_from_buffer(&mut bytes[offset..offset + read_len])?; + offset += read_len; + } + Ok(bytes) + } + + fn write_to_buffer(&mut self, bytes: &[u8]) -> Result<()> { + let ptr = self.call0::("get_wasm_memory_buffer")?; + self.memory + .write(&mut self.store, ptr as usize, bytes) + .context("failed to write bytes to bundled Ruff wasm")?; + Ok(()) + } + + fn read_from_buffer(&mut self, bytes: &mut [u8]) -> Result<()> { + let ptr = self.call0::("get_wasm_memory_buffer")?; + self.memory + .read(&self.store, ptr as usize, bytes) + .context("failed to read bytes from bundled Ruff wasm")?; + Ok(()) + } + + fn call0(&mut self, name: &str) -> Result + where + Rets: wasmi::WasmResults, + { + Ok(self + .get_export::<(), Rets>(name)? + .call(&mut self.store, ())?) + } + + fn call_void0(&mut self, name: &str) -> Result<()> { + Ok(self.get_export::<(), ()>(name)?.call(&mut self.store, ())?) + } + + fn call_void1(&mut self, name: &str, value: u32) -> Result<()> { + Ok(self + .get_export::(name)? + .call(&mut self.store, value)?) + } + + fn call_void2(&mut self, name: &str, value_one: u32, value_two: u32) -> Result<()> { + Ok(self + .get_export::<(u32, u32), ()>(name)? + .call(&mut self.store, (value_one, value_two))?) + } + + fn get_export(&self, name: &str) -> Result> + where + Args: wasmi::WasmParams, + Rets: wasmi::WasmResults, + { + self.instance + .get_typed_func::(&self.store, name) + .with_context(|| format!("bundled Ruff wasm does not export {name}")) + } +} + +struct FormatConfig { + id: FormatConfigId, + plugin: ConfigKeyMap, + global: GlobalConfiguration, +} + +impl FormatConfig { + fn default_python() -> Self { + let mut plugin = ConfigKeyMap::new(); + plugin.insert("lineLength".to_string(), ConfigKeyValue::Number(88)); + Self { + id: FormatConfigId::from_raw(1), + plugin, + global: GlobalConfiguration::default(), + } + } +} + +fn verify_wasm_artifact() -> Result<()> { + let digest = Sha256::digest(RUFF_WASM); + let actual = format!("{digest:x}"); + if actual != RUFF_WASM_SHA256 { + bail!("bundled Ruff wasm checksum mismatch: expected {RUFF_WASM_SHA256}, got {actual}"); + } + Ok(()) +} + +fn ensure_final_newline(mut code: String) -> String { + if !code.ends_with('\n') { + code.push('\n'); + } + code +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_python_with_bundled_ruff() { + let formatted = format_python_code("x={\"a\":1,\"b\":2}\n").unwrap(); + assert_eq!(formatted, "x = {\"a\": 1, \"b\": 2}\n"); + } + + #[test] + fn reports_syntax_errors() { + let error = format_python_code("def nope(:\n").unwrap_err().to_string(); + assert!(error.contains("failed to format generated Python code with bundled Ruff")); + } + + #[test] + fn wasm_artifact_checksum_matches() { + verify_wasm_artifact().unwrap(); + } +}