Source code for pyspark.inprocess.udf

#
# 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.
#

"""
Python API for in-process UDF registration.

Usage::

    import pyarrow.compute as pc
    from pyspark.inprocess import inprocess_udf
    from pyspark.sql.types import LongType

    @inprocess_udf(returnType=LongType())
    def double(x):
        # x is a pa.Array; return a pa.Array
        return pc.multiply(x, 2)

    df.select(double(df.value)).show()
"""

import io
import sys
from functools import update_wrapper
from inspect import signature
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union

from pyspark import Accumulator, Broadcast, cloudpickle
from pyspark.errors import PySparkNotImplementedError, PySparkTypeError, PySparkValueError
from pyspark.sql.column import Column
from pyspark.sql.types import DataType, _parse_datatype_string
from pyspark.util import PythonEvalType

if TYPE_CHECKING:
    from py4j.java_gateway import JavaObject


class _InProcessPickler(cloudpickle.CloudPickler):
    def reducer_override(self, obj: Any) -> Any:
        if isinstance(obj, (Broadcast, Accumulator)):
            raise PySparkNotImplementedError(
                errorClass="NOT_IMPLEMENTED",
                messageParameters={
                    "feature": "Spark broadcasts or accumulators in in-process UDFs"
                },
            )
        return super().reducer_override(obj)


def _serialize_udf(func: Callable) -> bytes:
    buffer = io.BytesIO()
    _InProcessPickler(buffer).dump(func)
    return buffer.getvalue()


def _check_return_type_argument(returnType: Any) -> Union[DataType, str]:
    if not isinstance(returnType, (DataType, str)):
        raise PySparkTypeError(
            errorClass="NOT_EXPECTED_TYPE",
            messageParameters={
                "expected_type": "DataType or str",
                "arg_name": "returnType",
                "arg_type": type(returnType).__name__,
            },
        )
    return returnType


class InProcessUDFWrapper:
    """
    Wraps a Python function as an in-process UDF.

    Returned by ``@inprocess_udf``. Calling an instance with Spark ``Column``
    arguments creates a ``Column`` expression backed by ``PythonUDF``
    on the JVM side.
    """

    def __init__(
        self, func: Callable, returnType: Union[DataType, str], deterministic: bool = True
    ) -> None:
        if not callable(func):
            raise PySparkTypeError(
                errorClass="NOT_EXPECTED_TYPE",
                messageParameters={
                    "expected_type": "function",
                    "arg_name": "f",
                    "arg_type": type(func).__name__,
                },
            )
        _check_return_type_argument(returnType)
        self._return_type = returnType
        self._parsed_return_type: Optional[DataType] = None
        self.evalType = PythonEvalType.SQL_SCALAR_ARROW_INPROCESS_UDF
        self._deterministic: bool = deterministic
        # As for other Python UDFs, e.g. a functools.partial or a callable object.
        self._name: str = getattr(func, "__name__", type(func).__name__)

        from pyspark.sql.pandas.utils import require_minimum_pyarrow_version

        require_minimum_pyarrow_version()
        if not signature(func).parameters:
            raise PySparkValueError(
                errorClass="INVALID_PANDAS_UDF",
                messageParameters={"detail": "0-arg inprocess_udfs are not supported."},
            )
        self._func = func
        # The JVM-side function, created once so that calls do not resend the command.
        self._jfunc: Optional["JavaObject"] = None
        update_wrapper(self, func, updated=())
        self.__name__ = self._name

    @property
    def func(self) -> Callable:
        return self._func

    @property
    def returnType(self) -> DataType:
        if self._parsed_return_type is None:
            parsed = (
                _parse_datatype_string(self._return_type)
                if isinstance(self._return_type, str)
                else self._return_type
            )
            from pyspark.sql.udf import UserDefinedFunction

            UserDefinedFunction._check_return_type(parsed, PythonEvalType.SQL_SCALAR_ARROW_UDF)
            from pyspark.sql.pandas.types import to_arrow_type

            to_arrow_type(parsed, timezone="UTC", error_on_duplicated_field_names_in_struct=True)
            self._parsed_return_type = parsed
        return self._parsed_return_type

    @property
    def deterministic(self) -> bool:
        return self._deterministic

    def asNondeterministic(self) -> "InProcessUDFWrapper":
        self._deterministic = False
        return self

    def __getstate__(self) -> Dict[str, Any]:
        # The JVM-side function holds the py4j gateway, which cannot be pickled or copied. A
        # copy, possibly in another Python, serializes the function and creates its own.
        state = self.__dict__.copy()
        state["_jfunc"] = None
        return state

    def _serialize(self) -> bytes:
        # Validate first, including driver-only UDT definitions. The command is not kept: the
        # JVM-side function, created once, holds it.
        self.returnType
        return _serialize_udf(self._func)

    def __call__(self, *cols: Union[Column, str], **kwargs: Union[Column, str]) -> Column:
        """
        Create a ``Column`` expression invoking this UDF with the given columns.

        Args:
            *cols: Spark ``Column`` objects (e.g. ``df.value``, ``col("x")``)

        Returns:
            pyspark.sql.Column
        """
        from pyspark.sql.classic.column import _to_java_column
        from pyspark.sql.utils import get_active_spark_context, is_remote

        if is_remote():
            raise PySparkNotImplementedError(
                errorClass="NOT_IMPLEMENTED",
                messageParameters={"feature": "In-process Python UDFs in Spark Connect"},
            )
        sc = get_active_spark_context()

        jvm = sc._jvm
        assert jvm is not None

        # Convert Python Column objects to JVM Column objects
        if not cols and not kwargs:
            raise PySparkValueError(
                errorClass="INVALID_PANDAS_UDF",
                messageParameters={"detail": "An inprocess_udf requires at least one argument."},
            )
        jcols = [_to_java_column(c) for c in cols]
        jcols.extend(
            jvm.PythonSQLUtils.namedArgumentExpression(name, _to_java_column(value))
            for name, value in kwargs.items()
        )

        # Build a Java ArrayList (py4j vararg spread doesn't work with Arrays.asList)
        jlist = jvm.java.util.ArrayList()
        for jcol in jcols:
            jlist.add(jcol)

        if self._jfunc is None:
            # Use the existing PythonUDF planning contracts with an in-process eval type.
            builder = jvm.org.apache.spark.sql.execution.python.InProcessPythonUDFBuilder
            self._jfunc = builder.create(
                self._name,
                self._serialize(),
                self.returnType.json(),
                "%d.%d" % sys.version_info[:2],
            )
        return Column(self._jfunc.apply(jlist, self._deterministic))


[docs] def inprocess_udf( f: Optional[Union[Callable, DataType, str]] = None, returnType: Optional[Union[DataType, str]] = None, *, deterministic: bool = True, ) -> Any: """ Creates an in-process UDF, as a decorator or from a function, like ``arrow_udf``. .. versionadded:: 4.4.0 The function receives one ``pa.Array`` per input column and must return a single ``pa.Array`` of the declared ``returnType``. The result must have the same length as the input batch and its Arrow type must match the declared Spark type, including nested fields. Timezone-aware timestamps are relabeled to the session timezone without changing their instants; string/binary offset widths are converted to match ``useLargeVarTypes``, and ``large_list``, ``fixed_size_list``, ``string_view``, ``binary_view``, ``fixed_size_binary`` and dictionary representations are cast to the declared type. Other value types must match exactly. Nested nullability may be widened, but actual nulls cannot be returned in non-nullable fields. Sliced results are copied when required by Arrow Java. Spark broadcasts, accumulators, ``SparkFiles``, ``--py-files``, ``spark.submit.pyFiles``, ``SparkContext.addPyFile`` and ``SparkSession.addArtifacts(..., pyfile=True)`` are unsupported. Install dependencies on executors before starting Spark. The driver's Python major.minor version must match the embedded interpreter. Parameters ---------- f : function, :class:`pyspark.sql.types.DataType` or str, optional The function to wrap. When used as a decorator, the return type may be passed here instead, as in ``@inprocess_udf(LongType())``. returnType : :class:`pyspark.sql.types.DataType` or str The return type of the UDF, as a DataType or a DDL-formatted type string. deterministic : bool, optional Keyword-only. Whether this UDF produces the same output for the same input. Set to ``False`` for UDFs that use randomness or external state, so the optimizer does not deduplicate or reorder calls to this UDF. Default: ``True``. Returns ------- :class:`InProcessUDFWrapper` or function The wrapper if ``f`` is a function, otherwise a decorator that wraps a function as one. Examples -------- .. code-block:: python import pyarrow.compute as pc double = inprocess_udf(lambda x: pc.multiply(x, 2), LongType()) @inprocess_udf(returnType=LongType()) def double(x): import pyarrow.compute as pc return pc.multiply(x, 2) @inprocess_udf(returnType=LongType(), deterministic=False) def random_noise(x): import pyarrow as pa, numpy as np return pa.array(np.random.randint(0, 100, len(x)), type=pa.int64()) """ if not isinstance(deterministic, bool): raise PySparkTypeError( errorClass="NOT_EXPECTED_TYPE", messageParameters={ "expected_type": "bool", "arg_name": "deterministic", "arg_type": type(deterministic).__name__, }, ) if f is None or isinstance(f, (DataType, str)): # @inprocess_udf(returnType) or @inprocess_udf(returnType=returnType) if f is not None and returnType is not None: raise PySparkTypeError( errorClass="CANNOT_SET_TOGETHER", messageParameters={"arg_list": "a return type in f and returnType"}, ) # Fails at once rather than when the decorator is applied. return_type = _check_return_type_argument(f if f is not None else returnType) def decorator(func: Callable) -> InProcessUDFWrapper: return InProcessUDFWrapper(func, return_type, deterministic) return decorator if not callable(f): raise PySparkTypeError( errorClass="NOT_EXPECTED_TYPE", messageParameters={ "expected_type": "function, DataType or str", "arg_name": "f", "arg_type": type(f).__name__, }, ) return InProcessUDFWrapper(f, _check_return_type_argument(returnType), deterministic)