blob: 1bf55396423a0f8bec6f55400d92fbd24e44ba1a [file]
#!/usr/bin/env python3
"""
Generator for dispatch and Python wrappers for all 440 Spark SQL functions.
Parses crates/spark-connect/src/functions.rs and generates optimized code.
"""
import argparse
import inspect
import re
import sys
from pathlib import Path
_ap = argparse.ArgumentParser(description=__doc__)
_ap.add_argument(
"--pyspark-src",
default=None,
help="Path to the reference PySpark package root (…/python). When given, generated "
"Python wrappers carry the reference's real parameter names/order so keyword calls "
"work; without it, wrappers fall back to the historical *args form.",
)
_ap.add_argument(
"--write-dispatch",
action="store_true",
help="Also (re)write crates/pyspark-rs/src/dispatch_generated.rs. OFF by default: "
"that file carries hand-tuned multi-arg arms (e.g. round_scale, "
"approx_count_distinct_rsd) this generator does not reproduce, so overwriting it "
"would regress. Only pass this when intentionally regenerating the Rust dispatch.",
)
_args = _ap.parse_args()
# Optionally load the reference pyspark to mirror real function signatures.
_ref_functions = None
if _args.pyspark_src:
sys.path.insert(0, _args.pyspark_src)
import pyspark.sql.functions as _ref_functions # noqa: E402
# Read the spark-connect functions file
functions_rs = Path("crates/spark-connect/src/functions.rs").read_text()
# Extract all function definitions with their full signatures
# Pattern: pub fn name(params) -> Column {
pattern = r"^pub fn ([a-zA-Z0-9_#]+)\((.*?)\)\s*->\s*Column\s*\{"
matches = re.finditer(pattern, functions_rs, re.MULTILINE | re.DOTALL)
functions: dict[str, dict] = {}
for match in matches:
full_name = match.group(1)
name = full_name.replace("r#", "") # Remove raw string prefix like r#struct
params_str = match.group(2).strip()
# Parse parameters
params = []
if params_str:
# Split by comma, but handle nested angle brackets
param_parts = []
current = ""
depth = 0
for char in params_str:
if char == "<":
depth += 1
elif char == ">":
depth -= 1
elif char == "," and depth == 0:
param_parts.append(current.strip())
current = ""
continue
current += char
if current.strip():
param_parts.append(current.strip())
for param in param_parts:
# Parse each parameter: name: type
if ":" in param:
parts = param.split(":")
pname = parts[0].strip()
ptype = ":".join(parts[1:]).strip()
params.append({"name": pname, "type": ptype})
functions[name] = {
"full_name": full_name,
"params": params,
"param_count": len(params),
}
print(f"Found {len(functions)} functions")
# Functions excluded from the generic dispatcher AND the Python wrappers because
# their Rust signatures don't fit the generic Vec<Column> -> Column form.
skip_functions = {
"col",
"lit",
"expr",
"sum",
"count",
"avg",
"max",
"min",
"call_function",
"call_udf",
"cast",
"column",
# Non-standard signatures:
# broadcast(DataFrame) -> DataFrame (a join hint, not a column function)
# variant_delete(Column, Vec<Column>) -> Column (variadic paths)
"broadcast",
"variant_delete",
# Mixed functions with dedicated pyfunc_* bindings:
# sha2(Column, i32) -> Column
# window(Column, &str) -> Column
# from_avro(Column, &str) -> Column
# from_avro_with_options(Column, &str, Column) -> Column
# to_avro_with_schema(Column, &str) -> Column
# from_protobuf(Column, &str) -> Column (and 3 variants)
# to_protobuf(Column, &str) -> Column (and 3 variants)
"sha2",
"window",
# window(Column, windowDuration, slideDuration, startTime): string args, so it is
# hand-wired via pyfunc_window_with_slide_and_start (functions.py), not the generic
# Vec<Column> dispatch.
"window_with_slide_and_start",
"from_avro",
"from_avro_with_options",
"to_avro_with_schema",
"from_protobuf",
"from_protobuf_with_descriptor",
"from_protobuf_with_descriptor_and_options",
"from_protobuf_with_options",
"to_protobuf",
"to_protobuf_with_descriptor",
"to_protobuf_with_descriptor_and_options",
"to_protobuf_with_options",
}
# Categorize functions by their parameter types
no_args = []
single_col = []
variadic_cols = []
multiple_cols = []
str_only = []
mixed = []
for name, fn in functions.items():
if name in skip_functions:
continue
if fn["param_count"] == 0:
no_args.append(name)
elif fn["param_count"] == 1:
param_type = fn["params"][0]["type"]
if "Vec<Column>" in param_type:
variadic_cols.append(name)
elif "Column" in param_type:
single_col.append(name)
elif "&str" in param_type or "str" in param_type:
str_only.append(name)
else:
mixed.append(name)
else:
# Check if all are Columns
all_cols = all("Column" in p["type"] for p in fn["params"])
if all_cols:
multiple_cols.append(name)
else:
mixed.append(name)
print(f" No args: {len(no_args)}")
print(f" Single Column: {len(single_col)}")
print(f" Multiple Columns: {len(multiple_cols)}")
print(f" String only: {len(str_only)}")
print(f" Mixed/Other: {len(mixed)}")
# Generate Rust dispatch function
rust_arms = []
# No-arg functions
for name in no_args:
fn = functions[name]
rust_arms.append(f' "{name}" => Ok(spark_funcs::{fn["full_name"]}()),')
# Single column functions
for name in single_col:
fn = functions[name]
rust_arms.append(f' "{name}" => {{')
rust_arms.append(
' if args.is_empty() { return Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>("Missing required column argument")); }'
)
rust_arms.append(f" Ok(spark_funcs::{fn['full_name']}(args[0].clone()))")
rust_arms.append(" },")
# Variadic (Vec<Column>) functions - forward all column args
for name in variadic_cols:
fn = functions[name]
rust_arms.append(f' "{name}" => Ok(spark_funcs::{fn["full_name"]}(args.clone())),')
# String-only functions - convert first arg to string literal
for name in str_only:
fn = functions[name]
param_name = fn["params"][0]["name"]
rust_arms.append(f' "{name}" => {{')
rust_arms.append(" // String parameter - use empty string as default")
rust_arms.append(f' Ok(spark_funcs::{fn["full_name"]}(""))')
rust_arms.append(" },")
# Multiple column functions
for name in multiple_cols:
fn = functions[name]
col_count = sum(1 for p in fn["params"] if "Column" in p["type"])
rust_arms.append(f' "{name}" => {{')
rust_arms.append(
f' if args.len() < {col_count} {{ return Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(format!("Missing required arguments for {name}: expected at least {col_count}, got {{}}", args.len()))); }}'
)
col_args = ", ".join([f"args[{i}].clone()" for i in range(col_count)])
rust_arms.append(f" Ok(spark_funcs::{fn['full_name']}({col_args}))")
rust_arms.append(" },")
# Mixed/other - try to handle generically
for name in mixed:
fn = functions[name]
# Special case for window(Column, &str)
if name == "window":
rust_arms.append(' "window" => {')
rust_arms.append(
' if args.len() < 1 { return Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>("window() requires at least 1 column argument")); }'
)
rust_arms.append(" // window takes a column and a string duration parameter")
rust_arms.append(' Ok(spark_funcs::window(args[0].clone(), ""))')
rust_arms.append(" },")
else:
# For other mixed functions, try generic handling
col_count = fn["param_count"]
rust_arms.append(f' "{name}" => {{')
rust_arms.append(" // Mixed/special case: trying generic handling")
col_args = ", ".join([f"args[{i}].clone()" for i in range(min(col_count, 1))])
if col_count <= 1:
if col_count == 0:
rust_arms.append(f" Ok(spark_funcs::{fn['full_name']}())")
else:
rust_arms.append(
' if args.is_empty() { return Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>("Missing arguments")); }'
)
rust_arms.append(
f" Ok(spark_funcs::{fn['full_name']}(args[0].clone()))"
)
else:
rust_arms.append(f" // TODO: special handling for {name}")
rust_arms.append(
f' Err(PyErr::new::<pyo3::exceptions::PyNotImplementedError, _>(format!("Function {name} not yet implemented in dispatch")))'
)
rust_arms.append(" },")
# Generate the dispatch code
dispatch_code = (
"""/// Auto-generated dispatch function for Spark SQL functions.
/// Generated by scripts/gen_fn_dispatch.py - do not edit directly.
pub fn call_builtin(name: &str, args: Vec<spark_connect::column::Column>) -> PyResult<spark_connect::column::Column> {
use spark_connect::functions as spark_funcs;
match name {
"""
+ "\n".join(rust_arms)
+ """
_ => Err(PyErr::new::<pyo3::exceptions::PyNameError, _>(
format!("Unknown function: {}", name)
)),
}
}
"""
)
# Write dispatch file (guarded: the committed file is hand-tuned; see --write-dispatch).
dispatch_file = Path("crates/pyspark-rs/src/dispatch_generated.rs")
if _args.write_dispatch:
dispatch_file.write_text(dispatch_code)
print(f"\nGenerated dispatch function in {dispatch_file}")
else:
print(f"\nSkipped {dispatch_file} (pass --write-dispatch to regenerate the Rust dispatch)")
# Generate Python wrapper functions
# (skip_functions is defined above, before categorization.)
#
# When a reference PySpark is available, emit an explicit ``def`` per function carrying
# the reference's real parameter names/order, so keyword calls (``F.first(col,
# ignorenulls=True)``) work. Optional params default to the ``_UNSET`` sentinel and are
# forwarded only when supplied, reproducing the historical ``*args`` dispatch exactly
# (same columns, same order) — see functions.py ``_dispatch`` / ``_UNSET``.
def _emit_def(name, sig):
"""Return source for ``def name(<ref params>): return _dispatch(name, [...])``."""
sig_parts = []
build = []
posonly = []
star_emitted = False
for p in sig.parameters.values():
k = p.kind
if k is inspect.Parameter.VAR_KEYWORD:
continue # **kwargs are not part of the positional dispatch
if k is inspect.Parameter.POSITIONAL_ONLY:
posonly.append(p.name)
sig_parts.append(p.name)
build.append(f" _a.append({p.name})")
elif k is inspect.Parameter.POSITIONAL_OR_KEYWORD:
if p.default is inspect.Parameter.empty:
sig_parts.append(p.name)
build.append(f" _a.append({p.name})")
else:
sig_parts.append(f"{p.name}=_UNSET")
build.append(f" if {p.name} is not _UNSET: _a.append({p.name})")
elif k is inspect.Parameter.VAR_POSITIONAL:
sig_parts.append(f"*{p.name}")
build.append(f" _a.extend({p.name})")
star_emitted = True
elif k is inspect.Parameter.KEYWORD_ONLY:
if not star_emitted:
sig_parts.append("*")
star_emitted = True
sig_parts.append(f"{p.name}=_UNSET")
build.append(f" if {p.name} is not _UNSET: _a.append({p.name})")
if posonly:
# insert the positional-only marker after the last positional-only param
idx = len(posonly)
sig_parts.insert(idx, "/")
header = f"def {name}({', '.join(sig_parts)}):"
body = [" _a = []", *build, f' return _dispatch("{name}", _a)']
return header + "\n" + "\n".join(body)
# The authoritative wrapper set is the names already in functions_generated.py (the
# shipped, golden-verified set). Deriving it from the Rust functions.rs regex is fragile:
# generic signatures (e.g. `pub fn concat_ws<C: Into<Column>>(...)`) are silently missed.
# We only *upgrade* each existing wrapper's signature; we never add or drop functions.
wrappers_file = Path("python/pyspark/sql/functions_generated.py")
_existing = wrappers_file.read_text() if wrappers_file.exists() else ""
_wrapper_names = re.findall(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*_create_wrapper\(", _existing, re.M)
_wrapper_names += re.findall(r"^def ([A-Za-z_][A-Za-z0-9_]*)\(", _existing, re.M)
_wrapper_names = sorted(set(_wrapper_names))
if not _wrapper_names:
# First-time generation: fall back to the Rust-parsed function set.
_wrapper_names = sorted(n for n in functions if n not in skip_functions)
python_wrappers = []
n_typed = 0
for name in _wrapper_names:
if name in skip_functions:
continue
ref_fn = getattr(_ref_functions, name, None) if _ref_functions is not None else None
sig = None
if ref_fn is not None and callable(ref_fn):
try:
sig = inspect.signature(ref_fn)
except (TypeError, ValueError):
sig = None
if sig is not None:
python_wrappers.append(_emit_def(name, sig))
n_typed += 1
else:
# Rust-only extra (not in the reference) — keep the historical *args form.
python_wrappers.append(f'{name} = _create_wrapper("{name}")')
python_code = (
'''"""Auto-generated wrapper functions for Spark SQL functions.
Generated by scripts/gen_fn_dispatch.py - do not edit directly.
This module is meant to be imported after _create_wrapper, _wrap, and _unwrap
are defined in functions.py
"""
# Generated wrappers (requires _create_wrapper to be defined)
'''
+ "\n".join(python_wrappers)
+ """
"""
)
print(f"Generated {len(python_wrappers)} Python wrapper functions")
print("\nSummary:")
print(f" Total functions: {len(functions)}")
print(f" Hand-written (skipped): {len(skip_functions)}")
print(f" Auto-generated: {len(functions) - len(skip_functions)}")
# Write Python wrappers file (to be imported in functions.py)
wrappers_file = Path("python/pyspark/sql/functions_generated.py")
wrappers_file.write_text(python_code)
print(f"\nGenerated Python wrappers in {wrappers_file}")