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.
Files changed (25) hide show
  1. dataframe_schema_guard-0.1.0/.gitignore +30 -0
  2. dataframe_schema_guard-0.1.0/LICENSE +21 -0
  3. dataframe_schema_guard-0.1.0/PKG-INFO +160 -0
  4. dataframe_schema_guard-0.1.0/README.md +134 -0
  5. dataframe_schema_guard-0.1.0/pyproject.toml +53 -0
  6. dataframe_schema_guard-0.1.0/src/schema_guard/__init__.py +76 -0
  7. dataframe_schema_guard-0.1.0/src/schema_guard/__main__.py +8 -0
  8. dataframe_schema_guard-0.1.0/src/schema_guard/_coerce.py +581 -0
  9. dataframe_schema_guard-0.1.0/src/schema_guard/_io.py +103 -0
  10. dataframe_schema_guard-0.1.0/src/schema_guard/_types.py +166 -0
  11. dataframe_schema_guard-0.1.0/src/schema_guard/cli.py +165 -0
  12. dataframe_schema_guard-0.1.0/src/schema_guard/guard.py +51 -0
  13. dataframe_schema_guard-0.1.0/src/schema_guard/result.py +142 -0
  14. dataframe_schema_guard-0.1.0/src/schema_guard/schema.py +741 -0
  15. dataframe_schema_guard-0.1.0/tests/conftest.py +41 -0
  16. dataframe_schema_guard-0.1.0/tests/test_cli.py +116 -0
  17. dataframe_schema_guard-0.1.0/tests/test_edge_cases.py +128 -0
  18. dataframe_schema_guard-0.1.0/tests/test_enforce.py +182 -0
  19. dataframe_schema_guard-0.1.0/tests/test_guard.py +76 -0
  20. dataframe_schema_guard-0.1.0/tests/test_infer.py +140 -0
  21. dataframe_schema_guard-0.1.0/tests/test_quickstart.py +42 -0
  22. dataframe_schema_guard-0.1.0/tests/test_regressions_round1.py +317 -0
  23. dataframe_schema_guard-0.1.0/tests/test_regressions_round2.py +301 -0
  24. dataframe_schema_guard-0.1.0/tests/test_serialization.py +120 -0
  25. 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)
@@ -0,0 +1,8 @@
1
+ """Allow ``python -m schema_guard``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())