use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use fidius_core::python_descriptor::{PythonInterfaceDescriptor, PythonMethodDesc};
use fidius_python::{load_python_plugin, PythonCallError};
const HASH: u64 = 0xDEADBEEF_CAFEF00D;
const GREETER_METHODS: [PythonMethodDesc; 3] = [
PythonMethodDesc {
name: "greet",
wire_raw: false,
},
PythonMethodDesc {
name: "double",
wire_raw: false,
},
PythonMethodDesc {
name: "reverse_bytes",
wire_raw: true,
},
];
const ERROR_METHODS: [PythonMethodDesc; 1] = [PythonMethodDesc {
name: "boom",
wire_raw: false,
}];
fn fresh_descriptor(
methods: &'static [PythonMethodDesc],
) -> (&'static PythonInterfaceDescriptor, String) {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let id = COUNTER.fetch_add(1, Ordering::SeqCst);
let name = format!("greeter_t{id}");
let leaked_name: &'static str = Box::leak(name.clone().into_boxed_str());
let desc = Box::leak(Box::new(PythonInterfaceDescriptor {
interface_name: leaked_name,
interface_hash: HASH,
methods,
}));
(desc, name)
}
fn make_plugin(
tmp: &tempfile::TempDir,
entry_module: &str,
declared_hash: u64,
methods_source: &str,
) -> PathBuf {
let dir = tmp.path().to_path_buf();
std::fs::write(
dir.join("package.toml"),
format!(
r#"
[package]
name = "{entry_module}-py"
version = "0.1.0"
interface = "greeter"
interface_version = 1
runtime = "python"
[metadata]
category = "test"
[python]
entry_module = "{entry_module}"
"#
),
)
.unwrap();
let sdk_src = repo_root().join("python/fidius");
let vendor = dir.join("vendor");
std::fs::create_dir_all(&vendor).unwrap();
copy_dir(&sdk_src, &vendor.join("fidius"));
std::fs::write(
dir.join(format!("{entry_module}.py")),
format!(
r#"
from fidius import method, PluginError
__interface_hash__ = {hash}
{methods_source}
"#,
hash = declared_hash,
methods_source = methods_source,
),
)
.unwrap();
dir
}
const GREETER_METHODS_SRC: &str = r#"
@method
def greet(name):
return f"Hello, {name}!"
@method
def double(payload):
return {"name": payload["name"], "twice": payload["count"] * 2}
@method
def reverse_bytes(data):
return bytes(reversed(data))
"#;
const ERROR_METHODS_SRC: &str = r#"
@method
def boom(arg):
raise PluginError("BAD_INPUT", "deliberate failure", details={"got": arg})
"#;
fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf()
}
fn copy_dir(src: &std::path::Path, dst: &std::path::Path) {
std::fs::create_dir_all(dst).unwrap();
for entry in std::fs::read_dir(src).unwrap() {
let entry = entry.unwrap();
let from = entry.path();
let to = dst.join(entry.file_name());
if from.is_dir() {
copy_dir(&from, &to);
} else {
std::fs::copy(&from, &to).unwrap();
}
}
}
fn load_greeter() -> (tempfile::TempDir, fidius_python::PythonPluginHandle) {
let tmp = tempfile::TempDir::new().unwrap();
let (desc, mod_name) = fresh_descriptor(&GREETER_METHODS);
let dir = make_plugin(&tmp, &mod_name, HASH, GREETER_METHODS_SRC);
let handle = load_python_plugin(&dir, desc).expect("load");
(tmp, handle)
}
#[test]
fn typed_call_round_trip_string() {
let (_tmp, handle) = load_greeter();
let input = serde_json::to_vec(&("World".to_string(),)).unwrap();
let out = handle.call_typed_json(0, &input).expect("greet");
let result: String = serde_json::from_slice(&out).unwrap();
assert_eq!(result, "Hello, World!");
}
#[test]
fn typed_call_with_struct_args() {
let (_tmp, handle) = load_greeter();
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
struct DoubleIn {
name: String,
count: i64,
}
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
struct DoubleOut {
name: String,
twice: i64,
}
let input = serde_json::to_vec(&(DoubleIn {
name: "alpha".to_string(),
count: 5,
},))
.unwrap();
let out = handle.call_typed_json(1, &input).expect("double");
let parsed: DoubleOut = serde_json::from_slice(&out).unwrap();
assert_eq!(
parsed,
DoubleOut {
name: "alpha".to_string(),
twice: 10,
}
);
}
#[test]
fn raw_call_round_trip_2mb() {
let (_tmp, handle) = load_greeter();
let payload: Vec<u8> = (0..(2 * 1024 * 1024u32))
.map(|i| (i & 0xFF) as u8)
.collect();
let result = handle.call_raw(2, &payload).expect("reverse_bytes");
assert_eq!(result.len(), payload.len());
assert_eq!(result.first(), payload.last());
assert_eq!(result.last(), payload.first());
}
#[test]
fn plugin_error_round_trips_with_code_and_details() {
let tmp = tempfile::TempDir::new().unwrap();
let (desc, mod_name) = fresh_descriptor(&ERROR_METHODS);
let dir = make_plugin(&tmp, &mod_name, HASH, ERROR_METHODS_SRC);
let handle = load_python_plugin(&dir, desc).expect("load");
let input = serde_json::to_vec(&(42i64,)).unwrap();
let err = handle.call_typed_json(0, &input).unwrap_err();
match err {
PythonCallError::Plugin(pe) => {
assert_eq!(pe.code, "PluginError");
assert!(pe.message.contains("deliberate failure"));
let details = pe.details.expect("details");
assert!(
details.contains("traceback"),
"expected traceback in details, got: {details}"
);
}
other => panic!("expected Plugin error, got {other:?}"),
}
}
#[test]
fn interface_hash_mismatch_is_rejected() {
let tmp = tempfile::TempDir::new().unwrap();
let (desc, mod_name) = fresh_descriptor(&GREETER_METHODS);
let dir = make_plugin(&tmp, &mod_name, 0xBAD_BAD_BAD_BAD, GREETER_METHODS_SRC);
let err = load_python_plugin(&dir, desc).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("interface hash mismatch"),
"expected hash mismatch, got: {msg}"
);
}
#[test]
fn wire_mode_mismatch_typed_called_as_raw_errors() {
let (_tmp, handle) = load_greeter();
let err = handle.call_raw(0, b"oops").unwrap_err();
assert!(matches!(err, PythonCallError::WireModeMismatch { .. }));
}
#[test]
fn out_of_range_method_index_errors() {
let (_tmp, handle) = load_greeter();
let err = handle.call_typed_json(99, b"[]").unwrap_err();
assert!(matches!(err, PythonCallError::InvalidMethodIndex { .. }));
}