dataassay 0.7.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.
- dataassay/__init__.py +11 -0
- dataassay/__main__.py +4 -0
- dataassay/audit.py +166 -0
- dataassay/checks/base.py +217 -0
- dataassay/checks/distribution.py +385 -0
- dataassay/checks/precision.py +147 -0
- dataassay/checks/registry.py +51 -0
- dataassay/checks/structural.py +400 -0
- dataassay/checks/temporal.py +605 -0
- dataassay/cli.py +671 -0
- dataassay/columns.py +389 -0
- dataassay/llm/__init__.py +9 -0
- dataassay/llm/interview.py +180 -0
- dataassay/llm/payload.py +111 -0
- dataassay/llm/provider.py +144 -0
- dataassay/manifest.py +158 -0
- dataassay/profile.py +377 -0
- dataassay/provenance.py +155 -0
- dataassay/rawscan.py +329 -0
- dataassay/report/__init__.py +142 -0
- dataassay/report/charts.py +326 -0
- dataassay/report/evidence.py +155 -0
- dataassay/report/html.py +356 -0
- dataassay/structure.py +381 -0
- dataassay-0.7.0.dist-info/METADATA +208 -0
- dataassay-0.7.0.dist-info/RECORD +29 -0
- dataassay-0.7.0.dist-info/WHEEL +4 -0
- dataassay-0.7.0.dist-info/entry_points.txt +2 -0
- dataassay-0.7.0.dist-info/licenses/LICENSE +21 -0
dataassay/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""dataassay — audit a tabular dataset.
|
|
2
|
+
|
|
3
|
+
An assay characterizes a sample before it makes any claim about it. This tool
|
|
4
|
+
works in that order too: establish what kind of thing each column is, run only
|
|
5
|
+
the checks that property makes valid, and report what could NOT be checked as
|
|
6
|
+
plainly as what failed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.7.0"
|
|
10
|
+
|
|
11
|
+
__all__ = ["__version__"]
|
dataassay/__main__.py
ADDED
dataassay/audit.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Orchestration: profile, infer structure, gate the checks, rank what survives.
|
|
2
|
+
|
|
3
|
+
Coverage is a first-class output, not a footnote. A reader cannot interpret an
|
|
4
|
+
empty findings list without knowing how many checks ran, which were withheld,
|
|
5
|
+
and which are waiting on an answer -- zero findings at 30% coverage and zero at
|
|
6
|
+
95% are entirely different objects, and one number cannot tell them apart.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import duckdb
|
|
15
|
+
|
|
16
|
+
from dataassay import manifest as manifest_mod
|
|
17
|
+
from dataassay import structure as structure_mod
|
|
18
|
+
from dataassay.checks.base import Confidence, Finding
|
|
19
|
+
from dataassay.checks.registry import CATALOG, CATALOG_VERSION
|
|
20
|
+
from dataassay.profile import Profile, build
|
|
21
|
+
from dataassay.provenance import reader_for, source_expr
|
|
22
|
+
from dataassay.structure import Structure
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Coverage:
|
|
27
|
+
ran: list[str] = field(default_factory=list)
|
|
28
|
+
withheld: list[tuple[str, str]] = field(default_factory=list)
|
|
29
|
+
blocked: list[tuple[str, str]] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def total(self) -> int:
|
|
33
|
+
return len(self.ran) + len(self.withheld) + len(self.blocked)
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict:
|
|
36
|
+
return {
|
|
37
|
+
"checks_total": self.total,
|
|
38
|
+
"ran": self.ran,
|
|
39
|
+
"withheld": [{"check": c, "reason": r} for c, r in self.withheld],
|
|
40
|
+
"blocked": [{"check": c, "question": qn} for c, qn in self.blocked],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Audit:
|
|
46
|
+
profile: Profile
|
|
47
|
+
structure: Structure
|
|
48
|
+
findings: list[Finding]
|
|
49
|
+
coverage: Coverage
|
|
50
|
+
catalog_version: str = CATALOG_VERSION
|
|
51
|
+
manifest_path: str | None = None
|
|
52
|
+
# Question codes a person has seen and deliberately declined to answer.
|
|
53
|
+
# An unanswered question and a declined one are different states and the
|
|
54
|
+
# report must not show them as the same thing: the first is work nobody has
|
|
55
|
+
# done yet, the second is a decision, and presenting a decision as an
|
|
56
|
+
# outstanding task is how a report trains people to ignore it.
|
|
57
|
+
skipped_questions: list[str] = field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def open_questions(self) -> list:
|
|
61
|
+
return [q for q in self.profile.questions
|
|
62
|
+
if q.code not in set(self.skipped_questions)]
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def declined_questions(self) -> list:
|
|
66
|
+
return [q for q in self.profile.questions
|
|
67
|
+
if q.code in set(self.skipped_questions)]
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict:
|
|
70
|
+
d = self.profile.to_dict()
|
|
71
|
+
d |= {
|
|
72
|
+
"catalog_version": self.catalog_version,
|
|
73
|
+
"structure": self.structure.to_dict(),
|
|
74
|
+
"coverage": self.coverage.to_dict(),
|
|
75
|
+
"manifest": self.manifest_path,
|
|
76
|
+
"skipped_questions": self.skipped_questions,
|
|
77
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
78
|
+
}
|
|
79
|
+
return d
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _corroborate(findings: list[Finding]) -> list[Finding]:
|
|
83
|
+
"""Promote findings that two independent checks agree on.
|
|
84
|
+
|
|
85
|
+
Agreement is the strongest confidence signal available, and the only one
|
|
86
|
+
that does not depend on trusting a single detector's threshold. A column
|
|
87
|
+
flagged by both a saturation check and a level-shift check is real in a way
|
|
88
|
+
that either alone is not.
|
|
89
|
+
"""
|
|
90
|
+
by_column: dict[str, set[str]] = {}
|
|
91
|
+
for f in findings:
|
|
92
|
+
if f.column:
|
|
93
|
+
by_column.setdefault(f.column, set()).add(f.check_id)
|
|
94
|
+
|
|
95
|
+
out = []
|
|
96
|
+
for f in findings:
|
|
97
|
+
others = by_column.get(f.column or "", set()) - {f.check_id}
|
|
98
|
+
if others and f.confidence.level != "high":
|
|
99
|
+
f = Finding(
|
|
100
|
+
check_id=f.check_id,
|
|
101
|
+
column=f.column,
|
|
102
|
+
disposition=f.disposition,
|
|
103
|
+
summary=f.summary,
|
|
104
|
+
evidence=f.evidence,
|
|
105
|
+
predicate=f.predicate,
|
|
106
|
+
confidence=f.confidence.corroborated_by(", ".join(sorted(others))),
|
|
107
|
+
raw_values=f.raw_values,
|
|
108
|
+
)
|
|
109
|
+
out.append(f)
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def run(
|
|
114
|
+
path: Path,
|
|
115
|
+
byte_cap: int | None = None,
|
|
116
|
+
manifest_path: Path | None = None,
|
|
117
|
+
use_manifest: bool = True,
|
|
118
|
+
) -> Audit:
|
|
119
|
+
from dataassay.checks.base import CheckContext
|
|
120
|
+
from dataassay.rawscan import BYTE_CAP
|
|
121
|
+
|
|
122
|
+
reader = reader_for(path)
|
|
123
|
+
con = duckdb.connect(":memory:")
|
|
124
|
+
try:
|
|
125
|
+
profile = build(path, byte_cap=byte_cap or BYTE_CAP, con=con)
|
|
126
|
+
source = source_expr(reader, profile.provenance.read_mode)
|
|
127
|
+
params = [str(path)]
|
|
128
|
+
manifest = (
|
|
129
|
+
manifest_mod.discover(path, manifest_path) if use_manifest else None
|
|
130
|
+
)
|
|
131
|
+
struct = structure_mod.infer(
|
|
132
|
+
profile.columns, con, source, params, profile.provenance.row_count,
|
|
133
|
+
manifest=manifest,
|
|
134
|
+
)
|
|
135
|
+
ctx = CheckContext(
|
|
136
|
+
profile=profile, structure=struct, con=con, source=source,
|
|
137
|
+
params=params, manifest=manifest,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
coverage = Coverage()
|
|
141
|
+
findings: list[Finding] = []
|
|
142
|
+
for check in CATALOG:
|
|
143
|
+
verdict = check.applies(ctx)
|
|
144
|
+
if not verdict.applicable:
|
|
145
|
+
if verdict.blocked:
|
|
146
|
+
coverage.blocked.append((check.spec.id, verdict.reason))
|
|
147
|
+
else:
|
|
148
|
+
coverage.withheld.append((check.spec.id, verdict.reason))
|
|
149
|
+
continue
|
|
150
|
+
coverage.ran.append(check.spec.id)
|
|
151
|
+
findings.extend(check.run(ctx))
|
|
152
|
+
finally:
|
|
153
|
+
con.close()
|
|
154
|
+
|
|
155
|
+
findings = _corroborate(findings)
|
|
156
|
+
findings.sort(key=lambda f: f.sort_key)
|
|
157
|
+
return Audit(
|
|
158
|
+
profile=profile, structure=struct, findings=findings, coverage=coverage,
|
|
159
|
+
manifest_path=(
|
|
160
|
+
str(manifest.source_path) if manifest and manifest.source_path else None
|
|
161
|
+
),
|
|
162
|
+
skipped_questions=list(manifest.skipped) if manifest else [],
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
__all__ = ["Audit", "Coverage", "Confidence", "run"]
|
dataassay/checks/base.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""What a check is, and what it is allowed to claim.
|
|
2
|
+
|
|
3
|
+
Three ideas carry the design:
|
|
4
|
+
|
|
5
|
+
A check is GATED. It declares the properties it needs, and the engine refuses
|
|
6
|
+
to run it when the profile has not established them. A sigma rule on a
|
|
7
|
+
tail-inflated column is not a weak check, it is an invalid one, and the way to
|
|
8
|
+
stop it producing confident nonsense is to never let it run.
|
|
9
|
+
|
|
10
|
+
A check that could not run is REPORTED. Withheld and blocked checks travel
|
|
11
|
+
beside the findings, because "seasonality undetermined -- 1.2 cycles of
|
|
12
|
+
history" is something the reader needs in order to know what the silence
|
|
13
|
+
means.
|
|
14
|
+
|
|
15
|
+
A finding carries its own PREDICATE. Whatever produced it must be re-runnable
|
|
16
|
+
by the person reading it, without our code. An audit nobody can reproduce is
|
|
17
|
+
an opinion.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import TYPE_CHECKING, Protocol
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
import duckdb
|
|
27
|
+
|
|
28
|
+
from dataassay.columns import ColumnProfile
|
|
29
|
+
from dataassay.manifest import Manifest
|
|
30
|
+
from dataassay.profile import Profile
|
|
31
|
+
from dataassay.structure import Structure
|
|
32
|
+
|
|
33
|
+
# -- disposition ---------------------------------------------------------------
|
|
34
|
+
# Most anomalies in real data are the source's own bookkeeping. A tool that
|
|
35
|
+
# cannot say so gets ignored within a week, so the disposition is part of the
|
|
36
|
+
# finding rather than left to the reader.
|
|
37
|
+
DEFECT = "defect" # very likely wrong
|
|
38
|
+
SUSPECT = "suspect" # wrong-looking, but a benign explanation exists
|
|
39
|
+
BOOKKEEPING = "bookkeeping" # the source doing something legitimate
|
|
40
|
+
|
|
41
|
+
SEVERITY_ORDER = {DEFECT: 0, SUSPECT: 1, BOOKKEEPING: 2}
|
|
42
|
+
|
|
43
|
+
# -- confidence ----------------------------------------------------------------
|
|
44
|
+
HIGH = "high"
|
|
45
|
+
MEDIUM = "medium"
|
|
46
|
+
LOW = "low"
|
|
47
|
+
|
|
48
|
+
_CONFIDENCE_ORDER = {HIGH: 0, MEDIUM: 1, LOW: 2}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Confidence:
|
|
53
|
+
"""Never an opaque score. Each input is named and shown.
|
|
54
|
+
|
|
55
|
+
Agreement between independent checks is the strongest signal available and
|
|
56
|
+
is applied by the engine after all checks have run -- a point flagged by two
|
|
57
|
+
unrelated detectors is real in a way that one 3-sigma hit never is.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
level: str
|
|
61
|
+
inputs: list[str] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict:
|
|
64
|
+
return {"level": self.level, "inputs": list(self.inputs)}
|
|
65
|
+
|
|
66
|
+
def corroborated_by(self, other_check: str) -> Confidence:
|
|
67
|
+
promoted = HIGH if self.level == MEDIUM else self.level
|
|
68
|
+
return Confidence(
|
|
69
|
+
level=promoted,
|
|
70
|
+
inputs=[*self.inputs, f"independently flagged by {other_check}"],
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Finding:
|
|
76
|
+
check_id: str
|
|
77
|
+
column: str | None
|
|
78
|
+
disposition: str
|
|
79
|
+
summary: str
|
|
80
|
+
evidence: dict
|
|
81
|
+
predicate: str
|
|
82
|
+
confidence: Confidence
|
|
83
|
+
# Set when `evidence` carries actual cell values. Acting on a finding
|
|
84
|
+
# usually requires seeing the offending key, so the report shows them -- but
|
|
85
|
+
# anything crossing a network boundary later has to know they are in there.
|
|
86
|
+
raw_values: bool = False
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def sort_key(self) -> tuple[int, int]:
|
|
90
|
+
return (
|
|
91
|
+
SEVERITY_ORDER.get(self.disposition, 9),
|
|
92
|
+
_CONFIDENCE_ORDER.get(self.confidence.level, 9),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def to_dict(self) -> dict:
|
|
96
|
+
return {
|
|
97
|
+
"check_id": self.check_id,
|
|
98
|
+
"column": self.column,
|
|
99
|
+
"disposition": self.disposition,
|
|
100
|
+
"summary": self.summary,
|
|
101
|
+
"evidence": self.evidence,
|
|
102
|
+
"predicate": self.predicate,
|
|
103
|
+
"confidence": self.confidence.to_dict(),
|
|
104
|
+
"raw_values": self.raw_values,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class Applicability:
|
|
110
|
+
"""Why a check will or will not run here.
|
|
111
|
+
|
|
112
|
+
`blocked` distinguishes the two kinds of no. A withheld check is impossible
|
|
113
|
+
on this data and nothing can change that; a blocked one is waiting on an
|
|
114
|
+
answer, and so belongs in the interview queue rather than the limitations
|
|
115
|
+
list.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
applicable: bool
|
|
119
|
+
reason: str = ""
|
|
120
|
+
blocked: bool = False
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def yes() -> Applicability:
|
|
124
|
+
return Applicability(True)
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def no(reason: str) -> Applicability:
|
|
128
|
+
return Applicability(False, reason)
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def needs_answer(question: str) -> Applicability:
|
|
132
|
+
return Applicability(False, question, blocked=True)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class CheckSpec:
|
|
137
|
+
"""The catalog entry. This is the part that is worth more than the code.
|
|
138
|
+
|
|
139
|
+
`not_the_obvious` exists because the most expensive lesson in this corpus is
|
|
140
|
+
that the obvious detector is often the wrong one: counting states finds
|
|
141
|
+
nothing in a panel where absence is encoded as zero, and watching zeros move
|
|
142
|
+
finds nothing in a ragged panel. Where a check has a near neighbour that
|
|
143
|
+
fails, the catalog says so.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
id: str
|
|
147
|
+
name: str
|
|
148
|
+
detects: str
|
|
149
|
+
gate: str
|
|
150
|
+
default_disposition: str
|
|
151
|
+
not_the_obvious: str = ""
|
|
152
|
+
traces_to: str = "" # the real finding that earned this check a place
|
|
153
|
+
|
|
154
|
+
def to_dict(self) -> dict:
|
|
155
|
+
return {
|
|
156
|
+
"id": self.id,
|
|
157
|
+
"name": self.name,
|
|
158
|
+
"detects": self.detects,
|
|
159
|
+
"gate": self.gate,
|
|
160
|
+
"default_disposition": self.default_disposition,
|
|
161
|
+
"not_the_obvious": self.not_the_obvious,
|
|
162
|
+
"traces_to": self.traces_to,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass
|
|
167
|
+
class CheckContext:
|
|
168
|
+
"""Everything a check is allowed to look at.
|
|
169
|
+
|
|
170
|
+
The connection is shared: checks run against one open handle over the same
|
|
171
|
+
source, so a file larger than memory is not re-opened per check.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
profile: Profile
|
|
175
|
+
structure: Structure
|
|
176
|
+
con: duckdb.DuckDBPyConnection
|
|
177
|
+
source: str
|
|
178
|
+
params: list[str]
|
|
179
|
+
manifest: Manifest | None = None
|
|
180
|
+
|
|
181
|
+
def columns(self, kind: str | None = None) -> list[ColumnProfile]:
|
|
182
|
+
cols = self.profile.columns
|
|
183
|
+
return [c for c in cols if kind is None or c.kind == kind]
|
|
184
|
+
|
|
185
|
+
def column(self, name: str) -> ColumnProfile | None:
|
|
186
|
+
return next((c for c in self.profile.columns if c.name == name), None)
|
|
187
|
+
|
|
188
|
+
def holds(self, column: str, prop: str) -> bool:
|
|
189
|
+
"""Has the profile established `prop` for this column?"""
|
|
190
|
+
col = self.column(column)
|
|
191
|
+
if col is None:
|
|
192
|
+
return False
|
|
193
|
+
return any(
|
|
194
|
+
p["property"] == prop and p["holds"] for p in col.observed_properties()
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def fetch(self, sql: str) -> list[tuple]:
|
|
198
|
+
"""Run SQL against the source, binding the path once per reference.
|
|
199
|
+
|
|
200
|
+
A check that names the source twice needs the parameter twice, and
|
|
201
|
+
forgetting that surfaces as an opaque binder error rather than a wrong
|
|
202
|
+
answer -- so the repetition is handled here instead of in every check.
|
|
203
|
+
"""
|
|
204
|
+
placeholders = sql.count("?")
|
|
205
|
+
params = self.params * max(1, placeholders // max(1, len(self.params)))
|
|
206
|
+
return self.con.execute(sql, params).fetchall()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class Check(Protocol):
|
|
210
|
+
spec: CheckSpec
|
|
211
|
+
|
|
212
|
+
def applies(self, ctx: CheckContext) -> Applicability:
|
|
213
|
+
"""Decide from the profile and structure alone, before touching data."""
|
|
214
|
+
...
|
|
215
|
+
|
|
216
|
+
def run(self, ctx: CheckContext) -> list[Finding]:
|
|
217
|
+
...
|