universal-data-cleaner 2.0.0__tar.gz → 2.2.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 (21) hide show
  1. {universal_data_cleaner-2.0.0 → universal_data_cleaner-2.2.0}/PKG-INFO +94 -7
  2. {universal_data_cleaner-2.0.0 → universal_data_cleaner-2.2.0}/README.md +93 -6
  3. {universal_data_cleaner-2.0.0 → universal_data_cleaner-2.2.0}/pyproject.toml +44 -4
  4. universal_data_cleaner-2.2.0/src/data_cleaner/__init__.py +37 -0
  5. universal_data_cleaner-2.2.0/src/data_cleaner/_roles.py +134 -0
  6. universal_data_cleaner-2.2.0/src/data_cleaner/_version.py +4 -0
  7. universal_data_cleaner-2.0.0/data_cleaner.py → universal_data_cleaner-2.2.0/src/data_cleaner/cleaner.py +127 -222
  8. universal_data_cleaner-2.2.0/src/data_cleaner/config.py +83 -0
  9. universal_data_cleaner-2.2.0/src/data_cleaner/contract.py +429 -0
  10. universal_data_cleaner-2.2.0/src/data_cleaner/profiling.py +92 -0
  11. universal_data_cleaner-2.2.0/src/data_cleaner/py.typed +0 -0
  12. {universal_data_cleaner-2.0.0 → universal_data_cleaner-2.2.0/src}/universal_data_cleaner.egg-info/PKG-INFO +94 -7
  13. universal_data_cleaner-2.2.0/src/universal_data_cleaner.egg-info/SOURCES.txt +17 -0
  14. universal_data_cleaner-2.2.0/tests/test_data_cleaner.py +415 -0
  15. universal_data_cleaner-2.0.0/tests/test_data_cleaner.py +0 -195
  16. universal_data_cleaner-2.0.0/universal_data_cleaner.egg-info/SOURCES.txt +0 -10
  17. {universal_data_cleaner-2.0.0 → universal_data_cleaner-2.2.0}/LICENSE +0 -0
  18. {universal_data_cleaner-2.0.0 → universal_data_cleaner-2.2.0}/setup.cfg +0 -0
  19. {universal_data_cleaner-2.0.0 → universal_data_cleaner-2.2.0/src}/universal_data_cleaner.egg-info/dependency_links.txt +0 -0
  20. {universal_data_cleaner-2.0.0 → universal_data_cleaner-2.2.0/src}/universal_data_cleaner.egg-info/requires.txt +0 -0
  21. {universal_data_cleaner-2.0.0 → universal_data_cleaner-2.2.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.0.0
3
+ Version: 2.2.0
4
4
  Summary: Production-ready, scikit-learn-compatible data cleaning transformer for pandas.
5
5
  Author: Haseeb Khan
6
6
  License-Expression: MIT
@@ -69,6 +69,10 @@ breaks at inference time because the median is recomputed on the new batch.
69
69
  - optional missing-value indicator columns
70
70
  - optional constant-column dropping
71
71
  - stable output schema and column order
72
+ - declarative column contracts (roles, allowed values, ranges, patterns, uniqueness)
73
+ - automatic train-schema contract inference and transform-time drift reports
74
+ - `on_drift` policy: `raise`, `warn`, or `ignore`
75
+ - identifier columns that are never numeric-coerced, median-imputed, or capped
72
76
  - joblib save / load with artifact versioning
73
77
  - scikit-learn `Pipeline`, `clone`, and `get_feature_names_out` compatibility
74
78
 
@@ -149,9 +153,9 @@ For a column that should be numeric even though it arrived as dirty strings:
149
153
 
150
154
  ```python
151
155
  config = CleanerConfig(
152
- numeric_columns=("salary",), # force numeric parsing
153
- datetime_columns=("joined",), # parse dates instead of treating them as categories
154
- unseen_category="keep", # or "fill" to replace unseen labels
156
+ numeric_columns=("salary",), # force numeric parsing
157
+ datetime_columns=("joined",), # parse dates instead of treating them as categories
158
+ unseen_category="keep", # or "fill" to replace unseen labels
155
159
  )
156
160
  ```
157
161
 
@@ -167,7 +171,7 @@ X_train, X_test, y_train, y_test = train_test_split(
167
171
  cleaner = DataCleaner(config).fit(X_train)
168
172
 
169
173
  X_train_clean = cleaner.transform(X_train)
170
- X_test_clean = cleaner.transform(X_test) # uses train statistics only
174
+ X_test_clean = cleaner.transform(X_test) # uses train statistics only
171
175
  ```
172
176
 
173
177
  Save the fitted cleaner with the model artifact:
@@ -177,6 +181,66 @@ cleaner.save("artifacts/data_cleaner.joblib")
177
181
  restored = DataCleaner.load("artifacts/data_cleaner.joblib")
178
182
  ```
179
183
 
184
+ ## Column contracts and drift detection
185
+
186
+ A contract makes the assumptions about your data explicit and checks them again
187
+ on every batch.
188
+
189
+ ```python
190
+ from data_cleaner import ColumnSpec, DataContract, CleanerConfig, DataCleaner
191
+
192
+ contract = DataContract(
193
+ columns=[
194
+ ColumnSpec("customer_id", role="id"),
195
+ ColumnSpec("age", role="numeric", min_value=0, max_value=120),
196
+ ColumnSpec("plan", role="category", allowed_values=("free", "pro", "team")),
197
+ ],
198
+ )
199
+
200
+ cleaner = DataCleaner(CleanerConfig(contract=contract, on_drift="warn")).fit(X_train)
201
+
202
+ X_test_clean = cleaner.transform(X_test) # drift is detected, never mutates
203
+ print(cleaner.drift_report())
204
+ ```
205
+
206
+ If you do not pass a contract, a conservative one is inferred from the training
207
+ schema automatically. Drift is then reported without any configuration:
208
+
209
+ ```python
210
+ cleaner = DataCleaner().fit(X_train)
211
+ cleaner.transform(X_test)
212
+ report = cleaner.drift_report()
213
+
214
+ if not report.empty:
215
+ print(report[["column", "check", "severity", "message"]])
216
+ ```
217
+
218
+ Supported drift checks: `missing_column`, `unexpected_column`, `dtype_change`,
219
+ `unexpected_missing`, `unexpected_category`, `value_out_of_range`,
220
+ `pattern_mismatch`, and `duplicate_values`.
221
+
222
+ Control what happens when drift is found with `CleanerConfig.on_drift`:
223
+
224
+ | `on_drift` | Behavior |
225
+ | --- | --- |
226
+ | `"warn"` (default) | Log the drift report and continue |
227
+ | `"raise"` | Raise `ValueError` when any error-severity finding exists |
228
+ | `"ignore"` | Record findings in `drift_report()` without logging |
229
+
230
+ `role="id"` is worth calling out: identifier columns are treated as text, never
231
+ numeric-imputed, never outlier-capped, and rendered without a trailing `.0`.
232
+ Use `role="ignore"` to drop operational columns from the output.
233
+
234
+ Contracts can be version-controlled and shared between training and serving:
235
+
236
+ ```python
237
+ contract.to_json() # store it with the model artifact
238
+ restored = DataContract.from_json(payload)
239
+
240
+ # Optional YAML support requires `pip install pyyaml`
241
+ # restored = DataContract.from_yaml(contract.to_yaml())
242
+ ```
243
+
180
244
  ## scikit-learn Pipeline
181
245
 
182
246
  ```python
@@ -214,7 +278,12 @@ predictions = pipeline.predict(X_test)
214
278
  | Name | Purpose |
215
279
  | --- | --- |
216
280
  | `CleanerConfig` | Validated cleaning configuration |
217
- | `DataCleaner` | Fit/transform cleaner with save/load |
281
+ | `DataCleaner` | Fit/transform cleaner with save/load and drift reporting |
282
+ | `ColumnSpec` | Declarative expectations and semantics for one column |
283
+ | `DataContract` | A set of column specs, with inference and (de)serialization |
284
+ | `DriftReport` | Findings from a contract check, with a Markdown summary |
285
+ | `DataCleaner.drift_report()` | Drift findings from the last `transform` |
286
+ | `DataCleaner.fitted_contract()` | The contract used by the fitted cleaner |
218
287
  | `profile_dataframe` | Column-level missing/type/cardinality profile |
219
288
  | `dataset_summary` | Row/column/missing/duplicate summary |
220
289
  | `drop_duplicate_rows` | Explicit duplicate removal helper |
@@ -224,6 +293,10 @@ predictions = pipeline.predict(X_test)
224
293
 
225
294
  - Fit the cleaner **after** splitting and **only** on training data.
226
295
  - Do not pass IDs, free-text notes, or the target column to the cleaner.
296
+ - Mark identifier columns with `ColumnSpec(..., role="id")` so they are never
297
+ treated as numeric measurements.
298
+ - Set `on_drift="raise"` in production when an unexpected schema change should
299
+ stop the job instead of silently cleaning it.
227
300
  - Keep `DataSource`-style constant columns out of the model, or let
228
301
  `drop_constant_columns=True` remove them.
229
302
  - `unseen_category="keep"` is the default because one-hot encoders with
@@ -237,18 +310,32 @@ predictions = pipeline.predict(X_test)
237
310
 
238
311
  ```text
239
312
  .
240
- ├── data_cleaner.py # reusable library module
313
+ ├── src/data_cleaner/ # typed, pip-installable package
314
+ │ ├── __init__.py # public API re-exports
315
+ │ ├── _version.py # version metadata
316
+ │ ├── _roles.py # semantic roles and dtype helpers
317
+ │ ├── contract.py # ColumnSpec / DataContract / DriftReport
318
+ │ ├── config.py # CleanerConfig
319
+ │ ├── cleaner.py # DataCleaner
320
+ │ ├── profiling.py # profiling and validation helpers
321
+ │ └── py.typed # PEP 561 marker
241
322
  ├── Universal_Data_Cleaner.ipynb # end-to-end reference workflow
242
323
  ├── tests/test_data_cleaner.py # unit and integration tests
243
324
  ├── pyproject.toml # package metadata and dev tooling
244
325
  ├── uv.lock # reproducible dependency lock
326
+ ├── .pre-commit-config.yaml # local lint/format/type hooks
245
327
  ├── README.md
328
+ ├── ROADMAP.md
246
329
  ├── CHANGELOG.md
247
330
  ├── CONTRIBUTING.md
248
331
  ├── SECURITY.md
249
332
  └── LICENSE
250
333
  ```
251
334
 
335
+ The package ships a `py.typed` marker, so `mypy`, `pyright`, and IDE tooling
336
+ pick up the type annotations automatically. Linting (`ruff`), formatting, and
337
+ type checking (`mypy`) run in CI and via `pre-commit`.
338
+
252
339
  ## Contributing
253
340
 
254
341
  Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the local
@@ -36,6 +36,10 @@ breaks at inference time because the median is recomputed on the new batch.
36
36
  - optional missing-value indicator columns
37
37
  - optional constant-column dropping
38
38
  - stable output schema and column order
39
+ - declarative column contracts (roles, allowed values, ranges, patterns, uniqueness)
40
+ - automatic train-schema contract inference and transform-time drift reports
41
+ - `on_drift` policy: `raise`, `warn`, or `ignore`
42
+ - identifier columns that are never numeric-coerced, median-imputed, or capped
39
43
  - joblib save / load with artifact versioning
40
44
  - scikit-learn `Pipeline`, `clone`, and `get_feature_names_out` compatibility
41
45
 
@@ -116,9 +120,9 @@ For a column that should be numeric even though it arrived as dirty strings:
116
120
 
117
121
  ```python
118
122
  config = CleanerConfig(
119
- numeric_columns=("salary",), # force numeric parsing
120
- datetime_columns=("joined",), # parse dates instead of treating them as categories
121
- unseen_category="keep", # or "fill" to replace unseen labels
123
+ numeric_columns=("salary",), # force numeric parsing
124
+ datetime_columns=("joined",), # parse dates instead of treating them as categories
125
+ unseen_category="keep", # or "fill" to replace unseen labels
122
126
  )
123
127
  ```
124
128
 
@@ -134,7 +138,7 @@ X_train, X_test, y_train, y_test = train_test_split(
134
138
  cleaner = DataCleaner(config).fit(X_train)
135
139
 
136
140
  X_train_clean = cleaner.transform(X_train)
137
- X_test_clean = cleaner.transform(X_test) # uses train statistics only
141
+ X_test_clean = cleaner.transform(X_test) # uses train statistics only
138
142
  ```
139
143
 
140
144
  Save the fitted cleaner with the model artifact:
@@ -144,6 +148,66 @@ cleaner.save("artifacts/data_cleaner.joblib")
144
148
  restored = DataCleaner.load("artifacts/data_cleaner.joblib")
145
149
  ```
146
150
 
151
+ ## Column contracts and drift detection
152
+
153
+ A contract makes the assumptions about your data explicit and checks them again
154
+ on every batch.
155
+
156
+ ```python
157
+ from data_cleaner import ColumnSpec, DataContract, CleanerConfig, DataCleaner
158
+
159
+ contract = DataContract(
160
+ columns=[
161
+ ColumnSpec("customer_id", role="id"),
162
+ ColumnSpec("age", role="numeric", min_value=0, max_value=120),
163
+ ColumnSpec("plan", role="category", allowed_values=("free", "pro", "team")),
164
+ ],
165
+ )
166
+
167
+ cleaner = DataCleaner(CleanerConfig(contract=contract, on_drift="warn")).fit(X_train)
168
+
169
+ X_test_clean = cleaner.transform(X_test) # drift is detected, never mutates
170
+ print(cleaner.drift_report())
171
+ ```
172
+
173
+ If you do not pass a contract, a conservative one is inferred from the training
174
+ schema automatically. Drift is then reported without any configuration:
175
+
176
+ ```python
177
+ cleaner = DataCleaner().fit(X_train)
178
+ cleaner.transform(X_test)
179
+ report = cleaner.drift_report()
180
+
181
+ if not report.empty:
182
+ print(report[["column", "check", "severity", "message"]])
183
+ ```
184
+
185
+ Supported drift checks: `missing_column`, `unexpected_column`, `dtype_change`,
186
+ `unexpected_missing`, `unexpected_category`, `value_out_of_range`,
187
+ `pattern_mismatch`, and `duplicate_values`.
188
+
189
+ Control what happens when drift is found with `CleanerConfig.on_drift`:
190
+
191
+ | `on_drift` | Behavior |
192
+ | --- | --- |
193
+ | `"warn"` (default) | Log the drift report and continue |
194
+ | `"raise"` | Raise `ValueError` when any error-severity finding exists |
195
+ | `"ignore"` | Record findings in `drift_report()` without logging |
196
+
197
+ `role="id"` is worth calling out: identifier columns are treated as text, never
198
+ numeric-imputed, never outlier-capped, and rendered without a trailing `.0`.
199
+ Use `role="ignore"` to drop operational columns from the output.
200
+
201
+ Contracts can be version-controlled and shared between training and serving:
202
+
203
+ ```python
204
+ contract.to_json() # store it with the model artifact
205
+ restored = DataContract.from_json(payload)
206
+
207
+ # Optional YAML support requires `pip install pyyaml`
208
+ # restored = DataContract.from_yaml(contract.to_yaml())
209
+ ```
210
+
147
211
  ## scikit-learn Pipeline
148
212
 
149
213
  ```python
@@ -181,7 +245,12 @@ predictions = pipeline.predict(X_test)
181
245
  | Name | Purpose |
182
246
  | --- | --- |
183
247
  | `CleanerConfig` | Validated cleaning configuration |
184
- | `DataCleaner` | Fit/transform cleaner with save/load |
248
+ | `DataCleaner` | Fit/transform cleaner with save/load and drift reporting |
249
+ | `ColumnSpec` | Declarative expectations and semantics for one column |
250
+ | `DataContract` | A set of column specs, with inference and (de)serialization |
251
+ | `DriftReport` | Findings from a contract check, with a Markdown summary |
252
+ | `DataCleaner.drift_report()` | Drift findings from the last `transform` |
253
+ | `DataCleaner.fitted_contract()` | The contract used by the fitted cleaner |
185
254
  | `profile_dataframe` | Column-level missing/type/cardinality profile |
186
255
  | `dataset_summary` | Row/column/missing/duplicate summary |
187
256
  | `drop_duplicate_rows` | Explicit duplicate removal helper |
@@ -191,6 +260,10 @@ predictions = pipeline.predict(X_test)
191
260
 
192
261
  - Fit the cleaner **after** splitting and **only** on training data.
193
262
  - Do not pass IDs, free-text notes, or the target column to the cleaner.
263
+ - Mark identifier columns with `ColumnSpec(..., role="id")` so they are never
264
+ treated as numeric measurements.
265
+ - Set `on_drift="raise"` in production when an unexpected schema change should
266
+ stop the job instead of silently cleaning it.
194
267
  - Keep `DataSource`-style constant columns out of the model, or let
195
268
  `drop_constant_columns=True` remove them.
196
269
  - `unseen_category="keep"` is the default because one-hot encoders with
@@ -204,18 +277,32 @@ predictions = pipeline.predict(X_test)
204
277
 
205
278
  ```text
206
279
  .
207
- ├── data_cleaner.py # reusable library module
280
+ ├── src/data_cleaner/ # typed, pip-installable package
281
+ │ ├── __init__.py # public API re-exports
282
+ │ ├── _version.py # version metadata
283
+ │ ├── _roles.py # semantic roles and dtype helpers
284
+ │ ├── contract.py # ColumnSpec / DataContract / DriftReport
285
+ │ ├── config.py # CleanerConfig
286
+ │ ├── cleaner.py # DataCleaner
287
+ │ ├── profiling.py # profiling and validation helpers
288
+ │ └── py.typed # PEP 561 marker
208
289
  ├── Universal_Data_Cleaner.ipynb # end-to-end reference workflow
209
290
  ├── tests/test_data_cleaner.py # unit and integration tests
210
291
  ├── pyproject.toml # package metadata and dev tooling
211
292
  ├── uv.lock # reproducible dependency lock
293
+ ├── .pre-commit-config.yaml # local lint/format/type hooks
212
294
  ├── README.md
295
+ ├── ROADMAP.md
213
296
  ├── CHANGELOG.md
214
297
  ├── CONTRIBUTING.md
215
298
  ├── SECURITY.md
216
299
  └── LICENSE
217
300
  ```
218
301
 
302
+ The package ships a `py.typed` marker, so `mypy`, `pyright`, and IDE tooling
303
+ pick up the type annotations automatically. Linting (`ruff`), formatting, and
304
+ type checking (`mypy`) run in CI and via `pre-commit`.
305
+
219
306
  ## Contributing
220
307
 
221
308
  Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the local
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "universal-data-cleaner"
7
- version = "2.0.0"
7
+ version = "2.2.0"
8
8
  description = "Production-ready, scikit-learn-compatible data cleaning transformer for pandas."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -53,14 +53,54 @@ Changelog = "https://github.com/devlprkhan/universal-data-cleaner/blob/main/CHAN
53
53
  dev = [
54
54
  "ipykernel>=7.3.0",
55
55
  "jupyterlab>=4.6.4",
56
+ "mypy>=2.4.0",
56
57
  "nbclient>=0.11.0",
57
58
  "nbformat>=5.11.1",
59
+ "pandas-stubs>=2.3.3.260113",
60
+ "pre-commit>=4.6.2",
58
61
  "pytest>=9.1.1",
62
+ "ruff>=0.17.1",
59
63
  ]
60
64
 
61
- [tool.setuptools]
62
- py-modules = ["data_cleaner"]
65
+ [tool.setuptools.packages.find]
66
+ where = ["src"]
67
+
68
+ [tool.setuptools.package-data]
69
+ data_cleaner = ["py.typed"]
63
70
 
64
71
  [tool.pytest.ini_options]
65
- pythonpath = ["."]
72
+ pythonpath = ["src"]
66
73
  testpaths = ["tests"]
74
+ addopts = "-q"
75
+
76
+ [tool.ruff]
77
+ line-length = 88
78
+ target-version = "py310"
79
+ src = ["src", "tests"]
80
+ extend-exclude = ["*.ipynb"]
81
+
82
+ [tool.ruff.lint]
83
+ select = ["E", "F", "I", "B", "UP", "C4", "SIM", "RUF"]
84
+ ignore = ["E501"]
85
+
86
+ [tool.ruff.lint.per-file-ignores]
87
+ "tests/**" = ["B011"]
88
+
89
+ [tool.ruff.format]
90
+ quote-style = "double"
91
+
92
+ [tool.mypy]
93
+ python_version = "3.10"
94
+ check_untyped_defs = true
95
+ no_implicit_optional = true
96
+ warn_redundant_casts = true
97
+ warn_unused_ignores = true
98
+ warn_unreachable = true
99
+ show_error_codes = true
100
+ ignore_missing_imports = true
101
+ mypy_path = "src"
102
+ files = ["src", "tests"]
103
+
104
+ [[tool.mypy.overrides]]
105
+ module = ["sklearn.*", "yaml", "joblib.*"]
106
+ ignore_missing_imports = true
@@ -0,0 +1,37 @@
1
+ """Universal Data Cleaner — production-oriented tabular cleaning toolkit.
2
+
3
+ The package is intentionally dependency-light:
4
+
5
+ - pandas + numpy for data handling
6
+ - scikit-learn base classes for Pipeline / clone compatibility
7
+ - joblib for artifact persistence
8
+
9
+ The public API is re-exported here so ``import data_cleaner`` keeps working
10
+ exactly as it did when the library shipped as a single module.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from ._version import __version__
16
+ from .cleaner import DataCleaner
17
+ from .config import CleanerConfig
18
+ from .contract import ColumnSpec, DataContract, DriftReport
19
+ from .profiling import (
20
+ assert_frame_clean,
21
+ dataset_summary,
22
+ drop_duplicate_rows,
23
+ profile_dataframe,
24
+ )
25
+
26
+ __all__ = [
27
+ "CleanerConfig",
28
+ "ColumnSpec",
29
+ "DataCleaner",
30
+ "DataContract",
31
+ "DriftReport",
32
+ "__version__",
33
+ "assert_frame_clean",
34
+ "dataset_summary",
35
+ "drop_duplicate_rows",
36
+ "profile_dataframe",
37
+ ]
@@ -0,0 +1,134 @@
1
+ """Semantic roles, dtype normalization, and identifier helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ from pandas.api import types as ptypes
10
+
11
+ _ALLOWED_DATETIME_FEATURES = {
12
+ "year",
13
+ "month",
14
+ "day",
15
+ "dayofweek",
16
+ "quarter",
17
+ "dayofyear",
18
+ "weekofyear",
19
+ "is_weekend",
20
+ "hour",
21
+ "minute",
22
+ }
23
+
24
+ _ROLE_ALIASES = {
25
+ "numeric": "numeric",
26
+ "measure": "numeric",
27
+ "number": "numeric",
28
+ "float": "numeric",
29
+ "int": "numeric",
30
+ "integer": "numeric",
31
+ "category": "category",
32
+ "categorical": "category",
33
+ "object": "category",
34
+ "string": "category",
35
+ "str": "category",
36
+ "boolean": "boolean",
37
+ "bool": "boolean",
38
+ "datetime": "datetime",
39
+ "date": "datetime",
40
+ "timestamp": "datetime",
41
+ "id": "id",
42
+ "identifier": "id",
43
+ "text": "text",
44
+ "ignore": "ignore",
45
+ "drop": "ignore",
46
+ }
47
+
48
+ _ROLE_TO_KIND = {
49
+ "numeric": "numeric",
50
+ "category": "categorical",
51
+ "boolean": "boolean",
52
+ "datetime": "datetime",
53
+ "id": "categorical",
54
+ "text": "categorical",
55
+ "ignore": None,
56
+ }
57
+
58
+ _DRIFT_COLUMNS = ["column", "check", "severity", "expected", "actual", "message"]
59
+
60
+ _ID_STRING_KINDS = (int, np.integer)
61
+
62
+
63
+ def _normalize_role(value: object) -> str | None:
64
+ """Map role aliases to a canonical role name."""
65
+ if value is None:
66
+ return None
67
+ key = str(value).strip().lower()
68
+ if key not in _ROLE_ALIASES:
69
+ valid = sorted(set(_ROLE_ALIASES.values()))
70
+ raise ValueError(f"Unsupported column role '{value}'. Valid roles: {valid}")
71
+ return _ROLE_ALIASES[key]
72
+
73
+
74
+ def _normalize_kind(value: object) -> str | None:
75
+ """Map dtype strings to a broad kind: numeric/categorical/boolean/datetime."""
76
+ if value is None:
77
+ return None
78
+ key = str(value).strip().lower()
79
+ mapping = {
80
+ "numeric": "numeric",
81
+ "number": "numeric",
82
+ "int": "numeric",
83
+ "integer": "numeric",
84
+ "float": "numeric",
85
+ "double": "numeric",
86
+ "int64": "numeric",
87
+ "int32": "numeric",
88
+ "int16": "numeric",
89
+ "int8": "numeric",
90
+ "float64": "numeric",
91
+ "float32": "numeric",
92
+ "boolean": "boolean",
93
+ "bool": "boolean",
94
+ "datetime": "datetime",
95
+ "datetime64": "datetime",
96
+ "datetime64[ns]": "datetime",
97
+ "date": "datetime",
98
+ "timestamp": "datetime",
99
+ "categorical": "categorical",
100
+ "category": "categorical",
101
+ "object": "categorical",
102
+ "string": "categorical",
103
+ "str": "categorical",
104
+ "text": "categorical",
105
+ "id": "categorical",
106
+ }
107
+ return mapping.get(key, key)
108
+
109
+
110
+ def _series_kind(series: pd.Series) -> str:
111
+ """Return the broad semantic kind of a pandas Series."""
112
+ if ptypes.is_bool_dtype(series):
113
+ return "boolean"
114
+ if ptypes.is_datetime64_any_dtype(series):
115
+ return "datetime"
116
+ if ptypes.is_numeric_dtype(series):
117
+ return "numeric"
118
+ return "categorical"
119
+
120
+
121
+ def _stringify_identifier(value: Any) -> object:
122
+ """Render an identifier without a trailing ``.0`` while preserving NaN."""
123
+ if value is None:
124
+ return value
125
+ try:
126
+ if pd.isna(value):
127
+ return value
128
+ except (TypeError, ValueError):
129
+ pass
130
+ if isinstance(value, _ID_STRING_KINDS):
131
+ return str(int(value))
132
+ if isinstance(value, (float, np.floating)) and float(value).is_integer():
133
+ return str(int(value))
134
+ return str(value)
@@ -0,0 +1,4 @@
1
+ """Single source of truth for the package version."""
2
+
3
+ __version__ = "2.2.0"
4
+ _ARTIFACT_VERSION = 1