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 45eb755f6a6e [SPARK-59959][PYTHON] Migrate SQL_SCALAR_PANDAS_ITER_UDF
and SQL_MAP_PANDAS_ITER_UDF to pandas eval type handlers
45eb755f6a6e is described below
commit 45eb755f6a6e2c02f57d716d439c078a6241ba48
Author: Yicong Huang <[email protected]>
AuthorDate: Wed Oct 7 22:51:31 2026 +0000
[SPARK-59959][PYTHON] Migrate SQL_SCALAR_PANDAS_ITER_UDF and
SQL_MAP_PANDAS_ITER_UDF to pandas eval type handlers
### What changes were proposed in this pull request?
This continues migrating the pandas UDF eval types onto the eval-handler
framework (umbrella SPARK-59415), following `PandasScalarUDFHandler`
(SPARK-59911) and the Arrow handler work. It moves two Batch-category iterator
eval types off the `worker.py::read_udfs` if/elif chain onto handler classes in
`python/pyspark/eval_handlers/_pandas.py`:
- `PandasScalarIterUDFHandler` for `SQL_SCALAR_PANDAS_ITER_UDF` -- the
pandas counterpart of `ArrowScalarIterUDFHandler`: the UDF receives an iterator
of its argument columns (struct columns as DataFrames), yields an iterator of
pandas Series/DataFrames, and the handler enforces the declared element type,
verifies the total row count matches the input, and converts each result back
to one RecordBatch.
- `PandasMapUDFHandler` for `SQL_MAP_PANDAS_ITER_UDF` (mapInPandas) -- the
pandas counterpart of `ArrowMapUDFHandler`: the UDF receives the input
RecordBatch stream as an iterator of pandas DataFrames (one per batch, the
single struct column expanded) and yields pandas DataFrames/Series, converted
back to one RecordBatch each.
Both extend `BatchEvalTypeHandler` directly and call the Arrow<->pandas
conversions in `pyspark.sql.conversion` (`ArrowToPandasConversion.to_pandas` /
`PandasToArrowConversion.from_pandas`) inline with the runner_conf-derived
parameters, exactly as `PandasScalarUDFHandler` does -- no pandas-specific
intermediate base and no conversion wrappers, so each handler reads as a
straight pandas analog of the Arrow one.
The class hierarchy after this PR (pandas mirrors Arrow -- both extend the
category base directly):
```
EvalTypeHandler
BatchEvalTypeHandler
ArrowScalarIterUDFHandler (SQL_SCALAR_ARROW_ITER_UDF, shipped)
ArrowMapUDFHandler (SQL_MAP_ARROW_ITER_UDF, shipped)
PandasScalarUDFHandler (SQL_SCALAR_PANDAS_UDF, shipped)
PandasScalarIterUDFHandler (SQL_SCALAR_PANDAS_ITER_UDF, new)
PandasMapUDFHandler (SQL_MAP_PANDAS_ITER_UDF, new)
```
The `SQL_SCALAR_PANDAS_ITER_UDF` and `SQL_MAP_PANDAS_ITER_UDF` branches are
removed from `read_udfs` (both the serializer-selection entries and the mapper
blocks); `read_udfs` now dispatches them through `get_eval_type_handler`,
exactly as the migrated Arrow types already do. All other pandas branches are
left untouched, and `read_single_udf` still handles both types (the handler
path calls it).
`verify_pandas_result` and its helper `_verify_column_schema` move from
`worker.py` into `pyspark/eval_handlers/verification.py`, where the other
result-verification helpers already live, so the handlers can call them without
importing `worker.py`. `worker.py` imports them back, so the remaining pandas
branches and `verify_arrow_result` are unchanged.
### Why are the changes needed?
This is a step in the umbrella SPARK-59415 effort to replace the long
`read_udfs` if/elif chain with a per-eval-type handler registry, so each eval
type's execution logic lives in one cohesive, directly-testable class rather
than a `worker.py` branch. Migrating these two iterator types keeps the pandas
side moving toward parity with the already-migrated Arrow handlers.
### Does this PR introduce _any_ user-facing change?
No. This is an internal refactor of the PySpark worker's dispatch; the UDF
API, the on-the-wire Arrow format, and all observable behavior (including the
iterator row-count / exhaustion / output-row-limit checks and the pandas result
verification) are preserved.
### How was this patch tested?
- New unit tests in
`python/pyspark/eval_handlers/tests/test_pandas_eval_type_handlers.py` for both
handlers (registration, batch-stream mapping, output coercion, row-count
mismatch, struct-return handling, and non-iterator / wrong-element-type
rejection), driving `run()` against the serializer wire format.
- Existing per-eval-type integration suites pass:
`pyspark.sql.tests.pandas.test_pandas_map` and
`pyspark.sql.tests.pandas.test_pandas_udf_scalar`.
- The ASV `bench_eval_type` benchmarks for both types
(`ScalarPandasIterUDF*Bench`, `MapPandasIterUDF*Bench`) continue to run the
full worker pipeline through the new handlers.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 4.8
This pull request and its description were written by Isaac.
Closes #59211 from Yicong-Huang/pandas-scalar-map-iter.
Authored-by: Yicong Huang <[email protected]>
Signed-off-by: Yicong-Huang <[email protected]>
(cherry picked from commit 37d06912ffea5a1472babe712d72ed04352d53ef)
Signed-off-by: Yicong-Huang <[email protected]>
---
python/pyspark/eval_handlers/_pandas.py | 169 ++++++++++++++--
.../tests/test_pandas_eval_type_handlers.py | 163 +++++++++++++++-
python/pyspark/eval_handlers/verification.py | 61 +++++-
python/pyspark/worker.py | 213 +--------------------
4 files changed, 377 insertions(+), 229 deletions(-)
diff --git a/python/pyspark/eval_handlers/_pandas.py
b/python/pyspark/eval_handlers/_pandas.py
index b5c0c7f83b3e..07302fd66f39 100644
--- a/python/pyspark/eval_handlers/_pandas.py
+++ b/python/pyspark/eval_handlers/_pandas.py
@@ -19,8 +19,8 @@
``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.
+pandas and pyarrow are imported lazily (inside ``__init__``/``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.
@@ -33,7 +33,14 @@ 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.eval_handlers.verification import (
+ verify_iter_result_row_count,
+ verify_iterator_exhausted,
+ verify_output_row_limit,
+ verify_pandas_result,
+ verify_result_row_count,
+ verify_return_type,
+)
from pyspark.sql.conversion import ArrowToPandasConversion,
PandasToArrowConversion
from pyspark.sql.pandas.utils import (
require_minimum_pandas_version,
@@ -70,17 +77,16 @@ class
PandasScalarUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
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,
+ timezone=self._runner_conf.timezone,
struct_in_pandas="dict",
ndarray_as_list=False,
- prefer_int_ext_dtype=runner_conf.prefer_int_ext_dtype,
+ prefer_int_ext_dtype=self._runner_conf.prefer_int_ext_dtype,
df_for_struct=True,
)
@@ -114,10 +120,151 @@ class
PandasScalarUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
yield PandasToArrowConversion.from_pandas(
results,
self._return_schema,
- timezone=runner_conf.timezone,
- safecheck=runner_conf.safecheck,
+ timezone=self._runner_conf.timezone,
+ safecheck=self._runner_conf.safecheck,
+ arrow_cast=True,
+ prefers_large_types=self._runner_conf.use_large_var_types,
+ assign_cols_by_name=self._runner_conf.assign_cols_by_name,
+
int_to_decimal_coercion_enabled=self._runner_conf.int_to_decimal_coercion_enabled,
+ )
+
+
+class PandasScalarIterUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
+ """SQL_SCALAR_PANDAS_ITER_UDF: the single UDF receives an iterator of its
+ argument columns (each a pandas Series, struct columns as DataFrames) and
yields
+ an iterator of pandas Series/DataFrames; enforce the declared element
type, verify
+ the total row count matches the input, and convert each result back to one
+ RecordBatch. The pandas counterpart of ArrowScalarIterUDFHandler, calling
the
+ Arrow<->pandas conversions in ``pyspark.sql.conversion`` directly."""
+
+ eval_type = PythonEvalType.SQL_SCALAR_PANDAS_ITER_UDF
+
+ def __init__(
+ self, udfs: list[tuple[Any, ...]], runner_conf: "RunnerConf",
eval_conf: "EvalConf"
+ ) -> None:
+ import pandas as pd
+
+ require_minimum_pandas_version()
+ require_minimum_pyarrow_version()
+ super().__init__(udfs, runner_conf, eval_conf)
+ assert len(udfs) == 1, "One SCALAR_PANDAS_ITER UDF expected here."
+ self._udf_func, self._args_offsets, _, self._return_type = udfs[0]
+ self._return_schema = StructType([StructField("_0",
self._return_type)])
+ self._expected_iter_type = (
+ Iterator[pd.DataFrame]
+ if isinstance(self._return_type, StructType)
+ else Iterator[pd.Series]
+ )
+
+ def run(self, split_index: int, data: "Iterator[pa.RecordBatch]") ->
"Iterator[pa.RecordBatch]":
+ num_input_rows = 0
+
+ def extract_args(batch: "pa.RecordBatch") -> Any:
+ nonlocal num_input_rows
+ # Input: Arrow -> pandas Series (struct columns become DataFrames).
+ pandas_columns = ArrowToPandasConversion.to_pandas(
+ batch,
+ timezone=self._runner_conf.timezone,
+ struct_in_pandas="dict",
+ ndarray_as_list=False,
+ prefer_int_ext_dtype=self._runner_conf.prefer_int_ext_dtype,
+ df_for_struct=True,
+ )
+ args = tuple(pandas_columns[o] for o in self._args_offsets)
+ num_input_rows += batch.num_rows
+ return args[0] if len(args) == 1 else args
+
+ # Extract args from input batches (streaming), then call the UDF and
verify the
+ # result type (iterator of pd.Series / pd.DataFrame).
+ args_iter = map(extract_args, data)
+ verified_iter = verify_return_type(self._udf_func(args_iter),
self._expected_iter_type)
+
+ def convert(result: Any) -> "pa.RecordBatch":
+ verify_pandas_result(
+ result, self._return_type, assign_cols_by_name=True,
truncate_return_schema=True
+ )
+ return PandasToArrowConversion.from_pandas(
+ [result],
+ self._return_schema,
+ timezone=self._runner_conf.timezone,
+ safecheck=self._runner_conf.safecheck,
+ arrow_cast=True,
+ prefers_large_types=self._runner_conf.use_large_var_types,
+ assign_cols_by_name=self._runner_conf.assign_cols_by_name,
+
int_to_decimal_coercion_enabled=self._runner_conf.int_to_decimal_coercion_enabled,
+ )
+
+ # Row-limit check (fail-fast) then exact row-count match (final).
+ batches = verify_output_row_limit(map(convert, verified_iter), lambda:
num_input_rows)
+ yield from verify_iter_result_row_count(batches, lambda:
num_input_rows)
+
+ # Verify the input iterator was fully consumed.
+ verify_iterator_exhausted(args_iter)
+
+
+class PandasMapUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
+ """SQL_MAP_PANDAS_ITER_UDF (mapInPandas): the single UDF receives the input
+ RecordBatch stream as an iterator of pandas DataFrames (one per batch, the
single
+ struct column expanded) and yields pandas DataFrames/Series, converted
back to one
+ RecordBatch each. The pandas counterpart of ArrowMapUDFHandler."""
+
+ eval_type = PythonEvalType.SQL_MAP_PANDAS_ITER_UDF
+
+ def __init__(
+ self, udfs: list[tuple[Any, ...]], runner_conf: "RunnerConf",
eval_conf: "EvalConf"
+ ) -> None:
+ import pandas as pd
+
+ require_minimum_pandas_version()
+ require_minimum_pyarrow_version()
+ super().__init__(udfs, runner_conf, eval_conf)
+ assert len(udfs) == 1, "One MAP_PANDAS_ITER UDF expected here."
+ self._udf_func, _, _, self._return_type = udfs[0]
+ self._return_schema = StructType([StructField("_0",
self._return_type)])
+ self._expected_iter_type = (
+ Iterator[pd.DataFrame]
+ if isinstance(self._return_type, StructType)
+ else Iterator[pd.Series]
+ )
+
+ def run(self, split_index: int, data: "Iterator[pa.RecordBatch]") ->
"Iterator[pa.RecordBatch]":
+ def dataframe_iter() -> "Iterator[Any]":
+ # Input batches have a single struct column (see
MapInBatchEvaluatorFactory);
+ # convert lazily so peakmem stays bounded by one batch.
+ for batch in data:
+ yield ArrowToPandasConversion.to_pandas(
+ batch,
+ timezone=self._runner_conf.timezone,
+
prefer_int_ext_dtype=self._runner_conf.prefer_int_ext_dtype,
+ df_for_struct=True,
+ )[0]
+
+ result = self._udf_func(dataframe_iter())
+ # The declared signature is Iterator[...], so a strict iterator is
required by
+ # default. With the legacy flag, accept any object Python can iterate
over -- via
+ # iter(...), which honors both __iter__ and the sequence protocol
(__getitem__) --
+ # by adapting it into an iterator before the shared element-type
verification.
+ if self._runner_conf.map_in_batch_legacy_accept_any_iterable and not
isinstance(
+ result, Iterator
+ ):
+ try:
+ result = iter(result)
+ except TypeError:
+ # Not iterable at all; leave it so verify_return_type below
raises the
+ # standard UDF_RETURN_TYPE error.
+ pass
+
+ for df in verify_return_type(result, self._expected_iter_type):
+ verify_pandas_result(
+ df, self._return_type, assign_cols_by_name=True,
truncate_return_schema=True
+ )
+ yield PandasToArrowConversion.from_pandas(
+ [df],
+ self._return_schema,
+ timezone=self._runner_conf.timezone,
+ safecheck=self._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,
+ prefers_large_types=self._runner_conf.use_large_var_types,
+ assign_cols_by_name=self._runner_conf.assign_cols_by_name,
+
int_to_decimal_coercion_enabled=self._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
index 3f2e4d516be6..befbd13b2284 100644
--- a/python/pyspark/eval_handlers/tests/test_pandas_eval_type_handlers.py
+++ b/python/pyspark/eval_handlers/tests/test_pandas_eval_type_handlers.py
@@ -47,7 +47,12 @@ if have_pandas and have_pyarrow:
import pandas as pd
import pyarrow as pa
- from pyspark.eval_handlers._pandas import PandasScalarUDFHandler
+ from pyspark.eval_handlers._pandas import (
+ PandasMapUDFHandler,
+ PandasScalarIterUDFHandler,
+ PandasScalarUDFHandler,
+ )
+ from pyspark.sql.conversion import ArrowBatchTransformer
_missing_message = pandas_requirement_message or pyarrow_requirement_message
@@ -72,14 +77,34 @@ def _handler(*udfs, runner_conf=None):
)
+def _scalar_iter_handler(udf, return_type=None, args=(0,), runner_conf=None):
+ """Build a PandasScalarIterUDFHandler whose one UDF reads the given arg
offsets."""
+ return PandasScalarIterUDFHandler(
+ udfs=[(udf, list(args), {}, return_type or LongType())],
+ runner_conf=runner_conf or RunnerConf({}),
+ eval_conf=None,
+ )
+
+
+def _map_handler(udf, return_type, runner_conf=None):
+ """Build a PandasMapUDFHandler from its ``(func, None, None,
return_type)`` UDF tuple."""
+ return PandasMapUDFHandler(
+ udfs=[(udf, None, None, return_type)],
+ runner_conf=runner_conf or RunnerConf({}),
+ eval_conf=None,
+ )
+
+
@unittest.skipIf(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,
- )
+ # Each migrated pandas eval type dispatches to its handler by lookup.
+ for eval_type, handler_cls in (
+ (PythonEvalType.SQL_SCALAR_PANDAS_UDF, PandasScalarUDFHandler),
+ (PythonEvalType.SQL_SCALAR_PANDAS_ITER_UDF,
PandasScalarIterUDFHandler),
+ (PythonEvalType.SQL_MAP_PANDAS_ITER_UDF, PandasMapUDFHandler),
+ ):
+ self.assertIs(get_eval_type_handler(eval_type), handler_cls)
@unittest.skipIf(not (have_pandas and have_pyarrow), _missing_message)
@@ -156,6 +181,132 @@ class PandasScalarUDFHandlerTests(unittest.TestCase):
self.assertTrue(pa.types.is_large_string(out[0].schema.field(0).type))
[email protected](not (have_pandas and have_pyarrow), _missing_message)
+class PandasScalarIterUDFHandlerTests(unittest.TestCase):
+ def test_invokes_udf_over_batch_stream(self):
+ def add_one(series_iter):
+ for s in series_iter:
+ yield s + 1
+
+ handler = _scalar_iter_handler(add_one)
+ out = list(handler.run(0, iter([_batch(a=[1, 2]), _batch(a=[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 yields int32, but the declared return type is LongType
(int64).
+ def add_one(series_iter):
+ for s in series_iter:
+ yield (s + 1).astype("int32")
+
+ handler = _scalar_iter_handler(add_one)
+ 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_rejects_too_many_rows(self):
+ # Emitting more rows than consumed fails fast via the output row-limit
guard.
+ def too_many(series_iter):
+ for s in series_iter:
+ yield pd.Series(list(range(len(s) + 1)))
+
+ handler = _scalar_iter_handler(too_many)
+ with self.assertRaises(PySparkRuntimeError) as cm:
+ list(handler.run(0, iter([_batch(a=[1, 2])])))
+ self.assertEqual(cm.exception.getCondition(),
"OUTPUT_EXCEEDS_INPUT_ROWS")
+
+ def test_rejects_too_few_rows(self):
+ # Emitting fewer rows than consumed fails the final row-count equality
guard.
+ def too_few(series_iter):
+ for s in series_iter:
+ yield s.head(len(s) - 1)
+
+ handler = _scalar_iter_handler(too_few)
+ with self.assertRaises(PySparkRuntimeError) as cm:
+ list(handler.run(0, iter([_batch(a=[1, 2, 3])])))
+ self.assertEqual(cm.exception.getCondition(), "RESULT_ROWS_MISMATCH")
+
+ def test_rejects_unconsumed_input(self):
+ # A UDF that stops reading before the input stream is exhausted must
raise
+ # INPUT_NOT_FULLY_CONSUMED.
+ def first_only(series_iter):
+ yield next(series_iter)
+
+ handler = _scalar_iter_handler(first_only)
+ with self.assertRaises(PySparkRuntimeError) as cm:
+ list(handler.run(0, iter([_batch(a=[1, 2]), _batch(a=[3, 4])])))
+ self.assertEqual(cm.exception.getCondition(),
"INPUT_NOT_FULLY_CONSUMED")
+
+ def test_struct_return_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())])
+
+ def to_struct(series_iter):
+ for s in series_iter:
+ yield pd.DataFrame({"x": s, "y": s * 10})
+
+ handler = _scalar_iter_handler(to_struct, 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}],
+ )
+
+
[email protected](not (have_pandas and have_pyarrow), _missing_message)
+class PandasMapUDFHandlerTests(unittest.TestCase):
+ _return_type = StructType([StructField("v", LongType())])
+
+ def test_maps_batch_stream(self):
+ # mapInPandas expands the wire struct into a DataFrame and re-wraps
the output.
+ def double_v(df_iter):
+ for df in df_iter:
+ yield pd.DataFrame({"v": df["v"] * 2})
+
+ handler = _map_handler(double_v, self._return_type)
+ # mapInPandas sends a single struct column per batch; the handler
expands it to
+ # a DataFrame, so build the input in that wire format here.
+ struct_batch = ArrowBatchTransformer.wrap_struct(_batch(v=[1, 2, 3]))
+ out = list(handler.run(0, iter([struct_batch])))
+ self.assertEqual(out[0].column(0).field("v").to_pylist(), [2, 4, 6])
+
+ def test_legacy_accepts_any_iterable(self):
+ # With the legacy flag on (its default), a UDF may return any iterable
-- here a
+ # plain list of DataFrames -- which the handler adapts via iter()
before verifying.
+ def as_list(df_iter):
+ return [pd.DataFrame({"v": df["v"] * 2}) for df in df_iter]
+
+ handler = _map_handler(as_list, self._return_type)
+ struct_batch = ArrowBatchTransformer.wrap_struct(_batch(v=[1, 2, 3]))
+ out = list(handler.run(0, iter([struct_batch])))
+ self.assertEqual(out[0].column(0).field("v").to_pylist(), [2, 4, 6])
+
+ def test_rejects_non_iterator_result(self):
+ # With the legacy accept-any-iterable flag off, a UDF returning a
DataFrame (not
+ # an iterator of them) is rejected as a non-iterator before the input
stream is
+ # read, so the input batch shape is irrelevant.
+ strict = RunnerConf(
+
{"spark.sql.execution.pythonUDF.mapInBatch.legacy.acceptAnyIterable.enabled":
"false"}
+ )
+ handler = _map_handler(
+ lambda df_iter: pd.DataFrame({"v": [1]}), self._return_type,
runner_conf=strict
+ )
+ with self.assertRaises(PySparkTypeError) as cm:
+ list(handler.run(0, iter([_batch(v=[1, 2])])))
+ # The non-iterator branch reports the actual type ("DataFrame"); the
per-element
+ # branch (test_rejects_wrong_element_type) reports "iterator of ...".
+ self.assertEqual(cm.exception.getMessageParameters()["actual"],
"DataFrame")
+
+ def test_rejects_wrong_element_type(self):
+ # Each yielded element must be a DataFrame for a struct return type.
+ def yields_series(df_iter):
+ for _ in df_iter:
+ yield pd.Series([1, 2]) # a Series, not a DataFrame
+
+ handler = _map_handler(yields_series, self._return_type)
+ with self.assertRaises(PySparkTypeError):
+ list(handler.run(0, iter([_batch(v=[1, 2])])))
+
+
if __name__ == "__main__":
from pyspark.testing import main
diff --git a/python/pyspark/eval_handlers/verification.py
b/python/pyspark/eval_handlers/verification.py
index 3b8399984dee..9f413e8dca0e 100644
--- a/python/pyspark/eval_handlers/verification.py
+++ b/python/pyspark/eval_handlers/verification.py
@@ -21,9 +21,13 @@ Used by the eval type handlers and by the worker's
``read_udfs``.
"""
from collections.abc import Iterator
-from typing import Any, Callable, Type, TypeVar, Union, cast, get_args,
get_origin
+from typing import TYPE_CHECKING, Any, Callable, Type, TypeVar, Union, cast,
get_args, get_origin
from pyspark.errors import PySparkRuntimeError, PySparkTypeError
+from pyspark.sql.types import DataType, StructType
+
+if TYPE_CHECKING:
+ import pandas as pd
T = TypeVar("T")
@@ -151,3 +155,58 @@ def verify_scalar_result(result: Any, num_rows: int) ->
Any:
)
verify_result_row_count(result_length, num_rows)
return result
+
+
+def _verify_column_schema(
+ actual_names: list, expected_names: list, *, assign_cols_by_name: bool
+) -> None:
+ """Check column names (by-name) or count (by-position) match the expected
schema."""
+ if assign_cols_by_name:
+ actual_set = set(actual_names)
+ expected_set = set(expected_names)
+ missing = sorted(expected_set.difference(actual_set))
+ extra = sorted(actual_set.difference(expected_set))
+ if missing or extra:
+ raise PySparkRuntimeError(
+ errorClass="RESULT_COLUMN_NAMES_MISMATCH",
+ messageParameters={
+ "missing": f" Missing: {', '.join(missing)}." if missing
else "",
+ "extra": f" Unexpected: {', '.join(extra)}." if extra else
"",
+ },
+ )
+ elif len(actual_names) != len(expected_names):
+ raise PySparkRuntimeError(
+ errorClass="RESULT_COLUMN_SCHEMA_MISMATCH",
+ messageParameters={
+ "expected": str(len(expected_names)),
+ "actual": str(len(actual_names)),
+ },
+ )
+
+
+def verify_pandas_result(
+ result: Union["pd.DataFrame", "pd.Series"],
+ return_type: DataType,
+ assign_cols_by_name: bool,
+ truncate_return_schema: bool,
+) -> None:
+ import pandas as pd
+
+ if not isinstance(return_type, StructType):
+ verify_return_type(result, pd.Series)
+ return
+
+ verify_return_type(result, pd.DataFrame)
+
+ # Skip schema check on a fully empty result (no rows and no columns).
+ if result.empty and len(result.columns) == 0:
+ return
+
+ field_names = [field.name for field in return_type.fields]
+ actual_names = (
+ list(result.columns[: len(field_names)]) if truncate_return_schema
else list(result.columns)
+ )
+ # By-name mode only applies when the result has string column names;
+ # a numeric RangeIndex falls back to a by-position count check.
+ by_name = assign_cols_by_name and any(isinstance(n, str) for n in
result.columns)
+ _verify_column_schema(actual_names, field_names,
assign_cols_by_name=by_name)
diff --git a/python/pyspark/worker.py b/python/pyspark/worker.py
index ffcf12b22c29..48373827a68d 100644
--- a/python/pyspark/worker.py
+++ b/python/pyspark/worker.py
@@ -56,9 +56,9 @@ from pyspark.errors import PySparkRuntimeError,
PySparkTypeError, PySparkValueEr
from pyspark.eval_handlers._base import get_eval_type_handler
from pyspark.eval_handlers.utils import extract_key_value_indexes
from pyspark.eval_handlers.verification import (
- verify_iter_result_row_count,
+ _verify_column_schema,
verify_iterator_exhausted,
- verify_output_row_limit,
+ verify_pandas_result,
verify_result_row_count,
verify_return_type,
verify_scalar_result,
@@ -152,61 +152,6 @@ def chain(f, g):
return lambda *a: g(f(*a))
-def _verify_column_schema(
- actual_names: list, expected_names: list, *, assign_cols_by_name: bool
-) -> None:
- """Check column names (by-name) or count (by-position) match the expected
schema."""
- if assign_cols_by_name:
- actual_set = set(actual_names)
- expected_set = set(expected_names)
- missing = sorted(expected_set.difference(actual_set))
- extra = sorted(actual_set.difference(expected_set))
- if missing or extra:
- raise PySparkRuntimeError(
- errorClass="RESULT_COLUMN_NAMES_MISMATCH",
- messageParameters={
- "missing": f" Missing: {', '.join(missing)}." if missing
else "",
- "extra": f" Unexpected: {', '.join(extra)}." if extra else
"",
- },
- )
- elif len(actual_names) != len(expected_names):
- raise PySparkRuntimeError(
- errorClass="RESULT_COLUMN_SCHEMA_MISMATCH",
- messageParameters={
- "expected": str(len(expected_names)),
- "actual": str(len(actual_names)),
- },
- )
-
-
-def verify_pandas_result(
- result: Union["pd.DataFrame", "pd.Series"],
- return_type: DataType,
- assign_cols_by_name: bool,
- truncate_return_schema: bool,
-) -> None:
- import pandas as pd
-
- if not isinstance(return_type, StructType):
- verify_return_type(result, pd.Series)
- return
-
- verify_return_type(result, pd.DataFrame)
-
- # Skip schema check on a fully empty result (no rows and no columns).
- if result.empty and len(result.columns) == 0:
- return
-
- field_names = [field.name for field in return_type.fields]
- actual_names = (
- list(result.columns[: len(field_names)]) if truncate_return_schema
else list(result.columns)
- )
- # By-name mode only applies when the result has string column names;
- # a numeric RangeIndex falls back to a by-position count check.
- by_name = assign_cols_by_name and any(isinstance(n, str) for n in
result.columns)
- _verify_column_schema(actual_names, field_names,
assign_cols_by_name=by_name)
-
-
def verify_arrow_result(
result: Union["pa.Table", "pa.RecordBatch"],
assign_cols_by_name: bool,
@@ -1818,8 +1763,6 @@ def read_udfs(pickleSer, udf_info_list, eval_type,
runner_conf, eval_conf):
PythonEvalType.SQL_SCALAR_ARROW_ELEMENTWISE_UDF,
PythonEvalType.SQL_SCALAR_ARROW_ITER_ELEMENTWISE_UDF,
PythonEvalType.SQL_COGROUPED_MAP_PANDAS_UDF,
- PythonEvalType.SQL_SCALAR_PANDAS_ITER_UDF,
- PythonEvalType.SQL_MAP_PANDAS_ITER_UDF,
PythonEvalType.SQL_GROUPED_MAP_PANDAS_UDF,
PythonEvalType.SQL_GROUPED_MAP_PANDAS_ITER_UDF,
PythonEvalType.SQL_GROUPED_AGG_PANDAS_UDF,
@@ -2188,82 +2131,6 @@ def read_udfs(pickleSer, udf_info_list, eval_type,
runner_conf, eval_conf):
return grouped_func, ser
- if eval_type == PythonEvalType.SQL_MAP_PANDAS_ITER_UDF:
- import pandas as pd
- import pyarrow as pa
-
- assert num_udfs == 1, "One MAP_PANDAS_ITER UDF expected here."
- map_udf, _, _, return_type = udfs[0]
- output_schema = StructType([StructField("_0", return_type)])
- iter_type_label = (
- "pandas.DataFrame" if isinstance(return_type, StructType) else
"pandas.Series"
- )
- elem_type = pd.DataFrame if isinstance(return_type, StructType) else
pd.Series
-
- def func(
- split_index: int,
- data: Iterator[pa.RecordBatch],
- ) -> Iterator[pa.RecordBatch]:
- """Apply mapInPandas UDF."""
-
- def dataframe_iter():
- # Input batches have a single struct column (see
- # MapInBatchEvaluatorFactory); convert lazily so peakmem stays
- # bounded by one batch.
- for batch in data:
- yield ArrowToPandasConversion.to_pandas(
- batch,
- timezone=runner_conf.timezone,
- prefer_int_ext_dtype=runner_conf.prefer_int_ext_dtype,
- df_for_struct=True,
- )[0]
-
- result = map_udf(dataframe_iter())
- # The declared signature is Iterator[...], so a strict iterator is
required by
- # default. With the legacy flag, accept any object Python can
iterate over -- via
- # iter(...), which honors both __iter__ and the sequence protocol
(__getitem__) --
- # by adapting it into an iterator.
- if runner_conf.map_in_batch_legacy_accept_any_iterable and not
isinstance(
- result, Iterator
- ):
- try:
- result = iter(result)
- except TypeError:
- pass # Not iterable; fall through to the UDF_RETURN_TYPE
error below.
- if not isinstance(result, Iterator):
- raise PySparkTypeError(
- errorClass="UDF_RETURN_TYPE",
- messageParameters={
- "expected": "iterator of {}".format(iter_type_label),
- "actual": type(result).__name__,
- },
- )
-
- for df in result:
- if not isinstance(df, elem_type):
- raise PySparkTypeError(
- errorClass="UDF_RETURN_TYPE",
- messageParameters={
- "expected": "iterator of
{}".format(iter_type_label),
- "actual": "iterator of
{}".format(type(df).__name__),
- },
- )
- verify_pandas_result(
- df, return_type, assign_cols_by_name=True,
truncate_return_schema=True
- )
- yield PandasToArrowConversion.from_pandas(
- [df],
- output_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_COGROUPED_MAP_PANDAS_UDF:
import pandas as pd
import pyarrow as pa
@@ -2945,82 +2812,6 @@ def read_udfs(pickleSer, udf_info_list, eval_type,
runner_conf, eval_conf):
return func, ser
- if eval_type == PythonEvalType.SQL_SCALAR_PANDAS_ITER_UDF:
- import pandas as pd
- import pyarrow as pa
-
- assert num_udfs == 1, "One SCALAR_PANDAS_ITER UDF expected here."
- udf_func, args_offsets, kwargs_offsets, return_type = udfs[0]
-
- # Pre-compute target schema for output coercion
- return_schema = StructType([StructField("_0", return_type)])
- expected_iter_type = (
- Iterator[pd.DataFrame] if isinstance(return_type, StructType) else
Iterator[pd.Series]
- )
-
- def func(split_index: int, data: Iterator[pa.RecordBatch]) ->
Iterator[pa.RecordBatch]:
- """Apply scalar pandas iterator UDF"""
-
- num_input_rows = 0
-
- def extract_args(batch: pa.RecordBatch):
- nonlocal num_input_rows
- # Input: Arrow -> pandas Series (struct columns become
DataFrames)
- pandas_columns = ArrowToPandasConversion.to_pandas(
- 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,
- )
- args = tuple(pandas_columns[o] for o in args_offsets)
- num_input_rows += batch.num_rows
- return args[0] if len(args) == 1 else args
-
- # Extract args from input batches (streaming)
- args_iter = map(extract_args, data)
-
- # Call UDF and verify result type (iterator of pd.Series /
pd.DataFrame)
- verified_iter = verify_return_type(udf_func(args_iter),
expected_iter_type)
-
- # Process results: verify each element and convert pandas -> Arrow
- def process_results():
- for result in verified_iter:
- verify_pandas_result(
- result, return_type, assign_cols_by_name=True,
truncate_return_schema=True
- )
- yield PandasToArrowConversion.from_pandas(
- [result],
- 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,
- )
-
- # Apply row limit check (fail-fast)
- limited = verify_output_row_limit(
- process_results(),
- lambda: num_input_rows,
- )
-
- # Apply row count match check (final)
- matched = verify_iter_result_row_count(
- limited,
- lambda: num_input_rows,
- )
-
- # Yield batches
- yield from matched
-
- # Verify iterator consumed
- verify_iterator_exhausted(args_iter)
-
- return func, ser
-
if eval_type == PythonEvalType.SQL_TRANSFORM_WITH_STATE_PANDAS_UDF:
import pandas as pd
import pyarrow as pa
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]