universal-data-cleaner 2.2.0__tar.gz → 2.3.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.
- {universal_data_cleaner-2.2.0/src/universal_data_cleaner.egg-info → universal_data_cleaner-2.3.0}/PKG-INFO +68 -1
- {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/README.md +67 -0
- {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/pyproject.toml +1 -1
- {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/data_cleaner/__init__.py +10 -0
- {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/data_cleaner/_version.py +2 -2
- universal_data_cleaner-2.3.0/src/data_cleaner/artifacts.py +171 -0
- {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/data_cleaner/cleaner.py +336 -4
- {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/data_cleaner/config.py +1 -0
- universal_data_cleaner-2.3.0/src/data_cleaner/decisions.py +50 -0
- {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0/src/universal_data_cleaner.egg-info}/PKG-INFO +68 -1
- {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/universal_data_cleaner.egg-info/SOURCES.txt +2 -0
- {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/tests/test_data_cleaner.py +223 -14
- {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/LICENSE +0 -0
- {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/setup.cfg +0 -0
- {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/data_cleaner/_roles.py +0 -0
- {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/data_cleaner/contract.py +0 -0
- {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/data_cleaner/profiling.py +0 -0
- {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/data_cleaner/py.typed +0 -0
- {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/universal_data_cleaner.egg-info/dependency_links.txt +0 -0
- {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/universal_data_cleaner.egg-info/requires.txt +0 -0
- {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/universal_data_cleaner.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: universal-data-cleaner
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Production-ready, scikit-learn-compatible data cleaning transformer for pandas.
|
|
5
5
|
Author: Haseeb Khan
|
|
6
6
|
License-Expression: MIT
|
|
@@ -241,6 +241,61 @@ restored = DataContract.from_json(payload)
|
|
|
241
241
|
# restored = DataContract.from_yaml(contract.to_yaml())
|
|
242
242
|
```
|
|
243
243
|
|
|
244
|
+
## Audit trail, manifests, and fingerprints
|
|
245
|
+
|
|
246
|
+
Every learned or configured decision is recorded during `fit()` and exposed as
|
|
247
|
+
a tidy DataFrame:
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
log = cleaner.decision_log()
|
|
251
|
+
# columns: column, action, reason, source, before, after
|
|
252
|
+
log[log["action"] == "cap_outliers"]
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`source` says whether a rule was `learned` from the training data, supplied via
|
|
256
|
+
`config`, declared in a `contract`, or `inferred` by the library. Imputation
|
|
257
|
+
values, outlier bounds, datetime expansion, missing indicators, dropped
|
|
258
|
+
constants, and identifier handling are all explained instead of hidden.
|
|
259
|
+
|
|
260
|
+
Build a JSON-serializable manifest for logs or a model registry:
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
import json
|
|
264
|
+
|
|
265
|
+
manifest = cleaner.to_manifest(X_train, include_fingerprint=True)
|
|
266
|
+
# library_version, artifact_version, environment, config, contract,
|
|
267
|
+
# input_schema, output_columns, fitted, decisions, fingerprint
|
|
268
|
+
|
|
269
|
+
json.dumps(manifest) # safe to ship to a registry or a log line
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
`save()` embeds the manifest and the training fingerprint in the artifact, and
|
|
273
|
+
`load()` restores them:
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
cleaner.save("cleaner.joblib")
|
|
277
|
+
restored = DataCleaner.load("cleaner.joblib")
|
|
278
|
+
restored.artifact_manifest()
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Fingerprints are stable SHA-256 hashes of the schema plus summary statistics.
|
|
282
|
+
They never hash raw values, so they are safe to log:
|
|
283
|
+
|
|
284
|
+
```python
|
|
285
|
+
from data_cleaner import (
|
|
286
|
+
assert_schema_stable,
|
|
287
|
+
compare_fingerprints,
|
|
288
|
+
fingerprint_dataframe,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
expected = fingerprint_dataframe(X_train)
|
|
292
|
+
same = compare_fingerprints(expected, fingerprint_dataframe(X_new))
|
|
293
|
+
assert_schema_stable(X_new, expected) # raises ValueError on schema/stat drift
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Set `CleanerConfig(fingerprint_on_fit=True)` to compute the training fingerprint
|
|
297
|
+
once during `fit()`, so `to_manifest(include_fingerprint=True)` needs no data.
|
|
298
|
+
|
|
244
299
|
## scikit-learn Pipeline
|
|
245
300
|
|
|
246
301
|
```python
|
|
@@ -279,11 +334,19 @@ predictions = pipeline.predict(X_test)
|
|
|
279
334
|
| --- | --- |
|
|
280
335
|
| `CleanerConfig` | Validated cleaning configuration |
|
|
281
336
|
| `DataCleaner` | Fit/transform cleaner with save/load and drift reporting |
|
|
337
|
+
| `CleaningDecision` | One auditable learned or configured decision |
|
|
282
338
|
| `ColumnSpec` | Declarative expectations and semantics for one column |
|
|
283
339
|
| `DataContract` | A set of column specs, with inference and (de)serialization |
|
|
284
340
|
| `DriftReport` | Findings from a contract check, with a Markdown summary |
|
|
285
341
|
| `DataCleaner.drift_report()` | Drift findings from the last `transform` |
|
|
286
342
|
| `DataCleaner.fitted_contract()` | The contract used by the fitted cleaner |
|
|
343
|
+
| `DataCleaner.decision_log()` | Tidy DataFrame of cleaning decisions |
|
|
344
|
+
| `DataCleaner.to_manifest()` | JSON-serializable artifact manifest |
|
|
345
|
+
| `DataCleaner.artifact_manifest()` | Manifest stored in a loaded artifact |
|
|
346
|
+
| `DataCleaner.fingerprint()` | Stable schema + statistics fingerprint |
|
|
347
|
+
| `fingerprint_dataframe` | Fingerprint any DataFrame for lineage |
|
|
348
|
+
| `compare_fingerprints` | Compare two fingerprints |
|
|
349
|
+
| `assert_schema_stable` | Raise when a frame's fingerprint changed |
|
|
287
350
|
| `profile_dataframe` | Column-level missing/type/cardinality profile |
|
|
288
351
|
| `dataset_summary` | Row/column/missing/duplicate summary |
|
|
289
352
|
| `drop_duplicate_rows` | Explicit duplicate removal helper |
|
|
@@ -305,6 +368,10 @@ predictions = pipeline.predict(X_test)
|
|
|
305
368
|
represented by the generated feature NaNs plus an optional missing indicator.
|
|
306
369
|
- IQR capping is skipped automatically when the IQR is zero, so constant or
|
|
307
370
|
near-constant numeric columns are not accidentally destroyed.
|
|
371
|
+
- Persist `cleaner.to_manifest(X_train, include_fingerprint=True)` alongside the
|
|
372
|
+
model so you can audit exactly which cleaning rules produced an artifact.
|
|
373
|
+
- `decision_log()` explains every learned or configured rule; ship it to your
|
|
374
|
+
experiment tracker when a model's preprocessing changes.
|
|
308
375
|
|
|
309
376
|
## Project layout
|
|
310
377
|
|
|
@@ -208,6 +208,61 @@ restored = DataContract.from_json(payload)
|
|
|
208
208
|
# restored = DataContract.from_yaml(contract.to_yaml())
|
|
209
209
|
```
|
|
210
210
|
|
|
211
|
+
## Audit trail, manifests, and fingerprints
|
|
212
|
+
|
|
213
|
+
Every learned or configured decision is recorded during `fit()` and exposed as
|
|
214
|
+
a tidy DataFrame:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
log = cleaner.decision_log()
|
|
218
|
+
# columns: column, action, reason, source, before, after
|
|
219
|
+
log[log["action"] == "cap_outliers"]
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`source` says whether a rule was `learned` from the training data, supplied via
|
|
223
|
+
`config`, declared in a `contract`, or `inferred` by the library. Imputation
|
|
224
|
+
values, outlier bounds, datetime expansion, missing indicators, dropped
|
|
225
|
+
constants, and identifier handling are all explained instead of hidden.
|
|
226
|
+
|
|
227
|
+
Build a JSON-serializable manifest for logs or a model registry:
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
import json
|
|
231
|
+
|
|
232
|
+
manifest = cleaner.to_manifest(X_train, include_fingerprint=True)
|
|
233
|
+
# library_version, artifact_version, environment, config, contract,
|
|
234
|
+
# input_schema, output_columns, fitted, decisions, fingerprint
|
|
235
|
+
|
|
236
|
+
json.dumps(manifest) # safe to ship to a registry or a log line
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`save()` embeds the manifest and the training fingerprint in the artifact, and
|
|
240
|
+
`load()` restores them:
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
cleaner.save("cleaner.joblib")
|
|
244
|
+
restored = DataCleaner.load("cleaner.joblib")
|
|
245
|
+
restored.artifact_manifest()
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Fingerprints are stable SHA-256 hashes of the schema plus summary statistics.
|
|
249
|
+
They never hash raw values, so they are safe to log:
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
from data_cleaner import (
|
|
253
|
+
assert_schema_stable,
|
|
254
|
+
compare_fingerprints,
|
|
255
|
+
fingerprint_dataframe,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
expected = fingerprint_dataframe(X_train)
|
|
259
|
+
same = compare_fingerprints(expected, fingerprint_dataframe(X_new))
|
|
260
|
+
assert_schema_stable(X_new, expected) # raises ValueError on schema/stat drift
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Set `CleanerConfig(fingerprint_on_fit=True)` to compute the training fingerprint
|
|
264
|
+
once during `fit()`, so `to_manifest(include_fingerprint=True)` needs no data.
|
|
265
|
+
|
|
211
266
|
## scikit-learn Pipeline
|
|
212
267
|
|
|
213
268
|
```python
|
|
@@ -246,11 +301,19 @@ predictions = pipeline.predict(X_test)
|
|
|
246
301
|
| --- | --- |
|
|
247
302
|
| `CleanerConfig` | Validated cleaning configuration |
|
|
248
303
|
| `DataCleaner` | Fit/transform cleaner with save/load and drift reporting |
|
|
304
|
+
| `CleaningDecision` | One auditable learned or configured decision |
|
|
249
305
|
| `ColumnSpec` | Declarative expectations and semantics for one column |
|
|
250
306
|
| `DataContract` | A set of column specs, with inference and (de)serialization |
|
|
251
307
|
| `DriftReport` | Findings from a contract check, with a Markdown summary |
|
|
252
308
|
| `DataCleaner.drift_report()` | Drift findings from the last `transform` |
|
|
253
309
|
| `DataCleaner.fitted_contract()` | The contract used by the fitted cleaner |
|
|
310
|
+
| `DataCleaner.decision_log()` | Tidy DataFrame of cleaning decisions |
|
|
311
|
+
| `DataCleaner.to_manifest()` | JSON-serializable artifact manifest |
|
|
312
|
+
| `DataCleaner.artifact_manifest()` | Manifest stored in a loaded artifact |
|
|
313
|
+
| `DataCleaner.fingerprint()` | Stable schema + statistics fingerprint |
|
|
314
|
+
| `fingerprint_dataframe` | Fingerprint any DataFrame for lineage |
|
|
315
|
+
| `compare_fingerprints` | Compare two fingerprints |
|
|
316
|
+
| `assert_schema_stable` | Raise when a frame's fingerprint changed |
|
|
254
317
|
| `profile_dataframe` | Column-level missing/type/cardinality profile |
|
|
255
318
|
| `dataset_summary` | Row/column/missing/duplicate summary |
|
|
256
319
|
| `drop_duplicate_rows` | Explicit duplicate removal helper |
|
|
@@ -272,6 +335,10 @@ predictions = pipeline.predict(X_test)
|
|
|
272
335
|
represented by the generated feature NaNs plus an optional missing indicator.
|
|
273
336
|
- IQR capping is skipped automatically when the IQR is zero, so constant or
|
|
274
337
|
near-constant numeric columns are not accidentally destroyed.
|
|
338
|
+
- Persist `cleaner.to_manifest(X_train, include_fingerprint=True)` alongside the
|
|
339
|
+
model so you can audit exactly which cleaning rules produced an artifact.
|
|
340
|
+
- `decision_log()` explains every learned or configured rule; ship it to your
|
|
341
|
+
experiment tracker when a model's preprocessing changes.
|
|
275
342
|
|
|
276
343
|
## Project layout
|
|
277
344
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "universal-data-cleaner"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.3.0"
|
|
8
8
|
description = "Production-ready, scikit-learn-compatible data cleaning transformer for pandas."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -13,9 +13,15 @@ exactly as it did when the library shipped as a single module.
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
from ._version import __version__
|
|
16
|
+
from .artifacts import (
|
|
17
|
+
assert_schema_stable,
|
|
18
|
+
compare_fingerprints,
|
|
19
|
+
fingerprint_dataframe,
|
|
20
|
+
)
|
|
16
21
|
from .cleaner import DataCleaner
|
|
17
22
|
from .config import CleanerConfig
|
|
18
23
|
from .contract import ColumnSpec, DataContract, DriftReport
|
|
24
|
+
from .decisions import CleaningDecision
|
|
19
25
|
from .profiling import (
|
|
20
26
|
assert_frame_clean,
|
|
21
27
|
dataset_summary,
|
|
@@ -25,13 +31,17 @@ from .profiling import (
|
|
|
25
31
|
|
|
26
32
|
__all__ = [
|
|
27
33
|
"CleanerConfig",
|
|
34
|
+
"CleaningDecision",
|
|
28
35
|
"ColumnSpec",
|
|
29
36
|
"DataCleaner",
|
|
30
37
|
"DataContract",
|
|
31
38
|
"DriftReport",
|
|
32
39
|
"__version__",
|
|
33
40
|
"assert_frame_clean",
|
|
41
|
+
"assert_schema_stable",
|
|
42
|
+
"compare_fingerprints",
|
|
34
43
|
"dataset_summary",
|
|
35
44
|
"drop_duplicate_rows",
|
|
45
|
+
"fingerprint_dataframe",
|
|
36
46
|
"profile_dataframe",
|
|
37
47
|
]
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Reproducibility helpers: data fingerprints and environment metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import platform
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
import sklearn
|
|
15
|
+
|
|
16
|
+
from ._roles import _series_kind
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def package_versions() -> dict[str, str]:
|
|
21
|
+
"""Return the versions of the runtime that produced an artifact."""
|
|
22
|
+
return {
|
|
23
|
+
"python": platform.python_version(),
|
|
24
|
+
"pandas": pd.__version__,
|
|
25
|
+
"numpy": np.__version__,
|
|
26
|
+
"scikit_learn": sklearn.__version__,
|
|
27
|
+
"universal_data_cleaner": __version__,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _json_default(value: object) -> str:
|
|
32
|
+
"""Render unknown scalar types (timestamps, decimals) for JSON encoding."""
|
|
33
|
+
return str(value)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _canonical_number(value: Any) -> float | str | None:
|
|
37
|
+
"""Normalize a number so JSON hashing is stable and NaN-safe."""
|
|
38
|
+
if value is None:
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
number = float(value)
|
|
42
|
+
except (TypeError, ValueError):
|
|
43
|
+
return str(value)
|
|
44
|
+
if math.isnan(number):
|
|
45
|
+
return "nan"
|
|
46
|
+
if math.isinf(number):
|
|
47
|
+
return "inf" if number > 0 else "-inf"
|
|
48
|
+
return round(number, 10)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _column_stats(series: pd.Series) -> dict[str, object]:
|
|
52
|
+
kind = _series_kind(series)
|
|
53
|
+
stats: dict[str, object] = {
|
|
54
|
+
"name": str(series.name),
|
|
55
|
+
"kind": kind,
|
|
56
|
+
"nulls": int(series.isna().sum()),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if kind == "numeric":
|
|
60
|
+
numeric = pd.to_numeric(series, errors="coerce")
|
|
61
|
+
numeric = numeric.replace([np.inf, -np.inf], np.nan).dropna()
|
|
62
|
+
stats.update(
|
|
63
|
+
{
|
|
64
|
+
"count": int(numeric.size),
|
|
65
|
+
"mean": _canonical_number(numeric.mean()) if numeric.size else None,
|
|
66
|
+
"std": _canonical_number(numeric.std()) if numeric.size > 1 else None,
|
|
67
|
+
"min": _canonical_number(numeric.min()) if numeric.size else None,
|
|
68
|
+
"max": _canonical_number(numeric.max()) if numeric.size else None,
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
elif kind == "boolean":
|
|
72
|
+
valid = series.dropna()
|
|
73
|
+
flags = valid.astype(bool)
|
|
74
|
+
stats.update(
|
|
75
|
+
{
|
|
76
|
+
"count": int(valid.size),
|
|
77
|
+
"true": int(flags.sum()),
|
|
78
|
+
"false": int((~flags).sum()),
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
elif kind == "datetime":
|
|
82
|
+
valid = series.dropna()
|
|
83
|
+
stats.update(
|
|
84
|
+
{
|
|
85
|
+
"count": int(valid.size),
|
|
86
|
+
"min": str(valid.min()) if not valid.empty else None,
|
|
87
|
+
"max": str(valid.max()) if not valid.empty else None,
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
try:
|
|
92
|
+
unique: int | None = int(series.nunique(dropna=True))
|
|
93
|
+
except TypeError:
|
|
94
|
+
unique = None
|
|
95
|
+
stats.update({"count": int(series.notna().sum()), "unique": unique})
|
|
96
|
+
|
|
97
|
+
return stats
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def fingerprint_dataframe(
|
|
101
|
+
frame: pd.DataFrame, columns: Sequence[str] | None = None
|
|
102
|
+
) -> str:
|
|
103
|
+
"""Return a stable SHA-256 fingerprint of a frame's schema and summary stats.
|
|
104
|
+
|
|
105
|
+
The hash deliberately excludes raw values: it is a lineage fingerprint, not
|
|
106
|
+
a row-level checksum. Column order participates in the hash, and summary
|
|
107
|
+
statistics are rounded so the result is stable across platforms.
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
frame:
|
|
112
|
+
DataFrame to fingerprint.
|
|
113
|
+
columns:
|
|
114
|
+
Optional subset/order of columns. Defaults to all columns in frame
|
|
115
|
+
order.
|
|
116
|
+
|
|
117
|
+
Raises
|
|
118
|
+
------
|
|
119
|
+
TypeError
|
|
120
|
+
If ``frame`` is not a pandas DataFrame.
|
|
121
|
+
ValueError
|
|
122
|
+
If ``frame`` has duplicate column names.
|
|
123
|
+
KeyError
|
|
124
|
+
If a requested column is missing from ``frame``.
|
|
125
|
+
"""
|
|
126
|
+
if not isinstance(frame, pd.DataFrame):
|
|
127
|
+
raise TypeError("frame must be a pandas DataFrame")
|
|
128
|
+
if frame.columns.has_duplicates:
|
|
129
|
+
duplicates = frame.columns[frame.columns.duplicated()].tolist()
|
|
130
|
+
raise ValueError(f"Duplicate column names are not allowed: {duplicates}")
|
|
131
|
+
|
|
132
|
+
if columns is None:
|
|
133
|
+
selected = list(frame.columns)
|
|
134
|
+
else:
|
|
135
|
+
selected = [str(column) for column in columns]
|
|
136
|
+
missing = [column for column in selected if column not in frame.columns]
|
|
137
|
+
if missing:
|
|
138
|
+
raise KeyError(f"Columns not found in frame: {missing}")
|
|
139
|
+
|
|
140
|
+
payload = {
|
|
141
|
+
"n_rows": len(frame),
|
|
142
|
+
"n_columns": len(selected),
|
|
143
|
+
"columns": [_column_stats(frame[column]) for column in selected],
|
|
144
|
+
}
|
|
145
|
+
encoded = json.dumps(
|
|
146
|
+
payload,
|
|
147
|
+
sort_keys=True,
|
|
148
|
+
separators=(",", ":"),
|
|
149
|
+
default=_json_default,
|
|
150
|
+
)
|
|
151
|
+
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def compare_fingerprints(left: str, right: str) -> bool:
|
|
155
|
+
"""Return ``True`` when two fingerprints are identical."""
|
|
156
|
+
return left == right
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def assert_schema_stable(
|
|
160
|
+
frame: pd.DataFrame, expected: str, *, name: str = "frame"
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Raise ``ValueError`` when ``frame`` does not match ``expected``.
|
|
163
|
+
|
|
164
|
+
``expected`` is a fingerprint produced by :func:`fingerprint_dataframe`.
|
|
165
|
+
"""
|
|
166
|
+
actual = fingerprint_dataframe(frame)
|
|
167
|
+
if not compare_fingerprints(actual, expected):
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"{name} fingerprint {actual} does not match expected {expected}. "
|
|
170
|
+
"The schema or summary statistics changed."
|
|
171
|
+
)
|