blob: e7034e24cbdfc3b535e1999ad1982823ae1dd485 [file]
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""
User-defined function (UDF) support for Spark Connect client.
Mirrors pyspark.sql.connect.udf and pyspark.sql.functions.udf/pandas_udf.
"""
import sys
from typing import Callable, Optional, Any
from pyspark.sql.types import DataType, StringType, _parse_datatype_json_string
from pyspark.serializers import CloudPickleSerializer
def _as_concrete_datatype(returnType: Any) -> DataType:
"""Return a concrete, picklable pyspark ``DataType``.
A DDL string (e.g. ``"double"``, ``"a int, b string"``) is parsed via the
Rust DDL parser and rebuilt into a concrete type through
``_parse_datatype_json_string``. This matters because the cloudpickled UDF
command must carry a real ``DataType`` (not a DDL string): the pandas/Arrow
worker pairs each result column with its spark type to build the Arrow batch,
so a bare string breaks it ("not enough values to unpack"). Mirrors how the
reference Connect client pickles the *parsed* output type.
"""
if isinstance(returnType, str):
return _parse_datatype_json_string(DataType.fromDDL(returnType).json())
return returnType
class UserDefinedFunction:
"""
Represents a user-defined function (UDF) that can be called with columns.
Stores the function, return type, eval type, and pickled command.
"""
def __init__(
self,
func: Callable[..., Any],
returnType: DataType,
evalType: int = 100, # SQL_BATCHED_UDF
name: Optional[str] = None,
deterministic: bool = True,
):
self.func = func
# Normalize to a concrete, picklable DataType so the cloudpickled command
# carries a real type (required by the pandas/Arrow worker, not just str).
self.returnType = _as_concrete_datatype(returnType)
self.evalType = evalType
self.name = name or (
func.__name__ if hasattr(func, "__name__") else "udf"
)
self.deterministic = deterministic
self.python_ver = f"{sys.version_info.major}.{sys.version_info.minor}"
# Cloudpickle the function (the command is the pickled (func, returnType) tuple).
# Use the normalized concrete DataType, not the raw arg, so the worker gets a
# real type it can turn into an Arrow schema.
serializer = CloudPickleSerializer()
self.command = serializer.dumps((func, self.returnType))
def __call__(self, *args: Any, **kwargs: Any) -> Any:
"""
Call the UDF with Column arguments.
Returns a Column representing the UDF call.
"""
from pyspark import _pyspark
from pyspark.sql.functions import col
# A UDF argument is ColumnOrName: a bare string is a COLUMN NAME (like PySpark),
# not a string literal. Coerce str -> col(str) before handing to the Rust layer
# (whose to_column would otherwise treat a str as a literal).
cols = [col(a) if isinstance(a, str) else a for a in args]
# pyfunc_make_udf accepts any DataType object or DDL string as the return type
# and builds the proto output type; the pickled command carries (func, returnType)
# for the server-side Python worker.
return _pyspark.functions.pyfunc_make_udf(
self.name,
self.returnType,
self.evalType,
self.command,
self.python_ver,
*cols,
deterministic=self.deterministic,
)
def asNondeterministic(self) -> "UserDefinedFunction":
"""Return a copy of this UDF marked as non-deterministic. Mirrors
``UserDefinedFunction.asNondeterministic`` — the server will not fold/reuse
results across rows."""
return UserDefinedFunction(
self.func, self.returnType, self.evalType, self.name, deterministic=False
)
def udf(
f: Optional[Callable[..., Any]] = None,
returnType: Optional[DataType] = None,
*,
useArrow: Optional[bool] = None,
) -> Any:
"""
Create a user-defined function (UDF).
Parameters
----------
f : callable, optional
The Python function to wrap as a UDF.
returnType : DataType, optional
The return type of the UDF. Defaults to StringType().
useArrow : bool, optional
Whether to use Arrow optimization. Defaults to None (auto-detect).
Returns
-------
UserDefinedFunction or callable
If f is provided, returns a UDF. If f is None, returns a decorator.
Examples
--------
>>> from pyspark.sql import functions as F
>>> from pyspark.sql.types import IntegerType
>>> u = F.udf(lambda x: x + 1, IntegerType())
>>> result = spark.range(3).select(u(F.col('id')).alias('inc_id'))
"""
# Decorator form @udf(returnType) / @udf("ddl"): the first positional is the
# return type (a DataType or DDL string), not the function.
if f is not None and not callable(f):
returnType, f = f, None
if returnType is None:
returnType = StringType()
evalType = 100 # SQL_BATCHED_UDF (default)
if useArrow is True:
evalType = 101 # SQL_ARROW_BATCHED_UDF
def _udf_decorator(func):
return UserDefinedFunction(func, returnType, evalType)
if f is not None:
# Direct call: @udf(f, returnType)
return _udf_decorator(f)
else:
# Decorator call: @udf(...) or @udf
return _udf_decorator
def pandas_udf(
f: Optional[Callable[..., Any]] = None,
returnType: Optional[DataType] = None,
functionType: Optional[str] = None,
) -> Any:
"""
Create a pandas UDF.
Parameters
----------
f : callable, optional
The Python function to wrap as a pandas UDF.
returnType : DataType, optional
The return type of the UDF. Defaults to StringType().
functionType : str, optional
The type of pandas UDF. Defaults to "scalar".
Options: "scalar", "grouped_map", "grouped_agg", "cogrouped_map", ...
Returns
-------
UserDefinedFunction or callable
If f is provided, returns a UDF. If f is None, returns a decorator.
Examples
--------
>>> from pyspark.sql import functions as F
>>> from pyspark.sql.types import IntegerType
>>> @F.pandas_udf(IntegerType())
... def inc_id(s):
... return s + 1
>>> result = spark.range(3).select(inc_id(F.col('id')).alias('inc_id'))
"""
# Decorator form @pandas_udf(returnType) / @pandas_udf("ddl"): the first
# positional is the return type, not the function.
if f is not None and not callable(f):
returnType, f = f, None
if returnType is None:
returnType = StringType()
# Map function type names to eval types
eval_type_map = {
"scalar": 200, # SQL_SCALAR_PANDAS_UDF
"grouped_map": 201, # SQL_GROUPED_MAP_PANDAS_UDF
"grouped_agg": 202, # SQL_GROUPED_AGG_PANDAS_UDF
"window_agg": 203, # SQL_WINDOW_AGG_PANDAS_UDF
"scalar_iter": 204, # SQL_SCALAR_PANDAS_ITER_UDF
"map_iter": 205, # SQL_MAP_PANDAS_ITER_UDF
"cogrouped_map": 206, # SQL_COGROUPED_MAP_PANDAS_UDF
}
def _pandas_udf_decorator(func):
et = eval_type_map.get(functionType) if functionType is not None else None
if et is None:
# Modern form @pandas_udf("type"): infer the eval type from the function's
# pandas type hints (Series->Series = scalar, Iterator[Series]->Iterator =
# scalar_iter, Series->scalar = grouped_agg), mirroring
# pyspark.sql.pandas.functions.pandas_udf.
try:
from inspect import signature
from typing import get_type_hints
from pyspark.sql.pandas.typehints import infer_eval_type
et = infer_eval_type(signature(func), get_type_hints(func))
except Exception:
et = None
if et is None:
et = 200 # SQL_SCALAR_PANDAS_UDF
return UserDefinedFunction(func, returnType, et)
if f is not None:
# Direct call
return _pandas_udf_decorator(f)
else:
# Decorator call
return _pandas_udf_decorator
def arrow_udf(
f: Optional[Callable[..., Any]] = None,
returnType: Optional[DataType] = None,
functionType: str = "scalar",
) -> Any:
"""
Create an Arrow user-defined function, mirroring ``pyspark.sql.functions.arrow_udf``.
Arrow UDFs transfer data via Arrow and operate on ``pyarrow.Array`` values. This is
the Arrow analogue of :func:`pandas_udf`.
Parameters
----------
f : callable, optional
The Python function to wrap.
returnType : DataType, optional
Return type; defaults to StringType().
functionType : str, optional
"scalar" (default) or "scalar_iter".
"""
# Decorator form @arrow_udf(returnType) / @arrow_udf("ddl").
if f is not None and not callable(f):
returnType, f = f, None
if returnType is None:
returnType = StringType()
# SQL_SCALAR_ARROW_UDF = 250, SQL_SCALAR_ARROW_ITER_UDF = 251.
eval_type_map = {"scalar": 250, "scalar_iter": 251}
evalType = eval_type_map.get(functionType, 250)
def _arrow_udf_decorator(func):
return UserDefinedFunction(func, returnType, evalType)
if f is not None:
return _arrow_udf_decorator(f)
return _arrow_udf_decorator
class UDFRegistration:
"""
Wrapper for user-defined function registration (spark.udf.register).
"""
def __init__(self, spark_session: Any):
self.spark_session = spark_session
def register(
self,
name: str,
f: Callable[..., Any],
returnType: Optional[DataType] = None,
) -> UserDefinedFunction:
"""
Register a Python UDF with the given name.
Parameters
----------
name : str
Name to register the UDF with.
f : callable
The Python function to register.
returnType : DataType, optional
The return type of the UDF. Defaults to StringType().
Returns
-------
UserDefinedFunction
The registered UDF that can be called with columns.
Examples
--------
>>> from pyspark.sql.types import IntegerType
>>> spark.udf.register("inc_id", lambda x: x + 1, IntegerType())
>>> result = spark.sql("SELECT inc_id(id) AS incremented FROM range(3)")
"""
# If f is already a UDF (from udf()/pandas_udf()), reuse its packed command;
# otherwise wrap the plain Python function as a SQL_BATCHED_UDF.
if hasattr(f, "command") and hasattr(f, "evalType"):
if returnType is not None:
from pyspark.errors import PySparkTypeError
raise PySparkTypeError(
errorClass="CANNOT_SPECIFY_RETURN_TYPE_FOR_UDF",
messageParameters={"arg_name": "f", "return_type": str(returnType)},
)
udf = f
else:
if returnType is None:
returnType = StringType()
udf = UserDefinedFunction(f, returnType, 100, name)
# Send the registration to the Connect server so `name` resolves in SQL
# (mirrors the reference client.register_udf: a RegisterFunction command
# carrying the cloudpickled PythonUDF with no bound arguments).
self.spark_session._registerPythonUdf(
name, udf.returnType, udf.evalType, udf.command, udf.python_ver, udf.deterministic
)
return udf
def registerJavaFunction(
self,
name: str,
javaClassName: str,
returnType: Optional[DataType] = None,
) -> None:
"""Register a Java UDF by fully-qualified class name so it is callable by
``name`` in SQL. Mirrors ``UDFRegistration.registerJavaFunction``.
Parameters
----------
name : str
Name to register the function with.
javaClassName : str
Fully-qualified name of the Java class implementing the UDF.
returnType : DataType or str, optional
Return type (a DataType or a DDL string). Defaults to the class's own type.
"""
rt_ddl = None
if returnType is not None:
rt_ddl = returnType if isinstance(returnType, str) else returnType.simpleString()
self.spark_session._registerJavaFunction(name, javaClassName, rt_ddl, False)
def registerJavaUDAF(self, name: str, javaClassName: str) -> None:
"""Register a Java user-defined aggregate function (UDAF) by class name.
Mirrors ``UDFRegistration.registerJavaUDAF``.
"""
self.spark_session._registerJavaFunction(name, javaClassName, None, True)