dataframe-schema-guard 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.
- dataframe_schema_guard-0.1.0/.gitignore +30 -0
- dataframe_schema_guard-0.1.0/LICENSE +21 -0
- dataframe_schema_guard-0.1.0/PKG-INFO +160 -0
- dataframe_schema_guard-0.1.0/README.md +134 -0
- dataframe_schema_guard-0.1.0/pyproject.toml +53 -0
- dataframe_schema_guard-0.1.0/src/schema_guard/__init__.py +76 -0
- dataframe_schema_guard-0.1.0/src/schema_guard/__main__.py +8 -0
- dataframe_schema_guard-0.1.0/src/schema_guard/_coerce.py +581 -0
- dataframe_schema_guard-0.1.0/src/schema_guard/_io.py +103 -0
- dataframe_schema_guard-0.1.0/src/schema_guard/_types.py +166 -0
- dataframe_schema_guard-0.1.0/src/schema_guard/cli.py +165 -0
- dataframe_schema_guard-0.1.0/src/schema_guard/guard.py +51 -0
- dataframe_schema_guard-0.1.0/src/schema_guard/result.py +142 -0
- dataframe_schema_guard-0.1.0/src/schema_guard/schema.py +741 -0
- dataframe_schema_guard-0.1.0/tests/conftest.py +41 -0
- dataframe_schema_guard-0.1.0/tests/test_cli.py +116 -0
- dataframe_schema_guard-0.1.0/tests/test_edge_cases.py +128 -0
- dataframe_schema_guard-0.1.0/tests/test_enforce.py +182 -0
- dataframe_schema_guard-0.1.0/tests/test_guard.py +76 -0
- dataframe_schema_guard-0.1.0/tests/test_infer.py +140 -0
- dataframe_schema_guard-0.1.0/tests/test_quickstart.py +42 -0
- dataframe_schema_guard-0.1.0/tests/test_regressions_round1.py +317 -0
- dataframe_schema_guard-0.1.0/tests/test_regressions_round2.py +301 -0
- dataframe_schema_guard-0.1.0/tests/test_serialization.py +120 -0
- dataframe_schema_guard-0.1.0/tests/test_validate.py +191 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Virtual environments and scratch space used while building. Never committed:
|
|
2
|
+
# they are large, machine-specific, and rebuilt from pyproject.toml anyway.
|
|
3
|
+
_venvs/
|
|
4
|
+
_proof/
|
|
5
|
+
.venv*/
|
|
6
|
+
|
|
7
|
+
# Build output. Wheels are built by the release workflow from this source, so a
|
|
8
|
+
# wheel in git could silently differ from the code beside it.
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
*.egg-info/
|
|
12
|
+
src/*.egg-info/
|
|
13
|
+
|
|
14
|
+
# Python noise
|
|
15
|
+
__pycache__/
|
|
16
|
+
*.py[cod]
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
|
|
21
|
+
# Local verification state, not source
|
|
22
|
+
verify_results.json
|
|
23
|
+
publish-log.txt
|
|
24
|
+
published.json
|
|
25
|
+
|
|
26
|
+
# Credentials. None of these belong here, and this line is the backstop.
|
|
27
|
+
.pypirc
|
|
28
|
+
.env
|
|
29
|
+
*.pem
|
|
30
|
+
*.key
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pranay Mahendrakar
|
|
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,160 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dataframe-schema-guard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Stop ML pipelines from breaking when incoming data changes shape: infer a DataFrame schema once, then validate or enforce it forever
|
|
5
|
+
Project-URL: Homepage, https://pypi.org/project/dataframe-schema-guard/
|
|
6
|
+
Project-URL: Author, https://pypi.org/user/pranaymahendrakar/
|
|
7
|
+
Author: Pranay Mahendrakar
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: data-contract,data-quality,dataframe,machine-learning,pandas,pipeline,schema,validation
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: numpy>=1.23
|
|
20
|
+
Requires-Dist: pandas>=1.5
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
23
|
+
Provides-Extra: parquet
|
|
24
|
+
Requires-Dist: pyarrow>=12; extra == 'parquet'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# dataframe-schema-guard
|
|
28
|
+
|
|
29
|
+
> Installs as `dataframe-schema-guard`; imports as `schema_guard`. The plain name
|
|
30
|
+
> `schema-guard` is blocked on PyPI for being too close to the existing
|
|
31
|
+
> `schemaguard` and `schema-guardian` projects.
|
|
32
|
+
|
|
33
|
+
Stop ML pipelines from breaking when incoming data changes shape: infer a schema from one good DataFrame, then validate or enforce it on every batch that follows.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install dataframe-schema-guard
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Reading or writing `.parquet` files needs `pip install "schema-guard[parquet]"`.
|
|
42
|
+
|
|
43
|
+
## Quickstart
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import pandas as pd
|
|
47
|
+
import schema_guard
|
|
48
|
+
|
|
49
|
+
schema = schema_guard.infer(pd.DataFrame({"id": [1, 2, 3], "city": ["Oslo", "Paris", "Oslo"], "score": [0.5, 0.9, 0.7]}))
|
|
50
|
+
new = pd.DataFrame({"city": ["Paris", "Rome"], "id": [4.0, None], "score": [0.1, 0.2]})
|
|
51
|
+
print(schema.validate(new).summary()) # id arrived as float with a null, unknown city 'Rome', columns out of order
|
|
52
|
+
fixed = schema.enforce(new) # id -> Int64, columns back in schema order; new itself is never modified
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The `print` lists every problem in a stable order: frame-level problems first, then each column in schema order. Warnings are reported but do not fail validation:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
Schema validation: FAILED - 2 rows x 3 columns (4 errors, 2 warnings)
|
|
59
|
+
[error] wrong_order: columns are not in schema order: expected ['id', 'city', 'score'], got ['city', 'id', 'score']
|
|
60
|
+
[error] dtype_mismatch 'id': expected int, got float64 (float); values are whole numbers, enforce() coerces them to Int64
|
|
61
|
+
[error] unexpected_null 'id': 1 null value(s) in a non-nullable column
|
|
62
|
+
[warning] out_of_range 'id': 1 value(s) outside the range 1 .. 3 (observed 4.0 .. 4.0)
|
|
63
|
+
[error] unknown_category 'city': 1 value(s) not in the allowed categories (e.g. 'Rome')
|
|
64
|
+
[warning] out_of_range 'score': 2 value(s) outside the range 0.5 .. 0.9 (observed 0.1 .. 0.2)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Keep the schema next to the model with `schema.save("schema.json")` and get it back with `schema_guard.load("schema.json")`. The JSON is human-readable, safe to edit by hand, and `load(save(schema)) == schema` always holds. It is standard [RFC 8259](https://www.rfc-editor.org/rfc/rfc8259) JSON, so `jq`, JavaScript, Go and anything else read it too: a value with no JSON literal (`inf`, `-inf`, `NaN`) is written as `null`, never as a bare `Infinity` token that only Python accepts.
|
|
68
|
+
|
|
69
|
+
Column names must be strings or integers - the two label types JSON keeps faithfully. A frame with other labels (`Timestamp` columns from a `pivot_table`, floats, tuples) raises a `ValueError` telling you to convert them first, rather than silently renaming them into something that matches nothing.
|
|
70
|
+
|
|
71
|
+
## What it checks
|
|
72
|
+
|
|
73
|
+
`schema.validate(df)` never changes the frame; it reports:
|
|
74
|
+
|
|
75
|
+
- `missing_column` - a schema column is absent from the frame.
|
|
76
|
+
- `extra_column` - the frame has a column the schema does not know.
|
|
77
|
+
- `wrong_order` - the schema columns appear in a different order (positional feature arrays care).
|
|
78
|
+
- `dtype_mismatch` - the dtype family differs. Families are `int`, `float`, `bool`, `datetime`, `string`, `category`; nullable `Int64`/`boolean` and `object` columns are classified by their values. Typical catches: an int column arriving as `float64` because of nulls, a zip code arriving as `int64`, dates arriving as strings, a `category` column arriving as `object`. The timezone of a `datetime` column is part of the schema, so a UTC column arriving tz-naive (a drift that shifts every timestamp by hours while the dtypes still look compatible) is reported here too. A column whose *values* are the right family but which is backed by `object` - a `NUMERIC` column out of SQL Server arriving as `Decimal`s, a frame that came back through JSON - is reported as a **warning**: `infer()` classifies an object column by its values, so making that an error would have the training frame fail the schema learned from it. `enforce()` gives it a real dtype.
|
|
79
|
+
- `unexpected_null` - nulls in a column that had none when the schema was inferred (or is marked `nullable: false`).
|
|
80
|
+
- `unknown_category` - text/category values outside the recorded allowed set (recorded for columns with at most `categorical_max_unique` distinct values, 50 by default; a text column that had no values records no set). A text column whose values are **all distinct** records no set either, and logs a warning saying so: every value being unique is the signature of an order id, an email or free text, not of a closed set, and freezing one would reject every later batch. Repetition is the evidence; a declared `category` dtype keeps the categories you declared regardless. A value that names a stored category in another JSON type matches it - a zip code arriving as `1001` or `1001.0` finds the stored `'1001'` - so `validate()` never calls unknown what `enforce()` maps straight onto a category. The two forms have to name each other exactly: `'02134'` is not `2134`, because the leading zero is the reason it is text at all.
|
|
81
|
+
- `out_of_range` - numeric values outside the recorded `min`/`max`. This is a **warning**: it does not fail validation unless you pass `strict_ranges=True`.
|
|
82
|
+
|
|
83
|
+
`schema.enforce(df)` returns a new frame and never mutates the input:
|
|
84
|
+
|
|
85
|
+
- coerces every column to its family: `float` with `NaN` -> `Int64`, `"12"` -> `12`, `"2024-01-31"` -> `datetime64`, `"yes"`/`"no"`/`1`/`0` -> `bool`, `int` -> `str`, `object` -> `category` using the schema's categories so category codes stay stable between batches (unseen values are appended as new categories, never silently turned into nulls; a date matches its stored category whether it arrives as a `Timestamp` or as `"2024-01-01"`, and a zip code matches whether the CSV reader made the column text, `int64` or `float64`);
|
|
86
|
+
- puts every `datetime` column in the schema's timezone: a tz-naive batch is localised to it, a differently-zoned one is converted to it (the instant is preserved), and a tz-aware batch for a naive schema is converted to UTC before the offset is dropped. Every batch therefore enforces to the *same* dtype, so `pd.concat` of two enforced frames does not degrade to `object`;
|
|
87
|
+
- writes whole numbers into a `string` column as whole numbers: `1001.0` becomes `"1001"`, not `"1001.0"`. One blank cell upcasts an integer column to `float64`, and an id rewritten that way matches nothing in a join, a lookup or an encoder;
|
|
88
|
+
- puts the columns in schema order;
|
|
89
|
+
- drops extra columns (`extra="drop"`), keeps them at the end (`extra="keep"`) or rejects them (`extra="raise"`);
|
|
90
|
+
- adds missing columns as all-null columns of the right dtype (`missing="fill"`) or rejects them (`missing="raise"`);
|
|
91
|
+
- with `mode="strict"` converts nothing: it raises `SchemaError` listing every error unless the frame already matches (warnings - out-of-range values, an object-backed column of otherwise-right values - do not block it);
|
|
92
|
+
- values that cannot be converted (`"abc"` into an int column, `2.5` into an int column, numbers into a datetime column) raise `SchemaError` naming every failing column with example values, so nothing is lost silently.
|
|
93
|
+
|
|
94
|
+
## API
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
schema_guard.infer(df, *, categorical_max_unique=50, numeric_ranges=True, nullable="observed") -> Schema
|
|
98
|
+
schema_guard.load(path) -> Schema
|
|
99
|
+
schema_guard.validate(df, schema, *, strict_ranges=False) -> ValidationResult
|
|
100
|
+
schema_guard.enforce(df, schema, *, mode="coerce", extra="drop", missing="fill") -> DataFrame
|
|
101
|
+
schema_guard.as_schema(obj) -> Schema
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`df` is a pandas DataFrame or a path to a `.csv`/`.tsv`/`.parquet` file wherever a frame is accepted; `schema` is a `Schema`, a schema dict or a path to a saved JSON - `as_schema()` is the function that turns any of those three into a `Schema`, and is what the module-level helpers call.
|
|
105
|
+
|
|
106
|
+
**`Schema`** - an ordered list of `ColumnSpec`.
|
|
107
|
+
|
|
108
|
+
- `Schema.infer(df, *, categorical_max_unique=50, numeric_ranges=True, nullable="observed")` - `nullable` is `"observed"` (nullable only where a null was seen), `"always"` or `"never"`.
|
|
109
|
+
- `schema.validate(df, *, strict_ranges=False) -> ValidationResult`
|
|
110
|
+
- `schema.enforce(df, *, mode="coerce", extra="drop", missing="fill") -> DataFrame`
|
|
111
|
+
- `schema.save(path)` / `Schema.load(path)` - human-readable JSON; `schema.to_dict()` / `Schema.from_dict(d)`; `schema.to_json()` / `Schema.from_json(text)`.
|
|
112
|
+
- `schema.column_names`, `schema["city"]`, `"city" in schema`, `len(schema)`, iteration over specs, `schema.select("id", "score")`, `schema.drop("target")`, `schema.summary()`.
|
|
113
|
+
|
|
114
|
+
**`ColumnSpec(name, family, nullable=True, categories=None, min=None, max=None, tz=None)`** - one column; build them by hand when you want control. `name` is a string or an integer; `tz` is the timezone a `datetime` column must be in (`None` means tz-naive) and applies to no other family.
|
|
115
|
+
|
|
116
|
+
**`ValidationResult`** - `.ok` (no error-level problems), `.problems` (all of them), `.errors`, `.warnings`, `.summary()` (text), `.to_dict()` (JSON-safe), `.raise_if_invalid()` (raises `SchemaError`, otherwise returns the result so it chains).
|
|
117
|
+
|
|
118
|
+
**`Problem(kind, column, message, detail, severity="error")`** - `kind` is one of `missing_column`, `extra_column`, `dtype_mismatch`, `unexpected_null`, `unknown_category`, `out_of_range`, `wrong_order`; `column` is `None` for frame-level problems; `detail` holds JSON-safe specifics such as counts and example values.
|
|
119
|
+
|
|
120
|
+
**`SchemaError`** - a `ValueError` whose message lists every problem and whose `.problems` attribute holds them.
|
|
121
|
+
|
|
122
|
+
**`@guard(schema, mode="coerce", *, extra="drop", missing="fill")`** - decorator that enforces the schema on the first DataFrame argument (positional first, then keyword) before the function runs; works on methods, and accepts a schema path:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
@schema_guard.guard("schema.json")
|
|
126
|
+
def predict(df):
|
|
127
|
+
return model.predict(df)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The saved JSON looks like this:
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{
|
|
134
|
+
"format": 1,
|
|
135
|
+
"columns": [
|
|
136
|
+
{"name": "id", "family": "int", "nullable": false, "categories": null, "min": 1, "max": 3},
|
|
137
|
+
{"name": "city", "family": "string", "nullable": false, "categories": ["Oslo", "Paris"], "min": null, "max": null},
|
|
138
|
+
{"name": "score", "family": "float", "nullable": false, "categories": null, "min": 0.5, "max": 0.9}
|
|
139
|
+
]
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
A `datetime` column carries one more key, `"tz"` - `"UTC"`, `"Europe/Oslo"` or `null` for tz-naive. No other family has one.
|
|
144
|
+
|
|
145
|
+
## CLI
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
schema-guard data.csv # infer and print the schema (same as: schema-guard infer data.csv)
|
|
149
|
+
schema-guard infer data.csv --json --output schema.json # print JSON and save it
|
|
150
|
+
schema-guard validate schema.json new.csv # print the summary; exit code 1 when validation fails
|
|
151
|
+
schema-guard validate schema.json new.csv --json --strict-ranges --output result.json
|
|
152
|
+
schema-guard enforce schema.json new.csv --output fixed.csv --extra keep --missing raise
|
|
153
|
+
schema-guard enforce schema.json new.csv --output fixed.csv --strict
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`infer` also takes `--max-categories N`, `--no-ranges` and `--nullable observed|always|never`. Input and output files may be `.csv`, `.tsv` or `.parquet`.
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# dataframe-schema-guard
|
|
2
|
+
|
|
3
|
+
> Installs as `dataframe-schema-guard`; imports as `schema_guard`. The plain name
|
|
4
|
+
> `schema-guard` is blocked on PyPI for being too close to the existing
|
|
5
|
+
> `schemaguard` and `schema-guardian` projects.
|
|
6
|
+
|
|
7
|
+
Stop ML pipelines from breaking when incoming data changes shape: infer a schema from one good DataFrame, then validate or enforce it on every batch that follows.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install dataframe-schema-guard
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Reading or writing `.parquet` files needs `pip install "schema-guard[parquet]"`.
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import pandas as pd
|
|
21
|
+
import schema_guard
|
|
22
|
+
|
|
23
|
+
schema = schema_guard.infer(pd.DataFrame({"id": [1, 2, 3], "city": ["Oslo", "Paris", "Oslo"], "score": [0.5, 0.9, 0.7]}))
|
|
24
|
+
new = pd.DataFrame({"city": ["Paris", "Rome"], "id": [4.0, None], "score": [0.1, 0.2]})
|
|
25
|
+
print(schema.validate(new).summary()) # id arrived as float with a null, unknown city 'Rome', columns out of order
|
|
26
|
+
fixed = schema.enforce(new) # id -> Int64, columns back in schema order; new itself is never modified
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The `print` lists every problem in a stable order: frame-level problems first, then each column in schema order. Warnings are reported but do not fail validation:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
Schema validation: FAILED - 2 rows x 3 columns (4 errors, 2 warnings)
|
|
33
|
+
[error] wrong_order: columns are not in schema order: expected ['id', 'city', 'score'], got ['city', 'id', 'score']
|
|
34
|
+
[error] dtype_mismatch 'id': expected int, got float64 (float); values are whole numbers, enforce() coerces them to Int64
|
|
35
|
+
[error] unexpected_null 'id': 1 null value(s) in a non-nullable column
|
|
36
|
+
[warning] out_of_range 'id': 1 value(s) outside the range 1 .. 3 (observed 4.0 .. 4.0)
|
|
37
|
+
[error] unknown_category 'city': 1 value(s) not in the allowed categories (e.g. 'Rome')
|
|
38
|
+
[warning] out_of_range 'score': 2 value(s) outside the range 0.5 .. 0.9 (observed 0.1 .. 0.2)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Keep the schema next to the model with `schema.save("schema.json")` and get it back with `schema_guard.load("schema.json")`. The JSON is human-readable, safe to edit by hand, and `load(save(schema)) == schema` always holds. It is standard [RFC 8259](https://www.rfc-editor.org/rfc/rfc8259) JSON, so `jq`, JavaScript, Go and anything else read it too: a value with no JSON literal (`inf`, `-inf`, `NaN`) is written as `null`, never as a bare `Infinity` token that only Python accepts.
|
|
42
|
+
|
|
43
|
+
Column names must be strings or integers - the two label types JSON keeps faithfully. A frame with other labels (`Timestamp` columns from a `pivot_table`, floats, tuples) raises a `ValueError` telling you to convert them first, rather than silently renaming them into something that matches nothing.
|
|
44
|
+
|
|
45
|
+
## What it checks
|
|
46
|
+
|
|
47
|
+
`schema.validate(df)` never changes the frame; it reports:
|
|
48
|
+
|
|
49
|
+
- `missing_column` - a schema column is absent from the frame.
|
|
50
|
+
- `extra_column` - the frame has a column the schema does not know.
|
|
51
|
+
- `wrong_order` - the schema columns appear in a different order (positional feature arrays care).
|
|
52
|
+
- `dtype_mismatch` - the dtype family differs. Families are `int`, `float`, `bool`, `datetime`, `string`, `category`; nullable `Int64`/`boolean` and `object` columns are classified by their values. Typical catches: an int column arriving as `float64` because of nulls, a zip code arriving as `int64`, dates arriving as strings, a `category` column arriving as `object`. The timezone of a `datetime` column is part of the schema, so a UTC column arriving tz-naive (a drift that shifts every timestamp by hours while the dtypes still look compatible) is reported here too. A column whose *values* are the right family but which is backed by `object` - a `NUMERIC` column out of SQL Server arriving as `Decimal`s, a frame that came back through JSON - is reported as a **warning**: `infer()` classifies an object column by its values, so making that an error would have the training frame fail the schema learned from it. `enforce()` gives it a real dtype.
|
|
53
|
+
- `unexpected_null` - nulls in a column that had none when the schema was inferred (or is marked `nullable: false`).
|
|
54
|
+
- `unknown_category` - text/category values outside the recorded allowed set (recorded for columns with at most `categorical_max_unique` distinct values, 50 by default; a text column that had no values records no set). A text column whose values are **all distinct** records no set either, and logs a warning saying so: every value being unique is the signature of an order id, an email or free text, not of a closed set, and freezing one would reject every later batch. Repetition is the evidence; a declared `category` dtype keeps the categories you declared regardless. A value that names a stored category in another JSON type matches it - a zip code arriving as `1001` or `1001.0` finds the stored `'1001'` - so `validate()` never calls unknown what `enforce()` maps straight onto a category. The two forms have to name each other exactly: `'02134'` is not `2134`, because the leading zero is the reason it is text at all.
|
|
55
|
+
- `out_of_range` - numeric values outside the recorded `min`/`max`. This is a **warning**: it does not fail validation unless you pass `strict_ranges=True`.
|
|
56
|
+
|
|
57
|
+
`schema.enforce(df)` returns a new frame and never mutates the input:
|
|
58
|
+
|
|
59
|
+
- coerces every column to its family: `float` with `NaN` -> `Int64`, `"12"` -> `12`, `"2024-01-31"` -> `datetime64`, `"yes"`/`"no"`/`1`/`0` -> `bool`, `int` -> `str`, `object` -> `category` using the schema's categories so category codes stay stable between batches (unseen values are appended as new categories, never silently turned into nulls; a date matches its stored category whether it arrives as a `Timestamp` or as `"2024-01-01"`, and a zip code matches whether the CSV reader made the column text, `int64` or `float64`);
|
|
60
|
+
- puts every `datetime` column in the schema's timezone: a tz-naive batch is localised to it, a differently-zoned one is converted to it (the instant is preserved), and a tz-aware batch for a naive schema is converted to UTC before the offset is dropped. Every batch therefore enforces to the *same* dtype, so `pd.concat` of two enforced frames does not degrade to `object`;
|
|
61
|
+
- writes whole numbers into a `string` column as whole numbers: `1001.0` becomes `"1001"`, not `"1001.0"`. One blank cell upcasts an integer column to `float64`, and an id rewritten that way matches nothing in a join, a lookup or an encoder;
|
|
62
|
+
- puts the columns in schema order;
|
|
63
|
+
- drops extra columns (`extra="drop"`), keeps them at the end (`extra="keep"`) or rejects them (`extra="raise"`);
|
|
64
|
+
- adds missing columns as all-null columns of the right dtype (`missing="fill"`) or rejects them (`missing="raise"`);
|
|
65
|
+
- with `mode="strict"` converts nothing: it raises `SchemaError` listing every error unless the frame already matches (warnings - out-of-range values, an object-backed column of otherwise-right values - do not block it);
|
|
66
|
+
- values that cannot be converted (`"abc"` into an int column, `2.5` into an int column, numbers into a datetime column) raise `SchemaError` naming every failing column with example values, so nothing is lost silently.
|
|
67
|
+
|
|
68
|
+
## API
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
schema_guard.infer(df, *, categorical_max_unique=50, numeric_ranges=True, nullable="observed") -> Schema
|
|
72
|
+
schema_guard.load(path) -> Schema
|
|
73
|
+
schema_guard.validate(df, schema, *, strict_ranges=False) -> ValidationResult
|
|
74
|
+
schema_guard.enforce(df, schema, *, mode="coerce", extra="drop", missing="fill") -> DataFrame
|
|
75
|
+
schema_guard.as_schema(obj) -> Schema
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`df` is a pandas DataFrame or a path to a `.csv`/`.tsv`/`.parquet` file wherever a frame is accepted; `schema` is a `Schema`, a schema dict or a path to a saved JSON - `as_schema()` is the function that turns any of those three into a `Schema`, and is what the module-level helpers call.
|
|
79
|
+
|
|
80
|
+
**`Schema`** - an ordered list of `ColumnSpec`.
|
|
81
|
+
|
|
82
|
+
- `Schema.infer(df, *, categorical_max_unique=50, numeric_ranges=True, nullable="observed")` - `nullable` is `"observed"` (nullable only where a null was seen), `"always"` or `"never"`.
|
|
83
|
+
- `schema.validate(df, *, strict_ranges=False) -> ValidationResult`
|
|
84
|
+
- `schema.enforce(df, *, mode="coerce", extra="drop", missing="fill") -> DataFrame`
|
|
85
|
+
- `schema.save(path)` / `Schema.load(path)` - human-readable JSON; `schema.to_dict()` / `Schema.from_dict(d)`; `schema.to_json()` / `Schema.from_json(text)`.
|
|
86
|
+
- `schema.column_names`, `schema["city"]`, `"city" in schema`, `len(schema)`, iteration over specs, `schema.select("id", "score")`, `schema.drop("target")`, `schema.summary()`.
|
|
87
|
+
|
|
88
|
+
**`ColumnSpec(name, family, nullable=True, categories=None, min=None, max=None, tz=None)`** - one column; build them by hand when you want control. `name` is a string or an integer; `tz` is the timezone a `datetime` column must be in (`None` means tz-naive) and applies to no other family.
|
|
89
|
+
|
|
90
|
+
**`ValidationResult`** - `.ok` (no error-level problems), `.problems` (all of them), `.errors`, `.warnings`, `.summary()` (text), `.to_dict()` (JSON-safe), `.raise_if_invalid()` (raises `SchemaError`, otherwise returns the result so it chains).
|
|
91
|
+
|
|
92
|
+
**`Problem(kind, column, message, detail, severity="error")`** - `kind` is one of `missing_column`, `extra_column`, `dtype_mismatch`, `unexpected_null`, `unknown_category`, `out_of_range`, `wrong_order`; `column` is `None` for frame-level problems; `detail` holds JSON-safe specifics such as counts and example values.
|
|
93
|
+
|
|
94
|
+
**`SchemaError`** - a `ValueError` whose message lists every problem and whose `.problems` attribute holds them.
|
|
95
|
+
|
|
96
|
+
**`@guard(schema, mode="coerce", *, extra="drop", missing="fill")`** - decorator that enforces the schema on the first DataFrame argument (positional first, then keyword) before the function runs; works on methods, and accepts a schema path:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
@schema_guard.guard("schema.json")
|
|
100
|
+
def predict(df):
|
|
101
|
+
return model.predict(df)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The saved JSON looks like this:
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"format": 1,
|
|
109
|
+
"columns": [
|
|
110
|
+
{"name": "id", "family": "int", "nullable": false, "categories": null, "min": 1, "max": 3},
|
|
111
|
+
{"name": "city", "family": "string", "nullable": false, "categories": ["Oslo", "Paris"], "min": null, "max": null},
|
|
112
|
+
{"name": "score", "family": "float", "nullable": false, "categories": null, "min": 0.5, "max": 0.9}
|
|
113
|
+
]
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
A `datetime` column carries one more key, `"tz"` - `"UTC"`, `"Europe/Oslo"` or `null` for tz-naive. No other family has one.
|
|
118
|
+
|
|
119
|
+
## CLI
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
schema-guard data.csv # infer and print the schema (same as: schema-guard infer data.csv)
|
|
123
|
+
schema-guard infer data.csv --json --output schema.json # print JSON and save it
|
|
124
|
+
schema-guard validate schema.json new.csv # print the summary; exit code 1 when validation fails
|
|
125
|
+
schema-guard validate schema.json new.csv --json --strict-ranges --output result.json
|
|
126
|
+
schema-guard enforce schema.json new.csv --output fixed.csv --extra keep --missing raise
|
|
127
|
+
schema-guard enforce schema.json new.csv --output fixed.csv --strict
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`infer` also takes `--max-categories N`, `--no-ranges` and `--nullable observed|always|never`. Input and output files may be `.csv`, `.tsv` or `.parquet`.
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dataframe-schema-guard"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Stop ML pipelines from breaking when incoming data changes shape: infer a DataFrame schema once, then validate or enforce it forever"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Pranay Mahendrakar" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"pandas",
|
|
16
|
+
"dataframe",
|
|
17
|
+
"schema",
|
|
18
|
+
"validation",
|
|
19
|
+
"data-quality",
|
|
20
|
+
"data-contract",
|
|
21
|
+
"machine-learning",
|
|
22
|
+
"pipeline",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 4 - Beta",
|
|
26
|
+
"Intended Audience :: Developers",
|
|
27
|
+
"Intended Audience :: Science/Research",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
30
|
+
"Operating System :: OS Independent",
|
|
31
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
32
|
+
]
|
|
33
|
+
dependencies = [
|
|
34
|
+
"pandas>=1.5",
|
|
35
|
+
"numpy>=1.23",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
parquet = ["pyarrow>=12"]
|
|
40
|
+
dev = ["pytest>=7"]
|
|
41
|
+
|
|
42
|
+
[project.scripts]
|
|
43
|
+
dataframe-schema-guard = "schema_guard.cli:main"
|
|
44
|
+
|
|
45
|
+
[project.urls]
|
|
46
|
+
Homepage = "https://pypi.org/project/dataframe-schema-guard/"
|
|
47
|
+
Author = "https://pypi.org/user/pranaymahendrakar/"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/schema_guard"]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""schema-guard: infer a DataFrame schema once, then validate or enforce it forever.
|
|
2
|
+
|
|
3
|
+
schema = schema_guard.infer(train_df) # learn the shape of good data
|
|
4
|
+
schema.save("schema.json") # keep it next to the model
|
|
5
|
+
|
|
6
|
+
result = schema.validate(new_df) # report what changed (never modifies new_df)
|
|
7
|
+
fixed = schema.enforce(new_df) # coerce dtypes, reorder, drop extras, fill missing
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Union
|
|
13
|
+
|
|
14
|
+
import pandas as pd
|
|
15
|
+
|
|
16
|
+
from ._types import FAMILIES
|
|
17
|
+
from .guard import guard
|
|
18
|
+
from .result import PROBLEM_KINDS, Problem, SchemaError, ValidationResult
|
|
19
|
+
from .schema import ColumnSpec, Schema, as_schema
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"__version__",
|
|
25
|
+
"infer",
|
|
26
|
+
"load",
|
|
27
|
+
"validate",
|
|
28
|
+
"enforce",
|
|
29
|
+
"guard",
|
|
30
|
+
"Schema",
|
|
31
|
+
"ColumnSpec",
|
|
32
|
+
"as_schema",
|
|
33
|
+
"ValidationResult",
|
|
34
|
+
"Problem",
|
|
35
|
+
"SchemaError",
|
|
36
|
+
"FAMILIES",
|
|
37
|
+
"PROBLEM_KINDS",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def infer(
|
|
42
|
+
df: Any,
|
|
43
|
+
*,
|
|
44
|
+
categorical_max_unique: int = 50,
|
|
45
|
+
numeric_ranges: bool = True,
|
|
46
|
+
nullable: Union[str, bool] = "observed",
|
|
47
|
+
) -> Schema:
|
|
48
|
+
"""Learn a :class:`Schema` from a DataFrame or a ``.csv``/``.parquet`` path (see :meth:`Schema.infer`)."""
|
|
49
|
+
return Schema.infer(
|
|
50
|
+
df,
|
|
51
|
+
categorical_max_unique=categorical_max_unique,
|
|
52
|
+
numeric_ranges=numeric_ranges,
|
|
53
|
+
nullable=nullable,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load(path: Any) -> Schema:
|
|
58
|
+
"""Read a schema saved with :meth:`Schema.save`."""
|
|
59
|
+
return Schema.load(path)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def validate(df: Any, schema: Any, *, strict_ranges: bool = False) -> ValidationResult:
|
|
63
|
+
"""Validate ``df`` against ``schema`` (a :class:`Schema`, a schema dict or a saved JSON path)."""
|
|
64
|
+
return as_schema(schema).validate(df, strict_ranges=strict_ranges)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def enforce(
|
|
68
|
+
df: Any,
|
|
69
|
+
schema: Any,
|
|
70
|
+
*,
|
|
71
|
+
mode: str = "coerce",
|
|
72
|
+
extra: str = "drop",
|
|
73
|
+
missing: str = "fill",
|
|
74
|
+
) -> pd.DataFrame:
|
|
75
|
+
"""Enforce ``schema`` on ``df`` and return the new frame (see :meth:`Schema.enforce`)."""
|
|
76
|
+
return as_schema(schema).enforce(df, mode=mode, extra=extra, missing=missing)
|