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

HyukjinKwon pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new 26660a9f92eb [SPARK-57989][CONNECT][PYTHON] Raise NOT_IMPLEMENTED for 
year-month interval in Connect collect, matching Classic's default
26660a9f92eb is described below

commit 26660a9f92ebadd601d87f2e34a71a9a1f34c64b
Author: Hyukjin Kwon <[email protected]>
AuthorDate: Tue Jul 7 16:49:26 2026 +0900

    [SPARK-57989][CONNECT][PYTHON] Raise NOT_IMPLEMENTED for year-month 
interval in Connect collect, matching Classic's default
    
    ### What changes were proposed in this pull request?
    
    This PR makes the PySpark Spark Connect client raise a clean 
`PySparkNotImplementedError` (error class `NOT_IMPLEMENTED`) when a result 
containing a `YearMonthIntervalType` is collected, instead of surfacing an 
opaque PyArrow error.
    
    Concretely:
    
    - `from_arrow_type` (in `pyspark/sql/pandas/types.py`) now maps the Arrow 
`YEAR_MONTH` interval type to `YearMonthIntervalType`. The JVM serializes 
Spark's `YearMonthIntervalType` to an Arrow `YEAR_MONTH` interval, but PyArrow 
exposes no `is_*()` helper or factory for it (only `MONTH_DAY_NANO` is in 
`pyarrow.types`), so the branch matches on the stable Arrow type id 
(`Type::INTERVAL_MONTHS == 21`).
    - `ArrowTableToRowsConversion.convert` (in `pyspark/sql/conversion.py`) 
checks the result schema before materializing and raises `NOT_IMPLEMENTED` if 
any field (including nested array/map/struct/UDT element types) is a 
`YearMonthIntervalType`. PyArrow cannot materialize such a column: 
`to_pylist()` raises an opaque `KeyError: 21` from `get_array_class_from_type`, 
and that lookup fails even for an empty column, so the check is intentionally 
unconditional in the row count.
    - `_has_type` (in `pyspark/sql/types.py`) now recurses into 
`UserDefinedType.sqlType()` so a year-month interval hidden inside a UDT is 
detected too.
    
    This matches the default behavior of the classic PySpark path, where 
`YearMonthIntervalType.fromInternal` raises `NOT_IMPLEMENTED`.
    
    ### Why are the changes needed?
    
    Collecting a year-month interval through the Spark Connect client 
previously failed with confusing, low-level errors rather than the intended 
`NOT_IMPLEMENTED`:
    
    - `PySparkTypeError: [UNSUPPORTED_DATA_TYPE_FOR_ARROW_CONVERSION] 
month_interval is not supported`, or
    - `pyarrow.lib.ArrowNotImplementedError: No known equivalent Pandas block 
for Arrow data of type month_interval`, or
    - an opaque `KeyError: 21` from PyArrow when materializing rows.
    
    Classic PySpark already raises a clear `NOT_IMPLEMENTED` error for this 
unsupported operation (`YearMonthIntervalType.fromInternal`). Spark Connect 
should behave the same so users get an actionable message and the two clients 
stay consistent.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes, for the Spark Connect Python client. Collecting a year-month interval 
value (`df.collect()`/`first()`/`take()`/`head()`) now raises 
`PySparkNotImplementedError` with error class `NOT_IMPLEMENTED` instead of a 
`PySparkTypeError`/`ArrowNotImplementedError`/`KeyError`. Collecting a 
year-month interval was never supported; only the surfaced error changes.
    
    Two behaviors remain specific to Spark Connect and differ from classic 
PySpark:
    
    - `PYSPARK_YM_INTERVAL_LEGACY=1` (which makes classic return the internal 
integer months) is not honored; collect always raises.
    - An empty result (e.g. `.limit(0).collect()`) raises rather than returning 
`[]`, because PyArrow cannot build the `INTERVAL_MONTHS` array class regardless 
of row count.
    
    ### How was this patch tested?
    
    - Updated 
`test_connect_error.SparkConnectErrorTests.test_ym_interval_in_collect` to 
expect `PySparkNotImplementedError` and added coverage for a year-month 
interval nested inside an array.
    - Added 
`test_connect_error.SparkConnectErrorTests.test_ym_interval_empty_collect` 
covering the empty-result case.
    - Updated the skip reason on `test_parity_types` for 
`test_ym_interval_in_collect` to explain why the inherited classic contract 
(which asserts the `PYSPARK_YM_INTERVAL_LEGACY=1` integer-months behavior) 
cannot be satisfied by Spark Connect.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.8)
    
    Closes #57068 from HyukjinKwon/connect-ym-interval-collect.
    
    Authored-by: Hyukjin Kwon <[email protected]>
    Signed-off-by: Hyukjin Kwon <[email protected]>
---
 python/pyspark/sql/conversion.py                   | 21 +++++++++++++++++++-
 python/pyspark/sql/pandas/types.py                 |  8 ++++++++
 .../sql/tests/connect/test_connect_error.py        | 23 +++++++++++++++++++---
 .../pyspark/sql/tests/connect/test_parity_types.py |  8 +++++++-
 python/pyspark/sql/types.py                        |  2 ++
 5 files changed, 57 insertions(+), 5 deletions(-)

diff --git a/python/pyspark/sql/conversion.py b/python/pyspark/sql/conversion.py
index 9110a6382725..bfa0d4a559a5 100644
--- a/python/pyspark/sql/conversion.py
+++ b/python/pyspark/sql/conversion.py
@@ -21,7 +21,7 @@ import decimal
 from typing import TYPE_CHECKING, Any, Callable, List, Optional, Sequence, 
Union, overload
 
 import pyspark
-from pyspark.errors import PySparkRuntimeError, PySparkValueError
+from pyspark.errors import PySparkNotImplementedError, PySparkRuntimeError, 
PySparkValueError
 from pyspark.sql.pandas.types import (
     _dedup_names,
     _deduplicate_field_names,
@@ -62,6 +62,7 @@ from pyspark.sql.types import (
     VariantType,
     VariantVal,
     _create_row,
+    _has_type,
 )
 
 if TYPE_CHECKING:
@@ -1276,6 +1277,24 @@ class ArrowTableToRowsConversion:
 
         assert schema is not None and isinstance(schema, StructType)
 
+        # YearMonthIntervalType is serialized by the JVM as an Arrow 
YEAR_MONTH interval, which
+        # PyArrow cannot materialize into Python values: `to_pylist()` raises 
an opaque
+        # `KeyError: <Arrow type id>` from `get_array_class_from_type`. That 
lookup fails for an
+        # empty column too (it resolves the array class before reading any 
element), so the check
+        # below is intentionally unconditional in the row count -- it covers 
empty results as well,
+        # surfacing a clean NOT_IMPLEMENTED instead of the opaque KeyError. 
Collecting such a value
+        # is therefore not supported in the Spark Connect client; raise the 
same NOT_IMPLEMENTED
+        # error as the classic PySpark path 
(YearMonthIntervalType.fromInternal). Note that, unlike
+        # classic, PYSPARK_YM_INTERVAL_LEGACY (returning the integer months) 
cannot be honored here,
+        # and an empty result raises rather than returning [] as classic would.
+        if any(_has_type(f.dataType, YearMonthIntervalType) for f in 
schema.fields):
+            raise PySparkNotImplementedError(
+                errorClass="NOT_IMPLEMENTED",
+                messageParameters={
+                    "feature": "Collecting a year-month interval value in 
Spark Connect"
+                },
+            )
+
         fields = schema.fieldNames()
 
         if len(fields) > 0:
diff --git a/python/pyspark/sql/pandas/types.py 
b/python/pyspark/sql/pandas/types.py
index 4c1ae1a0fedf..1a5c7a33c240 100644
--- a/python/pyspark/sql/pandas/types.py
+++ b/python/pyspark/sql/pandas/types.py
@@ -47,6 +47,7 @@ from pyspark.sql.types import (
     TimestampType,
     TimestampNTZType,
     DayTimeIntervalType,
+    YearMonthIntervalType,
     ArrayType,
     MapType,
     StructType,
@@ -410,6 +411,13 @@ def from_arrow_type(
             spark_type = TimestampType()
     elif types.is_duration(at):
         spark_type = DayTimeIntervalType()
+    elif at.id == 21:  # Arrow Type.INTERVAL_MONTHS
+        # The JVM serializes Spark's YearMonthIntervalType to an Arrow 
YEAR_MONTH interval
+        # (an integer number of months); see ArrowUtils.scala / 
ArrowWriter.scala. Unlike
+        # DayTimeIntervalType (sent as an Arrow Duration), PyArrow exposes no 
factory or
+        # is_*() helper for this type -- only MONTH_DAY_NANO is in 
pyarrow.types -- so match
+        # on the stable Arrow type id (Type::INTERVAL_MONTHS == 21).
+        spark_type = YearMonthIntervalType()
     elif types.is_list(at):
         spark_type = ArrayType(
             elementType=from_arrow_type(at.value_type, prefer_timestamp_ntz),
diff --git a/python/pyspark/sql/tests/connect/test_connect_error.py 
b/python/pyspark/sql/tests/connect/test_connect_error.py
index 50c6900cabc0..8f142dd55632 100644
--- a/python/pyspark/sql/tests/connect/test_connect_error.py
+++ b/python/pyspark/sql/tests/connect/test_connect_error.py
@@ -21,7 +21,7 @@ from pyspark.errors import PySparkAttributeError
 from pyspark.errors.exceptions.base import SessionNotSameException
 from pyspark.sql.types import Row
 from pyspark.sql import functions as F
-from pyspark.errors import PySparkTypeError
+from pyspark.errors import PySparkNotImplementedError, PySparkTypeError
 from pyspark.testing.connectutils import ReusedConnectTestCase
 from pyspark.util import is_remote_only
 
@@ -261,10 +261,27 @@ class SparkConnectErrorTests(ReusedConnectTestCase):
         )
 
     def test_ym_interval_in_collect(self):
-        # YearMonthIntervalType is not supported in python side arrow 
conversion
-        with self.assertRaises(PySparkTypeError):
+        # PyArrow cannot materialize Arrow YEAR_MONTH intervals, so collecting 
a year-month
+        # interval over Spark Connect raises NOT_IMPLEMENTED (matching the 
default of the classic
+        # PySpark path). Unlike classic, PYSPARK_YM_INTERVAL_LEGACY is not 
honored here.
+        with self.assertRaises(PySparkNotImplementedError):
             self.spark.sql("SELECT INTERVAL '10-8' YEAR TO MONTH AS 
interval").first()
 
+        # A year-month interval nested inside an array is rejected the same 
way (the schema-level
+        # check recurses into array/map/struct element types).
+        with self.assertRaises(PySparkNotImplementedError):
+            self.spark.sql("SELECT array(INTERVAL '10-8' YEAR TO MONTH) AS 
interval").first()
+
+    def test_ym_interval_empty_collect(self):
+        # Even an empty result raises NOT_IMPLEMENTED rather than returning 
[]. PyArrow cannot
+        # build the INTERVAL_MONTHS array class at all -- `to_pylist()` raises 
`KeyError: 21` from
+        # get_array_class_from_type regardless of row count -- so the 
schema-level check covers
+        # empty results too, giving a clean error instead of an opaque PyArrow 
KeyError. This is
+        # one place Spark Connect diverges from classic PySpark, which returns 
[] for an empty
+        # result (it never reaches PyArrow materialization).
+        with self.assertRaises(PySparkNotImplementedError):
+            self.spark.sql("SELECT INTERVAL '10-8' YEAR TO MONTH AS 
interval").limit(0).collect()
+
 
 if __name__ == "__main__":
     from pyspark.testing import main
diff --git a/python/pyspark/sql/tests/connect/test_parity_types.py 
b/python/pyspark/sql/tests/connect/test_parity_types.py
index 3f7417b36247..42346c4fc4a1 100644
--- a/python/pyspark/sql/tests/connect/test_parity_types.py
+++ b/python/pyspark/sql/tests/connect/test_parity_types.py
@@ -98,7 +98,13 @@ class TypesParityTests(TypesTestsMixin, 
ReusedConnectTestCase):
     def test_schema_with_collations_json_ser_de(self):
         super().test_schema_with_collations_json_ser_de()
 
-    @unittest.skip("This test is dedicated for PySpark Classic.")
+    @unittest.skip(
+        "The inherited Classic contract also asserts that 
PYSPARK_YM_INTERVAL_LEGACY=1 returns "
+        "the integer months (Row(interval=128)), which Spark Connect cannot 
satisfy: PyArrow has "
+        "no INTERVAL_MONTHS array support, so the legacy flag is not honored 
and collect raises "
+        "NOT_IMPLEMENTED regardless. The default-raise behavior Connect does 
match is covered by "
+        
"test_connect_error.SparkConnectErrorTests.test_ym_interval_in_collect."
+    )
     def test_ym_interval_in_collect(self):
         super().test_ym_interval_in_collect()
 
diff --git a/python/pyspark/sql/types.py b/python/pyspark/sql/types.py
index 6b9020b3b104..2757964bcb9c 100644
--- a/python/pyspark/sql/types.py
+++ b/python/pyspark/sql/types.py
@@ -2882,6 +2882,8 @@ def _has_type(dt: DataType, dts: Union[type, Tuple[type, 
...]]) -> bool:
         return _has_type(dt.elementType, dts)
     elif isinstance(dt, MapType):
         return _has_type(dt.keyType, dts) or _has_type(dt.valueType, dts)
+    elif isinstance(dt, UserDefinedType):
+        return _has_type(dt.sqlType(), dts)
     else:
         return False
 


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

Reply via email to