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.
- pyspark_data_quality-0.1.0/LICENSE +21 -0
- pyspark_data_quality-0.1.0/PKG-INFO +338 -0
- pyspark_data_quality-0.1.0/README.md +310 -0
- pyspark_data_quality-0.1.0/pyproject.toml +83 -0
- pyspark_data_quality-0.1.0/src/spark_data_quality/__init__.py +37 -0
- pyspark_data_quality-0.1.0/src/spark_data_quality/checks/__init__.py +27 -0
- pyspark_data_quality-0.1.0/src/spark_data_quality/checks/base.py +48 -0
- pyspark_data_quality-0.1.0/src/spark_data_quality/checks/builtin.py +243 -0
- pyspark_data_quality-0.1.0/src/spark_data_quality/exceptions.py +9 -0
- pyspark_data_quality-0.1.0/src/spark_data_quality/execution/__init__.py +5 -0
- pyspark_data_quality-0.1.0/src/spark_data_quality/execution/evaluation.py +128 -0
- pyspark_data_quality-0.1.0/src/spark_data_quality/execution/planning.py +327 -0
- pyspark_data_quality-0.1.0/src/spark_data_quality/execution/runner.py +38 -0
- pyspark_data_quality-0.1.0/src/spark_data_quality/fugue.py +175 -0
- pyspark_data_quality-0.1.0/src/spark_data_quality/models/__init__.py +21 -0
- pyspark_data_quality-0.1.0/src/spark_data_quality/models/results.py +107 -0
- pyspark_data_quality-0.1.0/src/spark_data_quality/models/values.py +20 -0
- pyspark_data_quality-0.1.0/src/spark_data_quality/profiling/__init__.py +6 -0
- pyspark_data_quality-0.1.0/src/spark_data_quality/profiling/models.py +86 -0
- pyspark_data_quality-0.1.0/src/spark_data_quality/profiling/profiler.py +204 -0
- pyspark_data_quality-0.1.0/src/spark_data_quality/suite.py +78 -0
|
@@ -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
|
+
[](https://github.com/datenwissenschaften/spark-data-quality/actions/workflows/ci.yml) [](https://pypi.org/project/pyspark-data-quality/)   [](https://github.com/datenwissenschaften/spark-data-quality/commits/main) [](LICENSE)   [](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
|
+

|
|
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
|
+
[](https://github.com/datenwissenschaften/spark-data-quality/actions/workflows/ci.yml) [](https://pypi.org/project/pyspark-data-quality/)   [](https://github.com/datenwissenschaften/spark-data-quality/commits/main) [](LICENSE)   [](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
|
+

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