This is an automated email from the ASF dual-hosted git repository.

Yicong-Huang pushed a commit to branch branch-4.3
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.3 by this push:
     new decb7346459a [SPARK-58601][PYTHON] Tighten mapInPandas return-value 
contract to require a strict Iterator
decb7346459a is described below

commit decb7346459a4da5b9e61b298fbaeff2fc5c7b4d
Author: Yicong Huang <[email protected]>
AuthorDate: Wed Aug 26 06:48:21 2026 +0000

    [SPARK-58601][PYTHON] Tighten mapInPandas return-value contract to require 
a strict Iterator
    
    Tighten the `mapInPandas` (`SQL_MAP_PANDAS_ITER_UDF`) return-value contract 
in `worker.py` to require a strict `Iterator`, matching `mapInArrow` 
(`SQL_MAP_ARROW_ITER_UDF`). Previously the runtime check was 
`isinstance(result, Iterator) or hasattr(result, "__iter__")`, which accepted 
any iterable (e.g. a returned `list`); it is now `isinstance(result, Iterator)`.
    
    A legacy escape hatch is added to restore the old behavior for both 
`mapInPandas` and `mapInArrow`, since they share the `MapInBatchExec` evaluator:
    
    - New config 
`spark.sql.execution.pythonUDF.mapInBatch.legacy.acceptAnyIterable.enabled` 
(internal, default `false`). When `true`, both UDFs may return any iterable 
rather than a strict iterator, matching the pre-4.3.0 behavior.
    - The flag is threaded to the Python workers via 
`ArrowPythonRunner.getPythonRunnerConfMap`.
    - The change is documented in the PySpark 4.2-to-4.3 migration guide.
    
    The declared signatures have always been `PandasMapIterFunction = 
Callable[[Iterator[DataFrameLike]], Iterator[DataFrameLike]]` and 
`ArrowMapIterFunction = Callable[[Iterator[pyarrow.RecordBatch]], 
Iterator[pyarrow.RecordBatch]]`, and the `DataFrame.mapInPandas` docstring 
states the function "outputs an iterator of pandas.DataFrames". The runtime, 
however, leniently accepted any iterable for `mapInPandas`, diverging from the 
documented contract. This aligns the `mapInPandas` runtime w [...]
    
    Yes. A `mapInPandas` UDF that returns a non-`Iterator` iterable (e.g. a 
`list`) is now rejected with `UDF_RETURN_TYPE` ("iterator of 
pandas.DataFrame"). Previously such a return value was accepted. Users who 
relied on the old behavior can set 
`spark.sql.execution.pythonUDF.mapInBatch.legacy.acceptAnyIterable.enabled=true`
 to restore it (this flag also covers `mapInArrow`).
    
    Previous behavior:
    ```python
    df.mapInPandas(lambda it: [pdf for pdf in it], schema)  # accepted
    ```
    New behavior:
    ```python
    df.mapInPandas(lambda it: [pdf for pdf in it], schema)  # PySparkTypeError: 
UDF_RETURN_TYPE
    
spark.conf.set("spark.sql.execution.pythonUDF.mapInBatch.legacy.acceptAnyIterable.enabled",
 True)
    ```
    
    Updated `test_pandas_map.py`: removed the "returning list of DataFrames" 
positive case from `test_map_in_pandas`; added a `list_not_iter` negative case 
to `check_other_than_dataframe_iter` asserting a returned `list` is rejected 
(mirroring `test_arrow_map.py::test_other_than_recordbatch_iter`); and added 
`test_map_in_pandas_legacy_accept_any_iterable` asserting the legacy flag 
restores acceptance. Added the matching 
`test_map_in_arrow_legacy_accept_any_iterable` to `test_arrow_map.py` [...]
    
    No
    
    Closes #57800 from Yicong-Huang/SPARK-58601.
    
    Authored-by: Yicong Huang <[email protected]>
    Signed-off-by: Yicong-Huang <[email protected]>
    (cherry picked from commit 69a108ba8adfef816f0652104c64062947c0df8c)
    Signed-off-by: Yicong-Huang <[email protected]>
---
 .../source/migration_guide/pyspark_upgrade.rst     |  4 ++
 python/pyspark/sql/connect/_typing.py              |  6 +--
 python/pyspark/sql/connect/dataframe.py            |  2 +-
 python/pyspark/sql/tests/arrow/test_arrow_map.py   | 37 ++++++++++++++++
 python/pyspark/sql/tests/pandas/test_pandas_map.py | 49 +++++++++++++++++++---
 python/pyspark/worker.py                           | 40 ++++++++++++++++--
 .../org/apache/spark/sql/internal/SQLConf.scala    | 14 +++++++
 .../sql/execution/python/ArrowPythonRunner.scala   |  1 +
 8 files changed, 140 insertions(+), 13 deletions(-)

diff --git a/python/docs/source/migration_guide/pyspark_upgrade.rst 
b/python/docs/source/migration_guide/pyspark_upgrade.rst
index b257ee49b7dd..35bfe5f23c96 100644
--- a/python/docs/source/migration_guide/pyspark_upgrade.rst
+++ b/python/docs/source/migration_guide/pyspark_upgrade.rst
@@ -19,6 +19,10 @@
 Upgrading PySpark
 ==================
 
+Upgrading from PySpark 4.2 to 4.3
+---------------------------------
+* In Spark 4.3, a ``mapInPandas`` UDF must return an iterator of 
``pandas.DataFrame``\s; returning any other iterable such as a ``list`` now 
raises ``UDF_RETURN_TYPE``, matching the existing ``mapInArrow`` behavior and 
the declared ``Iterator[...]`` signature. To restore the previous behavior of 
accepting any iterable for both ``mapInPandas`` and ``mapInArrow``, set 
``spark.sql.execution.pythonUDF.mapInBatch.legacy.acceptAnyIterable.enabled`` 
to ``true``.
+
 Upgrading from PySpark 4.1 to 4.2
 ---------------------------------
 * In Spark 4.2, the minimum supported version for PyArrow has been raised from 
15.0.0 to 18.0.0 in PySpark.
diff --git a/python/pyspark/sql/connect/_typing.py 
b/python/pyspark/sql/connect/_typing.py
index 50bb02be0948..646593825ea4 100644
--- a/python/pyspark/sql/connect/_typing.py
+++ b/python/pyspark/sql/connect/_typing.py
@@ -15,7 +15,7 @@
 # limitations under the License.
 #
 from types import FunctionType
-from typing import Any, Callable, Iterable, Union, Optional, NewType, 
Protocol, Tuple
+from typing import Any, Callable, Iterable, Iterator, Union, Optional, 
NewType, Protocol, Tuple
 import datetime
 import decimal
 
@@ -44,9 +44,9 @@ DataTypeOrString = Union[DataType, str]
 
 DataFrameLike = PandasDataFrame
 
-PandasMapIterFunction = Callable[[Iterable[DataFrameLike]], 
Iterable[DataFrameLike]]
+PandasMapIterFunction = Callable[[Iterator[DataFrameLike]], 
Iterator[DataFrameLike]]
 
-ArrowMapIterFunction = Callable[[Iterable[pyarrow.RecordBatch]], 
Iterable[pyarrow.RecordBatch]]
+ArrowMapIterFunction = Callable[[Iterator[pyarrow.RecordBatch]], 
Iterator[pyarrow.RecordBatch]]
 
 PandasGroupedMapFunction = Union[
     Callable[[DataFrameLike], DataFrameLike],
diff --git a/python/pyspark/sql/connect/dataframe.py 
b/python/pyspark/sql/connect/dataframe.py
index 093489757115..b6362b607b9b 100644
--- a/python/pyspark/sql/connect/dataframe.py
+++ b/python/pyspark/sql/connect/dataframe.py
@@ -2332,7 +2332,7 @@ class DataFrame(ParentDataFrame):
             for f in schema.fields
         ]
 
-        def foreach_partition_func(itr: Iterable[pa.RecordBatch]) -> 
Iterable[pa.RecordBatch]:
+        def foreach_partition_func(itr: Iterator[pa.RecordBatch]) -> 
Iterator[pa.RecordBatch]:
             def flatten() -> Iterator[Row]:
                 for table in itr:
                     columnar_data = [
diff --git a/python/pyspark/sql/tests/arrow/test_arrow_map.py 
b/python/pyspark/sql/tests/arrow/test_arrow_map.py
index 5119e0e827f6..47ee4287c24c 100644
--- a/python/pyspark/sql/tests/arrow/test_arrow_map.py
+++ b/python/pyspark/sql/tests/arrow/test_arrow_map.py
@@ -55,6 +55,43 @@ class MapInArrowTestsMixin:
         expected = df.collect()
         self.assertEqual(actual, expected)
 
+    def test_map_in_arrow_legacy_accept_any_iterable(self):
+        # With the legacy flag enabled, returning a non-Iterator iterable 
(e.g. list) is accepted.
+        def list_not_iter(iterator):
+            return [batch for batch in iterator]
+
+        with self.sql_conf(
+            
{"spark.sql.execution.pythonUDF.mapInBatch.legacy.acceptAnyIterable.enabled": 
True}
+        ):
+            df = self.spark.range(10)
+            actual = df.mapInArrow(list_not_iter, "id long").collect()
+            expected = df.collect()
+            self.assertEqual(actual, expected)
+
+    def test_map_in_arrow_legacy_accept_sequence_protocol(self):
+        # A sequence-protocol object (implements __getitem__ but not __iter__) 
is iterable via
+        # iter(...) even though it is not a collections.abc.Iterable, so the 
legacy flag must
+        # accept it too.
+        class SequenceOnly:
+            def __init__(self, items):
+                self._items = items
+
+            def __getitem__(self, index):
+                return self._items[index]
+
+        self.assertFalse(hasattr(SequenceOnly([]), "__iter__"))
+
+        def returns_sequence(iterator):
+            return SequenceOnly([batch for batch in iterator])
+
+        with self.sql_conf(
+            
{"spark.sql.execution.pythonUDF.mapInBatch.legacy.acceptAnyIterable.enabled": 
True}
+        ):
+            df = self.spark.range(10)
+            actual = df.mapInArrow(returns_sequence, "id long").collect()
+            expected = df.collect()
+            self.assertEqual(actual, expected)
+
     def test_map_in_arrow_with_limit(self):
         def get_size(iterator):
             for batch in iterator:
diff --git a/python/pyspark/sql/tests/pandas/test_pandas_map.py 
b/python/pyspark/sql/tests/pandas/test_pandas_map.py
index bfcedc6c8899..127555440ce3 100644
--- a/python/pyspark/sql/tests/pandas/test_pandas_map.py
+++ b/python/pyspark/sql/tests/pandas/test_pandas_map.py
@@ -86,11 +86,39 @@ class MapInPandasTestsMixin:
         expected = df.collect()
         self.assertEqual(actual, expected)
 
-        # test returning list of DataFrames
-        df = self.spark.range(10, numPartitions=3)
-        actual = df.mapInPandas(lambda it: [pdf for pdf in it], "id 
long").collect()
-        expected = df.collect()
-        self.assertEqual(actual, expected)
+    def test_map_in_pandas_legacy_accept_any_iterable(self):
+        # With the legacy flag enabled, returning a non-Iterator iterable 
(e.g. list) is accepted.
+        with self.sql_conf(
+            
{"spark.sql.execution.pythonUDF.mapInBatch.legacy.acceptAnyIterable.enabled": 
True}
+        ):
+            df = self.spark.range(10, numPartitions=3)
+            actual = df.mapInPandas(lambda it: [pdf for pdf in it], "id 
long").collect()
+            expected = df.collect()
+            self.assertEqual(actual, expected)
+
+    def test_map_in_pandas_legacy_accept_sequence_protocol(self):
+        # A sequence-protocol object (implements __getitem__ but not __iter__) 
is iterable via
+        # iter(...) even though it is not a collections.abc.Iterable, so the 
legacy flag must
+        # accept it too.
+        class SequenceOnly:
+            def __init__(self, items):
+                self._items = items
+
+            def __getitem__(self, index):
+                return self._items[index]
+
+        self.assertFalse(hasattr(SequenceOnly([]), "__iter__"))
+
+        def returns_sequence(iterator):
+            return SequenceOnly([pdf for pdf in iterator])
+
+        with self.sql_conf(
+            
{"spark.sql.execution.pythonUDF.mapInBatch.legacy.acceptAnyIterable.enabled": 
True}
+        ):
+            df = self.spark.range(10, numPartitions=3)
+            actual = df.mapInPandas(returns_sequence, "id long").collect()
+            expected = df.collect()
+            self.assertEqual(actual, expected)
 
     def test_multiple_columns(self):
         data = [(1, "foo"), (2, None), (3, "bar"), (4, "bar")]
@@ -186,6 +214,10 @@ class MapInPandasTestsMixin:
         def bad_iter_elem(_):
             return iter([1])
 
+        def list_not_iter(iterator):
+            # Iterable but not an Iterator: violates the 
Iterator[pandas.DataFrame] contract.
+            return [pdf for pdf in iterator]
+
         with self.assertRaisesRegex(
             PythonException,
             "Return type of the user-defined function should be iterator of 
pandas.DataFrame, "
@@ -200,6 +232,13 @@ class MapInPandasTestsMixin:
         ):
             (self.spark.range(10, numPartitions=3).mapInPandas(bad_iter_elem, 
"a int").count())
 
+        with self.assertRaisesRegex(
+            PythonException,
+            "Return type of the user-defined function should be iterator of 
pandas.DataFrame, "
+            "but is list",
+        ):
+            (self.spark.range(10, numPartitions=3).mapInPandas(list_not_iter, 
"a int").count())
+
     def test_dataframes_with_other_column_names(self):
         with self.quiet():
             self.check_dataframes_with_other_column_names()
diff --git a/python/pyspark/worker.py b/python/pyspark/worker.py
index d75e1c5fac32..8db0c5c5245d 100644
--- a/python/pyspark/worker.py
+++ b/python/pyspark/worker.py
@@ -147,6 +147,16 @@ class RunnerConf(Conf):
             == "true"
         )
 
+    @property
+    def map_in_batch_legacy_accept_any_iterable(self) -> bool:
+        return (
+            self.get(
+                
"spark.sql.execution.pythonUDF.mapInBatch.legacy.acceptAnyIterable.enabled",
+                "false",
+            )
+            == "true"
+        )
+
     @property
     def binary_as_bytes(self) -> bool:
         return self.get("spark.sql.execution.pyspark.binaryAsBytes", "true") 
== "true"
@@ -1923,6 +1933,20 @@ def read_udfs(pickleSer, udf_info_list, eval_type, 
runner_conf, eval_conf):
             # invoke the UDF
             output_batches = udf_func(input_batches)
 
+            # 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 runner_conf.map_in_batch_legacy_accept_any_iterable and not 
isinstance(
+                output_batches, Iterator
+            ):
+                try:
+                    output_batches = iter(output_batches)
+                except TypeError:
+                    # Not iterable at all; leave it so verify_return_type 
below raises the
+                    # standard UDF_RETURN_TYPE error.
+                    pass
+
             # Post-processing
             verified_iter = verify_return_type(
                 output_batches,
@@ -2698,11 +2722,19 @@ def read_udfs(pickleSer, udf_info_list, eval_type, 
runner_conf, eval_conf):
                         df_for_struct=True,
                     )[0]
 
-            # mapInPandas accepts any iterable (e.g. a list), not just an
-            # iterator, so the standard verify_return_type (which requires an
-            # Iterator) is intentionally not reused here.
             result = map_udf(dataframe_iter())
-            if not isinstance(result, Iterator) and not hasattr(result, 
"__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={
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index fd8eb92a6878..1a67e8cc0591 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -5459,6 +5459,17 @@ object SQLConf {
       .booleanConf
       .createWithDefault(false)
 
+  val PYTHON_UDF_MAP_IN_BATCH_LEGACY_ACCEPT_ANY_ITERABLE_ENABLED =
+    
buildConf("spark.sql.execution.pythonUDF.mapInBatch.legacy.acceptAnyIterable.enabled")
+      .internal()
+      .doc("When true, mapInPandas and mapInArrow UDFs may return any iterable 
(e.g. a list) " +
+        "rather than a strict iterator, matching the behavior before 4.3.0. 
When false, the " +
+        "returned value must be an iterator, matching the declared 
Iterator[...] signatures.")
+      .version("4.3.0")
+      .withBindingPolicy(ConfigBindingPolicy.SESSION)
+      .booleanConf
+      .createWithDefault(false)
+
   val PYTHON_PLANNER_EXEC_MEMORY =
     buildConf("spark.sql.planner.pythonExecution.memory")
       .doc("Specifies the memory allocation for executing Python code in Spark 
driver, in MiB. " +
@@ -9333,6 +9344,9 @@ class SQLConf extends Serializable with Logging with 
SqlApiConf {
 
   def legacyPandasConversionUDF: Boolean = 
getConf(PYTHON_UDF_LEGACY_PANDAS_CONVERSION_ENABLED)
 
+  def legacyMapInBatchAcceptAnyIterable: Boolean =
+    getConf(PYTHON_UDF_MAP_IN_BATCH_LEGACY_ACCEPT_ANY_ITERABLE_ENABLED)
+
   def pythonPlannerExecMemory: Option[Long] = 
getConf(PYTHON_PLANNER_EXEC_MEMORY)
 
   def replaceExceptWithFilter: Boolean = getConf(REPLACE_EXCEPT_WITH_FILTER)
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowPythonRunner.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowPythonRunner.scala
index 75b8465e2607..a1588959a061 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowPythonRunner.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowPythonRunner.scala
@@ -163,6 +163,7 @@ object ArrowPythonRunner {
       SQLConf.ARROW_EXECUTION_USE_LARGE_VAR_TYPES,
       SQLConf.PYTHON_TABLE_UDF_LEGACY_PANDAS_CONVERSION_ENABLED,
       SQLConf.PYTHON_UDF_LEGACY_PANDAS_CONVERSION_ENABLED,
+      SQLConf.PYTHON_UDF_MAP_IN_BATCH_LEGACY_ACCEPT_ANY_ITERABLE_ENABLED,
       SQLConf.PYTHON_UDF_PANDAS_INT_TO_DECIMAL_COERCION_ENABLED,
       SQLConf.PYTHON_UDF_PANDAS_PREFER_INT_EXTENSION_DTYPE,
       SQLConf.PYSPARK_BINARY_AS_BYTES,


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to