use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use base64::Engine as _;
use crate::column::Column;
use crate::expression::Expression;
use crate::types::DataType;
use crate::udf::{eval_type, CommonInlineUserDefinedFunctionExpression, PythonUDFPayload};
use spark_connect_core::error::Result;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AbiType {
I32,
I64,
F32,
F64,
Bool,
Str,
Binary,
Array(Box<AbiType>),
Nullable(Box<AbiType>),
}
impl AbiType {
pub fn descriptor(&self) -> String {
match self {
AbiType::I32 => "i32".to_string(),
AbiType::I64 => "i64".to_string(),
AbiType::F32 => "f32".to_string(),
AbiType::F64 => "f64".to_string(),
AbiType::Bool => "bool".to_string(),
AbiType::Str => "string".to_string(),
AbiType::Binary => "binary".to_string(),
AbiType::Array(inner) => format!("array:{}", inner.descriptor()),
AbiType::Nullable(inner) => format!("option:{}", inner.descriptor()),
}
}
pub fn to_data_type(&self) -> DataType {
match self {
AbiType::I32 => DataType::Integer,
AbiType::I64 => DataType::Long,
AbiType::F32 => DataType::Float,
AbiType::F64 => DataType::Double,
AbiType::Bool => DataType::Boolean,
AbiType::Str => DataType::String {
collation: "UTF8_BINARY".to_string(),
},
AbiType::Binary => DataType::Binary,
AbiType::Array(inner) => DataType::Array {
element_type: Box::new(inner.to_data_type()),
contains_null: matches!(**inner, AbiType::Nullable(_)),
},
AbiType::Nullable(inner) => inner.to_data_type(),
}
}
fn to_spark_json(&self) -> serde_json::Value {
use serde_json::json;
match self {
AbiType::I32 => json!("integer"),
AbiType::I64 => json!("long"),
AbiType::F32 => json!("float"),
AbiType::F64 => json!("double"),
AbiType::Bool => json!("boolean"),
AbiType::Str => json!("string"),
AbiType::Binary => json!("binary"),
AbiType::Array(inner) => json!({
"type": "array",
"elementType": inner.to_spark_json(),
"containsNull": matches!(**inner, AbiType::Nullable(_)),
}),
AbiType::Nullable(inner) => inner.to_spark_json(),
}
}
}
const PACKER_SRC: &str = include_str!("wasm_packer.py");
#[derive(Debug, Clone)]
pub struct PythonPacker {
pub python_exe: String,
pub pythonpath: Vec<PathBuf>,
}
impl Default for PythonPacker {
fn default() -> Self {
let python_exe = std::env::var("SPARK_CONNECT_PYTHON")
.or_else(|_| std::env::var("PYSPARK_PYTHON"))
.unwrap_or_else(|_| "python3".to_string());
let pythonpath = std::env::var("SPARK_CONNECT_WASM_PACKER_PATH")
.ok()
.map(|p| vec![PathBuf::from(p)])
.unwrap_or_default();
PythonPacker {
python_exe,
pythonpath,
}
}
}
impl PythonPacker {
fn run(&self, spec_json: &str) -> Result<Vec<u8>> {
use spark_connect_core::error::SparkError;
let mut cmd = Command::new(&self.python_exe);
cmd.arg("-c")
.arg(PACKER_SRC)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if !self.pythonpath.is_empty() {
let mut joined = self
.pythonpath
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(pathsep());
if let Ok(existing) = std::env::var("PYTHONPATH") {
joined.push_str(pathsep());
joined.push_str(&existing);
}
cmd.env("PYTHONPATH", joined);
}
let mut child = cmd.spawn().map_err(|e| {
SparkError::connect_msg(format!(
"failed to spawn WASM UDF packer '{}': {e}",
self.python_exe
))
})?;
child
.stdin
.take()
.expect("stdin was piped")
.write_all(spec_json.as_bytes())
.map_err(|e| SparkError::connect_msg(format!("failed to write UDF spec: {e}")))?;
let output = child
.wait_with_output()
.map_err(|e| SparkError::connect_msg(format!("WASM UDF packer failed: {e}")))?;
if !output.status.success() {
return Err(SparkError::connect_msg(format!(
"WASM UDF packer exited with {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
)));
}
if output.stdout.is_empty() {
return Err(SparkError::connect_msg(
"WASM UDF packer produced an empty command".to_string(),
));
}
Ok(output.stdout)
}
}
#[cfg(windows)]
fn pathsep() -> &'static str {
";"
}
#[cfg(not(windows))]
fn pathsep() -> &'static str {
":"
}
pub fn udf(
name: impl Into<String>,
wasm_module: impl Into<Vec<u8>>,
entrypoint: impl Into<String>,
arg_types: Vec<AbiType>,
ret_type: AbiType,
) -> UserDefinedFunction {
UserDefinedFunction {
name: name.into(),
wasm_module: wasm_module.into(),
entrypoint: entrypoint.into(),
arg_types,
ret_type,
deterministic: true,
eval_type: eval_type::SQL_ARROW_BATCHED_UDF,
python_ver: default_python_ver(),
packer: PythonPacker::default(),
}
}
#[derive(Debug, Clone)]
pub struct UserDefinedFunction {
name: String,
wasm_module: Vec<u8>,
entrypoint: String,
arg_types: Vec<AbiType>,
ret_type: AbiType,
deterministic: bool,
eval_type: i32,
python_ver: String,
packer: PythonPacker,
}
impl UserDefinedFunction {
pub fn as_nondeterministic(mut self) -> Self {
self.deterministic = false;
self
}
pub fn with_eval_type(mut self, value: i32) -> Self {
self.eval_type = value;
self
}
pub fn with_python_ver(mut self, value: impl Into<String>) -> Self {
self.python_ver = value.into();
self
}
pub fn with_packer(mut self, packer: PythonPacker) -> Self {
self.packer = packer;
self
}
pub fn output_type(&self) -> DataType {
self.ret_type.to_data_type()
}
fn build_spec(&self) -> String {
let spec = serde_json::json!({
"wasm_b64": base64::engine::general_purpose::STANDARD.encode(&self.wasm_module),
"entrypoint": self.entrypoint,
"arg_types": self.arg_types.iter().map(|t| t.descriptor()).collect::<Vec<_>>(),
"ret_type": self.ret_type.descriptor(),
"output_type": self.ret_type.to_spark_json(),
});
spec.to_string()
}
fn build_command(&self) -> Result<Vec<u8>> {
self.packer.run(&self.build_spec())
}
pub fn to_payload(&self) -> Result<PythonUDFPayload> {
Ok(PythonUDFPayload::new(
self.output_type(),
self.eval_type,
self.build_command()?,
self.python_ver.clone(),
))
}
pub fn to_expression(
&self,
args: Vec<Column>,
) -> Result<CommonInlineUserDefinedFunctionExpression> {
Ok(CommonInlineUserDefinedFunctionExpression::new(
self.name.clone(),
self.deterministic,
args.iter().map(|c| c.expression().clone()).collect(),
self.to_payload()?,
))
}
pub fn call<C: Into<Column>>(&self, args: impl IntoIterator<Item = C>) -> Result<Column> {
let args: Vec<Column> = args.into_iter().map(Into::into).collect();
let expr = self.to_expression(args)?;
Ok(Column::new(Expression::CommonInlineUserDefinedFunction(
Box::new(expr),
)))
}
fn registration_expression(
&self,
name: &str,
) -> Result<CommonInlineUserDefinedFunctionExpression> {
Ok(CommonInlineUserDefinedFunctionExpression::new(
name.to_string(),
self.deterministic,
vec![],
self.to_payload()?,
))
}
}
pub struct UdfRegistration<'a> {
session: &'a crate::session::SparkSession,
}
impl<'a> UdfRegistration<'a> {
pub fn register(&self, name: &str, udf: &UserDefinedFunction) -> Result<()> {
self.session
.register_function(udf.registration_expression(name)?)
}
}
impl crate::session::SparkSession {
pub fn udf(&self) -> UdfRegistration<'_> {
UdfRegistration { session: self }
}
}
fn default_python_ver() -> String {
let python_exe = std::env::var("SPARK_CONNECT_PYTHON")
.or_else(|_| std::env::var("PYSPARK_PYTHON"))
.unwrap_or_else(|_| "python3".to_string());
match detect_python_version(&python_exe) {
Some(version) => version,
None => {
"3.11".to_string()
}
}
}
fn detect_python_version(python_exe: &str) -> Option<String> {
use std::process::Command;
let output = Command::new(python_exe)
.arg("-c")
.arg("import sys;print(f'{sys.version_info.major}.{sys.version_info.minor}')")
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout)
.ok()
.and_then(|s| parse_python_version_output(&s))
}
fn parse_python_version_output(output: &str) -> Option<String> {
let trimmed = output.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> UserDefinedFunction {
udf(
"add_one",
vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00],
"add_one",
vec![AbiType::I64],
AbiType::I64,
)
}
#[test]
fn defaults_to_arrow_batched_udf() {
let f = sample();
assert_eq!(f.eval_type, eval_type::SQL_ARROW_BATCHED_UDF);
assert!(f.deterministic);
}
#[test]
fn as_nondeterministic_flips_flag() {
assert!(!sample().as_nondeterministic().deterministic);
}
#[test]
fn abitype_descriptors() {
assert_eq!(AbiType::I64.descriptor(), "i64");
assert_eq!(AbiType::Str.descriptor(), "string");
assert_eq!(AbiType::Binary.descriptor(), "binary");
assert_eq!(
AbiType::Array(Box::new(AbiType::I64)).descriptor(),
"array:i64"
);
assert_eq!(
AbiType::Nullable(Box::new(AbiType::Str)).descriptor(),
"option:string"
);
assert_eq!(
AbiType::Array(Box::new(AbiType::Nullable(Box::new(AbiType::I32)))).descriptor(),
"array:option:i32"
);
}
#[test]
fn abitype_to_data_type() {
assert_eq!(AbiType::I64.to_data_type(), DataType::Long);
assert_eq!(AbiType::Bool.to_data_type(), DataType::Boolean);
assert_eq!(AbiType::Binary.to_data_type(), DataType::Binary);
match AbiType::Array(Box::new(AbiType::Nullable(Box::new(AbiType::I64)))).to_data_type() {
DataType::Array {
element_type,
contains_null,
} => {
assert_eq!(*element_type, DataType::Long);
assert!(contains_null);
}
other => panic!("expected array, got {other:?}"),
}
}
#[test]
fn abitype_spark_json() {
assert_eq!(AbiType::I64.to_spark_json(), serde_json::json!("long"));
assert_eq!(
AbiType::Array(Box::new(AbiType::Str)).to_spark_json(),
serde_json::json!({"type": "array", "elementType": "string", "containsNull": false})
);
}
#[test]
fn build_spec_encodes_signature_and_wasm() {
let f = sample();
let spec: serde_json::Value = serde_json::from_str(&f.build_spec()).unwrap();
assert_eq!(spec["entrypoint"], "add_one");
assert_eq!(spec["arg_types"], serde_json::json!(["i64"]));
assert_eq!(spec["ret_type"], "i64");
assert_eq!(spec["output_type"], "long");
assert_eq!(spec["wasm_b64"], "AGFzbQEA");
}
#[test]
#[ignore]
fn packer_produces_nonempty_command() {
let f = sample();
let cmd = f.build_command().expect("packer should succeed");
assert!(!cmd.is_empty());
assert_eq!(cmd[0], 0x80); }
#[test]
fn parse_python_version_output_trims_whitespace() {
assert_eq!(
parse_python_version_output("3.11\n"),
Some("3.11".to_string())
);
assert_eq!(
parse_python_version_output("3.12"),
Some("3.12".to_string())
);
assert_eq!(
parse_python_version_output("3.9\n\n"),
Some("3.9".to_string())
);
assert_eq!(parse_python_version_output(""), None);
assert_eq!(parse_python_version_output(" \n"), None);
}
}