leakprobe 0.1.0__py3-none-any.whl

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.
leakprobe/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """leakprobe -- find features that read data they were never supposed to see.
2
+
3
+ import leakprobe as lc
4
+
5
+ report = lc.check(
6
+ compute=build_features,
7
+ sources={"events": events, "profiles": profiles},
8
+ timestamps={"events": "occurred_at", "profiles": "updated_at"},
9
+ declared={"rolling_7d_spend": ["events"]},
10
+ )
11
+ report.raise_for_leaks()
12
+ """
13
+
14
+ from .core import check
15
+ from .perturb import advance, delay, use_column
16
+ from .report import Finding, Report
17
+
18
+ __all__ = ["check", "delay", "advance", "use_column", "Report", "Finding"]
19
+ __version__ = "0.1.0"
leakprobe/core.py ADDED
@@ -0,0 +1,150 @@
1
+ """Find features that read a source they never declared.
2
+
3
+ The test needs no ground truth. It needs one change the answer must be
4
+ invariant to: move when a source became knowable, recompute, and read the list
5
+ of features that flinched.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable, Mapping, Sequence
11
+
12
+ import pandas as pd
13
+
14
+ from .perturb import delay
15
+ from .report import BYPASS, LEAK, OK, Finding, Report
16
+
17
+ __all__ = ["check"]
18
+
19
+ Sources = Mapping[str, pd.DataFrame]
20
+ Compute = Callable[[Sources], pd.DataFrame]
21
+
22
+
23
+ def _as_frame(result: object, label: str) -> pd.DataFrame:
24
+ if isinstance(result, pd.Series):
25
+ return result.to_frame()
26
+ if isinstance(result, pd.DataFrame):
27
+ return result
28
+ raise TypeError(f"{label} must return a DataFrame or Series, got {type(result).__name__}")
29
+
30
+
31
+ def _changed(left: pd.Series, right: pd.Series, tolerance: float) -> tuple[bool, float]:
32
+ """Did this column move? NaN in the same place counts as unchanged."""
33
+ both_null = left.isna() & right.isna()
34
+ if (left.isna() != right.isna()).any():
35
+ return True, float("inf")
36
+ if pd.api.types.is_numeric_dtype(left) and pd.api.types.is_numeric_dtype(right):
37
+ gap = (left - right).abs()
38
+ gap = gap[~both_null]
39
+ worst = float(gap.max()) if len(gap) else 0.0
40
+ return worst > tolerance, worst
41
+ unequal = (left != right) & ~both_null
42
+ return bool(unequal.any()), float(unequal.sum())
43
+
44
+
45
+ def check(
46
+ compute: Compute,
47
+ sources: Sources,
48
+ timestamps: Mapping[str, str],
49
+ declared: Mapping[str, Sequence[str]],
50
+ *,
51
+ by: pd.Timedelta = pd.Timedelta(days=30),
52
+ tolerance: float = 0.0,
53
+ perturb: Callable[[pd.DataFrame, str, pd.Timedelta], pd.DataFrame] = delay,
54
+ ) -> Report:
55
+ """Perturb each source's availability in turn; report features that moved.
56
+
57
+ Args:
58
+ compute: builds your features. Takes the sources dict, returns a frame
59
+ whose columns are features. Must be deterministic -- this is checked.
60
+ sources: name -> raw table. Each carries an availability timestamp.
61
+ timestamps: source name -> the column holding that timestamp, or None for
62
+ a static table with no clock, which is skipped.
63
+ declared: feature name -> the sources it is supposed to read. A feature
64
+ absent from this mapping is treated as declaring nothing, which is
65
+ the strictest reading and usually what you want for a new pipeline.
66
+ by: how far to move availability. Large enough to bite, small enough to
67
+ leave rows in the window.
68
+ tolerance: treat changes at or below this as no change. Leave at 0.0
69
+ unless your pipeline has genuine floating-point nondeterminism, and
70
+ prefer to fix that instead.
71
+ perturb: the perturbation. `delay` removes information and is the safe
72
+ default; see `leakprobe.perturb` for others.
73
+
74
+ Returns:
75
+ A Report. `report.leaks` is the list of undeclared dependencies, and
76
+ `report.raise_for_leaks()` turns them into a test failure.
77
+ """
78
+ absent = set(sources) - set(timestamps)
79
+ if absent:
80
+ raise KeyError(
81
+ f"No availability column given for source(s): {sorted(absent)}. "
82
+ "Pass None for a static table that has no clock."
83
+ )
84
+
85
+ unknown = {s for deps in declared.values() for s in deps} - set(sources)
86
+ if unknown:
87
+ raise KeyError(f"declared refers to unknown source(s): {sorted(unknown)}")
88
+
89
+ baseline = _as_frame(compute(sources), "compute")
90
+ control = _as_frame(compute(sources), "compute")
91
+
92
+ notes: list[str] = []
93
+ if list(baseline.columns) != list(control.columns):
94
+ raise RuntimeError(
95
+ "compute() returned different columns on two identical calls. The test "
96
+ "cannot separate a leak from nondeterminism until that is fixed."
97
+ )
98
+ unstable = [c for c in baseline.columns if _changed(baseline[c], control[c], tolerance)[0]]
99
+ if unstable:
100
+ raise RuntimeError(
101
+ "compute() is not deterministic: "
102
+ + ", ".join(unstable[:5])
103
+ + (" ..." if len(unstable) > 5 else "")
104
+ + ". Seed it, or every result below is noise."
105
+ )
106
+
107
+ features = [str(c) for c in baseline.columns]
108
+ findings: list[Finding] = []
109
+
110
+ for source in sources:
111
+ if timestamps[source] is None:
112
+ notes.append(
113
+ f"note: {source!r} has no availability column, so nothing about it was "
114
+ f"perturbed. Dependencies on it are untested."
115
+ )
116
+ continue
117
+ moved_frame = _as_frame(
118
+ compute({**sources, source: perturb(sources[source], timestamps[source], by)}),
119
+ "compute",
120
+ )
121
+ if list(moved_frame.columns) != list(baseline.columns):
122
+ notes.append(
123
+ f"note: perturbing {source!r} changed the feature set itself, which is "
124
+ f"a stronger dependency than this test is designed to describe."
125
+ )
126
+ continue
127
+
128
+ for column, feature in zip(baseline.columns, features):
129
+ moved, worst = _changed(baseline[column], moved_frame[column], tolerance)
130
+ is_declared = source in declared.get(feature, ())
131
+ if moved and not is_declared:
132
+ kind = LEAK
133
+ elif is_declared and not moved:
134
+ kind = BYPASS
135
+ else:
136
+ kind = OK
137
+ findings.append(
138
+ Finding(
139
+ feature=feature,
140
+ source=source,
141
+ kind=kind,
142
+ declared=is_declared,
143
+ moved=moved,
144
+ max_abs_change=worst,
145
+ )
146
+ )
147
+
148
+ return Report(
149
+ findings=findings, features=features, sources=list(sources), notes=notes
150
+ )
leakprobe/perturb.py ADDED
@@ -0,0 +1,64 @@
1
+ """Ways to move when a source becomes knowable.
2
+
3
+ A perturbation changes *availability*, never values. That distinction is what
4
+ makes the test sound: if a feature's number changes after a perturbation, the
5
+ feature read something whose timing moved, and timing is the only thing that
6
+ moved.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import pandas as pd
12
+
13
+ __all__ = ["delay", "advance", "use_column"]
14
+
15
+
16
+ def _check(frame: pd.DataFrame, column: str) -> None:
17
+ if column not in frame.columns:
18
+ raise KeyError(
19
+ f"No availability column {column!r}. Columns present: {list(frame.columns)}"
20
+ )
21
+ if not pd.api.types.is_datetime64_any_dtype(frame[column]):
22
+ raise TypeError(
23
+ f"Availability column {column!r} is {frame[column].dtype}, not a datetime. "
24
+ "Parse it with pd.to_datetime first."
25
+ )
26
+
27
+
28
+ def delay(frame: pd.DataFrame, column: str, by: pd.Timedelta) -> pd.DataFrame:
29
+ """The same rows, knowable `by` later.
30
+
31
+ The safe default. Delaying a source can only remove information, so a
32
+ feature that legitimately reads it will change and one that does not cannot.
33
+ """
34
+ _check(frame, column)
35
+ out = frame.copy()
36
+ out[column] = out[column] + by
37
+ return out
38
+
39
+
40
+ def advance(frame: pd.DataFrame, column: str, by: pd.Timedelta) -> pd.DataFrame:
41
+ """The same rows, knowable `by` earlier -- look-ahead, injected on purpose.
42
+
43
+ Use when you want to measure what a leak would be *worth* rather than
44
+ whether one exists.
45
+ """
46
+ _check(frame, column)
47
+ out = frame.copy()
48
+ out[column] = out[column] - by
49
+ return out
50
+
51
+
52
+ def use_column(frame: pd.DataFrame, column: str, source: str) -> pd.DataFrame:
53
+ """Availability taken from another column.
54
+
55
+ Models the specific mistake of treating a record as knowable when the period
56
+ it describes ended, rather than when it was published: `use_column(facts,
57
+ "available_at", "period_end")`.
58
+ """
59
+ _check(frame, column)
60
+ if source not in frame.columns:
61
+ raise KeyError(f"No column {source!r} to take availability from.")
62
+ out = frame.copy()
63
+ out[column] = out[source]
64
+ return out
leakprobe/report.py ADDED
@@ -0,0 +1,109 @@
1
+ """The verdict, and how it prints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ __all__ = ["Finding", "Report"]
8
+
9
+ LEAK = "leak"
10
+ BYPASS = "bypass"
11
+ OK = "ok"
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Finding:
16
+ """One (feature, source) pair, and what the perturbation did to it."""
17
+
18
+ feature: str
19
+ source: str
20
+ kind: str
21
+ declared: bool
22
+ moved: bool
23
+ max_abs_change: float
24
+
25
+ def __str__(self) -> str:
26
+ if self.kind == LEAK:
27
+ return (
28
+ f"{self.feature} moved when {self.source} was perturbed, and does not "
29
+ f"declare it (max change {self.max_abs_change:.3g})"
30
+ )
31
+ if self.kind == BYPASS:
32
+ return (
33
+ f"{self.feature} declares {self.source} but did not move when its clock "
34
+ f"did -- it reads the source without consulting availability, or the "
35
+ f"declaration is stale"
36
+ )
37
+ return f"{self.feature} / {self.source}: as declared"
38
+
39
+
40
+ @dataclass
41
+ class Report:
42
+ findings: list[Finding] = field(default_factory=list)
43
+ features: list[str] = field(default_factory=list)
44
+ sources: list[str] = field(default_factory=list)
45
+ notes: list[str] = field(default_factory=list)
46
+
47
+ @property
48
+ def leaks(self) -> list[Finding]:
49
+ """Undeclared dependencies. These are the bugs."""
50
+ return [f for f in self.findings if f.kind == LEAK]
51
+
52
+ @property
53
+ def bypassed(self) -> list[Finding]:
54
+ """Declared dependencies that did not respond to their own clock.
55
+
56
+ Either the declaration is stale, or the feature reaches the source by a
57
+ path that ignores its availability column -- which is how look-ahead
58
+ usually gets in. Not a failure on its own; worth reading.
59
+ """
60
+ return [f for f in self.findings if f.kind == BYPASS]
61
+
62
+ @property
63
+ def clean(self) -> bool:
64
+ return not self.leaks
65
+
66
+ def raise_for_leaks(self) -> None:
67
+ """For use in a test suite: turn a leak into a failure."""
68
+ if self.leaks:
69
+ listed = "\n".join(f" - {f}" for f in self.leaks)
70
+ raise AssertionError(f"{len(self.leaks)} undeclared dependencies:\n{listed}")
71
+
72
+ def __str__(self) -> str:
73
+ width = max((len(f) for f in self.features), default=7)
74
+ lines = [
75
+ f"{len(self.features)} features x {len(self.sources)} sources",
76
+ "",
77
+ f"{'feature':<{width}} " + " ".join(f"{s:>12}" for s in self.sources),
78
+ ]
79
+ by_pair = {(f.feature, f.source): f for f in self.findings}
80
+ for feature in self.features:
81
+ cells = []
82
+ for source in self.sources:
83
+ found = by_pair.get((feature, source))
84
+ if found is None:
85
+ cells.append(f"{'-':>12}")
86
+ elif found.kind == LEAK:
87
+ cells.append(f"{'LEAK':>12}")
88
+ elif found.kind == BYPASS:
89
+ cells.append(f"{'bypass?':>12}")
90
+ elif found.declared:
91
+ cells.append(f"{'reads it':>12}")
92
+ else:
93
+ cells.append(f"{'exactly 0':>12}")
94
+ lines.append(f"{feature:<{width}} " + " ".join(cells))
95
+
96
+ lines.append("")
97
+ if self.leaks:
98
+ lines.append(f"{len(self.leaks)} undeclared dependencies:")
99
+ lines += [f" - {f}" for f in self.leaks]
100
+ else:
101
+ lines.append("No undeclared dependencies.")
102
+ if self.bypassed:
103
+ lines.append("")
104
+ lines.append("Declared, but did not respond to the source's clock:")
105
+ lines += [f" - {f}" for f in self.bypassed]
106
+ if self.notes:
107
+ lines.append("")
108
+ lines += self.notes
109
+ return "\n".join(lines)
@@ -0,0 +1,224 @@
1
+ Metadata-Version: 2.4
2
+ Name: leakprobe
3
+ Version: 0.1.0
4
+ Summary: Find features that read data they were never supposed to see. Temporal leakage detection by metamorphic testing.
5
+ Author: Raunak Sood
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/quantraunak/leakprobe
8
+ Project-URL: Issues, https://github.com/quantraunak/leakprobe/issues
9
+ Keywords: leakage,data-leakage,machine-learning,testing,time-series,point-in-time,feature-engineering
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: pandas>=2.0
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=7.4; extra == "dev"
23
+ Requires-Dist: numpy>=1.26; extra == "dev"
24
+ Provides-Extra: examples
25
+ Requires-Dist: numpy>=1.26; extra == "examples"
26
+ Requires-Dist: openpyxl>=3.1; extra == "examples"
27
+ Dynamic: license-file
28
+
29
+ # leakprobe
30
+
31
+ [![CI](https://github.com/quantraunak/leakprobe/actions/workflows/ci.yml/badge.svg)](https://github.com/quantraunak/leakprobe/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/leakprobe.svg)](https://pypi.org/project/leakprobe/) [![Python](https://img.shields.io/pypi/pyversions/leakprobe.svg)](https://pypi.org/project/leakprobe/)
32
+
33
+
34
+ **Find features that read data they were never supposed to see.**
35
+
36
+ Temporal leakage is the most expensive quiet bug in applied ML. A feature reads
37
+ something that wasn't knowable yet, nothing throws, no number looks implausible,
38
+ and your model gets better. You find out in production, if you find out at all.
39
+
40
+ `leakprobe` finds it without needing to know the right answer. It needs one
41
+ thing you already know: **a change to your data that your features must be
42
+ invariant to.**
43
+
44
+ ```bash
45
+ pip install leakprobe
46
+ ```
47
+
48
+ ```python
49
+ import leakprobe as lp
50
+
51
+ report = lp.check(
52
+ compute=build_features, # (sources) -> DataFrame of features
53
+ sources={"events": events, "tickets": tickets},
54
+ timestamps={"events": "occurred_at", "tickets": "resolved_at"},
55
+ declared={
56
+ "total_spend": ["events"],
57
+ "event_count": ["events"],
58
+ "tickets_resolved": ["tickets"],
59
+ "avg_severity": ["tickets"],
60
+ },
61
+ )
62
+ report.raise_for_leaks() # fails your test suite if anything leaked
63
+ ```
64
+
65
+ ```
66
+ 4 features x 2 sources
67
+
68
+ feature events tickets
69
+ total_spend reads it exactly 0
70
+ event_count reads it exactly 0
71
+ tickets_resolved exactly 0 reads it
72
+ avg_severity exactly 0 bypass?
73
+
74
+ No undeclared dependencies.
75
+
76
+ Declared, but did not respond to the source's clock:
77
+ - avg_severity declares tickets but did not move when its clock did -- it
78
+ reads the source without consulting availability, or the declaration is stale
79
+ ```
80
+
81
+ That last line is a real bug. `avg_severity` filters tickets on `opened_at`
82
+ instead of `resolved_at`, so tickets that were still open at scoring time leak
83
+ in — exactly the ones that predict churn. Nothing about the code looks wrong.
84
+
85
+ ## Try it on real data
86
+
87
+ ```bash
88
+ pip install leakprobe openpyxl
89
+ python examples/online_retail.py
90
+ ```
91
+
92
+ The UCI Online Retail set: 397,924 real orders and 8,905 returns across 4,372
93
+ customers of a UK gift retailer, 2010-2011. Six ordinary per-customer features
94
+ built as of a cutoff, two of them wrong in the two ways temporal leakage
95
+ actually happens. Neither raises. Both are caught:
96
+
97
+ ```
98
+ feature orders returns
99
+ total_spend reads it exactly 0
100
+ order_count reads it exactly 0
101
+ recency_days reads it exactly 0
102
+ return_count exactly 0 reads it
103
+ avg_unit_price bypass? exactly 0
104
+ net_spend reads it LEAK
105
+
106
+ 1 undeclared dependencies:
107
+ - net_spend moved when returns was perturbed, and does not declare it (max change 7.46e+03)
108
+
109
+ Declared, but did not respond to the source's clock:
110
+ - avg_unit_price declares orders but did not move when its clock did
111
+ ```
112
+
113
+ `avg_unit_price` is missing one cutoff filter, so it averages invoices that had
114
+ not happened yet. `net_spend` reaches into the returns table without declaring
115
+ it, silently inheriting that table's latency. One dropped subscript and one
116
+ undeclared read -- the two shapes this bug takes in production.
117
+
118
+ ## How it works
119
+
120
+ Four steps, and no ground truth anywhere in them.
121
+
122
+ 1. **Move when a source became knowable.** Not its values — only its
123
+ availability timestamp. `delay` pushes it later, which can only ever remove
124
+ information.
125
+ 2. **Recompute every feature.**
126
+ 3. **Compare, exactly.** A feature that genuinely cannot read that source gets
127
+ the identical input arrays through the identical code and returns bit-identical
128
+ floats. Its difference is `0.0`, not `1e-15`. So any movement at all is proof
129
+ of a dependency, not a number you have to squint at.
130
+ 4. **Check what moved against what you declared.** Anything in one list and not
131
+ the other is the finding.
132
+
133
+ Two kinds of finding:
134
+
135
+ - **`leak`** — moved, but doesn't declare the source. It has a dependency you
136
+ didn't know about. In a temporal pipeline, an unknown dependency on *when*
137
+ data arrived is look-ahead.
138
+ - **`bypass?`** — declares the source but didn't move when that source's clock
139
+ did. It's reaching the data by a path that ignores availability, which is how
140
+ look-ahead usually gets in. Not a failure on its own. Worth reading.
141
+
142
+ Before any of that, `check` runs `compute` twice on untouched inputs and refuses
143
+ to continue if the two runs disagree. A nondeterministic pipeline makes every
144
+ result below it noise, so that's a hard error rather than a warning.
145
+
146
+ ## Declaring nothing
147
+
148
+ You don't have to write the `declared` map. Leave it out and every real timing
149
+ dependency is reported:
150
+
151
+ ```python
152
+ report = lp.check(compute, sources, timestamps, declared={})
153
+ for f in report.leaks:
154
+ print(f.feature, "reads", f.source)
155
+ ```
156
+
157
+ That's the fastest way to answer "what does this pipeline actually depend on"
158
+ for code you inherited, which is usually a shorter list than the author believed.
159
+
160
+ ## In CI
161
+
162
+ ```python
163
+ def test_no_temporal_leakage():
164
+ lp.check(build_features, SOURCES, TIMESTAMPS, DECLARED).raise_for_leaks()
165
+ ```
166
+
167
+ The declaration map becomes the thing code review argues about, which is where
168
+ that argument belongs.
169
+
170
+ ## Perturbations
171
+
172
+ | | |
173
+ |---|---|
174
+ | `delay(frame, col, by)` | knowable later. The safe default: removes information only. |
175
+ | `advance(frame, col, by)` | knowable earlier. Injects look-ahead on purpose, to measure what a leak is worth. |
176
+ | `use_column(frame, col, other)` | availability taken from another column. Models "treated as knowable when the period ended, not when it was published." |
177
+
178
+ ```python
179
+ report = lp.check(..., perturb=lp.advance, by=pd.Timedelta(days=90))
180
+ ```
181
+
182
+ ## What it will not catch
183
+
184
+ **Dependencies that don't flow through a timestamp.** The perturbation moves
185
+ availability, so it reveals as-of joins, merges on a date, and windows anchored
186
+ to one. A feature that reads a source's values with no reference to when they
187
+ arrived is invariant to it and will not be flagged. In practice this costs less
188
+ than it sounds: look-ahead *is* a dependency on timing, so the leaks worth
189
+ catching are the detectable ones. The boundary is asserted in the test suite so
190
+ it can't quietly stop being true.
191
+
192
+ **Sources with no clock.** Pass `None` and static tables are skipped, with a
193
+ note saying dependencies on them went untested.
194
+
195
+ **Leakage across rows rather than time** — target encoding fit on the full
196
+ dataset, a scaler fit before the split. Different bug, different tool.
197
+
198
+ ## Where this came from
199
+
200
+ The technique is metamorphic testing, which software testing has used for
201
+ decades and research code almost never does. This is an extraction of a check
202
+ built for a quantitative finance pipeline, where the sources are stock prices
203
+ and regulatory filings and the question is whether a factor read an earnings
204
+ figure before it was published.
205
+
206
+ It caught a real one. A factor called `turnover_1m` was classified as
207
+ price-and-volume — it's built from volume, it lives in `price.py`, and every
208
+ human who looked at it filed it under prices. It divides by shares outstanding,
209
+ which comes off a filing. It was the only member of its group that moved when
210
+ the filing calendar shifted, while eleven genuine price factors held at exactly
211
+ zero. That measurement is written up in
212
+ [bias-fingerprints](https://github.com/quantraunak/bias-fingerprints).
213
+
214
+ ## Install
215
+
216
+ ```bash
217
+ pip install leakprobe # pandas is the only dependency
218
+ ```
219
+
220
+ Python 3.10+.
221
+
222
+ ## Licence
223
+
224
+ MIT.
@@ -0,0 +1,9 @@
1
+ leakprobe/__init__.py,sha256=D1M2m_5CJRZ_I3W146fTQweiGgLXAboX37KZthBtnJg,589
2
+ leakprobe/core.py,sha256=QMoIUXZRootv4DAF4S8U6rTPX6YdBLvwXv5vxWeLXhw,5869
3
+ leakprobe/perturb.py,sha256=Crqkx7rICrNhg-f7bBqqXFGYZYL2byYbBY7UicEFrx0,2142
4
+ leakprobe/report.py,sha256=7XMoUHJTy4N3von3kABCBf2fMRgU_f9rPetpUgao4Tw,3799
5
+ leakprobe-0.1.0.dist-info/licenses/LICENSE,sha256=fYY74xctr_i5pdD0yvpdpWnLU9Mk4BH3_Vg6qMU13C0,1068
6
+ leakprobe-0.1.0.dist-info/METADATA,sha256=eDU3vaU2PnZ_z2rjP3quuadkGy7jVocPS_egNfgM564,8822
7
+ leakprobe-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ leakprobe-0.1.0.dist-info/top_level.txt,sha256=oz6lJczqUf_MZLxS6ZoOEHxt-pPrym9QH09DCawWf8c,10
9
+ leakprobe-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Raunak Sood
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 @@
1
+ leakprobe