This is an automated email from the ASF dual-hosted git repository.
Yicong-Huang pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.x by this push:
new 9cadc90a3dc9 [SPARK-59911][PYTHON] Migrate SQL_SCALAR_PANDAS_UDF to a
pandas eval type handler
9cadc90a3dc9 is described below
commit 9cadc90a3dc90c70eacece16160866a764fcbbe3
Author: Yicong Huang <[email protected]>
AuthorDate: Fri Oct 2 10:44:09 2026 +0000
[SPARK-59911][PYTHON] Migrate SQL_SCALAR_PANDAS_UDF to a pandas eval type
handler
### What changes were proposed in this pull request?
This is the first step of migrating the pandas UDF eval types onto the
eval-handler framework (umbrella SPARK-59415), following the Arrow handler work
already shipped in `pyspark/eval_handlers/_arrow.py`.
New file `python/pyspark/eval_handlers/_pandas.py` adds
`PandasScalarUDFHandler(BatchEvalTypeHandler)` for `SQL_SCALAR_PANDAS_UDF`, the
pandas counterpart of `ArrowScalarUDFHandler`: convert each input RecordBatch
to pandas Series (struct columns become DataFrames via `df_for_struct=True`),
invoke each UDF once per batch, check the row count, and convert the pandas
results back to one RecordBatch. It calls the Arrow<->pandas conversions in
`pyspark.sql.conversion` (`ArrowToPandasConve [...]
The class hierarchy after this PR (pandas mirrors Arrow -- both extend the
category base directly):
EvalTypeHandler
BatchEvalTypeHandler
ArrowScalarUDFHandler (SQL_SCALAR_ARROW_UDF, shipped)
PandasScalarUDFHandler (SQL_SCALAR_PANDAS_UDF, new)
The `SQL_SCALAR_PANDAS_UDF` branch is removed from `worker.py::read_udfs`
(both the serializer-selection entry and the mapper block); `read_udfs` now
dispatches it through `get_eval_type_handler`, exactly as the migrated Arrow
types already do. All other pandas branches are left untouched.
`read_single_udf` still handles `SQL_SCALAR_PANDAS_UDF` (the handler path calls
it).
One supporting fix in `pyspark/sql/conversion.py`:
`ArrowToPandasConversion.to_pandas` declared `timezone: str`, but every sibling
conversion method (and the `_convert_array` it delegates to) types it
`Optional[str]`, and the worker passes `runner_conf.timezone` which is
`Optional[str]`. The handler surfaces this (it is type-checked, unlike
`worker.py`), so the annotation is corrected to `Optional[str]`.
Annotation-only; no runtime change.
This is a pure refactor with no behavior change: the handler issues the
same `ArrowToPandasConversion.to_pandas` /
`PandasToArrowConversion.from_pandas` calls with the same parameters, in the
same validation order, raising the same error types -- it is the previous
`read_udfs` mapper body moved into the handler. The one structural change --
inlining the args/kwargs offsets (`f(*pos, **kw)`) instead of pre-combining
them through `wrap_kwargs_support` -- is equivalent (`wrap_kwargs_supp [...]
The handler passes `prefers_large_types=runner_conf.use_large_var_types`
inline, matching the current mapper. Note the pre-existing inconsistencies in
the other (untouched) pandas branches -- `GROUPED_AGG_PANDAS_ITER` pins
`prefers_large_types=False`, and the stateful branches omit it (use the
default). If these turn out to be bugs they should be fixed in a separate
ticket, not here.
#### Sketch: how SQL_GROUPED_MAP_PANDAS_UDF(201) would follow this pattern
Confirming the pattern generalizes to the Grouped category.
`SQL_GROUPED_MAP_PANDAS_UDF` is a grouped type (input
`Iterator[Iterator[pa.RecordBatch]]`, `ArrowStreamGroupSerializer`), so it
extends the grouped category base directly and calls the same conversions
directly -- no pandas-specific base:
class
PandasGroupedMapUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
eval_type = PythonEvalType.SQL_GROUPED_MAP_PANDAS_UDF
# __init__: parse key/value offsets (extract_key_value_indexes),
build
# output_schema = StructType([StructField("_0", return_type)]).
# run(split_index, data): for each group, materialize its batches
into one
# pa.Table, then
# all_series = ArrowToPandasConversion.to_pandas(table,
timezone=rc.timezone,
#
prefer_int_ext_dtype=rc.prefer_int_ext_dtype)
# build the value DataFrame from value_offsets, call the UDF
(with the key
# tuple when num_udf_args == 2), verify the pandas result, then
# yield PandasToArrowConversion.from_pandas([result],
output_schema, ...)
The current `GROUPED_MAP_PANDAS_UDF` mapper already makes exactly those two
calls, so moving it into a handler is the same mechanical move as this PR --
only the category base (`Grouped` vs `Batch`), the per-group loop, key
handling, the pandas result verification, and the output-batch resizing wrapper
differ. If a second pandas handler ends up repeating the same
runner_conf-to-parameter argument list, that is the point to factor a shared
helper (as the Arrow side keeps shared verifie [...]
### Why are the changes needed?
Consolidating each Arrow/Pandas UDF eval type behind an `EvalTypeHandler`
keeps the per-type logic together and shrinks the long `read_udfs` dispatch.
This PR extends the pattern from the Arrow types to the first pandas type.
### Does this PR introduce _any_ user-facing change?
No.
### How was this patch tested?
- New unit tests
`python/pyspark/eval_handlers/tests/test_pandas_eval_type_handlers.py` (built
on a real `RunnerConf`): registration, per-batch invocation, output type
coercion, multiple UDFs, keyword-offset binding, struct return (DataFrame), the
three error paths (non-sized result, row-count mismatch, struct return that is
not a DataFrame), and that the handler forwards `preferIntExtensionDtype` to
the input conversion and `useLargeVarTypes` to the output conversion.
- The existing per-eval-type suite
`pyspark.sql.tests.pandas.test_pandas_udf_scalar` (the behavior gate for
`SQL_SCALAR_PANDAS_UDF`) and the ASV per-eval-type benchmark (regression gate).
### Was this patch authored or co-authored using generative AI tooling?
Yes, authored with Claude (Opus 4.8) via Claude Code.
This pull request and its description were written by Isaac.
Closes #59171 from Yicong-Huang/pandas-handler-base.
Authored-by: Yicong Huang <[email protected]>
Signed-off-by: Yicong-Huang <[email protected]>
(cherry picked from commit 49a83896423a2bc6a1ff28f0ede8b94d6f7f6164)
Signed-off-by: Yicong-Huang <[email protected]>
---
dev/sparktestsupport/modules.py | 1 +
python/pyspark/eval_handlers/__init__.py | 6 +-
python/pyspark/eval_handlers/_pandas.py | 123 ++++++++++++++++
.../tests/test_pandas_eval_type_handlers.py | 162 +++++++++++++++++++++
python/pyspark/sql/conversion.py | 7 +-
python/pyspark/worker.py | 72 ---------
6 files changed, 293 insertions(+), 78 deletions(-)
diff --git a/dev/sparktestsupport/modules.py b/dev/sparktestsupport/modules.py
index ff921796e65d..cb964c62d437 100644
--- a/dev/sparktestsupport/modules.py
+++ b/dev/sparktestsupport/modules.py
@@ -665,6 +665,7 @@ pyspark_sql = Module(
# unittests
"pyspark.eval_handlers.tests.test_arrow_eval_type_handlers",
"pyspark.eval_handlers.tests.test_base_eval_type_handlers",
+ "pyspark.eval_handlers.tests.test_pandas_eval_type_handlers",
"pyspark.sql.tests.test_artifact",
"pyspark.sql.tests.test_catalog",
"pyspark.sql.tests.test_column",
diff --git a/python/pyspark/eval_handlers/__init__.py
b/python/pyspark/eval_handlers/__init__.py
index 2f90d4b68b6a..63ab04aab9fd 100644
--- a/python/pyspark/eval_handlers/__init__.py
+++ b/python/pyspark/eval_handlers/__init__.py
@@ -24,8 +24,8 @@ declares its ``eval_type`` and self-registers at class
definition, which
``read_udfs`` looks up via ``get_eval_type_handler``. Importing this package
imports the concrete handler submodules so they register.
-``_arrow`` imports pyarrow lazily, so it registers its handlers without pyarrow
-installed and defers the pyarrow check to when a handler runs.
+``_arrow`` and ``_pandas`` import pyarrow (and pandas) lazily, so they register
+their handlers without those installed and defer the checks to when a handler
runs.
"""
-from pyspark.eval_handlers import _arrow # noqa: F401 # registers handlers
on import
+from pyspark.eval_handlers import _arrow, _pandas # noqa: F401 # registers
handlers on import
diff --git a/python/pyspark/eval_handlers/_pandas.py
b/python/pyspark/eval_handlers/_pandas.py
new file mode 100644
index 000000000000..b5c0c7f83b3e
--- /dev/null
+++ b/python/pyspark/eval_handlers/_pandas.py
@@ -0,0 +1,123 @@
+#
+# 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.
+#
+
+"""Handlers for the pandas UDF eval types (the UDF exchanges ``pd.Series`` /
+``pd.DataFrame`` values, converted to and from Arrow via the conversion
utilities
+in ``pyspark.sql.conversion``, the pandas counterpart of
``ArrowBatchTransformer``).
+
+pandas and pyarrow are imported lazily (inside ``run`` and the type-checking
block)
+so the module stays importable and its handlers register without either
installed.
+Each handler calls ``require_minimum_pandas_version`` and
+``require_minimum_pyarrow_version`` in ``__init__`` so a missing or too-old
+dependency surfaces a clear error when the handler runs.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterator
+from typing import TYPE_CHECKING, Any
+
+from pyspark.errors import PySparkTypeError, PySparkValueError
+from pyspark.eval_handlers._base import BatchEvalTypeHandler
+from pyspark.eval_handlers.verification import verify_result_row_count
+from pyspark.sql.conversion import ArrowToPandasConversion,
PandasToArrowConversion
+from pyspark.sql.pandas.utils import (
+ require_minimum_pandas_version,
+ require_minimum_pyarrow_version,
+)
+from pyspark.sql.types import StructField, StructType
+from pyspark.util import PythonEvalType
+
+if TYPE_CHECKING:
+ import pyarrow as pa
+
+ from pyspark.worker_util import EvalConf, RunnerConf
+
+
+class PandasScalarUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
+ """SQL_SCALAR_PANDAS_UDF: convert each input RecordBatch to pandas Series
+ (struct columns become DataFrames), invoke each UDF once per batch, check
the
+ row count, and convert the pandas results back to one RecordBatch. The
pandas
+ counterpart of ArrowScalarUDFHandler, calling the Arrow<->pandas
conversions in
+ ``pyspark.sql.conversion`` directly with the runner_conf-derived
parameters."""
+
+ eval_type = PythonEvalType.SQL_SCALAR_PANDAS_UDF
+
+ def __init__(
+ self, udfs: list[tuple[Any, ...]], runner_conf: "RunnerConf",
eval_conf: "EvalConf"
+ ) -> None:
+ require_minimum_pandas_version()
+ require_minimum_pyarrow_version()
+ super().__init__(udfs, runner_conf, eval_conf)
+ self._return_schema = StructType(
+ [StructField("_%d" % i, rt) for i, (_, _, _, rt) in
enumerate(udfs)]
+ )
+
+ def run(self, split_index: int, data: "Iterator[pa.RecordBatch]") ->
"Iterator[pa.RecordBatch]":
+ import pandas as pd
+
+ runner_conf = self._runner_conf
+ for input_batch in data:
+ num_rows = input_batch.num_rows
+
+ # Input: Arrow -> pandas Series (struct columns become DataFrames).
+ pandas_columns = ArrowToPandasConversion.to_pandas(
+ input_batch,
+ timezone=runner_conf.timezone,
+ struct_in_pandas="dict",
+ ndarray_as_list=False,
+ prefer_int_ext_dtype=runner_conf.prefer_int_ext_dtype,
+ df_for_struct=True,
+ )
+
+ # Process: evaluate each UDF column-wise on pandas Series.
+ results = []
+ for udf_func, args_offsets, kwargs_offsets, return_type in
self._udfs:
+ result = udf_func(
+ *[pandas_columns[o] for o in args_offsets],
+ **{k: pandas_columns[v] for k, v in
kwargs_offsets.items()},
+ )
+ if not hasattr(result, "__len__"):
+ pd_type = (
+ "pandas.DataFrame"
+ if isinstance(return_type, StructType)
+ else "pandas.Series"
+ )
+ raise PySparkTypeError(
+ errorClass="UDF_RETURN_TYPE",
+ messageParameters={"expected": pd_type, "actual":
type(result).__name__},
+ )
+ verify_result_row_count(len(result), num_rows)
+ # struct_in_pandas="dict": UDF must return a DataFrame for
struct types.
+ if isinstance(return_type, StructType) and not
isinstance(result, pd.DataFrame):
+ raise PySparkValueError(
+ "Invalid return type. Please make sure that the UDF
returns a "
+ "pandas.DataFrame when the specified return type is
StructType."
+ )
+ results.append(result)
+
+ # Output: pandas -> Arrow.
+ yield PandasToArrowConversion.from_pandas(
+ results,
+ self._return_schema,
+ timezone=runner_conf.timezone,
+ safecheck=runner_conf.safecheck,
+ arrow_cast=True,
+ prefers_large_types=runner_conf.use_large_var_types,
+ assign_cols_by_name=runner_conf.assign_cols_by_name,
+
int_to_decimal_coercion_enabled=runner_conf.int_to_decimal_coercion_enabled,
+ )
diff --git
a/python/pyspark/eval_handlers/tests/test_pandas_eval_type_handlers.py
b/python/pyspark/eval_handlers/tests/test_pandas_eval_type_handlers.py
new file mode 100644
index 000000000000..3f2e4d516be6
--- /dev/null
+++ b/python/pyspark/eval_handlers/tests/test_pandas_eval_type_handlers.py
@@ -0,0 +1,162 @@
+#
+# 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.
+#
+
+"""Tests for the pandas eval type handlers (``_pandas``).
+
+The handler tests build handler input in the same wire format the serializers
+produce -- a flat RecordBatch, one column per UDF argument -- and assert on the
+output batches via ``run(0, <input>)``. Handlers are built on a real
``RunnerConf``
+(from a plain conf dict), so the tests exercise the actual conf keys the
handler
+reads; the forwarding tests pass a single conf key to confirm it reaches the
+Arrow<->pandas conversion.
+"""
+
+import os
+import unittest
+from unittest.mock import patch
+
+from pyspark.errors import PySparkRuntimeError, PySparkTypeError,
PySparkValueError
+from pyspark.eval_handlers._base import get_eval_type_handler
+from pyspark.sql.types import LongType, StringType, StructField, StructType
+from pyspark.testing.utils import (
+ have_pandas,
+ have_pyarrow,
+ pandas_requirement_message,
+ pyarrow_requirement_message,
+)
+from pyspark.util import PythonEvalType
+
+with patch.dict(os.environ, {"SPARK_PYTHON_RUNTIME": "PYTHON_WORKER"}):
+ from pyspark.worker_util import RunnerConf
+
+if have_pandas and have_pyarrow:
+ import pandas as pd
+ import pyarrow as pa
+
+ from pyspark.eval_handlers._pandas import PandasScalarUDFHandler
+
+_missing_message = pandas_requirement_message or pyarrow_requirement_message
+
+
+def _batch(**columns):
+ """A RecordBatch of int64 columns, one per ``name=values`` kwarg."""
+ return pa.RecordBatch.from_arrays(
+ [pa.array(values, type=pa.int64()) for values in columns.values()],
+ list(columns),
+ )
+
+
+def _udf(func, return_type=None, args=(0,), kwargs=None):
+ """A scalar pandas UDF tuple ``(func, args_offsets, kwargs_offsets,
return_type)``."""
+ return (func, list(args), dict(kwargs or {}), return_type or LongType())
+
+
+def _handler(*udfs, runner_conf=None):
+ """Build a PandasScalarUDFHandler from one or more ``_udf`` tuples."""
+ return PandasScalarUDFHandler(
+ udfs=list(udfs), runner_conf=runner_conf or RunnerConf({}),
eval_conf=None
+ )
+
+
[email protected](not (have_pandas and have_pyarrow), _missing_message)
+class PandasEvalTypeHandlerRegistrationTests(unittest.TestCase):
+ def test_pandas_eval_types_are_registered(self):
+ # The migrated pandas eval type dispatches to its handler by lookup.
+ self.assertIs(
+ get_eval_type_handler(PythonEvalType.SQL_SCALAR_PANDAS_UDF),
+ PandasScalarUDFHandler,
+ )
+
+
[email protected](not (have_pandas and have_pyarrow), _missing_message)
+class PandasScalarUDFHandlerTests(unittest.TestCase):
+ def test_invokes_udf_per_batch(self):
+ handler = _handler(_udf(lambda s: s + 1))
+ out = list(handler.run(0, iter([_batch(a=[1, 2, 3])])))
+ self.assertEqual([b.column(0).to_pylist() for b in out], [[2, 3, 4]])
+
+ def test_coerces_output_to_return_type(self):
+ # The UDF returns int32, but the declared return type is LongType
(int64).
+ handler = _handler(_udf(lambda s: (s + 1).astype("int32")))
+ out = list(handler.run(0, iter([_batch(a=[10, 20])])))
+ self.assertEqual(out[0].schema.field(0).type, pa.int64())
+ self.assertEqual(out[0].column(0).to_pylist(), [11, 21])
+
+ def test_multiple_udfs_produce_one_column_each(self):
+ handler = _handler(
+ _udf(lambda s: s + 1, args=(0,)),
+ _udf(lambda s: s * 2, args=(1,)),
+ )
+ out = list(handler.run(0, iter([_batch(a=[1, 2], b=[10, 20])])))
+ self.assertEqual(out[0].column(0).to_pylist(), [2, 3])
+ self.assertEqual(out[0].column(1).to_pylist(), [20, 40])
+
+ def test_passes_arg_by_keyword_offset(self):
+ # Validates the inline args/kwargs offset handling: y is bound by
keyword.
+ handler = _handler(_udf(lambda x, y: x - y, args=(0,), kwargs={"y":
1}))
+ out = list(handler.run(0, iter([_batch(a=[10, 20], b=[3, 5])])))
+ self.assertEqual(out[0].column(0).to_pylist(), [7, 15])
+
+ def test_struct_return_type_takes_dataframe(self):
+ # struct_in_pandas="dict" + df_for_struct=True: a struct return is a
DataFrame.
+ struct_type = StructType([StructField("x", LongType()),
StructField("y", LongType())])
+ handler = _handler(
+ _udf(lambda s: pd.DataFrame({"x": s, "y": s * 10}),
return_type=struct_type)
+ )
+ out = list(handler.run(0, iter([_batch(a=[1, 2])])))
+ self.assertEqual(
+ out[0].column(0).to_pylist(),
+ [{"x": 1, "y": 10}, {"x": 2, "y": 20}],
+ )
+
+ def test_rejects_non_sized_result(self):
+ handler = _handler(_udf(lambda s: 42))
+ with self.assertRaises(PySparkTypeError):
+ list(handler.run(0, iter([_batch(a=[1, 2])])))
+
+ def test_rejects_row_count_mismatch(self):
+ handler = _handler(_udf(lambda s: s.head(1)))
+ with self.assertRaises(PySparkRuntimeError):
+ list(handler.run(0, iter([_batch(a=[1, 2, 3])])))
+
+ def test_requires_dataframe_for_struct_return(self):
+ struct_type = StructType([StructField("x", LongType())])
+ handler = _handler(_udf(lambda s: s, return_type=struct_type))
+ with self.assertRaises(PySparkValueError):
+ list(handler.run(0, iter([_batch(a=[1, 2])])))
+
+ def test_forwards_prefer_int_ext_dtype_to_input_conversion(self):
+ # preferIntExtensionDtype flows to the Arrow->pandas conversion: the
UDF sees
+ # a nullable "Int64" input instead of the default "int64".
+ dtype_name = _udf(lambda s: pd.Series([str(s.dtype)] * len(s)),
return_type=StringType())
+ conf =
RunnerConf({"spark.sql.execution.pythonUDF.pandas.preferIntExtensionDtype":
"true"})
+ out = list(_handler(dtype_name, runner_conf=conf).run(0,
iter([_batch(a=[1, 2])])))
+ self.assertEqual(out[0].column(0).to_pylist(), ["Int64", "Int64"])
+
+ def test_forwards_use_large_var_types_to_output_conversion(self):
+ # useLargeVarTypes flows to the pandas->Arrow conversion: a string
result
+ # becomes large_string rather than string.
+ to_str = _udf(lambda s: s.astype(str), return_type=StringType())
+ conf = RunnerConf({"spark.sql.execution.arrow.useLargeVarTypes":
"true"})
+ out = list(_handler(to_str, runner_conf=conf).run(0,
iter([_batch(a=[1, 2])])))
+ self.assertTrue(pa.types.is_large_string(out[0].schema.field(0).type))
+
+
+if __name__ == "__main__":
+ from pyspark.testing import main
+
+ main()
diff --git a/python/pyspark/sql/conversion.py b/python/pyspark/sql/conversion.py
index b0c819a69d7f..0711a110d147 100644
--- a/python/pyspark/sql/conversion.py
+++ b/python/pyspark/sql/conversion.py
@@ -1824,7 +1824,7 @@ class ArrowToPandasConversion:
def to_pandas(
cls,
batch: Union["pa.RecordBatch", "pa.Table"],
- timezone: str,
+ timezone: Optional[str],
schema: Optional["StructType"] = None,
struct_in_pandas: str = "dict",
ndarray_as_list: bool = False,
@@ -1838,8 +1838,9 @@ class ArrowToPandasConversion:
----------
batch : pa.RecordBatch or pa.Table
The Arrow RecordBatch or Table to convert.
- timezone : str
- Timezone for timestamp conversion.
+ timezone : str or None
+ Timezone for timestamp conversion. Required if the data contains
+ timestamp types.
schema : StructType, optional
Spark schema for type conversion. If None, types are inferred from
Arrow.
struct_in_pandas : str
diff --git a/python/pyspark/worker.py b/python/pyspark/worker.py
index e92fa5d7afc0..ffcf12b22c29 100644
--- a/python/pyspark/worker.py
+++ b/python/pyspark/worker.py
@@ -1817,7 +1817,6 @@ def read_udfs(pickleSer, udf_info_list, eval_type,
runner_conf, eval_conf):
PythonEvalType.SQL_SCALAR_PANDAS_ITER_ELEMENTWISE_UDF,
PythonEvalType.SQL_SCALAR_ARROW_ELEMENTWISE_UDF,
PythonEvalType.SQL_SCALAR_ARROW_ITER_ELEMENTWISE_UDF,
- PythonEvalType.SQL_SCALAR_PANDAS_UDF,
PythonEvalType.SQL_COGROUPED_MAP_PANDAS_UDF,
PythonEvalType.SQL_SCALAR_PANDAS_ITER_UDF,
PythonEvalType.SQL_MAP_PANDAS_ITER_UDF,
@@ -2946,77 +2945,6 @@ def read_udfs(pickleSer, udf_info_list, eval_type,
runner_conf, eval_conf):
return func, ser
- if eval_type == PythonEvalType.SQL_SCALAR_PANDAS_UDF:
- import pandas as pd
- import pyarrow as pa
-
- # --- UDF preparation ---
- udf_infos = []
- for udf_func, udf_args_offsets, udf_kwargs_offsets, udf_return_type in
udfs:
- wrapped_func, args_kwargs_offsets = wrap_kwargs_support(
- udf_func, udf_args_offsets, udf_kwargs_offsets
- )
- udf_infos.append((wrapped_func, args_kwargs_offsets,
udf_return_type))
- return_schema = StructType(
- [StructField(f"_{i}", info[2]) for i, info in enumerate(udf_infos)]
- )
-
- def func(split_index: int, data: Iterator[pa.RecordBatch]) ->
Iterator[pa.RecordBatch]:
- for input_batch in data:
- num_rows = input_batch.num_rows
-
- # --- Input: Arrow -> pandas Series (struct columns become
DataFrames) ---
- pandas_columns = ArrowToPandasConversion.to_pandas(
- input_batch,
- timezone=runner_conf.timezone,
- struct_in_pandas="dict",
- ndarray_as_list=False,
- prefer_int_ext_dtype=runner_conf.prefer_int_ext_dtype,
- df_for_struct=True,
- )
-
- # --- Process: evaluate each UDF column-wise on pandas Series
---
- results = []
- for udf_func, offsets, udf_return_type in udf_infos:
- result = udf_func(*[pandas_columns[o] for o in offsets])
- if not hasattr(result, "__len__"):
- pd_type = (
- "pandas.DataFrame"
- if isinstance(udf_return_type, StructType)
- else "pandas.Series"
- )
- raise PySparkTypeError(
- errorClass="UDF_RETURN_TYPE",
- messageParameters={
- "expected": pd_type,
- "actual": type(result).__name__,
- },
- )
- verify_result_row_count(len(result), num_rows)
- # struct_in_pandas="dict": UDF must return DataFrame for
struct types
- if isinstance(udf_return_type, StructType) and not
isinstance(
- result, pd.DataFrame
- ):
- raise PySparkValueError(
- "Invalid return type. Please make sure that the
UDF returns a "
- "pandas.DataFrame when the specified return type
is StructType."
- )
- results.append(result)
-
- # --- Output: pandas -> Arrow ---
- yield PandasToArrowConversion.from_pandas(
- results,
- return_schema,
- timezone=runner_conf.timezone,
- safecheck=runner_conf.safecheck,
- arrow_cast=True,
- prefers_large_types=runner_conf.use_large_var_types,
- assign_cols_by_name=runner_conf.assign_cols_by_name,
-
int_to_decimal_coercion_enabled=runner_conf.int_to_decimal_coercion_enabled,
- )
-
- return func, ser
-
if eval_type == PythonEvalType.SQL_SCALAR_PANDAS_ITER_UDF:
import pandas as pd
import pyarrow as pa
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]