dataproof 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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 oluwagbemilesi
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,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: dataproof
3
+ Version: 0.1.0
4
+ Summary: Find out what your dataframe code actually did — silent row loss, join fan-out, misleading statistics
5
+ Author: oluwagbemilesi
6
+ License: MIT
7
+ Keywords: pandas,data-quality,verification,observability,AI,agents
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: pandas<4.0.0,>=2.2.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ # dataproof
28
+
29
+ Find out what your dataframe code actually did.
30
+
31
+ ```python
32
+ import pandas as pd, dataproof
33
+
34
+ with dataproof.observe() as report:
35
+ orders = pd.read_csv("orders.csv")
36
+ clean = orders.dropna()
37
+ joined = clean.merge(tiers, on="customer_id")
38
+ gold = (joined.tier == "gold").sum()
39
+
40
+ print(f"gold customers: {gold}")
41
+ print(report)
42
+ ```
43
+
44
+ ```
45
+ gold customers: 0
46
+ dataproof: 1 finding(s)
47
+ - dropna() removed ALL 122 rows — a row was dropped if any value in it was
48
+ null. Pass how='all', a subset=[...] of columns that matter, or thresh=N
49
+ to keep partly-populated rows. [analysis.py:5]
50
+ ```
51
+
52
+ The answer was zero. The real answer was 33. One column in that file was
53
+ entirely empty, so every row contained a null, so `dropna()` removed all of
54
+ them — and pandas said nothing, because nothing went wrong as far as pandas is
55
+ concerned.
56
+
57
+ That is the problem this solves. Not crashes: crashes are easy. **Plausible
58
+ answers that are quietly wrong.**
59
+
60
+ ## What it watches for
61
+
62
+ | | |
63
+ |---|---|
64
+ | **Silent row loss** | `dropna()` that empties a frame, deduplication that removes most of it |
65
+ | **Join fan-out** | a repeated key multiplying rows instead of pairing them |
66
+ | **Joins that matched nothing** | usually a type mismatch — `1` and `"1"` do not join |
67
+ | **Unmatched rows in a left join** | row count unchanged, but a third of the result is null on one side |
68
+ | **Numbers stored as text** | statistics silently computed over nothing |
69
+ | **Derived correlations** | `r = 1.000` because one column was computed from the other |
70
+ | **Sparse columns** | a confident mean resting on 4 of 60 values |
71
+
72
+ ## What it does not do
73
+
74
+ **It does not change your results.** Every wrapped call returns exactly what
75
+ pandas returned. The code inside the block is ordinary pandas.
76
+
77
+ **It does not ask you to write differently.** No new API to learn, no wrapper
78
+ types, no rewriting a pipeline to use someone else's functions. That is
79
+ deliberate — a verification tool you have to remember to call is one you will
80
+ not call.
81
+
82
+ **It does not cry wolf.** Every threshold here exists because a noisier version
83
+ was tried and rejected. A group-by is not a 96% row loss. A few points beyond
84
+ the IQR fence are not outliers worth reporting unless they actually move the
85
+ mean. **A finding that fires on correct code teaches you to skip all of them**,
86
+ which is worse than having none.
87
+
88
+ ## Install
89
+
90
+ ```bash
91
+ pip install dataproof
92
+ ```
93
+
94
+ Pandas is the only dependency.
95
+
96
+ ## Also useful
97
+
98
+ Check any frame on demand, without a block:
99
+
100
+ ```python
101
+ dataproof.Report().check(df)
102
+ ```
103
+
104
+ Use it as an assertion in a pipeline or a notebook:
105
+
106
+ ```python
107
+ with dataproof.observe() as report:
108
+ ...
109
+ assert report.ok, str(report)
110
+ ```
111
+
112
+ ## Why this exists
113
+
114
+ It came out of building a data library for AI agents, and measuring what agents
115
+ actually do. Given six purpose-built tools and a free choice, an agent used none
116
+ of them and wrote pandas — correctly. The tools were not the problem. The
117
+ problem was that neither the agent nor the person reading its answer could tell
118
+ when an operation had quietly destroyed the data.
119
+
120
+ Agents now write a great deal of dataframe code, at a speed no one reviews line
121
+ by line. They are good at it. But `dropna()` behaves the same for everyone, and
122
+ the failure is silent for everyone.
123
+
124
+ MIT licensed.
@@ -0,0 +1,98 @@
1
+ # dataproof
2
+
3
+ Find out what your dataframe code actually did.
4
+
5
+ ```python
6
+ import pandas as pd, dataproof
7
+
8
+ with dataproof.observe() as report:
9
+ orders = pd.read_csv("orders.csv")
10
+ clean = orders.dropna()
11
+ joined = clean.merge(tiers, on="customer_id")
12
+ gold = (joined.tier == "gold").sum()
13
+
14
+ print(f"gold customers: {gold}")
15
+ print(report)
16
+ ```
17
+
18
+ ```
19
+ gold customers: 0
20
+ dataproof: 1 finding(s)
21
+ - dropna() removed ALL 122 rows — a row was dropped if any value in it was
22
+ null. Pass how='all', a subset=[...] of columns that matter, or thresh=N
23
+ to keep partly-populated rows. [analysis.py:5]
24
+ ```
25
+
26
+ The answer was zero. The real answer was 33. One column in that file was
27
+ entirely empty, so every row contained a null, so `dropna()` removed all of
28
+ them — and pandas said nothing, because nothing went wrong as far as pandas is
29
+ concerned.
30
+
31
+ That is the problem this solves. Not crashes: crashes are easy. **Plausible
32
+ answers that are quietly wrong.**
33
+
34
+ ## What it watches for
35
+
36
+ | | |
37
+ |---|---|
38
+ | **Silent row loss** | `dropna()` that empties a frame, deduplication that removes most of it |
39
+ | **Join fan-out** | a repeated key multiplying rows instead of pairing them |
40
+ | **Joins that matched nothing** | usually a type mismatch — `1` and `"1"` do not join |
41
+ | **Unmatched rows in a left join** | row count unchanged, but a third of the result is null on one side |
42
+ | **Numbers stored as text** | statistics silently computed over nothing |
43
+ | **Derived correlations** | `r = 1.000` because one column was computed from the other |
44
+ | **Sparse columns** | a confident mean resting on 4 of 60 values |
45
+
46
+ ## What it does not do
47
+
48
+ **It does not change your results.** Every wrapped call returns exactly what
49
+ pandas returned. The code inside the block is ordinary pandas.
50
+
51
+ **It does not ask you to write differently.** No new API to learn, no wrapper
52
+ types, no rewriting a pipeline to use someone else's functions. That is
53
+ deliberate — a verification tool you have to remember to call is one you will
54
+ not call.
55
+
56
+ **It does not cry wolf.** Every threshold here exists because a noisier version
57
+ was tried and rejected. A group-by is not a 96% row loss. A few points beyond
58
+ the IQR fence are not outliers worth reporting unless they actually move the
59
+ mean. **A finding that fires on correct code teaches you to skip all of them**,
60
+ which is worse than having none.
61
+
62
+ ## Install
63
+
64
+ ```bash
65
+ pip install dataproof
66
+ ```
67
+
68
+ Pandas is the only dependency.
69
+
70
+ ## Also useful
71
+
72
+ Check any frame on demand, without a block:
73
+
74
+ ```python
75
+ dataproof.Report().check(df)
76
+ ```
77
+
78
+ Use it as an assertion in a pipeline or a notebook:
79
+
80
+ ```python
81
+ with dataproof.observe() as report:
82
+ ...
83
+ assert report.ok, str(report)
84
+ ```
85
+
86
+ ## Why this exists
87
+
88
+ It came out of building a data library for AI agents, and measuring what agents
89
+ actually do. Given six purpose-built tools and a free choice, an agent used none
90
+ of them and wrote pandas — correctly. The tools were not the problem. The
91
+ problem was that neither the agent nor the person reading its answer could tell
92
+ when an operation had quietly destroyed the data.
93
+
94
+ Agents now write a great deal of dataframe code, at a speed no one reviews line
95
+ by line. They are good at it. But `dropna()` behaves the same for everyone, and
96
+ the failure is silent for everyone.
97
+
98
+ MIT licensed.
@@ -0,0 +1,28 @@
1
+ """dataproof — find out what your dataframe code actually did.
2
+
3
+ Pandas fails quietly. `dropna()` on a frame with one empty column removes every
4
+ row. A join with a repeated key multiplies rows instead of pairing them. A
5
+ filter that matches nothing returns an empty frame, not an error. Each produces
6
+ a plausible answer that nothing questions.
7
+
8
+ import dataproof
9
+
10
+ with dataproof.observe() as report:
11
+ orders = pd.read_csv("orders.csv")
12
+ clean = orders.dropna()
13
+ joined = clean.merge(tiers, on="customer_id")
14
+
15
+ print(report)
16
+
17
+ The code inside the block is ordinary pandas, unchanged and unaffected. The
18
+ report says what it did.
19
+ """
20
+ from dataproof._observer import Finding, Report, observe
21
+
22
+ __all__ = ["observe", "Report", "Finding"]
23
+
24
+ try:
25
+ from importlib.metadata import PackageNotFoundError, version as _version
26
+ __version__ = _version("dataproof")
27
+ except Exception: # a source tree that was never installed
28
+ __version__ = "0.0.0.dev0"
@@ -0,0 +1,240 @@
1
+ """Watch what pandas actually did, without changing what it does.
2
+
3
+ The design constraint that produced this: a verification tool the analyst has to
4
+ *choose to call* will not be called. That was measured — an AI agent given six
5
+ purpose-built data tools and a free choice used none of them and wrote pandas
6
+ instead, because pandas is right there and already understood.
7
+
8
+ So this does not ask anyone to write different code. It wraps a handful of
9
+ pandas methods for the duration of a block, records anything surprising, and
10
+ puts the originals back. The code inside the block is ordinary pandas and
11
+ behaves identically; the only difference is that afterwards you can ask what it
12
+ did.
13
+
14
+ Three rules this module holds itself to:
15
+
16
+ 1. **Never change a result.** Every wrapper returns exactly what pandas returned.
17
+ 2. **Always restore.** The patch is undone in a finally block, even on an
18
+ exception, so a crash inside the block cannot leave pandas modified.
19
+ 3. **Never raise from a check.** A bug in a rule must not break the analysis it
20
+ is observing, so every check is wrapped and failures are swallowed.
21
+ """
22
+ import functools
23
+ import traceback
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+
27
+ import pandas as pd
28
+
29
+ from dataproof import _rules
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Finding:
34
+ """One thing an operation did that the caller may not have intended."""
35
+
36
+ operation: str
37
+ message: str
38
+ location: str = ""
39
+
40
+ def __str__(self):
41
+ where = f" [{self.location}]" if self.location else ""
42
+ return f"{self.operation}() {self.message}{where}"
43
+
44
+
45
+ @dataclass
46
+ class Report:
47
+ """What the observed code did. Empty means nothing surprising happened."""
48
+
49
+ findings: list = field(default_factory=list)
50
+
51
+ @property
52
+ def ok(self):
53
+ return not self.findings
54
+
55
+ def __len__(self):
56
+ return len(self.findings)
57
+
58
+ def __iter__(self):
59
+ return iter(self.findings)
60
+
61
+ def __str__(self):
62
+ if not self.findings:
63
+ return "dataproof: nothing surprising."
64
+ lines = [f"dataproof: {len(self.findings)} finding(s)"]
65
+ lines += [f" - {f}" for f in self.findings]
66
+ return "\n".join(lines)
67
+
68
+ def check(self, df, label="frame"):
69
+ """Inspect a finished frame on demand: sparse columns, derived
70
+ correlations, nothing numeric to compute over."""
71
+ for message in _safely(_rules.frame_quality, df) or []:
72
+ self.findings.append(Finding(label, message, _caller()))
73
+ return self
74
+
75
+
76
+ def _safely(rule, *args, **kwargs):
77
+ """Run a rule, swallowing any failure. A broken check must never break the
78
+ analysis it is watching."""
79
+ try:
80
+ return rule(*args, **kwargs)
81
+ except Exception:
82
+ return None
83
+
84
+
85
+ def _caller():
86
+ """The user's own line, skipping pandas internals and this package."""
87
+ here = str(Path(__file__).parent)
88
+ for frame in reversed(traceback.extract_stack()[:-1]):
89
+ name = frame.filename
90
+ if here in name or f"{'pandas'}{'/'}" in name or name.endswith("pandas/core/frame.py"):
91
+ continue
92
+ if "/pandas/" in name or "<frozen" in name:
93
+ continue
94
+ return f"{Path(name).name}:{frame.lineno}"
95
+ return ""
96
+
97
+
98
+ class _Patcher:
99
+ """Applies the wrappers and guarantees they come off again."""
100
+
101
+ def __init__(self, report):
102
+ self.report = report
103
+ self.originals = []
104
+
105
+ def wrap(self, owner, name, make_wrapper):
106
+ original = getattr(owner, name, None)
107
+ if original is None: # pandas version without this method
108
+ return
109
+ self.originals.append((owner, name, original))
110
+ setattr(owner, name, make_wrapper(original))
111
+
112
+ def restore(self):
113
+ for owner, name, original in reversed(self.originals):
114
+ setattr(owner, name, original)
115
+ self.originals.clear()
116
+
117
+ def record(self, operation, messages, location):
118
+ for message in messages or []:
119
+ if message:
120
+ self.report.findings.append(Finding(operation, message, location))
121
+
122
+
123
+ def _install(patcher):
124
+ report_to = patcher.record
125
+
126
+ def dropna_wrapper(original):
127
+ @functools.wraps(original)
128
+ def dropna(self, *args, **kwargs):
129
+ before = len(self)
130
+ result = original(self, *args, **kwargs)
131
+ if isinstance(result, pd.DataFrame):
132
+ how = kwargs.get("how", "any")
133
+ report_to("dropna", [_safely(
134
+ _rules.row_loss, before, len(result),
135
+ f"a row was dropped if {'every' if how == 'all' else 'any'} "
136
+ f"value in it was null.",
137
+ "Pass how='all', a subset=[...] of columns that matter, or "
138
+ "thresh=N to keep partly-populated rows.",
139
+ )], _caller())
140
+ return result
141
+ return dropna
142
+
143
+ def merge_wrapper(original):
144
+ @functools.wraps(original)
145
+ def merge(self, right, *args, **kwargs):
146
+ result = original(self, right, *args, **kwargs)
147
+ how = kwargs.get("how", "inner")
148
+ unmatched = None
149
+ if how in {"left", "outer"}:
150
+ unmatched = _safely(_unmatched_left, self, right, result, kwargs)
151
+ report_to("merge", _safely(
152
+ _rules.merge_result, len(self), len(right), len(result),
153
+ how=how, unmatched=unmatched,
154
+ ), _caller())
155
+ return result
156
+ return merge
157
+
158
+ def drop_duplicates_wrapper(original):
159
+ @functools.wraps(original)
160
+ def drop_duplicates(self, *args, **kwargs):
161
+ before = len(self)
162
+ result = original(self, *args, **kwargs)
163
+ if isinstance(result, pd.DataFrame):
164
+ report_to("drop_duplicates", [_safely(
165
+ _rules.row_loss, before, len(result),
166
+ "duplicate rows were removed.",
167
+ "If duplicates are meaningful here, keep them or pass a subset.",
168
+ )], _caller())
169
+ return result
170
+ return drop_duplicates
171
+
172
+ patcher.wrap(pd.DataFrame, "dropna", dropna_wrapper)
173
+ patcher.wrap(pd.DataFrame, "merge", merge_wrapper)
174
+ patcher.wrap(pd.DataFrame, "drop_duplicates", drop_duplicates_wrapper)
175
+
176
+
177
+ def _unmatched_left(left, right, result, kwargs):
178
+ """How many left rows found no match, read off the finished join.
179
+
180
+ Deliberately not recomputed from the keys. Building a set of the right-hand
181
+ keys and testing the left against it cost more than the merge itself — 71ms
182
+ against 46ms on 50k rows — which is exactly the kind of overhead that gets a
183
+ tool switched off. A left join already encodes the answer: an unmatched row
184
+ has nulls in every column that came from the right.
185
+
186
+ Uses a right-hand column that had no nulls to begin with, so a null in the
187
+ result can only mean "no match". Returns None when no such column exists,
188
+ because a wrong count is worse than no count.
189
+ """
190
+ on = kwargs.get("on")
191
+ right_on = kwargs.get("right_on", on)
192
+ keys = right_on if isinstance(right_on, (list, tuple)) else [right_on]
193
+ candidates = [c for c in right.columns if c not in keys]
194
+
195
+ for column in candidates:
196
+ # notna().all() short-circuits in C and is far cheaper than set + isin.
197
+ if not right[column].notna().all():
198
+ continue
199
+ merged_name = column if column in result.columns else f"{column}_y"
200
+ if merged_name not in result.columns:
201
+ continue
202
+ return int(result[merged_name].isna().sum())
203
+ return None
204
+
205
+
206
+ class observe:
207
+ """Watch pandas for the duration of a block.
208
+
209
+ with dataproof.observe() as report:
210
+ df = pd.read_csv("export.csv")
211
+ df = df.dropna()
212
+ joined = df.merge(other, on="id")
213
+
214
+ print(report)
215
+
216
+ The code inside is unmodified pandas and produces identical results. Nesting
217
+ is allowed; only the outermost block patches and restores.
218
+ """
219
+
220
+ _depth = 0
221
+
222
+ def __init__(self):
223
+ self.report = Report()
224
+ self._patcher = None
225
+
226
+ def __enter__(self):
227
+ if observe._depth == 0:
228
+ self._patcher = _Patcher(self.report)
229
+ _install(self._patcher)
230
+ observe._depth += 1
231
+ return self.report
232
+
233
+ def __exit__(self, *exc):
234
+ observe._depth -= 1
235
+ if self._patcher is not None:
236
+ # finally-equivalent: restoring must happen even if the observed
237
+ # block raised, or every later import of pandas stays wrapped.
238
+ self._patcher.restore()
239
+ self._patcher = None
240
+ return False
@@ -0,0 +1,160 @@
1
+ """What counts as worth reporting, and what does not.
2
+
3
+ These rules are the whole product. They were tuned over several releases of a
4
+ data library, and the tuning — not the detection — is the hard part. Two lessons
5
+ are baked into every threshold here:
6
+
7
+ **A warning that fires on correct behaviour destroys the rest.** An early version
8
+ reported a group-by collapsing 120 rows to 5 as a "96% row loss". That is a
9
+ description of grouping, not a finding. Anyone who sees one false alarm stops
10
+ reading all of them, so a rule that cannot stay quiet on healthy data is worse
11
+ than no rule.
12
+
13
+ **A warning must say what to do.** "Rows were dropped" is not actionable.
14
+ "Removed 268 of 1000 rows (27%) because every row containing any null was
15
+ dropped — pass how='all' or a subset" is.
16
+ """
17
+
18
+ # Share of rows an operation may remove before it is worth reporting. Not zero:
19
+ # a report on every removed row would fire constantly and teach the reader to
20
+ # skip it. Total loss is always reported regardless.
21
+ ROW_LOSS_SHARE = 0.10
22
+
23
+ # Correlations this close to 1 are almost never a discovery — they are one
24
+ # column derived from another, and reporting them as findings invites someone
25
+ # to announce a tautology as an insight.
26
+ DERIVED_CORRELATION = 0.999
27
+
28
+ # A statistic computed from a handful of values carries far less weight than its
29
+ # precision suggests, and nothing in "mean: 7.5" says so.
30
+ SPARSE_COLUMN_SHARE = 0.50
31
+
32
+
33
+ def _percent(part, whole):
34
+ return 0.0 if not whole else (part / whole) * 100
35
+
36
+
37
+ def row_loss(rows_in, rows_out, cause, remedy=None, threshold=ROW_LOSS_SHARE):
38
+ """Report rows removed, or None if the loss is unremarkable.
39
+
40
+ Total loss is always reported, whatever the threshold: an empty result is
41
+ the single most misleading thing an operation can produce silently, and it
42
+ is exactly what a plain `dropna()` does to a frame with one empty column.
43
+ """
44
+ if rows_in <= 0 or rows_out >= rows_in:
45
+ return None
46
+
47
+ lost = rows_in - rows_out
48
+ if rows_out == 0:
49
+ message = f"removed ALL {rows_in} rows"
50
+ elif lost / rows_in >= threshold:
51
+ message = f"removed {lost} of {rows_in} rows ({_percent(lost, rows_in):.0f}%)"
52
+ else:
53
+ return None
54
+
55
+ message = f"{message} — {cause}"
56
+ return f"{message} {remedy}" if remedy else message
57
+
58
+
59
+ def merge_result(left_rows, right_rows, result_rows, how="inner", unmatched=None):
60
+ """Report a join whose shape is probably not what was intended.
61
+
62
+ A join fails by multiplying rather than by raising, so nothing about a
63
+ successful result distinguishes a correct 3-row join from one that silently
64
+ became 9.
65
+ """
66
+ findings = []
67
+ if how == "cross":
68
+ return findings
69
+
70
+ # Fan-out is judged against the side the join is anchored to, not against
71
+ # max(left, right). A 2-row frame joined to a 3-row frame coming back with 3
72
+ # rows IS a fan-out — one left row matched twice — and comparing to the
73
+ # larger side missed exactly that case.
74
+ if (
75
+ (how in {"inner", "left"} and result_rows > left_rows)
76
+ or (how == "right" and result_rows > right_rows)
77
+ # An outer join legitimately exceeds either side, because unmatched rows
78
+ # from both are kept; it cannot exceed their sum without duplication.
79
+ or (how == "outer" and result_rows > left_rows + right_rows)
80
+ ):
81
+ findings.append(
82
+ f"joined {left_rows} x {right_rows} rows into {result_rows} — the key "
83
+ f"matched more than once, so rows were multiplied rather than paired. "
84
+ f"Pass validate='one_to_one' or 'many_to_one' to make that an error."
85
+ )
86
+ elif result_rows == 0 and left_rows and right_rows:
87
+ findings.append(
88
+ f"joined {left_rows} x {right_rows} rows into NOTHING — the key values "
89
+ f"do not overlap at all. Check the join column, and check its type: "
90
+ f"1 and '1' do not match."
91
+ )
92
+ elif how == "inner" and result_rows < left_rows:
93
+ findings.append(
94
+ f"inner join kept {result_rows} of {left_rows} left rows — the rest had "
95
+ f"no match and were dropped. Use how='left' to keep them."
96
+ )
97
+
98
+ if how in {"left", "outer"} and unmatched:
99
+ findings.append(
100
+ f"{unmatched} of {left_rows} left rows found no match, so their columns "
101
+ f"from the right frame are null. Counts and averages over those columns "
102
+ f"will be computed from {left_rows - unmatched} rows, not {left_rows}."
103
+ )
104
+ return findings
105
+
106
+
107
+ def frame_quality(df):
108
+ """Report columns whose statistics would mislead, given a finished frame."""
109
+ import pandas as pd
110
+
111
+ findings = []
112
+ total = len(df)
113
+ if total == 0:
114
+ return ["the frame is empty — every statistic computed from it is undefined."]
115
+
116
+ numeric = [c for c in df.columns if pd.api.types.is_numeric_dtype(df[c])]
117
+ if not numeric:
118
+ text_numbers = []
119
+ for column in df.columns:
120
+ series = df[column]
121
+ non_null = int(series.count())
122
+ if not non_null:
123
+ continue
124
+ share = int(pd.to_numeric(series, errors="coerce").count()) / non_null
125
+ if share >= 0.5:
126
+ text_numbers.append(f"{column!r} ({share:.0%} numeric)")
127
+ message = "no column is numeric, so any statistic over this frame is empty."
128
+ if text_numbers:
129
+ message += (
130
+ f" Column(s) [{', '.join(text_numbers)}] hold numbers stored as "
131
+ f"text — convert them first."
132
+ )
133
+ findings.append(message)
134
+ return findings
135
+
136
+ for column in df.columns:
137
+ series = df[column]
138
+ non_null = int(series.count())
139
+ if non_null == 0:
140
+ findings.append(f"column {column!r} is entirely null.")
141
+ continue
142
+ null_share = 1 - non_null / total
143
+ if null_share >= SPARSE_COLUMN_SHARE and pd.api.types.is_numeric_dtype(series):
144
+ findings.append(
145
+ f"column {column!r} is {null_share:.0%} null — statistics come from "
146
+ f"{non_null} of {total} values, so treat them as indicative."
147
+ )
148
+
149
+ for i, left in enumerate(numeric):
150
+ for right in numeric[i + 1:]:
151
+ if df[left].count() < 2 or df[right].count() < 2:
152
+ continue
153
+ value = df[left].corr(df[right])
154
+ if value == value and abs(value) >= DERIVED_CORRELATION:
155
+ findings.append(
156
+ f"columns {left!r} and {right!r} correlate at {value:.3f} — a "
157
+ f"correlation this perfect usually means one is derived from "
158
+ f"the other, not that a relationship was found."
159
+ )
160
+ return findings
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: dataproof
3
+ Version: 0.1.0
4
+ Summary: Find out what your dataframe code actually did — silent row loss, join fan-out, misleading statistics
5
+ Author: oluwagbemilesi
6
+ License: MIT
7
+ Keywords: pandas,data-quality,verification,observability,AI,agents
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: pandas<4.0.0,>=2.2.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ # dataproof
28
+
29
+ Find out what your dataframe code actually did.
30
+
31
+ ```python
32
+ import pandas as pd, dataproof
33
+
34
+ with dataproof.observe() as report:
35
+ orders = pd.read_csv("orders.csv")
36
+ clean = orders.dropna()
37
+ joined = clean.merge(tiers, on="customer_id")
38
+ gold = (joined.tier == "gold").sum()
39
+
40
+ print(f"gold customers: {gold}")
41
+ print(report)
42
+ ```
43
+
44
+ ```
45
+ gold customers: 0
46
+ dataproof: 1 finding(s)
47
+ - dropna() removed ALL 122 rows — a row was dropped if any value in it was
48
+ null. Pass how='all', a subset=[...] of columns that matter, or thresh=N
49
+ to keep partly-populated rows. [analysis.py:5]
50
+ ```
51
+
52
+ The answer was zero. The real answer was 33. One column in that file was
53
+ entirely empty, so every row contained a null, so `dropna()` removed all of
54
+ them — and pandas said nothing, because nothing went wrong as far as pandas is
55
+ concerned.
56
+
57
+ That is the problem this solves. Not crashes: crashes are easy. **Plausible
58
+ answers that are quietly wrong.**
59
+
60
+ ## What it watches for
61
+
62
+ | | |
63
+ |---|---|
64
+ | **Silent row loss** | `dropna()` that empties a frame, deduplication that removes most of it |
65
+ | **Join fan-out** | a repeated key multiplying rows instead of pairing them |
66
+ | **Joins that matched nothing** | usually a type mismatch — `1` and `"1"` do not join |
67
+ | **Unmatched rows in a left join** | row count unchanged, but a third of the result is null on one side |
68
+ | **Numbers stored as text** | statistics silently computed over nothing |
69
+ | **Derived correlations** | `r = 1.000` because one column was computed from the other |
70
+ | **Sparse columns** | a confident mean resting on 4 of 60 values |
71
+
72
+ ## What it does not do
73
+
74
+ **It does not change your results.** Every wrapped call returns exactly what
75
+ pandas returned. The code inside the block is ordinary pandas.
76
+
77
+ **It does not ask you to write differently.** No new API to learn, no wrapper
78
+ types, no rewriting a pipeline to use someone else's functions. That is
79
+ deliberate — a verification tool you have to remember to call is one you will
80
+ not call.
81
+
82
+ **It does not cry wolf.** Every threshold here exists because a noisier version
83
+ was tried and rejected. A group-by is not a 96% row loss. A few points beyond
84
+ the IQR fence are not outliers worth reporting unless they actually move the
85
+ mean. **A finding that fires on correct code teaches you to skip all of them**,
86
+ which is worse than having none.
87
+
88
+ ## Install
89
+
90
+ ```bash
91
+ pip install dataproof
92
+ ```
93
+
94
+ Pandas is the only dependency.
95
+
96
+ ## Also useful
97
+
98
+ Check any frame on demand, without a block:
99
+
100
+ ```python
101
+ dataproof.Report().check(df)
102
+ ```
103
+
104
+ Use it as an assertion in a pipeline or a notebook:
105
+
106
+ ```python
107
+ with dataproof.observe() as report:
108
+ ...
109
+ assert report.ok, str(report)
110
+ ```
111
+
112
+ ## Why this exists
113
+
114
+ It came out of building a data library for AI agents, and measuring what agents
115
+ actually do. Given six purpose-built tools and a free choice, an agent used none
116
+ of them and wrote pandas — correctly. The tools were not the problem. The
117
+ problem was that neither the agent nor the person reading its answer could tell
118
+ when an operation had quietly destroyed the data.
119
+
120
+ Agents now write a great deal of dataframe code, at a speed no one reviews line
121
+ by line. They are good at it. But `dropna()` behaves the same for everyone, and
122
+ the failure is silent for everyone.
123
+
124
+ MIT licensed.
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ dataproof/__init__.py
5
+ dataproof/_observer.py
6
+ dataproof/_rules.py
7
+ dataproof.egg-info/PKG-INFO
8
+ dataproof.egg-info/SOURCES.txt
9
+ dataproof.egg-info/dependency_links.txt
10
+ dataproof.egg-info/requires.txt
11
+ dataproof.egg-info/top_level.txt
12
+ tests/test_observer.py
@@ -0,0 +1,4 @@
1
+ pandas<4.0.0,>=2.2.0
2
+
3
+ [dev]
4
+ pytest>=8.0.0
@@ -0,0 +1 @@
1
+ dataproof
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "dataproof"
7
+ version = "0.1.0"
8
+ description = "Find out what your dataframe code actually did — silent row loss, join fan-out, misleading statistics"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "oluwagbemilesi"}]
13
+ keywords = ["pandas", "data-quality", "verification", "observability", "AI", "agents"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: Science/Research",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Scientific/Engineering :: Information Analysis",
26
+ ]
27
+
28
+ dependencies = ["pandas>=2.2.0,<4.0.0"]
29
+
30
+ [project.optional-dependencies]
31
+ dev = ["pytest>=8.0.0"]
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["."]
35
+ include = ["dataproof*"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,203 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ import pytest
4
+
5
+ import dataproof
6
+
7
+
8
+ # --- the safety properties -------------------------------------------------
9
+ # This patches a library everyone else's code depends on, so these matter more
10
+ # than the findings themselves.
11
+
12
+ def test_results_are_untouched():
13
+ df = pd.DataFrame({"a": [1.0, None, 3.0]})
14
+ expected = df.dropna()
15
+ with dataproof.observe():
16
+ observed = df.dropna()
17
+ pd.testing.assert_frame_equal(observed, expected)
18
+
19
+
20
+ def test_pandas_is_restored_afterwards():
21
+ original = pd.DataFrame.dropna
22
+ with dataproof.observe():
23
+ assert pd.DataFrame.dropna is not original
24
+ assert pd.DataFrame.dropna is original
25
+
26
+
27
+ def test_pandas_is_restored_after_an_exception():
28
+ # If a crash inside the block left pandas patched, every later import in
29
+ # the process would still be wrapped.
30
+ original = pd.DataFrame.merge
31
+ with pytest.raises(ValueError):
32
+ with dataproof.observe():
33
+ raise ValueError("boom")
34
+ assert pd.DataFrame.merge is original
35
+
36
+
37
+ def test_nesting_restores_only_once():
38
+ original = pd.DataFrame.dropna
39
+ with dataproof.observe():
40
+ with dataproof.observe():
41
+ assert pd.DataFrame.dropna is not original
42
+ # the inner block must not have unpatched the outer one
43
+ assert pd.DataFrame.dropna is not original
44
+ assert pd.DataFrame.dropna is original
45
+
46
+
47
+ def test_a_broken_rule_does_not_break_the_analysis(monkeypatch):
48
+ # A bug in a check must never take down the code it is watching.
49
+ from dataproof import _rules
50
+
51
+ def explode(*a, **k):
52
+ raise RuntimeError("rule is broken")
53
+
54
+ monkeypatch.setattr(_rules, "row_loss", explode)
55
+ df = pd.DataFrame({"a": [1.0, None]})
56
+ with dataproof.observe() as report:
57
+ result = df.dropna()
58
+ assert len(result) == 1
59
+ assert report.ok
60
+
61
+
62
+ # --- the findings ----------------------------------------------------------
63
+
64
+ def test_catches_dropna_removing_everything():
65
+ # The case that motivated the project: one entirely-empty column means every
66
+ # row holds a null, so a plain dropna() silently returns nothing.
67
+ df = pd.DataFrame({"keep": [1, 2, 3], "empty": [None, None, None]})
68
+ with dataproof.observe() as report:
69
+ result = df.dropna()
70
+ assert len(result) == 0
71
+ assert len(report) == 1
72
+ assert "removed ALL 3 rows" in str(report.findings[0])
73
+ assert "how='all'" in str(report.findings[0])
74
+
75
+
76
+ def test_catches_a_join_that_multiplied_rows():
77
+ left = pd.DataFrame({"k": [1, 2]})
78
+ right = pd.DataFrame({"k": [1, 1, 2], "v": ["a", "b", "c"]})
79
+ with dataproof.observe() as report:
80
+ left.merge(right, on="k")
81
+ assert any("multiplied" in str(f) for f in report)
82
+
83
+
84
+ def test_catches_a_join_that_matched_nothing():
85
+ with dataproof.observe() as report:
86
+ pd.DataFrame({"k": [1]}).merge(pd.DataFrame({"k": [99]}), on="k")
87
+ assert any("NOTHING" in str(f) for f in report)
88
+
89
+
90
+ def test_catches_unmatched_rows_in_a_left_join():
91
+ # A left join keeps its row count, so nothing else reveals that a third of
92
+ # the result is null on one side.
93
+ left = pd.DataFrame({"k": range(10)})
94
+ right = pd.DataFrame({"k": range(6), "v": ["x"] * 6})
95
+ with dataproof.observe() as report:
96
+ left.merge(right, on="k", how="left")
97
+ assert any("4 of 10 left rows found no match" in str(f) for f in report)
98
+
99
+
100
+ def test_catches_heavy_deduplication():
101
+ df = pd.DataFrame({"a": [1] * 50 + list(range(50))})
102
+ with dataproof.observe() as report:
103
+ df.drop_duplicates()
104
+ assert any("drop_duplicates" in str(f) for f in report)
105
+
106
+
107
+ def test_findings_name_the_line():
108
+ df = pd.DataFrame({"a": [1.0, None], "b": [None, None]})
109
+ with dataproof.observe() as report:
110
+ df.dropna()
111
+ assert "test_observer.py:" in report.findings[0].location
112
+
113
+
114
+ # --- silence on healthy work ----------------------------------------------
115
+ # The property the whole tool depends on. A false alarm teaches the reader to
116
+ # skip every finding, which is worse than having none.
117
+
118
+ def test_silent_on_a_healthy_pipeline():
119
+ rng = np.random.default_rng(0)
120
+ df = pd.DataFrame({"k": range(200), "v": rng.normal(size=200)})
121
+ keys = pd.DataFrame({"k": range(200), "label": rng.choice(["a", "b"], 200)})
122
+ with dataproof.observe() as report:
123
+ clean = df.dropna()
124
+ joined = clean.merge(keys, on="k")
125
+ joined.drop_duplicates()
126
+ assert report.ok, str(report)
127
+
128
+
129
+ def test_a_small_row_loss_is_not_reported():
130
+ df = pd.DataFrame({"a": [1.0] * 99 + [None]})
131
+ with dataproof.observe() as report:
132
+ df.dropna()
133
+ assert report.ok
134
+
135
+
136
+ def test_a_clean_one_to_one_join_is_silent():
137
+ left = pd.DataFrame({"k": range(50)})
138
+ right = pd.DataFrame({"k": range(50), "v": range(50)})
139
+ with dataproof.observe() as report:
140
+ left.merge(right, on="k", how="left")
141
+ assert report.ok
142
+
143
+
144
+ # --- on-demand frame checks ------------------------------------------------
145
+
146
+ def test_check_reports_a_derived_correlation():
147
+ df = pd.DataFrame({"signups": range(1, 61)})
148
+ df["revenue"] = df["signups"] * 12.5
149
+ report = dataproof.Report().check(df)
150
+ assert any("derived from" in str(f) for f in report)
151
+
152
+
153
+ def test_check_reports_numbers_stored_as_text():
154
+ df = pd.DataFrame({"revenue": ["1200.50", "980.00", "1450.75"]})
155
+ report = dataproof.Report().check(df)
156
+ assert any("stored as text" in str(f) for f in report)
157
+
158
+
159
+ def test_check_reports_a_sparse_column():
160
+ df = pd.DataFrame({"nps": [None] * 56 + [7.0, 8.0, 9.0, 6.0]})
161
+ report = dataproof.Report().check(df)
162
+ assert any("93% null" in str(f) for f in report)
163
+
164
+
165
+ def test_check_is_silent_on_a_healthy_frame():
166
+ rng = np.random.default_rng(1)
167
+ df = pd.DataFrame({"a": rng.normal(size=60), "b": rng.normal(size=60)})
168
+ assert dataproof.Report().check(df).ok
169
+
170
+
171
+ # --- the report object -----------------------------------------------------
172
+
173
+ def test_report_reads_well_when_empty():
174
+ assert "nothing surprising" in str(dataproof.Report())
175
+
176
+
177
+ def test_report_is_iterable_and_sized():
178
+ df = pd.DataFrame({"a": [1.0], "b": [None]})
179
+ with dataproof.observe() as report:
180
+ df.dropna()
181
+ assert len(report) == 1
182
+ assert len(list(report)) == 1
183
+
184
+
185
+ def test_fan_out_is_judged_against_the_anchored_side():
186
+ # 2 left rows becoming 3 is a fan-out, even though 3 does not exceed the
187
+ # 3-row right frame. Comparing against max(left, right) missed this.
188
+ left = pd.DataFrame({"k": [1, 2]})
189
+ right = pd.DataFrame({"k": [1, 1, 2], "v": ["a", "b", "c"]})
190
+ with dataproof.observe() as report:
191
+ result = left.merge(right, on="k")
192
+ assert len(result) == 3
193
+ assert any("multiplied" in str(f) for f in report)
194
+
195
+
196
+ def test_outer_join_keeping_both_sides_is_not_a_fan_out():
197
+ # An outer join legitimately returns more rows than either input.
198
+ left = pd.DataFrame({"k": [1, 2]})
199
+ right = pd.DataFrame({"k": [3, 4], "v": ["a", "b"]})
200
+ with dataproof.observe() as report:
201
+ result = left.merge(right, on="k", how="outer")
202
+ assert len(result) == 4
203
+ assert not any("multiplied" in str(f) for f in report)