finelint 0.2.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.
- finelint/__init__.py +3 -0
- finelint/__main__.py +4 -0
- finelint/analyzer.py +461 -0
- finelint/chat.py +752 -0
- finelint/cli.py +291 -0
- finelint/comparison.py +253 -0
- finelint/models.py +96 -0
- finelint/normalize.py +61 -0
- finelint/readers.py +162 -0
- finelint/report.py +187 -0
- finelint-0.2.0.dist-info/METADATA +227 -0
- finelint-0.2.0.dist-info/RECORD +16 -0
- finelint-0.2.0.dist-info/WHEEL +5 -0
- finelint-0.2.0.dist-info/entry_points.txt +2 -0
- finelint-0.2.0.dist-info/licenses/LICENSE +21 -0
- finelint-0.2.0.dist-info/top_level.txt +1 -0
finelint/__init__.py
ADDED
finelint/__main__.py
ADDED
finelint/analyzer.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections import Counter, defaultdict, deque
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .models import Finding, ReadResult, Record, ScanConfig
|
|
9
|
+
from .normalize import (
|
|
10
|
+
character_shingles,
|
|
11
|
+
jaccard,
|
|
12
|
+
minhash_bands,
|
|
13
|
+
minhash_signature,
|
|
14
|
+
normalize_text,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
PREVIEW_LENGTH = 160
|
|
18
|
+
LSH_BUCKET_WINDOW = 64
|
|
19
|
+
MAX_NEAR_CANDIDATES = 32
|
|
20
|
+
MIN_SHARED_MINHASH_VALUES = 2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConfigurationError(Exception):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _type_name(value: Any) -> str:
|
|
28
|
+
if value is None:
|
|
29
|
+
return "null"
|
|
30
|
+
if isinstance(value, bool):
|
|
31
|
+
return "boolean"
|
|
32
|
+
if isinstance(value, str):
|
|
33
|
+
return "string"
|
|
34
|
+
if isinstance(value, (int, float)):
|
|
35
|
+
return "number"
|
|
36
|
+
if isinstance(value, list):
|
|
37
|
+
return "array"
|
|
38
|
+
if isinstance(value, dict):
|
|
39
|
+
return "object"
|
|
40
|
+
return type(value).__name__
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _is_identifier_field(name: str) -> bool:
|
|
44
|
+
lowered = name.casefold()
|
|
45
|
+
return lowered in {"id", "uuid", "guid"} or lowered.endswith("_id")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def choose_text_fields(result: ReadResult, requested: list[str] | None) -> list[str]:
|
|
49
|
+
if requested:
|
|
50
|
+
missing = [field for field in requested if field not in result.fields]
|
|
51
|
+
if missing:
|
|
52
|
+
raise ConfigurationError(f"Unknown field(s): {', '.join(missing)}")
|
|
53
|
+
return list(dict.fromkeys(requested))
|
|
54
|
+
|
|
55
|
+
counters: dict[str, Counter[str]] = {field: Counter() for field in result.fields}
|
|
56
|
+
for record in result.records:
|
|
57
|
+
for field in result.fields:
|
|
58
|
+
if field in record.data and record.data[field] is not None:
|
|
59
|
+
counters[field][_type_name(record.data[field])] += 1
|
|
60
|
+
|
|
61
|
+
selected = []
|
|
62
|
+
for field in result.fields:
|
|
63
|
+
counts = counters[field]
|
|
64
|
+
observed = sum(counts.values())
|
|
65
|
+
if (
|
|
66
|
+
observed
|
|
67
|
+
and counts["string"] / observed >= 0.8
|
|
68
|
+
and not _is_identifier_field(field)
|
|
69
|
+
):
|
|
70
|
+
selected.append(field)
|
|
71
|
+
if not selected:
|
|
72
|
+
raise ConfigurationError(
|
|
73
|
+
"No text fields were detected. Select fields explicitly with --fields."
|
|
74
|
+
)
|
|
75
|
+
return selected
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _ref(record: Record, config: ScanConfig) -> dict[str, Any]:
|
|
79
|
+
value: dict[str, Any] = {"row": record.index, "line": record.line}
|
|
80
|
+
if config.id_field and config.id_field in record.data:
|
|
81
|
+
value["id"] = record.data[config.id_field]
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _preview(value: Any) -> str:
|
|
86
|
+
if value is None:
|
|
87
|
+
return "null"
|
|
88
|
+
text = (
|
|
89
|
+
value
|
|
90
|
+
if isinstance(value, str)
|
|
91
|
+
else json.dumps(value, ensure_ascii=False, sort_keys=True)
|
|
92
|
+
)
|
|
93
|
+
return text if len(text) <= PREVIEW_LENGTH else text[: PREVIEW_LENGTH - 1] + "…"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _raw_key(record: Record, fields: list[str]) -> str:
|
|
97
|
+
values = [[field, record.data.get(field)] for field in fields]
|
|
98
|
+
return json.dumps(
|
|
99
|
+
values, ensure_ascii=False, sort_keys=False, separators=(",", ":")
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _normalized_values(record: Record, fields: list[str]) -> tuple[str, ...]:
|
|
104
|
+
values: list[str] = []
|
|
105
|
+
for field in fields:
|
|
106
|
+
value = record.data.get(field)
|
|
107
|
+
if isinstance(value, str):
|
|
108
|
+
values.append(normalize_text(value))
|
|
109
|
+
elif value is None:
|
|
110
|
+
values.append("\0null")
|
|
111
|
+
else:
|
|
112
|
+
values.append(
|
|
113
|
+
"\0"
|
|
114
|
+
+ json.dumps(
|
|
115
|
+
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
return tuple(values)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _combined_text(record: Record, fields: list[str]) -> str:
|
|
122
|
+
return "\n".join(
|
|
123
|
+
f"{field}: {value}"
|
|
124
|
+
for field, value in zip(fields, _normalized_values(record, fields))
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _validate_config(result: ReadResult, config: ScanConfig, fields: list[str]) -> None:
|
|
129
|
+
known = set(result.fields)
|
|
130
|
+
role_fields = config.input_fields + config.output_fields + config.required_fields
|
|
131
|
+
if config.id_field:
|
|
132
|
+
role_fields.append(config.id_field)
|
|
133
|
+
missing = sorted(set(role_fields) - known)
|
|
134
|
+
if missing:
|
|
135
|
+
raise ConfigurationError(f"Unknown field(s): {', '.join(missing)}")
|
|
136
|
+
if bool(config.input_fields) != bool(config.output_fields):
|
|
137
|
+
raise ConfigurationError(
|
|
138
|
+
"--input-fields and --output-fields must be used together."
|
|
139
|
+
)
|
|
140
|
+
if not 0.0 <= config.similarity_threshold <= 1.0:
|
|
141
|
+
raise ConfigurationError("--similarity-threshold must be between 0 and 1.")
|
|
142
|
+
if config.min_text_length < 1:
|
|
143
|
+
raise ConfigurationError("--min-text-length must be at least 1.")
|
|
144
|
+
if not fields:
|
|
145
|
+
raise ConfigurationError("At least one text field is required.")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _structural_findings(
|
|
149
|
+
result: ReadResult, config: ScanConfig, fields: list[str]
|
|
150
|
+
) -> list[Finding]:
|
|
151
|
+
findings: list[Finding] = []
|
|
152
|
+
required = set(config.required_fields)
|
|
153
|
+
presence = Counter(field for record in result.records for field in record.data)
|
|
154
|
+
common = {
|
|
155
|
+
field
|
|
156
|
+
for field, count in presence.items()
|
|
157
|
+
if result.records and count / len(result.records) >= 0.95
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for record in result.records:
|
|
161
|
+
if not record.data or all(
|
|
162
|
+
value is None or (isinstance(value, str) and not value.strip())
|
|
163
|
+
for value in record.data.values()
|
|
164
|
+
):
|
|
165
|
+
findings.append(
|
|
166
|
+
Finding(
|
|
167
|
+
"EMPTY_RECORD",
|
|
168
|
+
"structural",
|
|
169
|
+
"error",
|
|
170
|
+
"Record has no usable values.",
|
|
171
|
+
[_ref(record, config)],
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
for field in sorted(required):
|
|
175
|
+
value = record.data.get(field)
|
|
176
|
+
if value is None or (isinstance(value, str) and not value.strip()):
|
|
177
|
+
findings.append(
|
|
178
|
+
Finding(
|
|
179
|
+
"MISSING_REQUIRED_FIELD",
|
|
180
|
+
"structural",
|
|
181
|
+
"error",
|
|
182
|
+
f"Required field '{field}' is missing or empty.",
|
|
183
|
+
[_ref(record, config)],
|
|
184
|
+
[field],
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
for field in sorted(common - required):
|
|
188
|
+
if field not in record.data:
|
|
189
|
+
findings.append(
|
|
190
|
+
Finding(
|
|
191
|
+
"MISSING_COMMON_FIELD",
|
|
192
|
+
"structural",
|
|
193
|
+
"warning",
|
|
194
|
+
f"Field '{field}' exists in at least 95% of records but is missing here.",
|
|
195
|
+
[_ref(record, config)],
|
|
196
|
+
[field],
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
for field in fields:
|
|
200
|
+
value = record.data.get(field)
|
|
201
|
+
if value is None:
|
|
202
|
+
if field not in required and field in record.data:
|
|
203
|
+
findings.append(
|
|
204
|
+
Finding(
|
|
205
|
+
"NULL_TEXT_FIELD",
|
|
206
|
+
"structural",
|
|
207
|
+
"warning",
|
|
208
|
+
f"Text field '{field}' is null.",
|
|
209
|
+
[_ref(record, config)],
|
|
210
|
+
[field],
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
elif not isinstance(value, str):
|
|
214
|
+
findings.append(
|
|
215
|
+
Finding(
|
|
216
|
+
"NON_STRING_TEXT_FIELD",
|
|
217
|
+
"structural",
|
|
218
|
+
"error",
|
|
219
|
+
f"Text field '{field}' contains {_type_name(value)}.",
|
|
220
|
+
[_ref(record, config)],
|
|
221
|
+
[field],
|
|
222
|
+
{"preview": _preview(value)},
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
elif not value.strip() and field not in required:
|
|
226
|
+
findings.append(
|
|
227
|
+
Finding(
|
|
228
|
+
"BLANK_TEXT_FIELD",
|
|
229
|
+
"structural",
|
|
230
|
+
"warning",
|
|
231
|
+
f"Text field '{field}' is blank.",
|
|
232
|
+
[_ref(record, config)],
|
|
233
|
+
[field],
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if result.format == "jsonl":
|
|
238
|
+
for field in result.fields:
|
|
239
|
+
typed = [
|
|
240
|
+
(record, _type_name(record.data[field]))
|
|
241
|
+
for record in result.records
|
|
242
|
+
if field in record.data and record.data[field] is not None
|
|
243
|
+
]
|
|
244
|
+
counts = Counter(value_type for _, value_type in typed)
|
|
245
|
+
if len(counts) <= 1:
|
|
246
|
+
continue
|
|
247
|
+
dominant = min(counts.items(), key=lambda item: (-item[1], item[0]))[0]
|
|
248
|
+
for record, value_type in typed:
|
|
249
|
+
if value_type != dominant and not (
|
|
250
|
+
field in fields and value_type != "string"
|
|
251
|
+
):
|
|
252
|
+
findings.append(
|
|
253
|
+
Finding(
|
|
254
|
+
"TYPE_MISMATCH",
|
|
255
|
+
"structural",
|
|
256
|
+
"warning",
|
|
257
|
+
f"Field '{field}' is usually {dominant} but this value is {value_type}.",
|
|
258
|
+
[_ref(record, config)],
|
|
259
|
+
[field],
|
|
260
|
+
{"preview": _preview(record.data[field])},
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
return findings
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _duplicate_findings(
|
|
267
|
+
result: ReadResult, config: ScanConfig, fields: list[str]
|
|
268
|
+
) -> tuple[list[Finding], dict[tuple[str, ...], list[Record]]]:
|
|
269
|
+
findings: list[Finding] = []
|
|
270
|
+
raw_groups: dict[str, list[Record]] = defaultdict(list)
|
|
271
|
+
normalized_groups: dict[tuple[str, ...], list[Record]] = defaultdict(list)
|
|
272
|
+
for record in result.records:
|
|
273
|
+
raw_groups[_raw_key(record, fields)].append(record)
|
|
274
|
+
normalized_groups[_normalized_values(record, fields)].append(record)
|
|
275
|
+
|
|
276
|
+
for group in raw_groups.values():
|
|
277
|
+
if len(group) > 1:
|
|
278
|
+
findings.append(
|
|
279
|
+
Finding(
|
|
280
|
+
"EXACT_DUPLICATE",
|
|
281
|
+
"exact_duplicate",
|
|
282
|
+
"warning",
|
|
283
|
+
f"{len(group)} records contain exactly the same selected field values.",
|
|
284
|
+
[_ref(record, config) for record in group],
|
|
285
|
+
fields,
|
|
286
|
+
{"preview": _preview(_combined_text(group[0], fields))},
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
for normalized_key, group in normalized_groups.items():
|
|
291
|
+
if len(group) <= 1:
|
|
292
|
+
continue
|
|
293
|
+
raw_variants = {_raw_key(record, fields) for record in group}
|
|
294
|
+
if len(raw_variants) > 1:
|
|
295
|
+
findings.append(
|
|
296
|
+
Finding(
|
|
297
|
+
"NORMALIZED_DUPLICATE",
|
|
298
|
+
"normalized_duplicate",
|
|
299
|
+
"warning",
|
|
300
|
+
f"{len(group)} records match after Unicode, casing, and whitespace normalization.",
|
|
301
|
+
[_ref(record, config) for record in group],
|
|
302
|
+
fields,
|
|
303
|
+
{"normalized_preview": _preview("\n".join(normalized_key))},
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
return findings, normalized_groups
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class _UnionFind:
|
|
310
|
+
def __init__(self, size: int) -> None:
|
|
311
|
+
self.parent = list(range(size))
|
|
312
|
+
|
|
313
|
+
def find(self, item: int) -> int:
|
|
314
|
+
while self.parent[item] != item:
|
|
315
|
+
self.parent[item] = self.parent[self.parent[item]]
|
|
316
|
+
item = self.parent[item]
|
|
317
|
+
return item
|
|
318
|
+
|
|
319
|
+
def union(self, left: int, right: int) -> None:
|
|
320
|
+
left_root, right_root = self.find(left), self.find(right)
|
|
321
|
+
if left_root != right_root:
|
|
322
|
+
self.parent[right_root] = left_root
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _near_duplicate_findings(
|
|
326
|
+
result: ReadResult,
|
|
327
|
+
config: ScanConfig,
|
|
328
|
+
fields: list[str],
|
|
329
|
+
normalized_groups: dict[tuple[str, ...], list[Record]],
|
|
330
|
+
) -> list[Finding]:
|
|
331
|
+
representatives = [
|
|
332
|
+
group[0]
|
|
333
|
+
for key, group in normalized_groups.items()
|
|
334
|
+
if any(key)
|
|
335
|
+
and all(isinstance(group[0].data.get(field), str) for field in fields)
|
|
336
|
+
]
|
|
337
|
+
texts = [_combined_text(record, fields) for record in representatives]
|
|
338
|
+
eligible = [
|
|
339
|
+
index for index, text in enumerate(texts) if len(text) >= config.min_text_length
|
|
340
|
+
]
|
|
341
|
+
buckets: dict[tuple[int, int], deque[int]] = defaultdict(
|
|
342
|
+
lambda: deque(maxlen=LSH_BUCKET_WINDOW)
|
|
343
|
+
)
|
|
344
|
+
union = _UnionFind(len(representatives))
|
|
345
|
+
matches: list[tuple[int, int, float]] = []
|
|
346
|
+
|
|
347
|
+
@lru_cache(maxsize=1024)
|
|
348
|
+
def shingles(index: int) -> set[str]:
|
|
349
|
+
return character_shingles(texts[index])
|
|
350
|
+
|
|
351
|
+
for index in eligible:
|
|
352
|
+
signature = minhash_signature(shingles(index))
|
|
353
|
+
keys = minhash_bands(signature)
|
|
354
|
+
candidate_counts: Counter[int] = Counter()
|
|
355
|
+
for key in keys:
|
|
356
|
+
candidate_counts.update(buckets[key])
|
|
357
|
+
candidates = [
|
|
358
|
+
candidate
|
|
359
|
+
for candidate, shared_values in candidate_counts.most_common(
|
|
360
|
+
MAX_NEAR_CANDIDATES
|
|
361
|
+
)
|
|
362
|
+
if shared_values >= MIN_SHARED_MINHASH_VALUES
|
|
363
|
+
]
|
|
364
|
+
current_shingles = shingles(index)
|
|
365
|
+
for other in sorted(candidates):
|
|
366
|
+
score = jaccard(current_shingles, shingles(other))
|
|
367
|
+
if score >= config.similarity_threshold:
|
|
368
|
+
union.union(index, other)
|
|
369
|
+
matches.append((other, index, score))
|
|
370
|
+
for key in keys:
|
|
371
|
+
buckets[key].append(index)
|
|
372
|
+
|
|
373
|
+
components: dict[int, set[int]] = defaultdict(set)
|
|
374
|
+
component_matches: dict[int, list[tuple[int, int, float]]] = defaultdict(list)
|
|
375
|
+
for left, right, score in matches:
|
|
376
|
+
root = union.find(left)
|
|
377
|
+
components[root].update((left, right))
|
|
378
|
+
component_matches[root].append((left, right, score))
|
|
379
|
+
|
|
380
|
+
findings: list[Finding] = []
|
|
381
|
+
for root in sorted(components, key=lambda item: min(components[item])):
|
|
382
|
+
member_indexes = sorted(components[root])
|
|
383
|
+
records: list[Record] = []
|
|
384
|
+
for index in member_indexes:
|
|
385
|
+
key = _normalized_values(representatives[index], fields)
|
|
386
|
+
records.extend(normalized_groups[key])
|
|
387
|
+
edge_values = [
|
|
388
|
+
{
|
|
389
|
+
"left_row": representatives[left].index,
|
|
390
|
+
"right_row": representatives[right].index,
|
|
391
|
+
"similarity": round(score, 6),
|
|
392
|
+
}
|
|
393
|
+
for left, right, score in sorted(component_matches[root])
|
|
394
|
+
]
|
|
395
|
+
findings.append(
|
|
396
|
+
Finding(
|
|
397
|
+
"NEAR_DUPLICATE",
|
|
398
|
+
"near_duplicate",
|
|
399
|
+
"warning",
|
|
400
|
+
f"{len(records)} records form a highly similar group.",
|
|
401
|
+
[
|
|
402
|
+
_ref(record, config)
|
|
403
|
+
for record in sorted(records, key=lambda record: record.index)
|
|
404
|
+
],
|
|
405
|
+
fields,
|
|
406
|
+
{"matches": edge_values, "preview": _preview(texts[member_indexes[0]])},
|
|
407
|
+
)
|
|
408
|
+
)
|
|
409
|
+
return findings
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _conflict_findings(result: ReadResult, config: ScanConfig) -> list[Finding]:
|
|
413
|
+
if not config.input_fields:
|
|
414
|
+
return []
|
|
415
|
+
grouped: dict[tuple[str, ...], list[Record]] = defaultdict(list)
|
|
416
|
+
for record in result.records:
|
|
417
|
+
role_fields = config.input_fields + config.output_fields
|
|
418
|
+
if not all(isinstance(record.data.get(field), str) for field in role_fields):
|
|
419
|
+
continue
|
|
420
|
+
input_key = _normalized_values(record, config.input_fields)
|
|
421
|
+
if not any(input_key):
|
|
422
|
+
continue
|
|
423
|
+
grouped[input_key].append(record)
|
|
424
|
+
|
|
425
|
+
findings: list[Finding] = []
|
|
426
|
+
for input_key, records in grouped.items():
|
|
427
|
+
if len(records) <= 1:
|
|
428
|
+
continue
|
|
429
|
+
outputs: dict[tuple[str, ...], list[Record]] = defaultdict(list)
|
|
430
|
+
for record in records:
|
|
431
|
+
outputs[_normalized_values(record, config.output_fields)].append(record)
|
|
432
|
+
if len(outputs) > 1:
|
|
433
|
+
findings.append(
|
|
434
|
+
Finding(
|
|
435
|
+
"CONFLICTING_OUTPUT",
|
|
436
|
+
"conflicting_output",
|
|
437
|
+
"error",
|
|
438
|
+
"The same normalized input has different normalized outputs.",
|
|
439
|
+
[_ref(record, config) for record in records],
|
|
440
|
+
config.input_fields + config.output_fields,
|
|
441
|
+
{
|
|
442
|
+
"input_preview": _preview("\n".join(input_key)),
|
|
443
|
+
"distinct_outputs": [
|
|
444
|
+
_preview("\n".join(output)) for output in outputs
|
|
445
|
+
],
|
|
446
|
+
},
|
|
447
|
+
)
|
|
448
|
+
)
|
|
449
|
+
return findings
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def analyze(result: ReadResult, config: ScanConfig) -> tuple[list[str], list[Finding]]:
|
|
453
|
+
fields = choose_text_fields(result, config.fields)
|
|
454
|
+
_validate_config(result, config, fields)
|
|
455
|
+
findings = list(result.findings)
|
|
456
|
+
findings.extend(_structural_findings(result, config, fields))
|
|
457
|
+
duplicates, normalized_groups = _duplicate_findings(result, config, fields)
|
|
458
|
+
findings.extend(duplicates)
|
|
459
|
+
findings.extend(_near_duplicate_findings(result, config, fields, normalized_groups))
|
|
460
|
+
findings.extend(_conflict_findings(result, config))
|
|
461
|
+
return fields, findings
|