Skip to content

[SPARK-60695][CONNECT] Print DataFrame schemas without executing queries - #131

Open
dbtsai wants to merge 3 commits into
apache:masterfrom
dbtsai:codex/spark-60695-print-schema
Open

dbtsai wants to merge 3 commits into
apache:masterfrom
dbtsai:codex/spark-60695-print-schema

Conversation

@dbtsai

@dbtsai dbtsai commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

What changes were proposed in this pull request?

Fix DataFrame.printSchema() calling show(0), which executes a zero-row query and emits no schema. Resolve schema metadata instead, render its tree at the requested depth, and call Python's print so sys.stdout capture works. Preserve reference behavior for default, nonpositive, false, fractional and invalid depth arguments.

Match reference tree formatting for scalar type names, nested struct/array/map children and nullability, field-name control-character escaping, and unlimited nonpositive depths. UDTs with Python class metadata use the lowercase class name, such as vectorudt or customtype; unavailable class metadata retains the generic udt fallback. Field names are escaped in one pass and tree lines are written directly to the output buffer. Direct StructType.treeString and Rust tree consumers are covered alongside the binding.

Reuse the existing mock Connect server with a module-scoped process and per-test resets. The tests count ExecutePlan requests and assert repeated printing needs exactly one schema analysis.

Why are the changes needed?

SPARK-60695. Printing a schema should emit schema text without sending ExecutePlan requests.

Does this PR introduce any user-facing change?

Yes. printSchema() now prints the schema through Python stdout, honors nesting depth, and performs schema analysis instead of query execution. Schema tree text uses reference formatting for nested collection types, scalar type names, and Python UDT class names when available. Repeated calls reuse the schema cache already merged into master.

How was this patch tested?

Local review-fix validation at 559cab3 (master base 617fa148) on Python 3.12 with an optimized release wheel:

  • 347 offline Python tests passed, covering exact stdout, depth arguments, errors and retry, schema-cache reuse, UDT names, and zero ExecutePlan requests.
  • 398 Rust library tests and 13 type-behavior tests passed. All Rust client test targets compiled with and without wasm-udf.
  • Reference PySpark 4.2.0 matched the expanded exact field-name output test, including all seven escaped controls, Unicode, literal backslashes and nonpositive depths. Earlier review validation at 5019808 also matched 17 exact depth outputs, four invalid-depth errors, and two UDT class names.
  • Release wheel build, Rust formatting, whitespace checks, and normal commit hooks passed.
  • Advisory workspace Clippy still fails on existing code. An exact-source audit placed all reported diagnostics on unchanged base lines. The earlier focused binding check at 5019808 had the same outcome.

No JVM/live Spark server was used. Mock gRPC servers exercise the real client transport in a separate process.

Historical measurements from the preceding implementation, not rerun on the rebased review-fix head:

  • An unchanged frozen harness and validators passed 195/195 samples, with five fresh-process samples per case/client across five printSchema cases and eight metadata/show/API/error guards.
  • Both measurement wheels included PR120 f87973b1e02fe500e3c4266a8ad3f353244994af. Repeated depth-256 printSchema measured 2.607229 ms → 0.001406 ms (Python reference 0.005496 ms); timed ExecutePlan calls 20 → 0. First-call depth-256 measured 3.174625 → 2.921541 ms. All eight unrelated guard sample ranges overlapped; median changes ranged from -14.6% to +8.0%, without a statistical equivalence claim.

The frozen inspection validator did not validate stdout, so those benchmark passes supplement the independent exact-output tests. Raw samples and setup provenance remain in the campaign artifacts. PR120 is now merged into the master base; this PR has no unmerged dependency.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: OpenAI Codex (GPT-6).

This pull request and its description were written by Isaac.

@dbtsai
dbtsai marked this pull request as ready for review October 30, 2026 17:53
@dbtsai
dbtsai force-pushed the codex/spark-60695-print-schema branch from 7027612 to 616c42b Compare October 30, 2026 18:12
@dbtsai
dbtsai requested review from HyukjinKwon and viirya October 30, 2026 18:12
dbtsai and others added 2 commits October 30, 2026 11:41
Reuse the shared schema mock server with per-test resets and count rejected
ExecutePlan calls. Render Python UDT class names in schema trees, including
nested values, and preserve the generic fallback when metadata is missing.
Assert repeated printing performs one schema analysis and tidy test formatting.
Convert schema errors within the detached call to avoid a large-error lint.

Co-authored-by: Isaac <no-reply@databricks.com>
Escape field names directly into the tree buffer in one pass and write line
fragments without allocating temporary formatted strings. Extend the exact
output test to preserve Unicode and literal backslashes alongside controls.

Co-authored-by: Isaac <no-reply@databricks.com>

@viirya viirya left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix is right: printSchema now goes through the cached schema() instead of show(0), and the depth and formatting semantics match v4.2.0 StructType.treeString. It also fixes the old nested prefix (" |" → " | |") and the array/map children that used to be inlined. A few things outside the diff:

  • DataFrame::print_schema in crates/spark-connect/src/dataframe.rs:1500 still does println!("{}", schema), which prints simple_string() (struct<a:int,...>) instead of a tree. It's the same bug for Rust callers. The binding works around it rather than fixing it. Could the core print tree_string(), with the binding reusing it? A follow-up JIRA would also be fine.
  • StructType.treeString (crates/pyspark-rs/src/types.rs:1813) still takes maxDepth: i32. So df.printSchema(2**100) and printSchema(1.5) work, but df.schema.treeString(2**100) raises OverflowError and treeString(1.5) raises TypeError. Reference accepts both. Either share the depth normalization between the two entry points, or keep the Optional[int] contract for both and drop the float/container handling and its tests.
  • Moving the mock server to module scope also changes the existing schema-cache tests. Please mention why in the description.

self.append_tree(
&mut out,
" |",
if max_depth > 0 { max_depth } else { i32::MAX },

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

if remaining <= 0 {
return;
}
let child_prefix = format!("{prefix} |");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

for f in fields {
out.push_str(prefix);
out.push_str("-- ");
for ch in f.name.chars() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

(None, _PRINT_SCHEMA_FULL),
(0, _PRINT_SCHEMA_FULL),
(-1, _PRINT_SCHEMA_FULL),
(-10, _PRINT_SCHEMA_FULL),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

assert service.execute_calls == 0


@pytest.mark.parametrize("failure", ["rpc", "no-result", "missing-schema", "invalid-type"])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

key_type: Box::new(DataType::String {
collation: String::new(),
}),
value_type: Box::new(udt("UnqualifiedUDT")),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants