cleanframe-engine 0.3.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.
- cleanframe/__init__.py +169 -0
- cleanframe/__main__.py +5 -0
- cleanframe/_util.py +438 -0
- cleanframe/_version.py +1 -0
- cleanframe/api.py +559 -0
- cleanframe/cli.py +688 -0
- cleanframe/codegen.py +666 -0
- cleanframe/dataio.py +506 -0
- cleanframe/detectors/__init__.py +43 -0
- cleanframe/detectors/base.py +221 -0
- cleanframe/detectors/categories.py +199 -0
- cleanframe/detectors/contacts.py +105 -0
- cleanframe/detectors/currency.py +112 -0
- cleanframe/detectors/dates.py +204 -0
- cleanframe/detectors/dedup.py +150 -0
- cleanframe/detectors/nulls.py +109 -0
- cleanframe/detectors/outliers.py +73 -0
- cleanframe/detectors/schema_mapping.py +125 -0
- cleanframe/detectors/text.py +105 -0
- cleanframe/detectors/units.py +86 -0
- cleanframe/diff.py +369 -0
- cleanframe/drift.py +283 -0
- cleanframe/errors.py +66 -0
- cleanframe/executor.py +229 -0
- cleanframe/fingerprint.py +83 -0
- cleanframe/issues.py +186 -0
- cleanframe/llm.py +811 -0
- cleanframe/ops.py +1245 -0
- cleanframe/planner.py +353 -0
- cleanframe/profile.py +413 -0
- cleanframe/py.typed +1 -0
- cleanframe/quality.py +81 -0
- cleanframe/readfix.py +160 -0
- cleanframe/recipe.py +398 -0
- cleanframe/report.py +345 -0
- cleanframe/result.py +144 -0
- cleanframe/schema.py +259 -0
- cleanframe/streaming.py +354 -0
- cleanframe/types.py +119 -0
- cleanframe/validate.py +363 -0
- cleanframe/workbook.py +370 -0
- cleanframe_engine-0.3.0.dist-info/METADATA +323 -0
- cleanframe_engine-0.3.0.dist-info/RECORD +46 -0
- cleanframe_engine-0.3.0.dist-info/WHEEL +4 -0
- cleanframe_engine-0.3.0.dist-info/entry_points.txt +2 -0
- cleanframe_engine-0.3.0.dist-info/licenses/LICENSE +201 -0
cleanframe/issues.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Detected problems and the fixes proposed for them.
|
|
2
|
+
|
|
3
|
+
A detector's job is to return :class:`Issues` — a small, ergonomic collection of
|
|
4
|
+
:class:`Issue` objects. Each issue may carry a :class:`Proposal`: the ops and/or
|
|
5
|
+
rename that would fix it. The planner later folds proposals into a recipe.
|
|
6
|
+
|
|
7
|
+
This separation is deliberate. Detectors own *domain knowledge* ("these three
|
|
8
|
+
spellings are the same city"); the planner owns *policy* ("include this fix only
|
|
9
|
+
if confidence clears the bar for the current mode"). Keeping proposals on the
|
|
10
|
+
issue means a rules-only run and an LLM-assisted run share the exact same fix
|
|
11
|
+
vocabulary.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Iterable, Iterator
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .types import Op, Severity
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _cap_examples(values: Iterable[Any], limit: int = 5) -> list[Any]:
|
|
24
|
+
"""Take up to ``limit`` example values for evidence, preserving order."""
|
|
25
|
+
out: list[Any] = []
|
|
26
|
+
for v in values:
|
|
27
|
+
out.append(v)
|
|
28
|
+
if len(out) >= limit:
|
|
29
|
+
break
|
|
30
|
+
return out
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Proposal:
|
|
35
|
+
"""A concrete, replayable fix for an issue: an optional rename plus ops.
|
|
36
|
+
|
|
37
|
+
``ops`` are applied in order to the column. ``rename_to`` (if set) becomes the
|
|
38
|
+
column's output name. The planner may merge proposals from several issues that
|
|
39
|
+
target the same column, concatenating their ops in detector-priority order.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
rename_to: str | None = None
|
|
43
|
+
ops: list[Op] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
def is_empty(self) -> bool:
|
|
46
|
+
return self.rename_to is None and not self.ops
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Issue:
|
|
51
|
+
"""One detected problem, optionally with a proposed fix.
|
|
52
|
+
|
|
53
|
+
Attributes
|
|
54
|
+
----------
|
|
55
|
+
detector:
|
|
56
|
+
Name of the detector that raised it (stamped automatically by the runner).
|
|
57
|
+
column:
|
|
58
|
+
Column the issue pertains to, or ``None`` for dataset-level issues
|
|
59
|
+
(duplicates, for example). Stamped by the runner for column detectors.
|
|
60
|
+
kind:
|
|
61
|
+
A stable machine slug (``"mixed_date_formats"``) used by reports and tests.
|
|
62
|
+
severity:
|
|
63
|
+
:class:`~cleanframe.types.Severity`.
|
|
64
|
+
message:
|
|
65
|
+
Human-readable, one line.
|
|
66
|
+
confidence:
|
|
67
|
+
How sure the detector is, in ``[0, 1]``. The planner gates on this.
|
|
68
|
+
evidence:
|
|
69
|
+
Small JSON-serialisable dict of counts and example values for the report.
|
|
70
|
+
proposal:
|
|
71
|
+
The fix, or ``None`` if the issue is informational / needs a human.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
kind: str
|
|
75
|
+
message: str
|
|
76
|
+
severity: Severity = Severity.WARNING
|
|
77
|
+
column: str | None = None
|
|
78
|
+
detector: str = ""
|
|
79
|
+
confidence: float = 1.0
|
|
80
|
+
evidence: dict[str, Any] = field(default_factory=dict)
|
|
81
|
+
proposal: Proposal | None = None
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def has_fix(self) -> bool:
|
|
85
|
+
return self.proposal is not None and not self.proposal.is_empty()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Issues:
|
|
89
|
+
"""An ordered, list-like collection of :class:`Issue` objects.
|
|
90
|
+
|
|
91
|
+
This is the public return type for detectors (``def detect(series) -> Issues``).
|
|
92
|
+
The :meth:`add` helper is the ergonomic path — it builds an :class:`Issue`,
|
|
93
|
+
appends it, and returns it so evidence can be tweaked inline::
|
|
94
|
+
|
|
95
|
+
issues = cf.Issues()
|
|
96
|
+
issues.add("bad_email", "3 invalid addresses", severity=cf.Severity.ERROR)
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, items: Iterable[Issue] | None = None) -> None:
|
|
100
|
+
self._items: list[Issue] = list(items) if items else []
|
|
101
|
+
|
|
102
|
+
# -- construction ----------------------------------------------------
|
|
103
|
+
def add(
|
|
104
|
+
self,
|
|
105
|
+
kind: str,
|
|
106
|
+
message: str,
|
|
107
|
+
*,
|
|
108
|
+
severity: Severity | str = Severity.WARNING,
|
|
109
|
+
column: str | None = None,
|
|
110
|
+
confidence: float = 1.0,
|
|
111
|
+
evidence: dict[str, Any] | None = None,
|
|
112
|
+
proposal: Proposal | None = None,
|
|
113
|
+
ops: list[Op] | None = None,
|
|
114
|
+
rename_to: str | None = None,
|
|
115
|
+
) -> Issue:
|
|
116
|
+
"""Build, append, and return an :class:`Issue`.
|
|
117
|
+
|
|
118
|
+
``ops`` / ``rename_to`` are a shorthand for constructing a
|
|
119
|
+
:class:`Proposal`; passing them alongside an explicit ``proposal`` is an
|
|
120
|
+
error to avoid silently dropping one.
|
|
121
|
+
"""
|
|
122
|
+
if proposal is not None and (ops or rename_to):
|
|
123
|
+
raise ValueError("Pass either `proposal` or `ops`/`rename_to`, not both.")
|
|
124
|
+
if ops or rename_to:
|
|
125
|
+
proposal = Proposal(rename_to=rename_to, ops=list(ops or []))
|
|
126
|
+
if not isinstance(severity, Severity):
|
|
127
|
+
severity = Severity(str(severity))
|
|
128
|
+
issue = Issue(
|
|
129
|
+
kind=kind,
|
|
130
|
+
message=message,
|
|
131
|
+
severity=severity,
|
|
132
|
+
column=column,
|
|
133
|
+
confidence=confidence,
|
|
134
|
+
evidence=evidence or {},
|
|
135
|
+
proposal=proposal,
|
|
136
|
+
)
|
|
137
|
+
self._items.append(issue)
|
|
138
|
+
return issue
|
|
139
|
+
|
|
140
|
+
def append(self, issue: Issue) -> None:
|
|
141
|
+
self._items.append(issue)
|
|
142
|
+
|
|
143
|
+
def extend(self, other: Iterable[Issue]) -> None:
|
|
144
|
+
self._items.extend(other)
|
|
145
|
+
|
|
146
|
+
# -- querying --------------------------------------------------------
|
|
147
|
+
def for_column(self, column: str | None) -> Issues:
|
|
148
|
+
return Issues(i for i in self._items if i.column == column)
|
|
149
|
+
|
|
150
|
+
def by_severity(self, minimum: Severity) -> Issues:
|
|
151
|
+
return Issues(i for i in self._items if i.severity.rank >= minimum.rank)
|
|
152
|
+
|
|
153
|
+
def with_fixes(self) -> Issues:
|
|
154
|
+
return Issues(i for i in self._items if i.has_fix)
|
|
155
|
+
|
|
156
|
+
def columns(self) -> list[str]:
|
|
157
|
+
seen: list[str] = []
|
|
158
|
+
for i in self._items:
|
|
159
|
+
if i.column is not None and i.column not in seen:
|
|
160
|
+
seen.append(i.column)
|
|
161
|
+
return seen
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def max_severity(self) -> Severity | None:
|
|
165
|
+
if not self._items:
|
|
166
|
+
return None
|
|
167
|
+
return max((i.severity for i in self._items), key=lambda s: s.rank)
|
|
168
|
+
|
|
169
|
+
# -- dunder ----------------------------------------------------------
|
|
170
|
+
def __iter__(self) -> Iterator[Issue]:
|
|
171
|
+
return iter(self._items)
|
|
172
|
+
|
|
173
|
+
def __len__(self) -> int:
|
|
174
|
+
return len(self._items)
|
|
175
|
+
|
|
176
|
+
def __getitem__(self, idx: int) -> Issue:
|
|
177
|
+
return self._items[idx]
|
|
178
|
+
|
|
179
|
+
def __bool__(self) -> bool:
|
|
180
|
+
return bool(self._items)
|
|
181
|
+
|
|
182
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
183
|
+
return f"Issues({len(self._items)} issue(s))"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
__all__ = ["Issue", "Issues", "Proposal", "_cap_examples"]
|