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

zhengruifeng 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 56132f95cec5 [SPARK-58919][PYTHON][TEST] Add tests for 
pa.Schema.from_pandas
56132f95cec5 is described below

commit 56132f95cec5e25427acde5baa39bb44e91edbd2
Author: Spenser Sun <[email protected]>
AuthorDate: Fri Aug 21 08:53:19 2026 +0800

    [SPARK-58919][PYTHON][TEST] Add tests for pa.Schema.from_pandas
    
    ### What changes were proposed in this pull request?
    
    Add golden-file tests for `pa.Schema.from_pandas` in 
`test_pyarrow_dataframe_from_pandas.py`, alongside the existing 
`RecordBatch.from_pandas` tests. The test sweeps `preserve_index` 
(`None`/`False`/`True`) across frame shape and index kind (0-column, 
single/multi-column, RangeIndex, named, unnamed, and MultiIndex), pinning each 
inferred field's name, type, and nullability.
    
    The index-aware input rendering (`_index_desc`/`_input_cell`) is lifted 
from the RecordBatch class into the shared base (now a second consumer), and 
`GoldenFileTestMixin` gains a `pa.Schema` repr (`repr_arrow_schema_value`) that 
surfaces field nullability.
    
    ### Why are the changes needed?
    
    Part of SPARK-54936 (monitor upstream library behavior changes). Spark 
builds a `StructType` from `pa.Schema.from_pandas` at two sites that diverge on 
`preserve_index`: classic `createDataFrame` passes `preserve_index=False` 
(index dropped), while Spark Connect passes it bare/`None` (a named or 
non-range index becomes an extra field). Both read each field's type and 
nullability. Pinning this in a golden file catches an upstream behavior change 
loudly instead of silently altering the i [...]
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. Test-only.
    
    ### How was this patch tested?
    
    New golden-file tests, run without a Spark session. Validated across a 
PyArrow 18-25 x pandas 2/3 sweep (16 combinations) with no version-specific 
overrides.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.8)
    
    Closes #58178 from Spenserrrr/schema-from-pandas-tests.
    
    Authored-by: Spenser Sun <[email protected]>
    Signed-off-by: Ruifeng Zheng <[email protected]>
---
 python/pyspark/testing/goldenutils.py              |  24 +++++
 .../pyarrow/golden_pyarrow_schema_from_pandas.csv  |  13 +++
 .../pyarrow/golden_pyarrow_schema_from_pandas.md   |  14 +++
 .../pyarrow/test_pyarrow_dataframe_from_pandas.py  | 106 +++++++++++++++++----
 4 files changed, 141 insertions(+), 16 deletions(-)

diff --git a/python/pyspark/testing/goldenutils.py 
b/python/pyspark/testing/goldenutils.py
index 8374d20eb422..df699a81b065 100644
--- a/python/pyspark/testing/goldenutils.py
+++ b/python/pyspark/testing/goldenutils.py
@@ -408,6 +408,27 @@ class GoldenFileTestMixin:
         v_str, schema = cls._repr_arrow_columns(value, max_len)
         return f"{v_str}@RecordBatch[{schema}]"
 
+    @classmethod
+    def repr_arrow_schema_value(cls, value: "pa.Schema", max_len: int = 32) -> 
str:
+        """
+        Format a PyArrow Schema for golden file.
+
+        Renders each field as "name: type nullable=...".  A Schema carries no 
data, so
+        nullability is included (unlike the Table/RecordBatch schema string): 
Spark reads
+        ``field.nullable`` to build its StructType, so a change there silently 
alters the
+        inferred Spark schema.
+
+        Returns
+        -------
+        str
+            "[name: type nullable=True, ...]@Schema"
+        """
+        fields = [f"{f.name}: {cls.repr_type(f.type)} nullable={f.nullable}" 
for f in value]
+        v_str = "[" + ", ".join(fields) + "]"
+        if max_len > 0:
+            v_str = v_str[:max_len]
+        return f"{v_str}@Schema"
+
     @classmethod
     def repr_pandas_value(cls, value: "pd.DataFrame", max_len: int = 32) -> 
str:
         """
@@ -483,6 +504,7 @@ class GoldenFileTestMixin:
         - PyArrow Array/ChunkedArray -> repr_arrow_value
         - PyArrow Table -> repr_arrow_table_value
         - PyArrow RecordBatch -> repr_arrow_record_batch_value
+        - PyArrow Schema -> repr_arrow_schema_value
         - pandas DataFrame -> repr_pandas_value
         - numpy ndarray -> repr_numpy_value
         - Everything else -> repr_python_value
@@ -505,6 +527,8 @@ class GoldenFileTestMixin:
             return cls.repr_arrow_table_value(value, max_len)
         if have_pyarrow and isinstance(value, pa.RecordBatch):
             return cls.repr_arrow_record_batch_value(value, max_len)
+        if have_pyarrow and isinstance(value, pa.Schema):
+            return cls.repr_arrow_schema_value(value, max_len)
 
         if have_pandas and isinstance(value, pd.DataFrame):
             return cls.repr_pandas_value(value, max_len)
diff --git 
a/python/pyspark/tests/upstream/pyarrow/golden_pyarrow_schema_from_pandas.csv 
b/python/pyspark/tests/upstream/pyarrow/golden_pyarrow_schema_from_pandas.csv
new file mode 100644
index 000000000000..66f7bbaf691b
--- /dev/null
+++ 
b/python/pyspark/tests/upstream/pyarrow/golden_pyarrow_schema_from_pandas.csv
@@ -0,0 +1,13 @@
+test case      pandas dataframe        preserve_index=None     
preserve_index=False    preserve_index=True
+0-columns:range-index  {}@Dataframe[][index=RangeIndex[0:3:1]] []@Schema       
[]@Schema       [__index_level_0__: int64 nullable=True]@Schema
+0-columns:named-index  {}@Dataframe[][index='idx':[100, 200, 300]]     [idx: 
int64 nullable=True]@Schema       []@Schema       [idx: int64 
nullable=True]@Schema
+0-columns:unnamed-index        {}@Dataframe[][index=None:[10, 20, 30]] 
[__index_level_0__: int64 nullable=True]@Schema []@Schema       
[__index_level_0__: int64 nullable=True]@Schema
+0-columns:empty        {}@Dataframe[][index=RangeIndex[0:0:1]] []@Schema       
[]@Schema       [__index_level_0__: int64 nullable=True]@Schema
+single-column:range-index      {'a': [1, 2, 3]}@Dataframe[a 
int64][index=RangeIndex[0:3:1]]    [a: int64 nullable=True]@Schema [a: int64 
nullable=True]@Schema [a: int64 nullable=True, __index_level_0__: int64 
nullable=True]@Schema
+single-column:named-index      {'a': [1, 2, 3]}@Dataframe[a 
int64][index='idx':[100, 200, 300]]        [a: int64 nullable=True, idx: int64 
nullable=True]@Schema       [a: int64 nullable=True]@Schema [a: int64 
nullable=True, idx: int64 nullable=True]@Schema
+single-column:unnamed-index    {'a': [1, 2, 3]}@Dataframe[a 
int64][index=None:[10, 20, 30]]    [a: int64 nullable=True, __index_level_0__: 
int64 nullable=True]@Schema [a: int64 nullable=True]@Schema [a: int64 
nullable=True, __index_level_0__: int64 nullable=True]@Schema
+multi-column:standard  {'i': [1, 2, 3], 'f': [1.5, 2.5, 3.5], 'b': [True, 
False, True], 's': ['a', 'b', 'c'], 't': [Timestamp('2020-01-01 05:30:00'), 
Timestamp('2020-01-01 05:30:00'), Timestamp('2020-01-01 
05:30:00')]}@Dataframe[i int64, f float64, b bool, s object, t 
datetime64[ns]][index=RangeIndex[0:3:1]]  [i: int64 nullable=True, f: float64 
nullable=True, b: bool nullable=True, s: string nullable=True, t: timestamp[ns] 
nullable=True]@Schema       [i: int64 nullable=True, f: float64 nullable= [...]
+multi-column:nullable  {'f': [1.5, nan, 3.5], 'b': [True, None, False], 's': 
['a', None, 'c'], 't': [Timestamp('2020-01-01 05:30:00'), NaT, 
Timestamp('2020-01-01 05:30:00')]}@Dataframe[f float64, b object, s object, t 
datetime64[ns]][index=RangeIndex[0:3:1]]     [f: float64 nullable=True, b: bool 
nullable=True, s: string nullable=True, t: timestamp[ns] nullable=True]@Schema  
     [f: float64 nullable=True, b: bool nullable=True, s: string nullable=True, 
t: timestamp[ns] nullable=True]@Schema       [f: fl [...]
+multi-column:no-rows   {'i': [], 'f': [], 'b': [], 't': []}@Dataframe[i int64, 
f float64, b bool, t datetime64[ns]][index=RangeIndex[0:0:1]]   [i: int64 
nullable=True, f: float64 nullable=True, b: bool nullable=True, t: 
timestamp[ns] nullable=True]@Schema        [i: int64 nullable=True, f: float64 
nullable=True, b: bool nullable=True, t: timestamp[ns] nullable=True]@Schema    
    [i: int64 nullable=True, f: float64 nullable=True, b: bool nullable=True, 
t: timestamp[ns] nullable=True, __index_level_0__: in [...]
+single-column:multiindex       {'a': [1, 2, 3]}@Dataframe[a 
int64][index=MultiIndex[names=['g', 'n']]] [a: int64 nullable=True, g: int64 
nullable=True, n: int64 nullable=True]@Schema [a: int64 nullable=True]@Schema 
[a: int64 nullable=True, g: int64 nullable=True, n: int64 nullable=True]@Schema
+single-column:multiindex-partial-name  {'a': [1, 2]}@Dataframe[a 
int64][index=MultiIndex[names=['g', None]]]   [a: int64 nullable=True, g: int64 
nullable=True, __index_level_1__: int64 nullable=True]@Schema [a: int64 
nullable=True]@Schema [a: int64 nullable=True, g: int64 nullable=True, 
__index_level_1__: int64 nullable=True]@Schema
diff --git 
a/python/pyspark/tests/upstream/pyarrow/golden_pyarrow_schema_from_pandas.md 
b/python/pyspark/tests/upstream/pyarrow/golden_pyarrow_schema_from_pandas.md
new file mode 100644
index 000000000000..bcf6286bdae4
--- /dev/null
+++ b/python/pyspark/tests/upstream/pyarrow/golden_pyarrow_schema_from_pandas.md
@@ -0,0 +1,14 @@
+| test case                             | pandas dataframe                     
                                                                                
                                                                                
                                                                                
          | preserve_index=None                                                 
                                                                      | 
preserve_index=False  [...]
+|---------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------|----------------------
 [...]
+| 0-columns:range-index                 | 
{}@Dataframe[][index=RangeIndex[0:3:1]]                                         
                                                                                
                                                                                
                                               | []@Schema                      
                                                                                
                           | []@Schema             [...]
+| 0-columns:named-index                 | {}@Dataframe[][index='idx':[100, 
200, 300]]                                                                      
                                                                                
                                                                                
              | [idx: int64 nullable=True]@Schema                               
                                                                          | 
[]@Schema             [...]
+| 0-columns:unnamed-index               | {}@Dataframe[][index=None:[10, 20, 
30]]                                                                            
                                                                                
                                                                                
            | [__index_level_0__: int64 nullable=True]@Schema                   
                                                                        | 
[]@Schema             [...]
+| 0-columns:empty                       | 
{}@Dataframe[][index=RangeIndex[0:0:1]]                                         
                                                                                
                                                                                
                                               | []@Schema                      
                                                                                
                           | []@Schema             [...]
+| single-column:range-index             | {'a': [1, 2, 3]}@Dataframe[a 
int64][index=RangeIndex[0:3:1]]                                                 
                                                                                
                                                                                
                  | [a: int64 nullable=True]@Schema                             
                                                                              | 
[a: int64 nullable=Tr [...]
+| single-column:named-index             | {'a': [1, 2, 3]}@Dataframe[a 
int64][index='idx':[100, 200, 300]]                                             
                                                                                
                                                                                
                  | [a: int64 nullable=True, idx: int64 nullable=True]@Schema   
                                                                              | 
[a: int64 nullable=Tr [...]
+| single-column:unnamed-index           | {'a': [1, 2, 3]}@Dataframe[a 
int64][index=None:[10, 20, 30]]                                                 
                                                                                
                                                                                
                  | [a: int64 nullable=True, __index_level_0__: int64 
nullable=True]@Schema                                                           
        | [a: int64 nullable=Tr [...]
+| multi-column:standard                 | {'i': [1, 2, 3], 'f': [1.5, 2.5, 
3.5], 'b': [True, False, True], 's': ['a', 'b', 'c'], 't': 
[Timestamp('2020-01-01 05:30:00'), Timestamp('2020-01-01 05:30:00'), 
Timestamp('2020-01-01 05:30:00')]}@Dataframe[i int64, f float64, b bool, s 
object, t datetime64[ns]][index=RangeIndex[0:3:1]] | [i: int64 nullable=True, 
f: float64 nullable=True, b: bool nullable=True, s: string nullable=True, t: 
timestamp[ns] nullable=True]@Schema | [i: int64 nullable=Tr [...]
+| multi-column:nullable                 | {'f': [1.5, nan, 3.5], 'b': [True, 
None, False], 's': ['a', None, 'c'], 't': [Timestamp('2020-01-01 05:30:00'), 
NaT, Timestamp('2020-01-01 05:30:00')]}@Dataframe[f float64, b object, s 
object, t datetime64[ns]][index=RangeIndex[0:3:1]]                              
                      | [f: float64 nullable=True, b: bool nullable=True, s: 
string nullable=True, t: timestamp[ns] nullable=True]@Schema                    
     | [f: float64 nullable= [...]
+| multi-column:no-rows                  | {'i': [], 'f': [], 'b': [], 't': 
[]}@Dataframe[i int64, f float64, b bool, t 
datetime64[ns]][index=RangeIndex[0:0:1]]                                        
                                                                                
                                                  | [i: int64 nullable=True, f: 
float64 nullable=True, b: bool nullable=True, t: timestamp[ns] 
nullable=True]@Schema                          | [i: int64 nullable=Tr [...]
+| single-column:multiindex              | {'a': [1, 2, 3]}@Dataframe[a 
int64][index=MultiIndex[names=['g', 'n']]]                                      
                                                                                
                                                                                
                  | [a: int64 nullable=True, g: int64 nullable=True, n: int64 
nullable=True]@Schema                                                           
| [a: int64 nullable=Tr [...]
+| single-column:multiindex-partial-name | {'a': [1, 2]}@Dataframe[a 
int64][index=MultiIndex[names=['g', None]]]                                     
                                                                                
                                                                                
                     | [a: int64 nullable=True, g: int64 nullable=True, 
__index_level_1__: int64 nullable=True]@Schema                                  
         | [a: int64 nullable=Tr [...]
\ No newline at end of file
diff --git 
a/python/pyspark/tests/upstream/pyarrow/test_pyarrow_dataframe_from_pandas.py 
b/python/pyspark/tests/upstream/pyarrow/test_pyarrow_dataframe_from_pandas.py
index 0b7af403fa07..b125e25d92d8 100644
--- 
a/python/pyspark/tests/upstream/pyarrow/test_pyarrow_dataframe_from_pandas.py
+++ 
b/python/pyspark/tests/upstream/pyarrow/test_pyarrow_dataframe_from_pandas.py
@@ -17,8 +17,8 @@
 
 """
 Golden-file tests for the PyArrow ``from_pandas`` constructors that take a 
whole pandas
-DataFrame: ``pa.RecordBatch.from_pandas`` (with ``pa.Table.from_pandas`` /
-``pa.Schema.from_pandas`` to follow in this file). These take a DataFrame, 
unlike
+DataFrame: ``pa.RecordBatch.from_pandas`` and ``pa.Schema.from_pandas`` (with
+``pa.Table.from_pandas`` to follow in this file). These take a DataFrame, 
unlike
 ``pa.Array.from_pandas`` which takes a Series (covered by 
test_pyarrow_array_from_pandas_*).
 
 Per-column type inference matches the Array tests, so these pin the 
DataFrame-level
@@ -29,6 +29,13 @@ for RecordBatch -- num_rows preservation for a 0-column 
DataFrame. Spark calls
 the default ``preserve_index=None`` to carry num_rows via the index metadata 
-- otherwise a
 0-column relation loses its rows.
 
+``Schema.from_pandas`` is inspected to build a Spark schema, and the two prod 
call sites
+diverge on ``preserve_index``: classic pandas/conversion.py:971 passes 
``False`` (index
+dropped), Connect session.py:573 passes it bare/``None`` (a named or non-range 
index becomes
+an extra field) -- so a named-index frame yields different field sets. Spark 
reads each
+field's type AND nullability (conversion.py:989 / session.py:590), so the 
schema test pins
+name/type/nullability across ``preserve_index``.
+
 Regenerate with SPARK_GENERATE_GOLDEN_FILES=1.
 """
 
@@ -51,11 +58,25 @@ if have_pyarrow:
 
 class _PyArrowFromPandasFrameTestBase(GoldenFileTestMixin, unittest.TestCase):
     """
-    Shared machinery for the DataFrame-input ``from_pandas`` constructors 
(RecordBatch
-    here; Table/Schema as followups). Owns the source-frame inventory and 
defines no
-    ``test_*`` of its own.
+    Shared machinery for the DataFrame-input ``from_pandas`` constructors 
(RecordBatch and
+    Schema here; Table as a followup). Owns the source-frame inventory and the 
index-aware
+    input-cell rendering (both constructors depend on the index under 
``preserve_index``);
+    defines no ``test_*`` of its own.
     """
 
+    @staticmethod
+    def _index_desc(index) -> str:
+        """Compact, deterministic description of a pandas index for the input 
cell."""
+        if isinstance(index, pd.MultiIndex):
+            return f"MultiIndex[names={list(index.names)}]"
+        if isinstance(index, pd.RangeIndex):
+            return f"RangeIndex[{index.start}:{index.stop}:{index.step}]"
+        return f"{index.name!r}:{index.tolist()}"
+
+    def _input_cell(self, df) -> str:
+        """Input DataFrame repr, extended with its index (repr_value drops 
it)."""
+        return f"{self.repr_value(df, 
max_len=0)}[index={self._index_desc(df.index)}]"
+
     def _build_source_frames(self):
         """Named pandas DataFrames covering shape x index-kind, plus a dtype 
sample."""
         dt = datetime.datetime(2020, 1, 1, 5, 30)
@@ -120,17 +141,6 @@ class _PyArrowFromPandasFrameTestBase(GoldenFileTestMixin, 
unittest.TestCase):
 class PyArrowRecordBatchFromPandasTests(_PyArrowFromPandasFrameTestBase):
     """Tests pa.RecordBatch.from_pandas() across preserve_index via golden 
file comparison."""
 
-    @staticmethod
-    def _index_desc(index) -> str:
-        """Compact, deterministic description of a pandas index for the input 
cell."""
-        if isinstance(index, pd.RangeIndex):
-            return f"RangeIndex[{index.start}:{index.stop}:{index.step}]"
-        return f"{index.name!r}:{index.tolist()}"
-
-    def _input_cell(self, df) -> str:
-        """Input DataFrame repr, extended with its index (repr_value drops 
it)."""
-        return f"{self.repr_value(df, 
max_len=0)}[index={self._index_desc(df.index)}]"
-
     def _from_pandas_cell(self, df, **kwargs) -> str:
         """
         Convert ``df`` via RecordBatch.from_pandas(**kwargs) and append 
num_rows -- the
@@ -173,6 +183,70 @@ class 
PyArrowRecordBatchFromPandasTests(_PyArrowFromPandasFrameTestBase):
         )
 
 
[email protected](
+    not have_pyarrow or not have_pandas,
+    pyarrow_requirement_message or pandas_requirement_message,
+)
+class PyArrowSchemaFromPandasTests(_PyArrowFromPandasFrameTestBase):
+    """Tests pa.Schema.from_pandas() across preserve_index via golden file 
comparison."""
+
+    def _schema_source_frames(self):
+        """Shared frames plus two MultiIndex rows -- a MultiIndex has several 
index levels,
+        each becoming its own field, so these pin multi-level index-to-field 
naming at the
+        schema. Level values are integers (stable int64 on pandas 2 and 3; 
strings drift)."""
+        frames = self._build_source_frames()
+        frames["single-column:multiindex"] = pd.DataFrame(
+            {"a": [1, 2, 3]},
+            index=pd.MultiIndex.from_tuples([(1, 10), (1, 20), (2, 30)], 
names=["g", "n"]),
+        )
+        frames["single-column:multiindex-partial-name"] = pd.DataFrame(
+            {"a": [1, 2]},
+            index=pd.MultiIndex.from_tuples([(1, 10), (2, 20)], names=["g", 
None]),
+        )
+        return frames
+
+    def _from_pandas_cell(self, df, **kwargs) -> str:
+        """
+        Infer the schema via Schema.from_pandas(**kwargs) and render its 
fields with
+        nullability -- the name/type/nullable Spark reads to build its 
StructType. Returns
+        ERR@<ExceptionClass> if inference raises; a formatting error is a test 
bug.
+        """
+        try:
+            schema = pa.Schema.from_pandas(df, **kwargs)
+        except Exception as e:
+            return f"ERR@{type(e).__name__}"
+        return self.repr_value(schema, max_len=0)
+
+    def test_from_pandas(self):
+        """Test pa.Schema.from_pandas() across preserve_index against golden 
file."""
+        sources = self._schema_source_frames()
+        row_names = list(sources.keys())
+        preserve = {
+            "preserve_index=None": None,
+            "preserve_index=False": False,
+            "preserve_index=True": True,
+        }
+        col_names = ["pandas dataframe", *preserve.keys()]
+
+        # Version-specific expected values go here, keyed by (row, col), for 
known drift.
+        overrides: dict[tuple[str, str], str] = {}
+
+        def compute_cell(row_name, col_name):
+            df = sources[row_name]
+            if col_name == "pandas dataframe":
+                return self._input_cell(df)
+            return self._from_pandas_cell(df, 
preserve_index=preserve[col_name])
+
+        self.compare_or_generate_golden_matrix(
+            row_names=row_names,
+            col_names=col_names,
+            compute_cell=compute_cell,
+            golden_file_prefix="golden_pyarrow_schema_from_pandas",
+            index_name="test case",
+            overrides=overrides,
+        )
+
+
 if __name__ == "__main__":
     from pyspark.testing import main
 


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

Reply via email to