flasheda 0.4.0__tar.gz → 0.5.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 (56) hide show
  1. {flasheda-0.4.0 → flasheda-0.5.0}/PKG-INFO +36 -3
  2. {flasheda-0.4.0 → flasheda-0.5.0}/README.md +28 -1
  3. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/__init__.py +13 -4
  4. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/codegen/__init__.py +3 -1
  5. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/codegen/writers/base.py +12 -2
  6. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/codegen/writers/pandas_writer.py +19 -0
  7. flasheda-0.5.0/flasheda/compare/__init__.py +224 -0
  8. flasheda-0.5.0/flasheda/contract/__init__.py +29 -0
  9. flasheda-0.5.0/flasheda/contract/check.py +251 -0
  10. flasheda-0.5.0/flasheda/contract/rules.py +216 -0
  11. flasheda-0.5.0/flasheda/contract/script.py +227 -0
  12. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/engine/__init__.py +3 -0
  13. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/engine/sampler.py +4 -1
  14. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/engine/source.py +13 -0
  15. flasheda-0.5.0/flasheda/engine/sql.py +129 -0
  16. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/report.py +8 -1
  17. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda.egg-info/PKG-INFO +36 -3
  18. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda.egg-info/SOURCES.txt +10 -0
  19. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda.egg-info/requires.txt +7 -0
  20. {flasheda-0.4.0 → flasheda-0.5.0}/setup.py +5 -4
  21. flasheda-0.5.0/tests/test_compare.py +90 -0
  22. flasheda-0.5.0/tests/test_contract.py +140 -0
  23. flasheda-0.5.0/tests/test_contract_script.py +81 -0
  24. flasheda-0.5.0/tests/test_sql.py +81 -0
  25. {flasheda-0.4.0 → flasheda-0.5.0}/MIT.License +0 -0
  26. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/analyzers/__init__.py +0 -0
  27. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/analyzers/categorical.py +0 -0
  28. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/analyzers/correlation.py +0 -0
  29. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/analyzers/missing.py +0 -0
  30. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/analyzers/numeric.py +0 -0
  31. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/analyzers/overview.py +0 -0
  32. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/analyzers/target.py +0 -0
  33. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/clean/__init__.py +0 -0
  34. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/clean/detectors.py +0 -0
  35. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/codegen/planner.py +0 -0
  36. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/codegen/steps.py +0 -0
  37. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/codegen/validate.py +0 -0
  38. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/codegen/writers/__init__.py +0 -0
  39. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/codegen/writers/duckdb_writer.py +0 -0
  40. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/codegen/writers/polars_writer.py +0 -0
  41. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/codegen/writers/pyspark_writer.py +0 -0
  42. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/engine/backends.py +0 -0
  43. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/features/__init__.py +0 -0
  44. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/modeling/__init__.py +0 -0
  45. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/modeling/script.py +0 -0
  46. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/modeling/specs.py +0 -0
  47. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/sampler.py +0 -0
  48. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda.egg-info/dependency_links.txt +0 -0
  49. {flasheda-0.4.0 → flasheda-0.5.0}/flasheda.egg-info/top_level.txt +0 -0
  50. {flasheda-0.4.0 → flasheda-0.5.0}/pyproject.toml +0 -0
  51. {flasheda-0.4.0 → flasheda-0.5.0}/setup.cfg +0 -0
  52. {flasheda-0.4.0 → flasheda-0.5.0}/tests/test_clean_target.py +0 -0
  53. {flasheda-0.4.0 → flasheda-0.5.0}/tests/test_codegen.py +0 -0
  54. {flasheda-0.4.0 → flasheda-0.5.0}/tests/test_engine.py +0 -0
  55. {flasheda-0.4.0 → flasheda-0.5.0}/tests/test_features_model.py +0 -0
  56. {flasheda-0.4.0 → flasheda-0.5.0}/tests/test_writers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flasheda
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Fixed-time EDA for any dataset size, plus validated cleaning, feature and training code for pandas, polars, DuckDB and PySpark.
5
5
  Home-page: https://github.com/sjapanjots/Flash-EDA
6
6
  Author: Japanjot Singh
@@ -9,7 +9,7 @@ License: MIT
9
9
  Project-URL: Source, https://github.com/sjapanjots/Flash-EDA
10
10
  Project-URL: Changelog, https://github.com/sjapanjots/Flash-EDA/blob/main/CHANGELOG.md
11
11
  Project-URL: Issues, https://github.com/sjapanjots/Flash-EDA/issues
12
- Keywords: eda,data-cleaning,pandas,polars,duckdb,pyspark,code-generation,feature-engineering,automl
12
+ Keywords: eda,data-cleaning,pandas,polars,duckdb,pyspark,code-generation,feature-engineering,automl,data-quality,data-drift
13
13
  Classifier: Development Status :: 4 - Beta
14
14
  Classifier: Intended Audience :: Science/Research
15
15
  Classifier: Intended Audience :: Developers
@@ -35,17 +35,23 @@ Requires-Dist: pyspark>=3.4; extra == "spark"
35
35
  Provides-Extra: ml
36
36
  Requires-Dist: scikit-learn>=1.2; extra == "ml"
37
37
  Requires-Dist: pyarrow>=12; extra == "ml"
38
+ Provides-Extra: sql
39
+ Requires-Dist: sqlalchemy>=1.4; extra == "sql"
40
+ Requires-Dist: pyarrow>=12; extra == "sql"
38
41
  Provides-Extra: all
39
42
  Requires-Dist: duckdb>=0.10; extra == "all"
40
43
  Requires-Dist: polars>=0.20; extra == "all"
41
44
  Requires-Dist: pyarrow>=12; extra == "all"
42
45
  Requires-Dist: scikit-learn>=1.2; extra == "all"
46
+ Requires-Dist: sqlalchemy>=1.4; extra == "all"
43
47
  Provides-Extra: dev
44
48
  Requires-Dist: pytest>=7; extra == "dev"
45
49
  Requires-Dist: duckdb>=0.10; extra == "dev"
46
50
  Requires-Dist: polars>=0.20; extra == "dev"
47
51
  Requires-Dist: pyarrow>=12; extra == "dev"
48
52
  Requires-Dist: scikit-learn>=1.2; extra == "dev"
53
+ Requires-Dist: pandera; extra == "dev"
54
+ Requires-Dist: sqlalchemy>=1.4; extra == "dev"
49
55
  Requires-Dist: build; extra == "dev"
50
56
  Requires-Dist: twine; extra == "dev"
51
57
  Dynamic: author
@@ -72,7 +78,7 @@ Dynamic: summary
72
78
  [![Tests](https://github.com/sjapanjots/Flash-EDA/actions/workflows/tests.yml/badge.svg)](https://github.com/sjapanjots/Flash-EDA/actions/workflows/tests.yml)
73
79
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](MIT.License)
74
80
 
75
- **Current version: 0.4.0** · [Changelog](CHANGELOG.md)
81
+ **Current version: 0.5.0** · [Changelog](CHANGELOG.md)
76
82
 
77
83
  Analyse any dataset in a few seconds, then get **validated cleaning, feature and training code** that runs on the full data, in the engine that fits its size: pandas, polars, DuckDB or PySpark.
78
84
 
@@ -84,6 +90,7 @@ FlashEDA analyses a fixed-size random sample, so analysis time barely changes be
84
90
  pip install flasheda # core
85
91
  pip install "flasheda[fast]" # + polars, DuckDB, pyarrow (recommended for big files)
86
92
  pip install "flasheda[ml]" # + scikit-learn, for flasheda.train
93
+ pip install "flasheda[sql]" # + SQLAlchemy, for database tables
87
94
  pip install "flasheda[spark]" # + PySpark
88
95
  pip install "flasheda[all]" # everything except PySpark
89
96
  ```
@@ -106,6 +113,18 @@ code.save("clean_orders.py") # then: python clean_orders.py
106
113
  result = fe.train("orders.csv", target="churn", engine="polars")
107
114
  result.show() # leaderboard
108
115
  result.save("prep.py", "train.py") # python prep.py && python train.py → model.joblib
116
+
117
+ # 4. Guard: learn rules from good data, check every new file
118
+ contract = fe.analyze("orders_jan.csv").contract()
119
+ fe.check("orders_feb.csv", contract).show()
120
+ open("check.py", "w").write(contract.script()) # python check.py new.csv → exit 1 on failure
121
+
122
+ # 5. Compare: what changed between two datasets
123
+ fe.compare("orders_jan.csv", "orders_feb.csv").show()
124
+
125
+ # Databases work like files
126
+ src = fe.sql("postgresql://user:pass@host/shop", table="orders")
127
+ fe.analyze(src, target="churn")
109
128
  ```
110
129
 
111
130
  ## What it does
@@ -120,6 +139,9 @@ result.save("prep.py", "train.py") # python prep.py && python train.py → model
120
139
  | **Code generation** | one cleaning plan → pandas, polars (lazy, streaming), DuckDB SQL (larger than memory) or PySpark |
121
140
  | **Features** | date parts, one-hot / frequency encoding, ratios that beat their inputs |
122
141
  | **Models** | baseline, linear, random forest, gradient boosting compared with the right metric and split; training script for the winner |
142
+ | **Data contracts** | rules learned from good data; full-data checks; standalone check script for CI; pandera export |
143
+ | **Drift** | schema changes and per-column drift (PSI, KS, new categories, null-rate change) between two datasets |
144
+ | **Databases** | any SQLAlchemy URL; sampling runs in the database; passwords never stored |
123
145
  | **Validation** | generated code is run on the sample and compared with a reference result before you get it |
124
146
 
125
147
  ### Cleaning steps
@@ -141,6 +163,13 @@ Each step in the generated code has a comment saying why it was added:
141
163
 
142
164
  The metric and split follow the target: PR-AUC for imbalanced binary targets, MAE for skewed regression targets, time-based splits when there's a date column, and so on.
143
165
 
166
+ ### Data contracts and drift
167
+ A **contract** describes what good data looks like, learned from a sample with tolerances so normal variation passes:
168
+ type, missing %, 1st/99th percentile range (catches unit changes like ×100), no negatives, allowed values, uniqueness of IDs.
169
+ `fe.check()` checks every row of a file in one DuckDB pass; `contract.script()` writes the same check as a standalone script that exits with code 1 on failure, ready for a pipeline or CI job.
170
+
171
+ `fe.compare(old, new)` reports added/removed columns, type changes and how much each column shifted (PSI: < 0.1 stable, 0.1–0.25 moderate, ≥ 0.25 major). Dates and IDs are shown as range shifts, since they always move.
172
+
144
173
  ### Engine choice
145
174
 
146
175
  | Rows | Engine |
@@ -163,12 +192,16 @@ Measured on synthetic messy data:
163
192
  | Run the generated polars script on 1M rows | 0.9 s |
164
193
  | `fe.train()` on a 1M-row CSV (plan, features, 4 models, checks) | 6.7 s |
165
194
  | Generated `prep.py` + `train.py` on 1M rows | 1.3 s + 20 s |
195
+ | Learn a contract from 1M rows / run the check script on 1M rows | 0.8 s / 0.6–0.9 s |
196
+ | Compare two 1M-row files | 0.6 s |
197
+ | Analyse a 1M-row SQLite table (5,000 rows transferred) | 0.3 s |
166
198
 
167
199
  ## Examples
168
200
 
169
201
  - [`examples/phase1_demo.py`](examples/phase1_demo.py): engine choice and code generation at 0.1M / 1M / 5M rows
170
202
  - [`examples/phase2_demo.py`](examples/phase2_demo.py): target analysis and cleaning on a 1M-row churn CSV
171
203
  - [`examples/phase3_demo.py`](examples/phase3_demo.py): features, model comparison and training on 1M rows
204
+ - [`examples/phase4_demo.py`](examples/phase4_demo.py): contracts, check script, drift and a database on 1M rows
172
205
 
173
206
  ## Development
174
207
 
@@ -7,7 +7,7 @@
7
7
  [![Tests](https://github.com/sjapanjots/Flash-EDA/actions/workflows/tests.yml/badge.svg)](https://github.com/sjapanjots/Flash-EDA/actions/workflows/tests.yml)
8
8
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](MIT.License)
9
9
 
10
- **Current version: 0.4.0** · [Changelog](CHANGELOG.md)
10
+ **Current version: 0.5.0** · [Changelog](CHANGELOG.md)
11
11
 
12
12
  Analyse any dataset in a few seconds, then get **validated cleaning, feature and training code** that runs on the full data, in the engine that fits its size: pandas, polars, DuckDB or PySpark.
13
13
 
@@ -19,6 +19,7 @@ FlashEDA analyses a fixed-size random sample, so analysis time barely changes be
19
19
  pip install flasheda # core
20
20
  pip install "flasheda[fast]" # + polars, DuckDB, pyarrow (recommended for big files)
21
21
  pip install "flasheda[ml]" # + scikit-learn, for flasheda.train
22
+ pip install "flasheda[sql]" # + SQLAlchemy, for database tables
22
23
  pip install "flasheda[spark]" # + PySpark
23
24
  pip install "flasheda[all]" # everything except PySpark
24
25
  ```
@@ -41,6 +42,18 @@ code.save("clean_orders.py") # then: python clean_orders.py
41
42
  result = fe.train("orders.csv", target="churn", engine="polars")
42
43
  result.show() # leaderboard
43
44
  result.save("prep.py", "train.py") # python prep.py && python train.py → model.joblib
45
+
46
+ # 4. Guard: learn rules from good data, check every new file
47
+ contract = fe.analyze("orders_jan.csv").contract()
48
+ fe.check("orders_feb.csv", contract).show()
49
+ open("check.py", "w").write(contract.script()) # python check.py new.csv → exit 1 on failure
50
+
51
+ # 5. Compare: what changed between two datasets
52
+ fe.compare("orders_jan.csv", "orders_feb.csv").show()
53
+
54
+ # Databases work like files
55
+ src = fe.sql("postgresql://user:pass@host/shop", table="orders")
56
+ fe.analyze(src, target="churn")
44
57
  ```
45
58
 
46
59
  ## What it does
@@ -55,6 +68,9 @@ result.save("prep.py", "train.py") # python prep.py && python train.py → model
55
68
  | **Code generation** | one cleaning plan → pandas, polars (lazy, streaming), DuckDB SQL (larger than memory) or PySpark |
56
69
  | **Features** | date parts, one-hot / frequency encoding, ratios that beat their inputs |
57
70
  | **Models** | baseline, linear, random forest, gradient boosting compared with the right metric and split; training script for the winner |
71
+ | **Data contracts** | rules learned from good data; full-data checks; standalone check script for CI; pandera export |
72
+ | **Drift** | schema changes and per-column drift (PSI, KS, new categories, null-rate change) between two datasets |
73
+ | **Databases** | any SQLAlchemy URL; sampling runs in the database; passwords never stored |
58
74
  | **Validation** | generated code is run on the sample and compared with a reference result before you get it |
59
75
 
60
76
  ### Cleaning steps
@@ -76,6 +92,13 @@ Each step in the generated code has a comment saying why it was added:
76
92
 
77
93
  The metric and split follow the target: PR-AUC for imbalanced binary targets, MAE for skewed regression targets, time-based splits when there's a date column, and so on.
78
94
 
95
+ ### Data contracts and drift
96
+ A **contract** describes what good data looks like, learned from a sample with tolerances so normal variation passes:
97
+ type, missing %, 1st/99th percentile range (catches unit changes like ×100), no negatives, allowed values, uniqueness of IDs.
98
+ `fe.check()` checks every row of a file in one DuckDB pass; `contract.script()` writes the same check as a standalone script that exits with code 1 on failure, ready for a pipeline or CI job.
99
+
100
+ `fe.compare(old, new)` reports added/removed columns, type changes and how much each column shifted (PSI: < 0.1 stable, 0.1–0.25 moderate, ≥ 0.25 major). Dates and IDs are shown as range shifts, since they always move.
101
+
79
102
  ### Engine choice
80
103
 
81
104
  | Rows | Engine |
@@ -98,12 +121,16 @@ Measured on synthetic messy data:
98
121
  | Run the generated polars script on 1M rows | 0.9 s |
99
122
  | `fe.train()` on a 1M-row CSV (plan, features, 4 models, checks) | 6.7 s |
100
123
  | Generated `prep.py` + `train.py` on 1M rows | 1.3 s + 20 s |
124
+ | Learn a contract from 1M rows / run the check script on 1M rows | 0.8 s / 0.6–0.9 s |
125
+ | Compare two 1M-row files | 0.6 s |
126
+ | Analyse a 1M-row SQLite table (5,000 rows transferred) | 0.3 s |
101
127
 
102
128
  ## Examples
103
129
 
104
130
  - [`examples/phase1_demo.py`](examples/phase1_demo.py): engine choice and code generation at 0.1M / 1M / 5M rows
105
131
  - [`examples/phase2_demo.py`](examples/phase2_demo.py): target analysis and cleaning on a 1M-row churn CSV
106
132
  - [`examples/phase3_demo.py`](examples/phase3_demo.py): features, model comparison and training on 1M rows
133
+ - [`examples/phase4_demo.py`](examples/phase4_demo.py): contracts, check script, drift and a database on 1M rows
107
134
 
108
135
  ## Development
109
136
 
@@ -10,21 +10,26 @@ import pandas as pd
10
10
  from . import codegen, engine
11
11
  from .clean import clean
12
12
  from .modeling import train
13
- from .engine import available_engines, choose_engine, inspect_source, sample, get_sample_size, set_sample_size
13
+ from .contract import check
14
+ from .compare import compare
15
+ from .engine import available_engines, choose_engine, inspect_source, sample, get_sample_size, set_sample_size, sql
14
16
  from .analyzers import overview, missing, numeric, categorical, correlation
15
17
  from .analyzers import target as target_analyzer
16
18
  from .report import EDAReport
17
19
 
18
- __version__ = "0.4.0"
20
+ __version__ = "0.5.0"
19
21
  __all__ = [
20
22
  "analyze",
21
23
  "available_engines",
24
+ "check",
22
25
  "clean",
26
+ "compare",
23
27
  "codegen",
24
28
  "engine",
25
29
  "get_sample_size",
26
30
  "inspect_source",
27
31
  "set_sample_size",
32
+ "sql",
28
33
  "train",
29
34
  "EDAReport",
30
35
  ]
@@ -42,7 +47,8 @@ def analyze(
42
47
  target: Optional[str] = None,
43
48
  ) -> EDAReport:
44
49
  """
45
- Analyse a fixed-size sample of `source` (DataFrame, CSV/TSV or Parquet path).
50
+ Analyse a fixed-size sample of `source`: a DataFrame, a CSV/TSV or Parquet
51
+ path, or a database table/query from flasheda.sql().
46
52
 
47
53
  `target` names the column you want to predict; it adds task detection,
48
54
  class balance, per-feature predictive power and leakage checks.
@@ -121,7 +127,10 @@ def analyze(
121
127
  warnings=warnings,
122
128
  target=tgt,
123
129
  source=str(info.path) if info.path else None,
124
- recommended_engine=choose_engine(info.n_rows, prefer=engine),
130
+ sql_source=info.sql,
131
+ # database sources are read with pandas (or exported with .to_parquet())
132
+ recommended_engine=choose_engine(
133
+ info.n_rows, prefer=engine or ("pandas" if info.sql is not None else None)),
125
134
  sample=sampled,
126
135
  )
127
136
 
@@ -58,10 +58,12 @@ def generate(
58
58
  output: str = "cleaned.parquet",
59
59
  sample: Optional[pd.DataFrame] = None,
60
60
  columns: Optional[List[str]] = None,
61
+ sql: Any = None,
61
62
  ) -> GeneratedCode:
63
+ """`sql` is a flasheda.sql() source to read from instead of a file."""
62
64
  if columns is None and sample is not None:
63
65
  columns = [str(c) for c in sample.columns]
64
- code = get_writer(engine).render(plan, source, output, columns)
66
+ code = get_writer(engine).render(plan, source, output, columns, sql=sql)
65
67
  return GeneratedCode(engine, code, plan, sample)
66
68
 
67
69
 
@@ -14,7 +14,7 @@ from __future__ import annotations
14
14
  import textwrap
15
15
  from abc import ABC, abstractmethod
16
16
  from pathlib import Path
17
- from typing import List, Optional
17
+ from typing import Any, List, Optional
18
18
 
19
19
  from ..steps import Plan, Step
20
20
 
@@ -23,6 +23,8 @@ class Writer(ABC):
23
23
  engine: str = ""
24
24
  install_hint: str = ""
25
25
  columns: Optional[List[str]] = None
26
+ sql: Any = None
27
+ reads_sql = False # writers that can load from a database URL
26
28
 
27
29
  def render(
28
30
  self,
@@ -30,9 +32,17 @@ class Writer(ABC):
30
32
  source: Optional[str],
31
33
  output: str,
32
34
  columns: Optional[List[str]] = None,
35
+ sql: Any = None,
33
36
  ) -> str:
34
- """`columns` are the source's column names, when known."""
37
+ """`columns` are the source's column names, when known; `sql` a database source."""
35
38
  self.columns = list(columns) if columns is not None else None
39
+ self.sql = sql
40
+ if sql is not None and not self.reads_sql:
41
+ raise ValueError(
42
+ f"The {self.engine} writer can't read from a database directly. Use engine='pandas', "
43
+ "or export first: flasheda.sql(...).to_parquet('data.parquet'), then analyze that file.")
44
+ if sql is not None:
45
+ source = sql.display
36
46
  parts = [
37
47
  self._docstring(plan, source),
38
48
  self.imports(),
@@ -13,6 +13,7 @@ from .base import Writer, source_format
13
13
  class PandasWriter(Writer):
14
14
  engine = "pandas"
15
15
  install_hint = "pandas, pyarrow"
16
+ reads_sql = True
16
17
 
17
18
  def imports(self) -> str:
18
19
  return "import numpy as np\nimport pandas as pd"
@@ -73,6 +74,8 @@ class PandasWriter(Writer):
73
74
  raise TypeError(f"Unsupported step {step!r}")
74
75
 
75
76
  def main(self, source: Optional[str]) -> str:
77
+ if self.sql is not None:
78
+ return self._main_sql()
76
79
  fmt = source_format(source)
77
80
  if fmt == "parquet":
78
81
  load = "pd.read_parquet(SOURCE)"
@@ -85,3 +88,19 @@ class PandasWriter(Writer):
85
88
  " df.to_parquet(OUTPUT, index=False)\n"
86
89
  ' print(f"Wrote {len(df):,} rows -> {OUTPUT}")'
87
90
  )
91
+
92
+ def _main_sql(self) -> str:
93
+ from ...engine.sql import URL_ENV_VAR
94
+ query = f"SELECT * FROM {self.sql.from_sql()}"
95
+ return (
96
+ "def main() -> None:\n"
97
+ " import os\n"
98
+ " from sqlalchemy import create_engine, text\n\n"
99
+ f" # The database URL is read from ${URL_ENV_VAR} so no password is stored here.\n"
100
+ f" url = os.environ[{URL_ENV_VAR!r}]\n"
101
+ " with create_engine(url).connect() as con:\n"
102
+ f" df = pd.read_sql(text({query!r}), con)\n"
103
+ " df = transform(df)\n"
104
+ " df.to_parquet(OUTPUT, index=False)\n"
105
+ ' print(f"Wrote {len(df):,} rows -> {OUTPUT}")'
106
+ )
@@ -0,0 +1,224 @@
1
+ """
2
+ flasheda.compare
3
+ ~~~~~~~~~~~~~~~~
4
+ What changed between two datasets (last month vs this month, train vs
5
+ test), measured on fixed-size samples so it takes seconds at any size:
6
+
7
+ drift = flasheda.compare("orders_jan.csv", "orders_feb.csv")
8
+ drift.show()
9
+
10
+ schema columns added / removed, type changes
11
+ per column PSI (population stability index) on reference quantile bins or
12
+ categories; KS test for numbers; new categories; null-rate change
13
+ dates, IDs reported as a range shift (they always move), not as drift
14
+ severity PSI < 0.1 stable · 0.1–0.25 moderate · ≥ 0.25 major (the usual
15
+ credit-risk thresholds); a null rate moving ≥ 10 points is at
16
+ least moderate
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+ from typing import Any, Dict, List, Optional
23
+
24
+ import numpy as np
25
+ import pandas as pd
26
+
27
+ from ..contract.rules import _kind
28
+ from ..engine import inspect_source, sample
29
+
30
+ __all__ = ["DriftReport", "compare", "psi"]
31
+
32
+ PSI_MODERATE = 0.10
33
+ PSI_MAJOR = 0.25
34
+ NULL_SHIFT_POINTS = 10.0
35
+ BINS = 10
36
+ TOP_CATEGORIES = 20
37
+ EPS = 1e-4
38
+ HIGH_CARDINALITY = 0.5 # unique / rows above this: IDs, free text
39
+
40
+
41
+ @dataclass
42
+ class DriftReport:
43
+ rows_reference: int
44
+ rows_current: int
45
+ added_columns: List[str]
46
+ removed_columns: List[str]
47
+ type_changes: List[Dict[str, str]]
48
+ columns: List[Dict[str, Any]] = field(default_factory=list)
49
+
50
+ @property
51
+ def drifted(self) -> List[Dict[str, Any]]:
52
+ return [c for c in self.columns if c["severity"] != "stable"]
53
+
54
+ @property
55
+ def ok(self) -> bool:
56
+ """No schema changes and no major drift."""
57
+ return (not self.added_columns and not self.removed_columns and not self.type_changes
58
+ and all(c["severity"] != "major" for c in self.columns))
59
+
60
+ def __getitem__(self, column: str) -> Dict[str, Any]:
61
+ for c in self.columns:
62
+ if c["column"] == column:
63
+ return c
64
+ raise KeyError(column)
65
+
66
+ def to_dict(self) -> Dict[str, Any]:
67
+ return {"ok": self.ok, "rows_reference": self.rows_reference,
68
+ "rows_current": self.rows_current, "added_columns": self.added_columns,
69
+ "removed_columns": self.removed_columns, "type_changes": self.type_changes,
70
+ "columns": self.columns}
71
+
72
+ def show(self) -> None:
73
+ from rich import box
74
+ from rich.console import Console
75
+ from rich.table import Table
76
+
77
+ console = Console()
78
+ console.print(f"[bold]Drift[/bold]: {self.rows_reference:,} → {self.rows_current:,} rows "
79
+ + ("[green]no major drift[/]" if self.ok else "[red]changes found[/]"))
80
+ if self.added_columns:
81
+ console.print(f" [yellow]added columns:[/] {', '.join(self.added_columns)}")
82
+ if self.removed_columns:
83
+ console.print(f" [red]removed columns:[/] {', '.join(self.removed_columns)}")
84
+ for t in self.type_changes:
85
+ console.print(f" [red]type change:[/] {t['column']}: {t['reference']} → {t['current']}")
86
+ table = Table(box=box.SIMPLE)
87
+ for h in ("column", "PSI", "severity", "null % (ref → cur)", "detail"):
88
+ table.add_column(h)
89
+ colors = {"stable": "green", "moderate": "yellow", "major": "red"}
90
+ for c in self.columns:
91
+ table.add_row(c["column"], f"{c['psi']:.3f}", f"[{colors[c['severity']]}]{c['severity']}[/]",
92
+ f"{c['null_pct_reference']} → {c['null_pct_current']}", c["detail"])
93
+ console.print(table)
94
+
95
+ def __repr__(self) -> str:
96
+ return (f"DriftReport(ok={self.ok}, drifted={len(self.drifted)}/{len(self.columns)}, "
97
+ f"added={len(self.added_columns)}, removed={len(self.removed_columns)})")
98
+
99
+
100
+ def compare(reference: Any, current: Any, *, n: Optional[int] = None,
101
+ random_state: int = 42) -> DriftReport:
102
+ ref_info, cur_info = inspect_source(reference), inspect_source(current)
103
+ ref = sample(reference, n=n, random_state=random_state)
104
+ cur = sample(current, n=n, random_state=random_state)
105
+ ref.columns = [str(c) for c in ref.columns]
106
+ cur.columns = [str(c) for c in cur.columns]
107
+
108
+ common = [c for c in ref.columns if c in cur.columns]
109
+ report = DriftReport(
110
+ rows_reference=ref_info.n_rows if ref_info.n_rows is not None else len(ref),
111
+ rows_current=cur_info.n_rows if cur_info.n_rows is not None else len(cur),
112
+ added_columns=[c for c in cur.columns if c not in ref.columns],
113
+ removed_columns=[c for c in ref.columns if c not in cur.columns],
114
+ type_changes=[],
115
+ )
116
+ for col in common:
117
+ k_ref, k_cur = _kind(ref[col]), _kind(cur[col])
118
+ if k_ref != k_cur:
119
+ report.type_changes.append({"column": col, "reference": k_ref, "current": k_cur})
120
+ continue
121
+ report.columns.append(_column_drift(col, ref[col], cur[col], k_ref))
122
+ report.columns.sort(key=lambda c: c["psi"], reverse=True)
123
+ return report
124
+
125
+
126
+ def psi(expected: np.ndarray, actual: np.ndarray) -> float:
127
+ """Population stability index between two proportion vectors."""
128
+ e = np.clip(np.asarray(expected, float), EPS, None)
129
+ a = np.clip(np.asarray(actual, float), EPS, None)
130
+ return float(np.sum((a - e) * np.log(a / e)))
131
+
132
+
133
+ def _column_drift(col: str, ref: pd.Series, cur: pd.Series, kind: str) -> Dict[str, Any]:
134
+ null_ref = round(float(ref.isna().mean() * 100), 2)
135
+ null_cur = round(float(cur.isna().mean() * 100), 2)
136
+ out: Dict[str, Any] = {"column": col, "kind": kind,
137
+ "null_pct_reference": null_ref, "null_pct_current": null_cur}
138
+
139
+ if kind == "datetime":
140
+ # Dates move forward by nature, so they're reported as a range shift,
141
+ # not as drift; only their null rate can raise the severity.
142
+ r = pd.to_datetime(ref, errors="coerce", format="mixed").dropna()
143
+ c = pd.to_datetime(cur, errors="coerce", format="mixed").dropna()
144
+ value = 0.0
145
+ out["detail"] = (f"{r.min():%Y-%m-%d}…{r.max():%Y-%m-%d} → {c.min():%Y-%m-%d}…{c.max():%Y-%m-%d}"
146
+ if len(r) and len(c) else "")
147
+ elif kind == "integer" and _is_id_like(ref):
148
+ # New IDs always differ from old ones: report the range, not drift.
149
+ r, c = ref.dropna(), cur.dropna()
150
+ value = 0.0
151
+ out["detail"] = (f"ID-like: {r.min():,}…{r.max():,} → {c.min():,}…{c.max():,}"
152
+ if len(r) and len(c) else "ID-like")
153
+ elif kind in ("number", "integer"):
154
+ r = pd.to_numeric(ref, errors="coerce")
155
+ c = pd.to_numeric(cur, errors="coerce")
156
+ value = _numeric_psi(r, c)
157
+ out.update(_numeric_details(r.dropna(), c.dropna()))
158
+ else:
159
+ value, new_share, new_values = _categorical_psi(ref.astype("string"), cur.astype("string"))
160
+ values = ref.dropna()
161
+ if len(values) and values.nunique() / len(values) > HIGH_CARDINALITY:
162
+ out["detail"] = "mostly unique values: compared by most common values only"
163
+ else:
164
+ out["new_values_pct"] = round(new_share * 100, 2)
165
+ out["new_values"] = new_values
166
+ out["detail"] = (f"{out['new_values_pct']}% new values, e.g. {', '.join(new_values[:3])}"
167
+ if new_values else "")
168
+ out["psi"] = round(value, 4)
169
+
170
+ severity = "major" if value >= PSI_MAJOR else "moderate" if value >= PSI_MODERATE else "stable"
171
+ if severity == "stable" and abs(null_cur - null_ref) >= NULL_SHIFT_POINTS:
172
+ severity = "moderate"
173
+ out["severity"] = severity
174
+ return out
175
+
176
+
177
+ def _is_id_like(s: pd.Series) -> bool:
178
+ values = s.dropna()
179
+ return len(values) > 0 and values.nunique() / len(values) > 0.95
180
+
181
+
182
+ def _numeric_psi(ref: pd.Series, cur: pd.Series) -> float:
183
+ r, c = ref.dropna().to_numpy(), cur.dropna().to_numpy()
184
+ if not len(r) or not len(c):
185
+ return 0.0
186
+ edges = np.unique(np.quantile(r, np.linspace(0, 1, BINS + 1)))
187
+ if len(edges) < 2: # constant reference
188
+ return psi([1.0, 0.0], [np.mean(c == edges[0]), np.mean(c != edges[0])])
189
+ edges[0], edges[-1] = -np.inf, np.inf
190
+ # missing values get their own bin, so a jump in nulls counts as drift
191
+ e = np.append(np.histogram(r, edges)[0] / len(ref), ref.isna().mean())
192
+ a = np.append(np.histogram(c, edges)[0] / len(cur), cur.isna().mean())
193
+ return psi(e, a)
194
+
195
+
196
+ def _numeric_details(r: pd.Series, c: pd.Series) -> Dict[str, Any]:
197
+ from scipy.stats import ks_2samp
198
+
199
+ if not len(r) or not len(c):
200
+ return {"detail": "no values to compare"}
201
+ ks = ks_2samp(r, c)
202
+ std = float(r.std()) or 1.0
203
+ shift = (float(c.median()) - float(r.median())) / std
204
+ return {
205
+ "median_reference": float(r.median()),
206
+ "median_current": float(c.median()),
207
+ "median_shift_std": round(shift, 3),
208
+ "ks_statistic": round(float(ks.statistic), 4),
209
+ "ks_pvalue": float(ks.pvalue),
210
+ "detail": f"median {float(r.median()):.4g} → {float(c.median()):.4g} ({shift:+.2f} std)",
211
+ }
212
+
213
+
214
+ def _categorical_psi(ref: pd.Series, cur: pd.Series):
215
+ ref_counts = ref.fillna("<null>").value_counts(normalize=True)
216
+ cur_counts = cur.fillna("<null>").value_counts(normalize=True)
217
+ top = list(ref_counts.index[:TOP_CATEGORIES])
218
+ if "<null>" not in top:
219
+ top.append("<null>")
220
+ e = [ref_counts.get(v, 0.0) for v in top] + [1 - ref_counts.reindex(top).fillna(0).sum()]
221
+ a = [cur_counts.get(v, 0.0) for v in top] + [1 - cur_counts.reindex(top).fillna(0).sum()]
222
+ seen = set(ref_counts.index) | {"<null>"} # nulls are reported as null %, not new values
223
+ new = cur_counts[[v not in seen for v in cur_counts.index]]
224
+ return psi(e, a), float(new.sum()), [str(v) for v in new.index[:10]]
@@ -0,0 +1,29 @@
1
+ """
2
+ flasheda.contract
3
+ ~~~~~~~~~~~~~~~~~
4
+ Learn a data contract from a sample, then check new or full data against it:
5
+
6
+ report = flasheda.analyze("orders_jan.csv")
7
+ contract = report.contract()
8
+ contract.save("orders.contract.json")
9
+
10
+ result = flasheda.check("orders_feb.csv", "orders.contract.json")
11
+ result.show() # failures, if any
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+ from typing import Any, Union
18
+
19
+ from .check import CheckResult, Outcome
20
+ from .rules import ColumnRule, Contract, infer_contract
21
+
22
+ __all__ = ["CheckResult", "ColumnRule", "Contract", "Outcome", "check", "infer_contract"]
23
+
24
+
25
+ def check(source: Any, contract: Union[Contract, str, Path]) -> CheckResult:
26
+ """Check `source` against a Contract or a saved contract JSON file."""
27
+ if not isinstance(contract, Contract):
28
+ contract = Contract.load(contract)
29
+ return contract.check(source)