Add experimental native pyspark.sql.DataFrame tracking - #73
Open
w-martin wants to merge 1 commit into
Open
Conversation
Tracks Spark DataFrames as a native type, alongside pandas and polars,
rather than only through the existing spark.sql(sql).toPandas() SQL path.
Rust:
- extract_spark_col_name for F.col()/sf.col()/functions.col(), Spark's
counterpart to pl.col(); wired into the shared collect_pl_col_names so
every existing validator sees both idioms
- Structural ops under Spark's own names: withColumn/withColumns (assign),
withColumnRenamed/withColumnsRenamed (rename), varargs select and drop,
toDF, union/unionAll/unionByName
- Read/schema inference from an explicit schema (DDL string, StructType, or
list of names -- inline or held in a variable), or from a select chained
onto an undeclared read. A bare spark.read.csv(path) is genuinely unknown
at lint time and reports untracked-dataframe, as a bare pd.read_csv() does
- Native spark.sql(...) tracking, gated on the receiver being a recognized
SparkSession so DuckDB's duckdb.sql() relations are left alone
- SPARK_ROW_PASSTHROUGH_METHODS and SPARK_RESERVED_ATTRIBUTES; the latter
stops df.write/df.printSchema() reading as unknown columns
- drop() now reads varargs, which fixes polars' drop("a", "b") too, while
keeping pandas' positional-axis drop("a", 1) resolving exactly as before
join stays untracked: its output depends on join type, on whether `on` is a
name list or a condition, and on cross-side name collisions. UDFs likewise.
Verified against the whole existing corpus: checker output is byte-identical
to main on all 101 tracked .py files, so the SQL-connector path is untouched.
Docs: README backend matrix row and a native-PySpark section, the method
matrix's three tables, and examples/backends/pyspark/ demonstrating two real
caught bugs.
Claude-Session: https://claude.ai/code/session_013nRj6HvRbwkzAoj3QPbiSj
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds experimental tracking of
pyspark.sql.DataFrameas a native type, alongside pandas and polars. Today Spark is only reachable through the SQL-connector path (spark.sql(sql).toPandas(), which ends in a pandas frame); this covers the case where the frame stays a Spark frame all the way through.Why this one is different from a pandas-mirror backend
Spark isn't a pandas-API clone, so almost nothing could be reused by name:
assign(col=…)withColumn(name, expr)/withColumns({…})rename(columns={…})withColumnRenamed(old, new)— two positional args, not a mappingdrop(columns=[…])drop(*cols)— bare varargspl.col("x")F.col("x")/functions.col("x")/sf.col("x")read_csv(usecols=[…])What's tracked
extract_spark_col_name— theF.col("x")analogue ofextract_pl_col_name, wired into the sharedcollect_pl_col_namesso every existing validator picks it up for free. The barefrom pyspark.sql.functions import colform already worked via the existing name-only branch.withColumn/withColumns,withColumnRenamed/withColumnsRenamed, varargsselect(including.alias()output names), varargsdrop,toDF,union/unionAll/unionByName.where,limit,offset,orderBy,distinct,dropDuplicates,sortWithinPartitions,repartition,coalesce,cache/persist/unpersist,checkpoint,alias,hint. Each checked against its real signature for whether it actually returns a column-identical DataFrame.StructType, or list of names; inline or held in a variable, which is the idiomatic spelling), or from a.select(...)chained onto an undeclared read. A barespark.read.csv(path)is genuinely unknowable and reportsuntracked-dataframe, reusing the existing warning and coverage accounting rather than inventing a new code.spark.sql(...)kept native — columns from theSELECTlist via the existingregister_sql_dataframe.Annotated[pyspark.sql.DataFrame, Schema]— already worked (the recognizer keys on theDataFrameattribute name, not the module); now covered by tests so it stays working.The judgment calls worth a second look
join— left untracked, deliberately. Its output depends on the join type (left_semi/left_antireturn only the left side's columns), on whetheronis a name list (which collapses the join keys into one column) or a condition (which keeps both), and on how duplicate names across the two sides resolve. That is squarely the runtime-dependent bucket. This is more conservative than pandas'merge, which is tracked as a union — I think correctly so, but it's the call I'd most want reviewed.union/unionAll— tracked as the LEFT schema, not a union. PySpark documents these as resolving columns by position, "following the standard behavior in SQL", so the result carries the left frame's names whatever the right frame calls its columns. Spark rejects a mismatched column count outright, so for any program that runs at all, left's schema is the result's. This is more precise than pandas'concathandling (sort+dedup of both), not a weaker version of it.unionByName— left schema, except withallowMissingColumns=True. That flag is the one case where the result really is the union of both sides, and it's tracked only when the right side is itself tracked; otherwise the binding is dropped rather than guessed at.Unresolvable ops drop the binding rather than keeping it.
df = df.withColumn(runtime_name, …)can't be resolved, and leaving the previous schema in place would then report the newly added column as unknown — a false positive on the most common Spark shape there is.untrack_targetsexists for exactly this.Not done, and why
selectExpr(...)— output names come from SQL expression strings; parsing them wasn't worth the false-positive risk.pandas_udf,.rdd.map, a lambda inwithColumn) — untracked by design, per the project's stated philosophy.withColumnstill adds the column when its name is a literal; only the contents are opaque.df.filter(…).limit(5)doesn't track, because the row-passthrough branch requires a plainNamereceiver. This is a pre-existing limitation shared with pandas (df.query(…).head()behaves the same), not new here; fixing it means recursive chain resolution across the whole linter, which felt out of scope.spark.read.text(path)— schema is documented asvalueplus any partition columns, so neither "unknown" nor["value"]is accurate. Left out rather than half-modelled.head/tail/first(which returnRow/list[Row]) still fall under the sharedROW_PASSTHROUGH_METHODSthat pandas needs. Noted in the code and the method matrix.Coexistence with the existing SQL path
The two never see the same AST node: the SQL-connector path matches on the outer
.toPandas()call, this one only when the.sql(...)call is itself the assigned value. Native.sql()is additionally gated on the receiver being a recognizedSparkSession(from agetOrCreate()chain or aSparkSession-annotated parameter) — without that gate, DuckDB'sduckdb.sql(q)returns a relation, and registering one would makerel.fetchall()read as an unknown column.Evidence it's genuinely untouched: I ran the checker from this branch and from
mainover all 101 tracked.pyfiles (examples, tests, src) and diffed the full output. Byte-identical. One regression surfaced that way during development —session.createDataFrame(rows, [...])inexamples/sql_connectors/databricks/madeseed_df.writeread as an unknown column — and is fixed bySPARK_RESERVED_ATTRIBUTES.One shared-path change is intentional:
drop()now reads varargs, which also fixes polars'drop("a", "b")(previously only the first column was dropped). pandas' deprecated positional-axisdrop("a", 1)still resolves exactly as before, and there's a test pinning that.Verification status — please read
No Spark was executed. This machine has no JVM (
/usr/bin/javais the macOS stub: "Unable to locate a Java Runtime"), so nothing here is execution-verified against a running Spark session.pyspark==4.2.0and read the actual signatures and docstrings inpyspark/sql/dataframe.py,readwriter.py,session.pyandtypes.py. The load-bearing claims all come from there, not from memory:unionresolving by position andunionByNameby name;dropandwithColumnRenamedbeing documented no-ops when the column is absent;csv/jsontakingschemaas the second positional argument andloadas the third;head/tailreturningRow/list[Row];text'svalue-plus-partitions schema.So: what the checker does with Spark source is well tested. Whether Spark at runtime agrees is docs/source-verified only. The
withColumnRenamed-is-a-no-op claim drives anunknown-columnerror, so it's the one most worth a sanity check by someone with a JVM.Checks
uv run inv allpasses end to end — ruff format/check, ty, bandit, complexipy,cargo fmt --check,cargo clippy -D warnings, 301 Python tests at the 100% branch-coverage gate, 235 Rust tests, licensecheck. No lint-ignore rules or bandit skips were added; the example usesfrom pyspark.sql import functionsrather than the conventionalas Fpurely because the aliased form trips ruff's N812 and I wasn't going to add an ignore for it (the checker acceptsF,sf,functions, and barecolidentically — noted in the example's docstring).https://claude.ai/code/session_013nRj6HvRbwkzAoj3QPbiSj