diff --git a/docs/public-api.txt b/docs/public-api.txt index 8903fdb..3919fa3 100644 --- a/docs/public-api.txt +++ b/docs/public-api.txt @@ -573,6 +573,8 @@ pub fn ea_compiler::bind_common::is_pointer(ty: &str) -> bool pub fn ea_compiler::bind_common::parse_exports(json: &str) -> core::result::Result, alloc::string::String> pub fn ea_compiler::bind_common::parse_string_field(json: &str, key: &str) -> core::option::Option pub fn ea_compiler::bind_common::pointer_inner(ty: &str) -> core::option::Option<&str> +pub fn ea_compiler::bind_common::python_lib_path_expr(lib_name: &str) -> alloc::string::String +pub fn ea_compiler::bind_common::rust_link_stem(lib_name: &str) -> alloc::string::String pub mod ea_compiler::bind_cpp pub fn ea_compiler::bind_cpp::generate(json_str: &str, module_stem: &str) -> core::result::Result pub mod ea_compiler::bind_python diff --git a/src/bind_common.rs b/src/bind_common.rs index 379cd6f..e0a88a1 100644 --- a/src/bind_common.rs +++ b/src/bind_common.rs @@ -237,3 +237,27 @@ fn find_matching_brace(s: &str) -> Result { } Err("unmatched '{'".into()) } + +/// Build a Python expression that resolves `lib_name` relative to the generated +/// module's directory. Splits on `/` so directory components survive +/// `pathlib.Path.with_name`'s no-slashes constraint (issue #1). +pub fn python_lib_path_expr(lib_name: &str) -> String { + let mut expr = String::from("_Path(__file__).parent"); + for segment in lib_name.split('/').filter(|s| !s.is_empty()) { + expr.push_str(" / \""); + expr.push_str(segment); + expr.push('"'); + } + expr +} + +/// Normalize a library path/filename into a Rust `#[link(name = ...)]` stem. +/// Strips directory components, the trailing `.so`/`.dll`, and the Unix `lib` +/// prefix — e.g. `lib/libfoo.so` -> `foo` (issue #1). +pub fn rust_link_stem(lib_name: &str) -> String { + let basename = lib_name.rsplit('/').next().unwrap_or(lib_name); + let trimmed = basename + .trim_end_matches(".so") + .trim_end_matches(".dll"); + trimmed.strip_prefix("lib").unwrap_or(trimmed).to_string() +} diff --git a/src/bind_python.rs b/src/bind_python.rs index 25615d6..40793a1 100644 --- a/src/bind_python.rs +++ b/src/bind_python.rs @@ -1,6 +1,6 @@ use crate::bind_common::{ ExportFunc, find_collapsed_args, has_out_params, parse_exports, parse_string_field, - pointer_inner, + pointer_inner, python_lib_path_expr, }; pub fn generate(json_str: &str, module_stem: &str) -> Result { @@ -16,7 +16,8 @@ pub fn generate(json_str: &str, module_stem: &str) -> Result { out.push_str("import numpy as _np\n"); out.push_str("from pathlib import Path as _Path\n\n"); out.push_str(&format!( - "_lib = _ct.CDLL(str(_Path(__file__).with_name(\"{lib_name}\")))\n\n" + "_lib = _ct.CDLL(str({}))\n\n", + python_lib_path_expr(&lib_name) )); let has_parallel = exports.iter().any(crate::bind_common::is_parallelizable); diff --git a/src/bind_pytorch.rs b/src/bind_pytorch.rs index fdd2adc..ad39c1d 100644 --- a/src/bind_pytorch.rs +++ b/src/bind_pytorch.rs @@ -1,6 +1,6 @@ use crate::bind_common::{ ExportFunc, find_collapsed_args, is_mut_pointer, parse_exports, parse_string_field, - pointer_inner, + pointer_inner, python_lib_path_expr, }; pub fn generate(json_str: &str, module_stem: &str) -> Result { @@ -16,7 +16,8 @@ pub fn generate(json_str: &str, module_stem: &str) -> Result { out.push_str("import torch as _torch\n"); out.push_str("from pathlib import Path as _Path\n\n"); out.push_str(&format!( - "_lib = _ct.CDLL(str(_Path(__file__).with_name(\"{lib_name}\")))\n" + "_lib = _ct.CDLL(str({}))\n", + python_lib_path_expr(&lib_name) )); for func in &exports { diff --git a/src/bind_rust.rs b/src/bind_rust.rs index 543e9ea..c21f4c9 100644 --- a/src/bind_rust.rs +++ b/src/bind_rust.rs @@ -1,16 +1,12 @@ use crate::bind_common::{ ExportFunc, find_collapsed_args, is_mut_pointer, parse_exports, parse_string_field, - pointer_inner, + pointer_inner, rust_link_stem, }; pub fn generate(json_str: &str, module_stem: &str) -> Result { let exports = parse_exports(json_str)?; let lib_name = parse_string_field(json_str, "library") - .map(|l| { - l.trim_end_matches(".so") - .trim_end_matches(".dll") - .to_string() - }) + .map(|l| rust_link_stem(&l)) .unwrap_or_else(|| module_stem.to_string()); let mut out = String::new(); diff --git a/tests/bind.rs b/tests/bind.rs index 5cb0bb0..68b1fa6 100644 --- a/tests/bind.rs +++ b/tests/bind.rs @@ -466,6 +466,36 @@ fn test_python_parallel_in_all() { ); } +#[test] +fn test_python_library_path_with_slash() { + // Issue #1: when `library` contains a directory component (e.g. `-o lib/libfoo.so`), + // generated Python used `Path.with_name("lib/libfoo.so")` which raises ValueError — + // `with_name` rejects any name containing `/`. Fix splits the path and joins it + // onto `_Path(__file__).parent`. + let json = r#"{"library": "lib/libfoo.so", "exports": [{"name": "noop", "args": [], "return_type": null}], "structs": []}"#; + let py = ea_compiler::bind_python::generate(json, "foo").unwrap(); + + assert!( + !py.contains("with_name(\"lib/libfoo.so\")"), + "must not pass slashed path to with_name (Python rejects it), got:\n{py}" + ); + assert!( + py.contains("_Path(__file__).parent / \"lib\" / \"libfoo.so\""), + "should build path by joining parent with each segment, got:\n{py}" + ); +} + +#[test] +fn test_python_library_plain_name_unchanged() { + // Plain (no-slash) library names should still work — emitted as a single parent/name join. + let json = r#"{"library": "kernel.so", "exports": [{"name": "noop", "args": [], "return_type": null}], "structs": []}"#; + let py = ea_compiler::bind_python::generate(json, "kernel").unwrap(); + assert!( + py.contains("_Path(__file__).parent / \"kernel.so\""), + "should join parent with library name, got:\n{py}" + ); +} + #[test] fn test_is_not_parallelizable_no_pointer() { use ea_compiler::bind_common::{Arg, ExportFunc, is_parallelizable}; diff --git a/tests/bind_generators.rs b/tests/bind_generators.rs index f4c9f11..e68ca9c 100644 --- a/tests/bind_generators.rs +++ b/tests/bind_generators.rs @@ -359,6 +359,44 @@ fn test_pytorch_type_hints() { ); } +#[test] +fn test_pytorch_library_path_with_slash() { + // Issue #1: same `with_name` bug as bind_python — pytorch binding must split path segments. + let json = r#"{"library": "lib/libfoo.so", "exports": [{"name": "noop", "args": [], "return_type": null}], "structs": []}"#; + let py = ea_compiler::bind_pytorch::generate(json, "foo").unwrap(); + + assert!( + !py.contains("with_name(\"lib/libfoo.so\")"), + "must not pass slashed path to with_name, got:\n{py}" + ); + assert!( + py.contains("_Path(__file__).parent / \"lib\" / \"libfoo.so\""), + "should build path by joining parent with each segment, got:\n{py}" + ); +} + +#[test] +fn test_rust_library_path_with_slash() { + // Issue #1 (Rust variant): `#[link(name = "lib/libfoo")]` is invalid — Rust link + // names must be a bare library stem (no path, no `lib` prefix on Unix). + // For `library: "lib/libfoo.so"`, the link name should be `foo`. + let json = r#"{"library": "lib/libfoo.so", "exports": [{"name": "noop", "args": [], "return_type": null}], "structs": []}"#; + let rs = ea_compiler::bind_rust::generate(json, "foo").unwrap(); + + assert!( + !rs.contains("name = \"lib/libfoo\""), + "link name must not contain directory components, got:\n{rs}" + ); + assert!( + !rs.contains("name = \"libfoo\""), + "link name must strip the `lib` prefix (Unix convention), got:\n{rs}" + ); + assert!( + rs.contains("#[link(name = \"foo\")]"), + "expected stem-only link name `foo`, got:\n{rs}" + ); +} + #[test] fn test_pytorch_friendly_docstring() { let json = r#"{"library": "k.so", "exports": [{"name": "dot", "args": [{"name": "a", "type": "*f32"}, {"name": "b", "type": "*f32"}, {"name": "n", "type": "i32"}], "return_type": "f32"}], "structs": []}"#;