pyspark-data-quality 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 datenwissenschaften
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,338 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyspark-data-quality
3
+ Version: 0.1.0
4
+ Summary: Typed, Spark-native data-quality validation for PySpark DataFrames
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Author: datenwissenschaften
8
+ Author-email: martin.franke@datenwissenschaften.com
9
+ Requires-Python: >=3.12,<3.13
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Provides-Extra: fugue
13
+ Provides-Extra: notebook
14
+ Requires-Dist: fugue[spark] (>=0.9.7,<0.10) ; extra == "fugue"
15
+ Requires-Dist: ipykernel (>=6.29,<7) ; extra == "notebook"
16
+ Requires-Dist: jupyter (>=1.1,<2) ; extra == "notebook"
17
+ Requires-Dist: kaleido (>=1.0,<2) ; extra == "notebook"
18
+ Requires-Dist: nbclient (>=0.10,<1) ; extra == "notebook"
19
+ Requires-Dist: nbformat (>=5.10,<6) ; extra == "notebook"
20
+ Requires-Dist: packaging (>=23) ; extra == "fugue"
21
+ Requires-Dist: pandas (>=2.2,<3) ; extra == "notebook"
22
+ Requires-Dist: plotly (>=6.1,<7) ; extra == "notebook"
23
+ Requires-Dist: pydantic (>=2.10,<3)
24
+ Requires-Dist: pyspark (>=4.0,<4.2)
25
+ Project-URL: Repository, https://github.com/datenwissenschaften/spark-data-quality
26
+ Description-Content-Type: text/markdown
27
+
28
+ # spark-data-quality
29
+
30
+ [![CI](https://github.com/datenwissenschaften/spark-data-quality/actions/workflows/ci.yml/badge.svg)](https://github.com/datenwissenschaften/spark-data-quality/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/pyspark-data-quality.svg)](https://pypi.org/project/pyspark-data-quality/) ![Python 3.12](https://img.shields.io/badge/python-3.12-blue.svg) ![PySpark 4](https://img.shields.io/badge/PySpark-4.x-E25A1C.svg) [![Last commit](https://img.shields.io/github/last-commit/datenwissenschaften/spark-data-quality)](https://github.com/datenwissenschaften/spark-data-quality/commits/main) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) ![Ruff](https://img.shields.io/badge/lint-Ruff-D7FF64.svg) ![mypy](https://img.shields.io/badge/types-mypy-2A6DB2.svg) [![codecov](https://codecov.io/gh/datenwissenschaften/spark-data-quality/graph/badge.svg)](https://codecov.io/gh/datenwissenschaften/spark-data-quality)
31
+
32
+ `spark-data-quality` is a small, typed validation library for PySpark DataFrames. It
33
+ turns reusable expectations into Spark expressions, combines compatible metrics into
34
+ one aggregation, and returns Pydantic reports that remain usable after the Spark
35
+ session ends.
36
+
37
+ The library is intended for batch pipelines, local Spark applications, and managed
38
+ Spark runtimes such as Databricks. It has no Databricks-specific runtime dependency.
39
+
40
+ See the end-to-end data-quality workflow →
41
+ [`notebooks/data_quality_workflow.ipynb`](notebooks/data_quality_workflow.ipynb), a
42
+ game-analytics case study that profiles, validates, and visualizes a synthetic
43
+ player/session dataset with this library.
44
+
45
+ ![Quality check outcomes for the notebook's synthetic game_sessions_ingest suite](docs/assets/data-quality-workflow/quality-results.png)
46
+
47
+ ## Example
48
+
49
+ ```python
50
+ from pyspark.sql import SparkSession
51
+
52
+ from spark_data_quality import (
53
+ AllowedValues,
54
+ ColumnExists,
55
+ InRange,
56
+ NotNull,
57
+ QualitySuite,
58
+ Unique,
59
+ )
60
+
61
+ spark = SparkSession.builder.getOrCreate()
62
+ players = spark.createDataFrame(
63
+ [(1, 24, "DE"), (2, 31, "ZA")],
64
+ "player_id long, age long, country string",
65
+ )
66
+
67
+ suite = QualitySuite(
68
+ name="players",
69
+ checks=[
70
+ ColumnExists("player_id"),
71
+ NotNull("player_id"),
72
+ Unique("player_id"),
73
+ InRange("age", min_value=13, max_value=120),
74
+ AllowedValues("country", frozenset({"DE", "ZA", "GB"})),
75
+ ],
76
+ )
77
+
78
+ report = suite.validate(players)
79
+ print(report.status)
80
+ print(report.model_dump_json(indent=2))
81
+ ```
82
+
83
+ ## Core capabilities
84
+
85
+ - Immutable, typed expectation definitions with construction-time configuration checks
86
+ - Explicit `PASS`, `FAIL`, and `ERROR` outcomes
87
+ - Spark SQL expressions and aggregations only; no Python UDFs or pandas conversion
88
+ - One shared aggregate action for compatible validation metrics
89
+ - Deterministic check ordering, identifiers, metric aliases, and categorical tie-breaking
90
+ - Standards-compatible JSON reports with row counts, explicit denominators, and diagnostics
91
+ - Numeric and string profiling with bounded top-value collection
92
+
93
+ ## Architecture
94
+
95
+ ```mermaid
96
+ flowchart LR
97
+ A[QualitySuite and typed checks] --> B[AggregationPlanner]
98
+ B --> C[Schema-only results]
99
+ B --> D[Shared aggregate expressions]
100
+ D --> E[Spark DataFrame.agg]
101
+ E --> F[One scalar Row on driver]
102
+ C --> G[Result evaluator]
103
+ F --> G
104
+ G --> H[Pydantic SuiteReport]
105
+ ```
106
+
107
+ Check objects define expectations and configuration. The planner resolves schema
108
+ requirements and deduplicates scalar metrics. The runner executes the aggregate plan.
109
+ The evaluator interprets scalar values and constructs public result models. See
110
+ [the architecture notes](docs/architecture.md) for the boundaries and error semantics.
111
+
112
+ ## Execution model
113
+
114
+ `ColumnExists` and `HasType` use the DataFrame schema and require no action. All valid
115
+ row-level checks in a suite contribute expressions to one `DataFrame.agg` action. The
116
+ planner deduplicates identical total, null, and other metrics. Exact uniqueness uses
117
+ `count` and `countDistinct` in that aggregate; Spark may introduce a shuffle and
118
+ multiple physical stages even though the library starts one aggregate action.
119
+
120
+ The validation runner collects exactly one aggregate row. It never collects source
121
+ rows or unbounded values. Execution metadata reports the number of library-level
122
+ aggregate actions and scalar expressions; it does not claim to count Spark stages.
123
+ It also separates suite planning, shared Spark execution, result evaluation, and total
124
+ wall-clock durations. Individual checks do not claim a private share of the common
125
+ Spark execution time.
126
+
127
+ The profiler similarly shares scalar statistics in one aggregate action. Each selected
128
+ string column needs a separate grouped top-values action. Those results are ordered by
129
+ count descending and value ascending, then limited to `top_k` (maximum 100).
130
+
131
+ ## Available checks
132
+
133
+ | Check | Semantics | SQL NULL | Floating-point NaN | Empty DataFrame |
134
+ | --- | --- | --- | --- | --- |
135
+ | `ColumnExists` | Exact top-level name is present | Not applicable | Not applicable | Uses schema |
136
+ | `HasType` | Exact Spark `DataType` equality | Type is schema-level | Type is schema-level | Uses schema |
137
+ | `NotNull` | SQL null count is zero | Fails | Not a null; passes | Passes vacuously |
138
+ | `Unique` | Exact distinct count equals non-null count | Ignored, including repeated nulls | Participates as a value; repeated NaNs are duplicates | Passes vacuously |
139
+ | `UniqueKey` | Exact distinct count of the column combination equals the count of rows with every key column non-null | Rows with a null in any key column are ignored | Participates as a value; repeated NaNs are duplicates | Passes vacuously |
140
+ | `InRange` | Values satisfy configured inclusive or exclusive bounds | Ignored | Always fails | Passes vacuously |
141
+ | `AllowedValues` | Values belong to a non-empty, homogeneous scalar set | Ignored | Fails because configured values must be finite | Passes vacuously |
142
+ | `MatchesRegex` | Spark/JVM `rlike` matches the pattern | Ignored | Non-string columns are `ERROR` | Passes vacuously |
143
+ | `RowCount` | Count satisfies inclusive minimum/maximum bounds | Not applicable | Not applicable | Depends on bounds |
144
+
145
+ An absent column produces `FAIL` for `ColumnExists` and `ERROR` for checks that need
146
+ that column to continue. A `HasType` mismatch is a valid evaluation and therefore
147
+ produces `FAIL`. `InRange`, `MatchesRegex`, and `AllowedValues` reject
148
+ incompatible Spark types with `ERROR`. Use `NotNull` beside checks that should also
149
+ reject nulls.
150
+
151
+ `HasType` compares Spark `DataType` objects directly. It does not group byte, short,
152
+ integer, and long columns into one logical family, and decimal precision and scale are
153
+ part of equality.
154
+
155
+ For `Unique`, `affected_rows` is duplicate excess: non-null row count minus exact
156
+ distinct count. This is stable and aggregate-friendly; it is not the count of every row
157
+ participating in a duplicate group. Spark normalizes NaN for exact distinct aggregation,
158
+ so two NaNs contribute one excess duplicate. Multiple nulls never contribute.
159
+
160
+ For checks that ignore nulls, `evaluated_rows` is the non-null count and
161
+ `failure_fraction` is `affected_rows / evaluated_rows`. For `NotNull`, every source row
162
+ is evaluated. When zero rows are evaluated, `failure_fraction` is `None` rather than a
163
+ fabricated zero. `total_rows` always describes the complete DataFrame when a row-level
164
+ check ran.
165
+
166
+ Range bounds must be finite. NaN is explicitly invalid regardless of one-sided or
167
+ two-sided bounds. Positive and negative infinity are compared normally, so they pass
168
+ only when the configured finite bound does not exclude them. Decimal columns accept
169
+ `Decimal` or integer bounds; float bounds are rejected to avoid silent precision loss.
170
+
171
+ Regex patterns follow Java's regular-expression syntax because Spark evaluates `rlike`
172
+ on the JVM. Empty patterns are rejected at construction. The planner asks the active
173
+ Spark JVM to compile other patterns without starting a Spark action; malformed patterns
174
+ become an `INVALID_REGEX` result and never enter the shared aggregate.
175
+
176
+ ## Profiling
177
+
178
+ ```python
179
+ from spark_data_quality import profile
180
+
181
+ profile_report = profile(players, ["age", "country"], top_k=5)
182
+ print(profile_report.model_dump_json(indent=2))
183
+ ```
184
+
185
+ Numeric profiles contain null and distinct counts, minimum, maximum, mean, and sample
186
+ standard deviation. Float and double profiles also contain `nan_count`. Spark's native
187
+ aggregate semantics are preserved: NaN sorts above finite values and may make maximum,
188
+ mean, or standard deviation NaN; infinities can likewise produce non-finite aggregates.
189
+ Reports encode non-finite values as `"NaN"`, `"Infinity"`, or `"-Infinity"`, producing
190
+ standards-compatible JSON. Decimal min, max, and mean remain `Decimal` values in Python
191
+ and serialize without conversion through binary float. `standard_deviation` is the
192
+ exception: Spark's `stddev_samp` always returns `DoubleType`, even for a `DecimalType`
193
+ input column, so it is reported as `float` while the other statistics for that same
194
+ column stay `Decimal`. This is Spark's own aggregate-function typing, not a conversion
195
+ performed by this library. String profiles contain null and
196
+ distinct counts, minimum and
197
+ maximum lengths, and bounded frequent values. Unsupported selected types are rejected
198
+ before execution instead of receiving ambiguous statistics.
199
+
200
+ ## Fugue integration (optional)
201
+
202
+ **Native PySpark is the optimized, primary backend for everything above.** The
203
+ optional `fugue` extra adds exactly one thing: the ability to hand `QualitySuite`
204
+ or `profile` a non-PySpark, Fugue-compatible dataframe (pandas, Arrow, Polars,
205
+ DuckDB, a list of dictionaries, ...) instead of hand-writing the Spark
206
+ conversion yourself.
207
+
208
+ ```bash
209
+ pip install "pyspark-data-quality[fugue]"
210
+ ```
211
+
212
+ ```python
213
+ import pandas as pd
214
+
215
+ from spark_data_quality import InRange, NotNull, QualitySuite
216
+ from spark_data_quality.fugue import validate
217
+
218
+ suite = QualitySuite("players", [NotNull("player_id"), InRange("age", min_value=13, max_value=120)])
219
+ pandas_players = pd.DataFrame({"player_id": [1, 2, 3], "age": [24, 31, 19]})
220
+
221
+ report = validate(suite, pandas_players) # binds to SparkSession.builder.getOrCreate() by default
222
+ ```
223
+
224
+ `spark_data_quality.fugue` converts the input into a native PySpark `DataFrame`
225
+ bound to a caller-controlled `SparkSession` (pass `spark=...` to bind explicitly;
226
+ it never creates or stops a session beyond PySpark's own ambient-session
227
+ convention), then calls the exact same `QualitySuite.validate` used for native
228
+ input. There is no separate Fugue execution engine for checks themselves:
229
+
230
+ - **Fugue provides input portability**, not alternate execution semantics. Every
231
+ built-in check runs through the identical Spark-native aggregation planning
232
+ described above, whether the input arrived as a PySpark `DataFrame` directly or
233
+ through this conversion. An already-native PySpark `DataFrame` passes through
234
+ unchanged (no conversion cost on the optimized path).
235
+ - Because there is only one execution path (Spark) behind this boundary, check
236
+ semantics are identical for every built-in check regardless of the input
237
+ source — see the table below.
238
+ - Converting *from* pandas has real, verified caveats independent of this
239
+ library: a pandas float `NaN` becomes Spark `NaN` (not SQL `NULL`), a pandas
240
+ integer column with a missing value is upcast to `float64` before Fugue ever
241
+ sees it, and a null inside a schema-declared integral column currently fails
242
+ the conversion outright with `PySparkTypeError` rather than silently
243
+ succeeding. These are documented in `spark_data_quality/fugue.py` and
244
+ exercised by `tests/integration/test_fugue.py`; pass an explicit Fugue
245
+ `schema` string, or use a native PySpark `DataFrame`, when a column's exact
246
+ type or null semantics must be preserved.
247
+
248
+ | Check | Native PySpark | Via `spark_data_quality.fugue` | Notes |
249
+ | --- | --- | --- | --- |
250
+ | `ColumnExists` | yes | yes (identical) | Executes as native Spark after conversion. |
251
+ | `HasType` | yes | yes (identical) | Sensitive to the pandas type-inference caveats above. |
252
+ | `NotNull` | yes | yes (identical) | Pandas `NaN` is not SQL `NULL`; see caveats above. |
253
+ | `Unique` | yes | yes (identical) | No behavior change; input source is irrelevant post-conversion. |
254
+ | `InRange` | yes | yes (identical) | Explicitly rejects NaN regardless of input source. |
255
+ | `AllowedValues` | yes | yes (identical) | No behavior change. |
256
+ | `MatchesRegex` | yes | yes (identical) | No behavior change. |
257
+ | `RowCount` | yes | yes (identical) | No behavior change. |
258
+
259
+ The `notebook` extra's dependencies (Jupyter, Plotly, Kaleido, pandas) are
260
+ required only to reproduce `notebooks/data_quality_workflow.ipynb`; neither
261
+ `fugue` nor `notebook` is required to use the core library. Installing `fugue`
262
+ pulls in `fugue[spark]`, and transitively pandas, pyarrow, and a few smaller
263
+ packages; `packaging` is pinned explicitly in this extra as a safeguard for a
264
+ verified gap in `fugue`'s own dependency (`triad` imports `packaging` without
265
+ declaring it), which otherwise surfaces as `ModuleNotFoundError` in a bare
266
+ environment that installs only this extra's declared requirements.
267
+
268
+ ## Installation
269
+
270
+ Python 3.12, PySpark 4.0 through 4.1, and a compatible JVM are required. CI uses Java 17.
271
+
272
+ The distribution is published on PyPI as `pyspark-data-quality`; the import package is
273
+ `spark_data_quality`.
274
+
275
+ ```bash
276
+ pip install pyspark-data-quality
277
+ ```
278
+
279
+ For source development with Poetry:
280
+
281
+ ```bash
282
+ poetry install --with dev
283
+ poetry install --with dev --extras "fugue notebook" # optional integrations
284
+ ```
285
+
286
+ ## Development and testing
287
+
288
+ The test suite starts a real `local[2]` Spark session. It requires no cloud services,
289
+ credentials, or network access after dependencies are installed.
290
+
291
+ ```bash
292
+ ruff check .
293
+ ruff format --check .
294
+ mypy
295
+ pytest
296
+ ```
297
+
298
+ Regenerate the end-to-end notebook and its committed PNG assets (requires the
299
+ `fugue` and `notebook` extras):
300
+
301
+ ```bash
302
+ make notebooks
303
+ ```
304
+
305
+ ## Limitations
306
+
307
+ - Exact `countDistinct` can cause an expensive shuffle on high-cardinality columns.
308
+ - Large `AllowedValues` sets become literal `isin` expressions; use a reference-table
309
+ join outside this library when the allowed domain contains thousands of values.
310
+ - The profiler supports numeric and string columns only.
311
+ - Top-value profiling performs one bounded grouped action per selected string column.
312
+ - Validation reports aggregate failures but do not include failing source-row samples.
313
+ - JVM regex prevalidation uses the classic PySpark JVM gateway. Spark Connect is not a
314
+ currently supported execution mode.
315
+ - Durations are wall-clock observations and are not stable benchmark measurements.
316
+ - `Check` is a public abstract base class, but the planner and evaluator only recognize
317
+ the built-in check types above. Constructing a suite with a custom `Check` subclass
318
+ raises `TypeError` from `QualitySuite.validate` rather than silently skipping the
319
+ check or producing a fabricated result; this is deliberate (see
320
+ [Architecture](docs/architecture.md)) but means user-defined checks are not currently
321
+ a supported extension point.
322
+ - `spark_data_quality.fugue` executes checks on Spark only. It does not run checks
323
+ natively on pandas, DuckDB, Polars, or any other Fugue-supported engine; it converts
324
+ the input into Spark first. A null inside a schema-declared integral column does not
325
+ currently survive Fugue's row-oriented (list-of-dictionaries) conversion path — it
326
+ fails with `PySparkTypeError` rather than being silently dropped or reinterpreted.
327
+
328
+ ## Roadmap
329
+
330
+ Technically useful next steps include configurable approximate distinct counts,
331
+ cross-column predicates, bounded failing-row samples with explicit privacy controls,
332
+ and a second execution group for checks that cannot share scalar aggregation. A CLI is
333
+ not planned until it provides value beyond the Python API.
334
+
335
+ ## License
336
+
337
+ MIT
338
+
@@ -0,0 +1,310 @@
1
+ # spark-data-quality
2
+
3
+ [![CI](https://github.com/datenwissenschaften/spark-data-quality/actions/workflows/ci.yml/badge.svg)](https://github.com/datenwissenschaften/spark-data-quality/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/pyspark-data-quality.svg)](https://pypi.org/project/pyspark-data-quality/) ![Python 3.12](https://img.shields.io/badge/python-3.12-blue.svg) ![PySpark 4](https://img.shields.io/badge/PySpark-4.x-E25A1C.svg) [![Last commit](https://img.shields.io/github/last-commit/datenwissenschaften/spark-data-quality)](https://github.com/datenwissenschaften/spark-data-quality/commits/main) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) ![Ruff](https://img.shields.io/badge/lint-Ruff-D7FF64.svg) ![mypy](https://img.shields.io/badge/types-mypy-2A6DB2.svg) [![codecov](https://codecov.io/gh/datenwissenschaften/spark-data-quality/graph/badge.svg)](https://codecov.io/gh/datenwissenschaften/spark-data-quality)
4
+
5
+ `spark-data-quality` is a small, typed validation library for PySpark DataFrames. It
6
+ turns reusable expectations into Spark expressions, combines compatible metrics into
7
+ one aggregation, and returns Pydantic reports that remain usable after the Spark
8
+ session ends.
9
+
10
+ The library is intended for batch pipelines, local Spark applications, and managed
11
+ Spark runtimes such as Databricks. It has no Databricks-specific runtime dependency.
12
+
13
+ See the end-to-end data-quality workflow →
14
+ [`notebooks/data_quality_workflow.ipynb`](notebooks/data_quality_workflow.ipynb), a
15
+ game-analytics case study that profiles, validates, and visualizes a synthetic
16
+ player/session dataset with this library.
17
+
18
+ ![Quality check outcomes for the notebook's synthetic game_sessions_ingest suite](docs/assets/data-quality-workflow/quality-results.png)
19
+
20
+ ## Example
21
+
22
+ ```python
23
+ from pyspark.sql import SparkSession
24
+
25
+ from spark_data_quality import (
26
+ AllowedValues,
27
+ ColumnExists,
28
+ InRange,
29
+ NotNull,
30
+ QualitySuite,
31
+ Unique,
32
+ )
33
+
34
+ spark = SparkSession.builder.getOrCreate()
35
+ players = spark.createDataFrame(
36
+ [(1, 24, "DE"), (2, 31, "ZA")],
37
+ "player_id long, age long, country string",
38
+ )
39
+
40
+ suite = QualitySuite(
41
+ name="players",
42
+ checks=[
43
+ ColumnExists("player_id"),
44
+ NotNull("player_id"),
45
+ Unique("player_id"),
46
+ InRange("age", min_value=13, max_value=120),
47
+ AllowedValues("country", frozenset({"DE", "ZA", "GB"})),
48
+ ],
49
+ )
50
+
51
+ report = suite.validate(players)
52
+ print(report.status)
53
+ print(report.model_dump_json(indent=2))
54
+ ```
55
+
56
+ ## Core capabilities
57
+
58
+ - Immutable, typed expectation definitions with construction-time configuration checks
59
+ - Explicit `PASS`, `FAIL`, and `ERROR` outcomes
60
+ - Spark SQL expressions and aggregations only; no Python UDFs or pandas conversion
61
+ - One shared aggregate action for compatible validation metrics
62
+ - Deterministic check ordering, identifiers, metric aliases, and categorical tie-breaking
63
+ - Standards-compatible JSON reports with row counts, explicit denominators, and diagnostics
64
+ - Numeric and string profiling with bounded top-value collection
65
+
66
+ ## Architecture
67
+
68
+ ```mermaid
69
+ flowchart LR
70
+ A[QualitySuite and typed checks] --> B[AggregationPlanner]
71
+ B --> C[Schema-only results]
72
+ B --> D[Shared aggregate expressions]
73
+ D --> E[Spark DataFrame.agg]
74
+ E --> F[One scalar Row on driver]
75
+ C --> G[Result evaluator]
76
+ F --> G
77
+ G --> H[Pydantic SuiteReport]
78
+ ```
79
+
80
+ Check objects define expectations and configuration. The planner resolves schema
81
+ requirements and deduplicates scalar metrics. The runner executes the aggregate plan.
82
+ The evaluator interprets scalar values and constructs public result models. See
83
+ [the architecture notes](docs/architecture.md) for the boundaries and error semantics.
84
+
85
+ ## Execution model
86
+
87
+ `ColumnExists` and `HasType` use the DataFrame schema and require no action. All valid
88
+ row-level checks in a suite contribute expressions to one `DataFrame.agg` action. The
89
+ planner deduplicates identical total, null, and other metrics. Exact uniqueness uses
90
+ `count` and `countDistinct` in that aggregate; Spark may introduce a shuffle and
91
+ multiple physical stages even though the library starts one aggregate action.
92
+
93
+ The validation runner collects exactly one aggregate row. It never collects source
94
+ rows or unbounded values. Execution metadata reports the number of library-level
95
+ aggregate actions and scalar expressions; it does not claim to count Spark stages.
96
+ It also separates suite planning, shared Spark execution, result evaluation, and total
97
+ wall-clock durations. Individual checks do not claim a private share of the common
98
+ Spark execution time.
99
+
100
+ The profiler similarly shares scalar statistics in one aggregate action. Each selected
101
+ string column needs a separate grouped top-values action. Those results are ordered by
102
+ count descending and value ascending, then limited to `top_k` (maximum 100).
103
+
104
+ ## Available checks
105
+
106
+ | Check | Semantics | SQL NULL | Floating-point NaN | Empty DataFrame |
107
+ | --- | --- | --- | --- | --- |
108
+ | `ColumnExists` | Exact top-level name is present | Not applicable | Not applicable | Uses schema |
109
+ | `HasType` | Exact Spark `DataType` equality | Type is schema-level | Type is schema-level | Uses schema |
110
+ | `NotNull` | SQL null count is zero | Fails | Not a null; passes | Passes vacuously |
111
+ | `Unique` | Exact distinct count equals non-null count | Ignored, including repeated nulls | Participates as a value; repeated NaNs are duplicates | Passes vacuously |
112
+ | `UniqueKey` | Exact distinct count of the column combination equals the count of rows with every key column non-null | Rows with a null in any key column are ignored | Participates as a value; repeated NaNs are duplicates | Passes vacuously |
113
+ | `InRange` | Values satisfy configured inclusive or exclusive bounds | Ignored | Always fails | Passes vacuously |
114
+ | `AllowedValues` | Values belong to a non-empty, homogeneous scalar set | Ignored | Fails because configured values must be finite | Passes vacuously |
115
+ | `MatchesRegex` | Spark/JVM `rlike` matches the pattern | Ignored | Non-string columns are `ERROR` | Passes vacuously |
116
+ | `RowCount` | Count satisfies inclusive minimum/maximum bounds | Not applicable | Not applicable | Depends on bounds |
117
+
118
+ An absent column produces `FAIL` for `ColumnExists` and `ERROR` for checks that need
119
+ that column to continue. A `HasType` mismatch is a valid evaluation and therefore
120
+ produces `FAIL`. `InRange`, `MatchesRegex`, and `AllowedValues` reject
121
+ incompatible Spark types with `ERROR`. Use `NotNull` beside checks that should also
122
+ reject nulls.
123
+
124
+ `HasType` compares Spark `DataType` objects directly. It does not group byte, short,
125
+ integer, and long columns into one logical family, and decimal precision and scale are
126
+ part of equality.
127
+
128
+ For `Unique`, `affected_rows` is duplicate excess: non-null row count minus exact
129
+ distinct count. This is stable and aggregate-friendly; it is not the count of every row
130
+ participating in a duplicate group. Spark normalizes NaN for exact distinct aggregation,
131
+ so two NaNs contribute one excess duplicate. Multiple nulls never contribute.
132
+
133
+ For checks that ignore nulls, `evaluated_rows` is the non-null count and
134
+ `failure_fraction` is `affected_rows / evaluated_rows`. For `NotNull`, every source row
135
+ is evaluated. When zero rows are evaluated, `failure_fraction` is `None` rather than a
136
+ fabricated zero. `total_rows` always describes the complete DataFrame when a row-level
137
+ check ran.
138
+
139
+ Range bounds must be finite. NaN is explicitly invalid regardless of one-sided or
140
+ two-sided bounds. Positive and negative infinity are compared normally, so they pass
141
+ only when the configured finite bound does not exclude them. Decimal columns accept
142
+ `Decimal` or integer bounds; float bounds are rejected to avoid silent precision loss.
143
+
144
+ Regex patterns follow Java's regular-expression syntax because Spark evaluates `rlike`
145
+ on the JVM. Empty patterns are rejected at construction. The planner asks the active
146
+ Spark JVM to compile other patterns without starting a Spark action; malformed patterns
147
+ become an `INVALID_REGEX` result and never enter the shared aggregate.
148
+
149
+ ## Profiling
150
+
151
+ ```python
152
+ from spark_data_quality import profile
153
+
154
+ profile_report = profile(players, ["age", "country"], top_k=5)
155
+ print(profile_report.model_dump_json(indent=2))
156
+ ```
157
+
158
+ Numeric profiles contain null and distinct counts, minimum, maximum, mean, and sample
159
+ standard deviation. Float and double profiles also contain `nan_count`. Spark's native
160
+ aggregate semantics are preserved: NaN sorts above finite values and may make maximum,
161
+ mean, or standard deviation NaN; infinities can likewise produce non-finite aggregates.
162
+ Reports encode non-finite values as `"NaN"`, `"Infinity"`, or `"-Infinity"`, producing
163
+ standards-compatible JSON. Decimal min, max, and mean remain `Decimal` values in Python
164
+ and serialize without conversion through binary float. `standard_deviation` is the
165
+ exception: Spark's `stddev_samp` always returns `DoubleType`, even for a `DecimalType`
166
+ input column, so it is reported as `float` while the other statistics for that same
167
+ column stay `Decimal`. This is Spark's own aggregate-function typing, not a conversion
168
+ performed by this library. String profiles contain null and
169
+ distinct counts, minimum and
170
+ maximum lengths, and bounded frequent values. Unsupported selected types are rejected
171
+ before execution instead of receiving ambiguous statistics.
172
+
173
+ ## Fugue integration (optional)
174
+
175
+ **Native PySpark is the optimized, primary backend for everything above.** The
176
+ optional `fugue` extra adds exactly one thing: the ability to hand `QualitySuite`
177
+ or `profile` a non-PySpark, Fugue-compatible dataframe (pandas, Arrow, Polars,
178
+ DuckDB, a list of dictionaries, ...) instead of hand-writing the Spark
179
+ conversion yourself.
180
+
181
+ ```bash
182
+ pip install "pyspark-data-quality[fugue]"
183
+ ```
184
+
185
+ ```python
186
+ import pandas as pd
187
+
188
+ from spark_data_quality import InRange, NotNull, QualitySuite
189
+ from spark_data_quality.fugue import validate
190
+
191
+ suite = QualitySuite("players", [NotNull("player_id"), InRange("age", min_value=13, max_value=120)])
192
+ pandas_players = pd.DataFrame({"player_id": [1, 2, 3], "age": [24, 31, 19]})
193
+
194
+ report = validate(suite, pandas_players) # binds to SparkSession.builder.getOrCreate() by default
195
+ ```
196
+
197
+ `spark_data_quality.fugue` converts the input into a native PySpark `DataFrame`
198
+ bound to a caller-controlled `SparkSession` (pass `spark=...` to bind explicitly;
199
+ it never creates or stops a session beyond PySpark's own ambient-session
200
+ convention), then calls the exact same `QualitySuite.validate` used for native
201
+ input. There is no separate Fugue execution engine for checks themselves:
202
+
203
+ - **Fugue provides input portability**, not alternate execution semantics. Every
204
+ built-in check runs through the identical Spark-native aggregation planning
205
+ described above, whether the input arrived as a PySpark `DataFrame` directly or
206
+ through this conversion. An already-native PySpark `DataFrame` passes through
207
+ unchanged (no conversion cost on the optimized path).
208
+ - Because there is only one execution path (Spark) behind this boundary, check
209
+ semantics are identical for every built-in check regardless of the input
210
+ source — see the table below.
211
+ - Converting *from* pandas has real, verified caveats independent of this
212
+ library: a pandas float `NaN` becomes Spark `NaN` (not SQL `NULL`), a pandas
213
+ integer column with a missing value is upcast to `float64` before Fugue ever
214
+ sees it, and a null inside a schema-declared integral column currently fails
215
+ the conversion outright with `PySparkTypeError` rather than silently
216
+ succeeding. These are documented in `spark_data_quality/fugue.py` and
217
+ exercised by `tests/integration/test_fugue.py`; pass an explicit Fugue
218
+ `schema` string, or use a native PySpark `DataFrame`, when a column's exact
219
+ type or null semantics must be preserved.
220
+
221
+ | Check | Native PySpark | Via `spark_data_quality.fugue` | Notes |
222
+ | --- | --- | --- | --- |
223
+ | `ColumnExists` | yes | yes (identical) | Executes as native Spark after conversion. |
224
+ | `HasType` | yes | yes (identical) | Sensitive to the pandas type-inference caveats above. |
225
+ | `NotNull` | yes | yes (identical) | Pandas `NaN` is not SQL `NULL`; see caveats above. |
226
+ | `Unique` | yes | yes (identical) | No behavior change; input source is irrelevant post-conversion. |
227
+ | `InRange` | yes | yes (identical) | Explicitly rejects NaN regardless of input source. |
228
+ | `AllowedValues` | yes | yes (identical) | No behavior change. |
229
+ | `MatchesRegex` | yes | yes (identical) | No behavior change. |
230
+ | `RowCount` | yes | yes (identical) | No behavior change. |
231
+
232
+ The `notebook` extra's dependencies (Jupyter, Plotly, Kaleido, pandas) are
233
+ required only to reproduce `notebooks/data_quality_workflow.ipynb`; neither
234
+ `fugue` nor `notebook` is required to use the core library. Installing `fugue`
235
+ pulls in `fugue[spark]`, and transitively pandas, pyarrow, and a few smaller
236
+ packages; `packaging` is pinned explicitly in this extra as a safeguard for a
237
+ verified gap in `fugue`'s own dependency (`triad` imports `packaging` without
238
+ declaring it), which otherwise surfaces as `ModuleNotFoundError` in a bare
239
+ environment that installs only this extra's declared requirements.
240
+
241
+ ## Installation
242
+
243
+ Python 3.12, PySpark 4.0 through 4.1, and a compatible JVM are required. CI uses Java 17.
244
+
245
+ The distribution is published on PyPI as `pyspark-data-quality`; the import package is
246
+ `spark_data_quality`.
247
+
248
+ ```bash
249
+ pip install pyspark-data-quality
250
+ ```
251
+
252
+ For source development with Poetry:
253
+
254
+ ```bash
255
+ poetry install --with dev
256
+ poetry install --with dev --extras "fugue notebook" # optional integrations
257
+ ```
258
+
259
+ ## Development and testing
260
+
261
+ The test suite starts a real `local[2]` Spark session. It requires no cloud services,
262
+ credentials, or network access after dependencies are installed.
263
+
264
+ ```bash
265
+ ruff check .
266
+ ruff format --check .
267
+ mypy
268
+ pytest
269
+ ```
270
+
271
+ Regenerate the end-to-end notebook and its committed PNG assets (requires the
272
+ `fugue` and `notebook` extras):
273
+
274
+ ```bash
275
+ make notebooks
276
+ ```
277
+
278
+ ## Limitations
279
+
280
+ - Exact `countDistinct` can cause an expensive shuffle on high-cardinality columns.
281
+ - Large `AllowedValues` sets become literal `isin` expressions; use a reference-table
282
+ join outside this library when the allowed domain contains thousands of values.
283
+ - The profiler supports numeric and string columns only.
284
+ - Top-value profiling performs one bounded grouped action per selected string column.
285
+ - Validation reports aggregate failures but do not include failing source-row samples.
286
+ - JVM regex prevalidation uses the classic PySpark JVM gateway. Spark Connect is not a
287
+ currently supported execution mode.
288
+ - Durations are wall-clock observations and are not stable benchmark measurements.
289
+ - `Check` is a public abstract base class, but the planner and evaluator only recognize
290
+ the built-in check types above. Constructing a suite with a custom `Check` subclass
291
+ raises `TypeError` from `QualitySuite.validate` rather than silently skipping the
292
+ check or producing a fabricated result; this is deliberate (see
293
+ [Architecture](docs/architecture.md)) but means user-defined checks are not currently
294
+ a supported extension point.
295
+ - `spark_data_quality.fugue` executes checks on Spark only. It does not run checks
296
+ natively on pandas, DuckDB, Polars, or any other Fugue-supported engine; it converts
297
+ the input into Spark first. A null inside a schema-declared integral column does not
298
+ currently survive Fugue's row-oriented (list-of-dictionaries) conversion path — it
299
+ fails with `PySparkTypeError` rather than being silently dropped or reinterpreted.
300
+
301
+ ## Roadmap
302
+
303
+ Technically useful next steps include configurable approximate distinct counts,
304
+ cross-column predicates, bounded failing-row samples with explicit privacy controls,
305
+ and a second execution group for checks that cannot share scalar aggregation. A CLI is
306
+ not planned until it provides value beyond the Python API.
307
+
308
+ ## License
309
+
310
+ MIT