viirya commented on code in PR #131:
URL: 
https://github.com/apache/spark-connect-rust/pull/131#discussion_r4431033036


##########
crates/spark-connect/src/types.rs:
##########
@@ -865,7 +865,11 @@ impl DataType {
         match self {
             DataType::Struct { .. } => {
                 let mut out = String::from("root\n");
-                self.append_tree(&mut out, " |", 1, max_depth);
+                self.append_tree(
+                    &mut out,
+                    " |",
+                    if max_depth > 0 { max_depth } else { i32::MAX },

Review Comment:
   The doc comment on `tree_string_with_depth` is stale. Truncation now applies 
to array/map children as well as structs, and `max_depth <= 0` means unlimited, 
where it used to print only the top level. That's a behavior change in a public 
API, so it should be documented.



##########
crates/spark-connect/src/types.rs:
##########
@@ -875,33 +879,94 @@ impl DataType {
         }
     }
 
-    fn append_tree(&self, out: &mut String, prefix: &str, depth: i32, 
max_depth: i32) {
-        if let DataType::Struct { fields } = self {
-            for f in fields {
-                // A nested struct is rendered as the bare type name 
("struct") and its
-                // fields are shown by recursing below, so `max_depth` can 
truncate them.
-                // Using `simple_string()` here would inline the children (e.g.
-                // "struct<inner:int>"), leaking them past `max_depth`. 
Non-struct types
-                // have no depth-controlled children, so their simple string 
is used.
-                let is_struct = matches!(f.data_type, DataType::Struct { .. });
-                let type_repr = if is_struct {
-                    f.data_type.type_name()
-                } else {
-                    f.data_type.simple_string()
-                };
-                out.push_str(&format!(
-                    "{}-- {}: {} (nullable = {})\n",
-                    prefix, f.name, type_repr, f.nullable
-                ));
-                if is_struct && depth < max_depth {
-                    f.data_type.append_tree(
+    fn tree_type_name(&self) -> String {
+        match self {
+            DataType::Decimal { .. }
+            | DataType::Char { .. }
+            | DataType::Varchar { .. }
+            | DataType::Time { .. }
+            | DataType::YearMonthInterval { .. }
+            | DataType::DayTimeInterval { .. } => self.simple_string(),
+            DataType::Udt {
+                python_class: Some(python_class),
+                ..
+            } => python_class
+                .rsplit('.')
+                .next()
+                .filter(|class_name| !class_name.is_empty())
+                // UserDefinedType.typeName() preserves the entire class name,
+                // unlike DataType.typeName(), which strips the "Type" suffix.
+                .map(str::to_lowercase)
+                .unwrap_or_else(|| self.type_name()),
+            _ => self.type_name(),
+        }
+    }
+
+    fn append_tree(&self, out: &mut String, prefix: &str, remaining: i32) {
+        if remaining <= 0 {
+            return;
+        }
+        let child_prefix = format!("{prefix}    |");

Review Comment:
   This allocates a prefix on every call, including leaf fields that return 
through `_ => {}`. Can it move into the Struct/Array/Map arms?



##########
crates/pyspark-rs/src/dataframe.rs:
##########
@@ -1359,13 +1359,25 @@ impl PyDataFrame {
         self.dataframe.is_empty().to_pyerr()
     }
 
-    /// Print the schema of the DataFrame. `level` (max nesting depth to 
print) is
-    /// accepted for signature parity with reference pyspark; the connect core 
does not
-    /// yet thread a depth into the schema tree-string, so it is currently 
ignored.
+    /// Print the schema through Python stdout, honoring the maximum nesting 
depth.
     #[pyo3(signature = (level=None))]
-    #[allow(unused_variables)]
-    fn printSchema(&self, level: Option<i32>) -> PyResult<()> {
-        self.dataframe.show(0).to_pyerr()
+    fn printSchema(&self, py: Python<'_>, level: Option<&Bound<'_, PyAny>>) -> 
PyResult<()> {
+        let schema = py.detach(|| self.dataframe.schema().to_pyerr())?;
+        // Reference PySpark treats false values and nonpositive depths as 
unlimited.
+        // Positive fractional depths include the partially remaining level as 
well.

Review Comment:
   Suggest stating the equivalence directly: reference does `if level:` and 
then compares `> 0` at each level, so a positive fractional depth behaves as 
`ceil(level)`.



##########
crates/spark-connect/src/types.rs:
##########
@@ -875,33 +879,94 @@ impl DataType {
         }
     }
 
-    fn append_tree(&self, out: &mut String, prefix: &str, depth: i32, 
max_depth: i32) {
-        if let DataType::Struct { fields } = self {
-            for f in fields {
-                // A nested struct is rendered as the bare type name 
("struct") and its
-                // fields are shown by recursing below, so `max_depth` can 
truncate them.
-                // Using `simple_string()` here would inline the children (e.g.
-                // "struct<inner:int>"), leaking them past `max_depth`. 
Non-struct types
-                // have no depth-controlled children, so their simple string 
is used.
-                let is_struct = matches!(f.data_type, DataType::Struct { .. });
-                let type_repr = if is_struct {
-                    f.data_type.type_name()
-                } else {
-                    f.data_type.simple_string()
-                };
-                out.push_str(&format!(
-                    "{}-- {}: {} (nullable = {})\n",
-                    prefix, f.name, type_repr, f.nullable
-                ));
-                if is_struct && depth < max_depth {
-                    f.data_type.append_tree(
+    fn tree_type_name(&self) -> String {
+        match self {
+            DataType::Decimal { .. }
+            | DataType::Char { .. }
+            | DataType::Varchar { .. }
+            | DataType::Time { .. }
+            | DataType::YearMonthInterval { .. }
+            | DataType::DayTimeInterval { .. } => self.simple_string(),
+            DataType::Udt {
+                python_class: Some(python_class),
+                ..
+            } => python_class
+                .rsplit('.')
+                .next()
+                .filter(|class_name| !class_name.is_empty())
+                // UserDefinedType.typeName() preserves the entire class name,
+                // unlike DataType.typeName(), which strips the "Type" suffix.
+                .map(str::to_lowercase)
+                .unwrap_or_else(|| self.type_name()),
+            _ => self.type_name(),
+        }
+    }
+
+    fn append_tree(&self, out: &mut String, prefix: &str, remaining: i32) {
+        if remaining <= 0 {
+            return;
+        }
+        let child_prefix = format!("{prefix}    |");
+        match self {
+            DataType::Struct { fields } => {
+                for f in fields {
+                    out.push_str(prefix);
+                    out.push_str("-- ");
+                    for ch in f.name.chars() {

Review Comment:
   Nit: could this be a small `push_escaped_name(out, &f.name)` helper, to 
mirror `escape_meta_characters` and keep `append_tree` focused on structure?



##########
python/tests/test_dropin_offline.py:
##########
@@ -3085,3 +3092,172 @@ def allow_response_after_request():
     else:
         assert result == [("id", "bigint")]
     assert service.calls == 1
+
+
+# --------------------------------------------------------------------------- 
printSchema
+
+
+def _print_schema_field(name, data_type, nullable=True):
+    return (
+        message_field(1, name)
+        + message_field(2, data_type)
+        + (b"\x18\x01" if nullable else b"\x18\x00")
+    )
+
+
+def _print_schema_type():
+    def struct(*fields):
+        return message_field(21, b"".join(message_field(1, field) for field in 
fields))
+
+    integer = message_field(6, b"")
+    long = message_field(7, b"")
+    string = message_field(11, b"")
+    array = message_field(
+        20, message_field(1, struct(_print_schema_field("item", long))) + 
b"\x10\x00"
+    )
+    mapping = message_field(
+        22,
+        message_field(1, string)
+        + message_field(2, struct(_print_schema_field("value", integer)))
+        + b"\x18\x01",
+    )
+    return struct(
+        _print_schema_field("outer", struct(_print_schema_field("inner", 
integer, False))),
+        _print_schema_field("items", array, False),
+        _print_schema_field("lookup", mapping),
+    )
+
+
+_PRINT_SCHEMA_LEVEL1 = (
+    "root\n"
+    " |-- outer: struct (nullable = true)\n"
+    " |-- items: array (nullable = false)\n"
+    " |-- lookup: map (nullable = true)\n\n"
+)
+_PRINT_SCHEMA_LEVEL2 = (
+    "root\n"
+    " |-- outer: struct (nullable = true)\n"
+    " |    |-- inner: integer (nullable = false)\n"
+    " |-- items: array (nullable = false)\n"
+    " |    |-- element: struct (containsNull = false)\n"
+    " |-- lookup: map (nullable = true)\n"
+    " |    |-- key: string\n"
+    " |    |-- value: struct (valueContainsNull = true)\n\n"
+)
+_PRINT_SCHEMA_FULL = (
+    "root\n"
+    " |-- outer: struct (nullable = true)\n"
+    " |    |-- inner: integer (nullable = false)\n"
+    " |-- items: array (nullable = false)\n"
+    " |    |-- element: struct (containsNull = false)\n"
+    " |    |    |-- item: long (nullable = true)\n"
+    " |-- lookup: map (nullable = true)\n"
+    " |    |-- key: string\n"
+    " |    |-- value: struct (valueContainsNull = true)\n"
+    " |    |    |-- value: integer (nullable = true)\n\n"
+)
+
+
[email protected](
+    "level, expected",
+    [
+        (None, _PRINT_SCHEMA_FULL),
+        (0, _PRINT_SCHEMA_FULL),
+        (-1, _PRINT_SCHEMA_FULL),
+        (-10, _PRINT_SCHEMA_FULL),
+        (1, _PRINT_SCHEMA_LEVEL1),
+        (2, _PRINT_SCHEMA_LEVEL2),
+        (3, _PRINT_SCHEMA_FULL),
+        (2**100, _PRINT_SCHEMA_FULL),
+        (False, _PRINT_SCHEMA_FULL),
+        (True, _PRINT_SCHEMA_LEVEL1),
+        (0.5, _PRINT_SCHEMA_LEVEL1),
+        (1.5, _PRINT_SCHEMA_LEVEL2),
+        (float("inf"), _PRINT_SCHEMA_FULL),
+        (float("nan"), _PRINT_SCHEMA_FULL),
+        ("", _PRINT_SCHEMA_FULL),
+        ([], _PRINT_SCHEMA_FULL),
+        ({}, _PRINT_SCHEMA_FULL),
+    ],
+)
+def test_print_schema_exact_python_stdout(mock_connect, capsys, level, 
expected):
+    spark, service = mock_connect
+    service.data_type = _print_schema_type()
+    df = spark.range(10)
+    for _ in range(2):
+        assert df.printSchema(level) is None
+        captured = capsys.readouterr()
+        assert captured.out == expected
+        assert captured.err == ""
+    assert service.calls == 1
+    assert service.execute_calls == 0
+
+
[email protected]("level", ["two", [1], object(), 1 + 2j])
+def test_print_schema_invalid_depth(mock_connect, capsys, level):
+    spark, service = mock_connect
+    service.data_type = _print_schema_type()
+    with pytest.raises(TypeError):
+        spark.range(10).printSchema(level)
+    assert capsys.readouterr().out == ""
+    assert service.execute_calls == 0
+
+
[email protected]("failure", ["rpc", "no-result", "missing-schema", 
"invalid-type"])

Review Comment:
   Retry-after-failure is `schema()` cache behavior, and the schema-cache tests 
already cover it for these four failure modes. For `printSchema`, one case that 
checks the error propagates and nothing is printed should be enough.



##########
python/tests/test_dropin_offline.py:
##########
@@ -3085,3 +3092,172 @@ def allow_response_after_request():
     else:
         assert result == [("id", "bigint")]
     assert service.calls == 1
+
+
+# --------------------------------------------------------------------------- 
printSchema
+
+
+def _print_schema_field(name, data_type, nullable=True):
+    return (
+        message_field(1, name)
+        + message_field(2, data_type)
+        + (b"\x18\x01" if nullable else b"\x18\x00")
+    )
+
+
+def _print_schema_type():
+    def struct(*fields):
+        return message_field(21, b"".join(message_field(1, field) for field in 
fields))
+
+    integer = message_field(6, b"")
+    long = message_field(7, b"")
+    string = message_field(11, b"")
+    array = message_field(
+        20, message_field(1, struct(_print_schema_field("item", long))) + 
b"\x10\x00"
+    )
+    mapping = message_field(
+        22,
+        message_field(1, string)
+        + message_field(2, struct(_print_schema_field("value", integer)))
+        + b"\x18\x01",
+    )
+    return struct(
+        _print_schema_field("outer", struct(_print_schema_field("inner", 
integer, False))),
+        _print_schema_field("items", array, False),
+        _print_schema_field("lookup", mapping),
+    )
+
+
+_PRINT_SCHEMA_LEVEL1 = (
+    "root\n"
+    " |-- outer: struct (nullable = true)\n"
+    " |-- items: array (nullable = false)\n"
+    " |-- lookup: map (nullable = true)\n\n"
+)
+_PRINT_SCHEMA_LEVEL2 = (
+    "root\n"
+    " |-- outer: struct (nullable = true)\n"
+    " |    |-- inner: integer (nullable = false)\n"
+    " |-- items: array (nullable = false)\n"
+    " |    |-- element: struct (containsNull = false)\n"
+    " |-- lookup: map (nullable = true)\n"
+    " |    |-- key: string\n"
+    " |    |-- value: struct (valueContainsNull = true)\n\n"
+)
+_PRINT_SCHEMA_FULL = (
+    "root\n"
+    " |-- outer: struct (nullable = true)\n"
+    " |    |-- inner: integer (nullable = false)\n"
+    " |-- items: array (nullable = false)\n"
+    " |    |-- element: struct (containsNull = false)\n"
+    " |    |    |-- item: long (nullable = true)\n"
+    " |-- lookup: map (nullable = true)\n"
+    " |    |-- key: string\n"
+    " |    |-- value: struct (valueContainsNull = true)\n"
+    " |    |    |-- value: integer (nullable = true)\n\n"
+)
+
+
[email protected](
+    "level, expected",
+    [
+        (None, _PRINT_SCHEMA_FULL),
+        (0, _PRINT_SCHEMA_FULL),
+        (-1, _PRINT_SCHEMA_FULL),
+        (-10, _PRINT_SCHEMA_FULL),

Review Comment:
   Several of these cases cover the same branch: `-1`/`-10`, 
`3`/`2**100`/`inf`, and `""`/`[]`/`{}`. Re-checking `calls == 1` on every case 
also re-tests the #120 cache. Can we keep one case per branch and check cache 
reuse once?



##########
crates/spark-connect/tests/types_behavior.rs:
##########
@@ -314,13 +314,96 @@ fn tree_string_with_depth_truncates_nested_structs() {
     // Full depth shows the nested field, rendering the struct as the bare 
type name.
     let full = nested.tree_string().unwrap();
     assert!(full.contains("outer: struct (nullable = true)"));
-    assert!(full.contains("inner: int (nullable = true)"));
+    assert!(full.contains("inner: integer (nullable = true)"));
     // max_depth = 1 prints only the top level; the nested field is omitted.
     let shallow = nested.tree_string_with_depth(1).unwrap();
     assert!(shallow.contains("outer: struct (nullable = true)"));
     assert!(!shallow.contains("inner"));
 }
 
+#[test]
+fn tree_string_uses_python_udt_class_names() {
+    use spark_connect::types::StructField;
+    let udt = |python_class: &str| DataType::Udt {
+        type_str: "udt".to_string(),
+        jvm_class: None,
+        python_class: Some(python_class.to_string()),
+        serialized_python_class: None,
+        sql_type: Some(Box::new(DataType::Integer)),
+    };
+    let schema = DataType::Struct {
+        fields: vec![
+            StructField {
+                name: "features".to_string(),
+                data_type: udt("pyspark.ml.linalg.VectorUDT"),
+                nullable: true,
+                metadata: Default::default(),
+            },
+            StructField {
+                name: "array".to_string(),
+                data_type: DataType::Array {
+                    element_type: Box::new(udt("example.CustomType")),
+                    contains_null: false,
+                },
+                nullable: false,
+                metadata: Default::default(),
+            },
+            StructField {
+                name: "map".to_string(),
+                data_type: DataType::Map {
+                    key_type: Box::new(DataType::String {
+                        collation: String::new(),
+                    }),
+                    value_type: Box::new(udt("UnqualifiedUDT")),

Review Comment:
   Reference can't produce an unqualified `python_class`: 
`UserDefinedType.fromJson` would fail to import it. The same goes for 
`Some("")` and `Some("example.")` at L383. These assertions pin behavior that 
has no reference to match. Can we drop them and keep the real 
`pyspark.ml.linalg.VectorUDT` and `None` cases?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to