datascope-dq 2.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.
- datascope/__init__.py +5 -0
- datascope/__main__.py +6 -0
- datascope/analyzers/__init__.py +16 -0
- datascope/analyzers/base.py +19 -0
- datascope/analyzers/cardinality.py +112 -0
- datascope/analyzers/format_check.py +232 -0
- datascope/analyzers/sentinel.py +157 -0
- datascope/analyzers/type_consistency.py +134 -0
- datascope/cli.py +276 -0
- datascope/findings/__init__.py +21 -0
- datascope/findings/composer.py +57 -0
- datascope/findings/pipeline.py +39 -0
- datascope/findings/severity.py +47 -0
- datascope/findings/templates.py +355 -0
- datascope/loaders/__init__.py +9 -0
- datascope/loaders/base.py +58 -0
- datascope/loaders/csv_loader.py +179 -0
- datascope/loaders/excel.py +98 -0
- datascope/models.py +93 -0
- datascope/reports/__init__.py +9 -0
- datascope/reports/pdf.py +626 -0
- datascope_dq-2.1.0.dist-info/METADATA +185 -0
- datascope_dq-2.1.0.dist-info/RECORD +27 -0
- datascope_dq-2.1.0.dist-info/WHEEL +5 -0
- datascope_dq-2.1.0.dist-info/entry_points.txt +2 -0
- datascope_dq-2.1.0.dist-info/licenses/LICENSE +21 -0
- datascope_dq-2.1.0.dist-info/top_level.txt +1 -0
datascope/__init__.py
ADDED
datascope/__main__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""datascope.analyzers -- detectors that produce Finding objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datascope.analyzers.type_consistency import analyze_type_consistency
|
|
6
|
+
from datascope.analyzers.sentinel import analyze_sentinels
|
|
7
|
+
from datascope.analyzers.format_check import analyze_leading_zeros, analyze_mixed_dates
|
|
8
|
+
from datascope.analyzers.cardinality import analyze_cardinality
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"analyze_type_consistency",
|
|
12
|
+
"analyze_sentinels",
|
|
13
|
+
"analyze_leading_zeros",
|
|
14
|
+
"analyze_mixed_dates",
|
|
15
|
+
"analyze_cardinality",
|
|
16
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Analyzer contract and registry.
|
|
2
|
+
|
|
3
|
+
There is no abstract base class. An analyzer is any callable matching::
|
|
4
|
+
|
|
5
|
+
Callable[[LoaderResult], list[Finding]]
|
|
6
|
+
|
|
7
|
+
It receives a :class:`~datascope.models.LoaderResult` and returns zero or
|
|
8
|
+
more :class:`~datascope.models.Finding` instances. The pipeline runner
|
|
9
|
+
(U9) will call each registered analyzer and merge the results.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Callable
|
|
15
|
+
|
|
16
|
+
from datascope.models import Finding, LoaderResult
|
|
17
|
+
|
|
18
|
+
#: Type alias documenting the analyzer contract.
|
|
19
|
+
Analyzer = Callable[[LoaderResult], list[Finding]]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Cardinality-anomaly detector.
|
|
2
|
+
|
|
3
|
+
Flags columns that are near-constant (very low uniqueness) or that look
|
|
4
|
+
like ID columns with unexpected duplicates (very high but not 100%
|
|
5
|
+
uniqueness).
|
|
6
|
+
|
|
7
|
+
Produces :class:`~datascope.models.Finding` instances with
|
|
8
|
+
:attr:`~datascope.models.FindingType.NEAR_CONSTANT` or
|
|
9
|
+
:attr:`~datascope.models.FindingType.DUPLICATE_IDS`.
|
|
10
|
+
|
|
11
|
+
Severity is *not* assigned here -- that is the severity classifier's job (U7).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections import Counter
|
|
17
|
+
|
|
18
|
+
from datascope.models import Finding, FindingType, LoaderResult
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Thresholds
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
_MIN_ROWS = 10 # Skip columns with fewer non-null rows
|
|
26
|
+
_NEAR_CONSTANT_MAX = 0.01 # uniqueness_ratio < this => near-constant
|
|
27
|
+
_SUSPECTED_ID_MIN = 0.95 # uniqueness_ratio > this AND < 1.0 => suspected-ID dups
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Detector
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def analyze_cardinality(result: LoaderResult) -> list[Finding]:
|
|
35
|
+
"""Detect cardinality anomalies in each column.
|
|
36
|
+
|
|
37
|
+
Two patterns are flagged:
|
|
38
|
+
|
|
39
|
+
* **Near-constant**: fewer than 1% of values are unique.
|
|
40
|
+
* **Suspected duplicate IDs**: more than 95% unique but less than
|
|
41
|
+
100%, suggesting an ID column with unexpected duplicates.
|
|
42
|
+
|
|
43
|
+
Columns with fewer than 10 non-null rows are skipped because
|
|
44
|
+
cardinality ratios are unreliable for tiny samples.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
result:
|
|
49
|
+
A :class:`~datascope.models.LoaderResult`.
|
|
50
|
+
|
|
51
|
+
Returns
|
|
52
|
+
-------
|
|
53
|
+
list[Finding]
|
|
54
|
+
One finding per column that exhibits a cardinality anomaly.
|
|
55
|
+
"""
|
|
56
|
+
findings: list[Finding] = []
|
|
57
|
+
|
|
58
|
+
for col_name in result.dataframe.columns:
|
|
59
|
+
series = result.dataframe[col_name]
|
|
60
|
+
filled = series.dropna()
|
|
61
|
+
total_count = len(filled)
|
|
62
|
+
|
|
63
|
+
if total_count < _MIN_ROWS:
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
unique_count = filled.nunique()
|
|
67
|
+
uniqueness_ratio = round(unique_count / total_count, 4)
|
|
68
|
+
|
|
69
|
+
if uniqueness_ratio < _NEAR_CONSTANT_MAX:
|
|
70
|
+
# Near-constant column
|
|
71
|
+
value_counts = Counter(filled)
|
|
72
|
+
top_values = [
|
|
73
|
+
{"value": str(val), "count": cnt}
|
|
74
|
+
for val, cnt in value_counts.most_common(5)
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
evidence = {
|
|
78
|
+
"unique_count": unique_count,
|
|
79
|
+
"total_count": total_count,
|
|
80
|
+
"uniqueness_ratio": uniqueness_ratio,
|
|
81
|
+
"top_values": top_values,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
findings.append(Finding(
|
|
85
|
+
field_name=col_name,
|
|
86
|
+
finding_type=FindingType.NEAR_CONSTANT,
|
|
87
|
+
evidence=evidence,
|
|
88
|
+
))
|
|
89
|
+
|
|
90
|
+
elif _SUSPECTED_ID_MIN < uniqueness_ratio < 1.0:
|
|
91
|
+
# Suspected duplicate IDs
|
|
92
|
+
value_counts = Counter(filled)
|
|
93
|
+
duplicate_values = [
|
|
94
|
+
str(val)
|
|
95
|
+
for val, cnt in value_counts.most_common()
|
|
96
|
+
if cnt > 1
|
|
97
|
+
][:10]
|
|
98
|
+
|
|
99
|
+
evidence = {
|
|
100
|
+
"unique_count": unique_count,
|
|
101
|
+
"total_count": total_count,
|
|
102
|
+
"uniqueness_ratio": uniqueness_ratio,
|
|
103
|
+
"duplicate_values": duplicate_values,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
findings.append(Finding(
|
|
107
|
+
field_name=col_name,
|
|
108
|
+
finding_type=FindingType.DUPLICATE_IDS,
|
|
109
|
+
evidence=evidence,
|
|
110
|
+
))
|
|
111
|
+
|
|
112
|
+
return findings
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Format-inconsistency detectors: leading zeros and mixed date formats.
|
|
2
|
+
|
|
3
|
+
Produces :class:`~datascope.models.Finding` instances with
|
|
4
|
+
:attr:`~datascope.models.FindingType.LEADING_ZEROS` or
|
|
5
|
+
:attr:`~datascope.models.FindingType.MIXED_DATES`.
|
|
6
|
+
|
|
7
|
+
Severity is *not* assigned here -- that is the severity classifier's job (U7).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
|
|
15
|
+
from datascope.models import Finding, FindingType, LoaderResult
|
|
16
|
+
from datascope.analyzers.type_consistency import normalize_type
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Leading-zero detector
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
# Matches strings that are purely digits (at least two chars) and start with 0.
|
|
24
|
+
_LEADING_ZERO_RE = re.compile(r"^0\d+$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def analyze_leading_zeros(result: LoaderResult) -> list[Finding]:
|
|
28
|
+
"""Detect leading-zero inconsistencies within string-typed cell values.
|
|
29
|
+
|
|
30
|
+
For each column, examines cells whose Python type is ``str``.
|
|
31
|
+
Among those that consist entirely of digits, checks whether some
|
|
32
|
+
have leading zeros and others do not. Also checks for the
|
|
33
|
+
cross-type case: a column with mostly numeric cells but some
|
|
34
|
+
string cells that carry leading zeros (e.g. ``"00123"`` alongside
|
|
35
|
+
``123``).
|
|
36
|
+
|
|
37
|
+
Leading-zero detection is limited to string-typed cells because
|
|
38
|
+
Excel returns numeric cells as Python numbers with no original
|
|
39
|
+
string representation (openpyxl limitation).
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
result:
|
|
44
|
+
A :class:`~datascope.models.LoaderResult`.
|
|
45
|
+
|
|
46
|
+
Returns
|
|
47
|
+
-------
|
|
48
|
+
list[Finding]
|
|
49
|
+
One finding per column that exhibits leading-zero
|
|
50
|
+
inconsistency.
|
|
51
|
+
"""
|
|
52
|
+
findings: list[Finding] = []
|
|
53
|
+
|
|
54
|
+
for col_name, types_list in result.cell_types.items():
|
|
55
|
+
col_values = list(result.dataframe[col_name])
|
|
56
|
+
|
|
57
|
+
# Collect string-typed values that are purely digits
|
|
58
|
+
with_leading_zero: list[str] = []
|
|
59
|
+
without_leading_zero: list[str] = []
|
|
60
|
+
|
|
61
|
+
for val, ct in zip(col_values, types_list):
|
|
62
|
+
if ct is not str:
|
|
63
|
+
continue
|
|
64
|
+
if val is None:
|
|
65
|
+
continue
|
|
66
|
+
val_str = str(val)
|
|
67
|
+
# Must be purely digits and at least one char
|
|
68
|
+
if not val_str.isdigit() or len(val_str) == 0:
|
|
69
|
+
continue
|
|
70
|
+
if _LEADING_ZERO_RE.match(val_str):
|
|
71
|
+
with_leading_zero.append(val_str)
|
|
72
|
+
else:
|
|
73
|
+
without_leading_zero.append(val_str)
|
|
74
|
+
|
|
75
|
+
leading_zero_count = len(with_leading_zero)
|
|
76
|
+
no_leading_zero_count = len(without_leading_zero)
|
|
77
|
+
|
|
78
|
+
if leading_zero_count == 0:
|
|
79
|
+
# No leading zeros found at all -- nothing to report
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
# Case 1: mixed within string-typed cells (some have zeros, some don't)
|
|
83
|
+
has_string_inconsistency = (
|
|
84
|
+
leading_zero_count > 0 and no_leading_zero_count > 0
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Case 2: cross-type -- string cells with leading zeros alongside
|
|
88
|
+
# numeric cells (the numeric cells implicitly lack leading zeros)
|
|
89
|
+
non_null_types = [ct for ct in types_list if ct is not type(None)]
|
|
90
|
+
normalized = [normalize_type(ct) for ct in non_null_types]
|
|
91
|
+
numeric_count = sum(1 for n in normalized if n == "numeric")
|
|
92
|
+
has_cross_type = leading_zero_count > 0 and numeric_count > 0
|
|
93
|
+
|
|
94
|
+
if not has_string_inconsistency and not has_cross_type:
|
|
95
|
+
# All string-digit values consistently have leading zeros,
|
|
96
|
+
# and no numeric cells exist -- no inconsistency.
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
evidence = {
|
|
100
|
+
"leading_zero_count": leading_zero_count,
|
|
101
|
+
"no_leading_zero_count": no_leading_zero_count,
|
|
102
|
+
"examples_with_zeros": with_leading_zero[:5],
|
|
103
|
+
"examples_without_zeros": without_leading_zero[:5],
|
|
104
|
+
"total_checked": leading_zero_count + no_leading_zero_count,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
findings.append(Finding(
|
|
108
|
+
field_name=col_name,
|
|
109
|
+
finding_type=FindingType.LEADING_ZEROS,
|
|
110
|
+
evidence=evidence,
|
|
111
|
+
))
|
|
112
|
+
|
|
113
|
+
return findings
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Mixed-date-format detector
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
# Common date format patterns to try, ordered from most specific to least.
|
|
121
|
+
_DATE_FORMATS: list[str] = [
|
|
122
|
+
"%Y-%m-%d", # 2026-01-15
|
|
123
|
+
"%Y/%m/%d", # 2026/01/15
|
|
124
|
+
"%m/%d/%Y", # 01/15/2026
|
|
125
|
+
"%d/%m/%Y", # 15/01/2026
|
|
126
|
+
"%m-%d-%Y", # 01-15-2026
|
|
127
|
+
"%d-%m-%Y", # 15-01-2026
|
|
128
|
+
"%m/%d/%y", # 01/15/26
|
|
129
|
+
"%d/%m/%y", # 15/01/26
|
|
130
|
+
"%Y.%m.%d", # 2026.01.15
|
|
131
|
+
"%d.%m.%Y", # 15.01.2026
|
|
132
|
+
"%b %d, %Y", # Jan 15, 2026
|
|
133
|
+
"%B %d, %Y", # January 15, 2026
|
|
134
|
+
"%d %b %Y", # 15 Jan 2026
|
|
135
|
+
"%d %B %Y", # 15 January 2026
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
# Quick pre-filter: a date-like string has at least one digit and a
|
|
139
|
+
# separator or is in month-name form.
|
|
140
|
+
_DATE_LIKE_RE = re.compile(
|
|
141
|
+
r"^\d{1,4}[\-/\.]\d{1,2}[\-/\.]\d{1,4}$"
|
|
142
|
+
r"|^\w+ \d{1,2},? \d{2,4}$"
|
|
143
|
+
r"|^\d{1,2} \w+ \d{2,4}$"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _try_parse_date(value: str) -> str | None:
|
|
148
|
+
"""Try to parse *value* with each known format.
|
|
149
|
+
|
|
150
|
+
Returns the first matching format string, or ``None`` if no
|
|
151
|
+
format matched.
|
|
152
|
+
"""
|
|
153
|
+
value = value.strip()
|
|
154
|
+
for fmt in _DATE_FORMATS:
|
|
155
|
+
try:
|
|
156
|
+
datetime.strptime(value, fmt)
|
|
157
|
+
return fmt
|
|
158
|
+
except ValueError:
|
|
159
|
+
continue
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def analyze_mixed_dates(result: LoaderResult) -> list[Finding]:
|
|
164
|
+
"""Detect columns where date-like strings use multiple date formats.
|
|
165
|
+
|
|
166
|
+
Only inspects string-typed cell values that look date-like.
|
|
167
|
+
If all parseable dates within a column match a single format,
|
|
168
|
+
no finding is produced.
|
|
169
|
+
|
|
170
|
+
Parameters
|
|
171
|
+
----------
|
|
172
|
+
result:
|
|
173
|
+
A :class:`~datascope.models.LoaderResult`.
|
|
174
|
+
|
|
175
|
+
Returns
|
|
176
|
+
-------
|
|
177
|
+
list[Finding]
|
|
178
|
+
One finding per column that contains mixed date formats.
|
|
179
|
+
"""
|
|
180
|
+
findings: list[Finding] = []
|
|
181
|
+
|
|
182
|
+
for col_name, types_list in result.cell_types.items():
|
|
183
|
+
col_values = list(result.dataframe[col_name])
|
|
184
|
+
|
|
185
|
+
# Track which format each date-like string matched
|
|
186
|
+
format_to_examples: dict[str, list[str]] = {}
|
|
187
|
+
total_date_values = 0
|
|
188
|
+
|
|
189
|
+
for val, ct in zip(col_values, types_list):
|
|
190
|
+
if ct is not str:
|
|
191
|
+
continue
|
|
192
|
+
if val is None:
|
|
193
|
+
continue
|
|
194
|
+
val_str = str(val).strip()
|
|
195
|
+
if not val_str:
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
# Quick pre-filter before expensive strptime calls
|
|
199
|
+
if not _DATE_LIKE_RE.match(val_str):
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
fmt = _try_parse_date(val_str)
|
|
203
|
+
if fmt is None:
|
|
204
|
+
# Unparseable -- skip gracefully
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
total_date_values += 1
|
|
208
|
+
if fmt not in format_to_examples:
|
|
209
|
+
format_to_examples[fmt] = []
|
|
210
|
+
if len(format_to_examples[fmt]) < 5:
|
|
211
|
+
format_to_examples[fmt].append(val_str)
|
|
212
|
+
|
|
213
|
+
if len(format_to_examples) <= 1:
|
|
214
|
+
# Zero or one format -- no inconsistency
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
evidence = {
|
|
218
|
+
"formats_found": list(format_to_examples.keys()),
|
|
219
|
+
"examples_per_format": {
|
|
220
|
+
fmt: examples
|
|
221
|
+
for fmt, examples in format_to_examples.items()
|
|
222
|
+
},
|
|
223
|
+
"total_date_values": total_date_values,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
findings.append(Finding(
|
|
227
|
+
field_name=col_name,
|
|
228
|
+
finding_type=FindingType.MIXED_DATES,
|
|
229
|
+
evidence=evidence,
|
|
230
|
+
))
|
|
231
|
+
|
|
232
|
+
return findings
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Sentinel-value detector.
|
|
2
|
+
|
|
3
|
+
Scans each column for hidden sentinel strings -- values like "N/A", "TBD",
|
|
4
|
+
or "pending" that lurk inside otherwise-typed columns. Tools silently drop
|
|
5
|
+
or coerce these, so surfacing them early prevents data loss.
|
|
6
|
+
|
|
7
|
+
Produces a :class:`~datascope.models.Finding` for every non-string column
|
|
8
|
+
that contains sentinel values after frequency disambiguation.
|
|
9
|
+
|
|
10
|
+
Severity is *not* assigned here -- that is the severity classifier's job (U7).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections import Counter
|
|
16
|
+
|
|
17
|
+
from datascope.models import Finding, FindingType, LoaderResult
|
|
18
|
+
from datascope.analyzers.type_consistency import normalize_type
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Default sentinel list
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
DEFAULT_SENTINELS: frozenset[str] = frozenset({
|
|
26
|
+
"n/a",
|
|
27
|
+
"na",
|
|
28
|
+
"n.a.",
|
|
29
|
+
"null",
|
|
30
|
+
"none",
|
|
31
|
+
"nil",
|
|
32
|
+
"tbd",
|
|
33
|
+
"pending",
|
|
34
|
+
"unknown",
|
|
35
|
+
"missing",
|
|
36
|
+
"—", # em-dash
|
|
37
|
+
"-",
|
|
38
|
+
".",
|
|
39
|
+
"..",
|
|
40
|
+
"...",
|
|
41
|
+
"#n/a",
|
|
42
|
+
"#ref!",
|
|
43
|
+
"#value!",
|
|
44
|
+
"#div/0!",
|
|
45
|
+
"#name?",
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Detector
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def analyze_sentinels(
|
|
54
|
+
result: LoaderResult,
|
|
55
|
+
sentinel_list: frozenset[str] | None = None,
|
|
56
|
+
) -> list[Finding]:
|
|
57
|
+
"""Detect hidden sentinel values in non-string columns.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
result:
|
|
62
|
+
A :class:`~datascope.models.LoaderResult` whose ``cell_types``
|
|
63
|
+
dict maps column names to lists of Python ``type`` objects (one
|
|
64
|
+
per row).
|
|
65
|
+
sentinel_list:
|
|
66
|
+
Optional custom set of lowercase sentinel strings to match
|
|
67
|
+
against. Defaults to :data:`DEFAULT_SENTINELS`.
|
|
68
|
+
|
|
69
|
+
Returns
|
|
70
|
+
-------
|
|
71
|
+
list[Finding]
|
|
72
|
+
One finding per non-string column that contains sentinel values
|
|
73
|
+
(after frequency disambiguation). String/categorical columns
|
|
74
|
+
are skipped because sentinels there are not "hidden".
|
|
75
|
+
"""
|
|
76
|
+
sentinels = sentinel_list if sentinel_list is not None else DEFAULT_SENTINELS
|
|
77
|
+
findings: list[Finding] = []
|
|
78
|
+
|
|
79
|
+
for col_name, types_list in result.cell_types.items():
|
|
80
|
+
# 1. Determine majority type (ignoring NoneType)
|
|
81
|
+
non_null_types = [t for t in types_list if t is not type(None)]
|
|
82
|
+
if not non_null_types:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
normalized = [normalize_type(t) for t in non_null_types]
|
|
86
|
+
type_counts = Counter(normalized)
|
|
87
|
+
majority_type = type_counts.most_common(1)[0][0]
|
|
88
|
+
|
|
89
|
+
# 2. Skip string/categorical columns -- sentinels are not hidden there
|
|
90
|
+
if majority_type == "str":
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
# 3. Scan cell values for sentinel matches
|
|
94
|
+
col_values = list(result.dataframe[col_name])
|
|
95
|
+
total_non_null = sum(
|
|
96
|
+
1 for v in col_values if v is not None and str(v).strip() != ""
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if total_non_null == 0:
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
# Collect sentinel hits: track original-case value and count
|
|
103
|
+
sentinel_hits: Counter[str] = Counter() # lowercase -> count
|
|
104
|
+
sentinel_originals: dict[str, str] = {} # lowercase -> first original-case
|
|
105
|
+
for val in col_values:
|
|
106
|
+
if val is None:
|
|
107
|
+
continue
|
|
108
|
+
val_str = str(val)
|
|
109
|
+
if not val_str.strip():
|
|
110
|
+
continue
|
|
111
|
+
val_lower = val_str.lower()
|
|
112
|
+
if val_lower in sentinels:
|
|
113
|
+
sentinel_hits[val_lower] += 1
|
|
114
|
+
if val_lower not in sentinel_originals:
|
|
115
|
+
sentinel_originals[val_lower] = val_str
|
|
116
|
+
|
|
117
|
+
if not sentinel_hits:
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
# 4. Frequency disambiguation: if a specific sentinel is >50%
|
|
121
|
+
# of non-null values, treat it as a legitimate category
|
|
122
|
+
surviving: dict[str, int] = {}
|
|
123
|
+
for val_lower, count in sentinel_hits.items():
|
|
124
|
+
if count / total_non_null <= 0.50:
|
|
125
|
+
surviving[val_lower] = count
|
|
126
|
+
sentinel_hits = Counter(surviving)
|
|
127
|
+
|
|
128
|
+
if not sentinel_hits:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
# 5. Build evidence and produce Finding
|
|
132
|
+
total_sentinel_count = sum(sentinel_hits.values())
|
|
133
|
+
sentinel_pct = round(total_sentinel_count / total_non_null * 100, 2) if total_non_null else 0.0
|
|
134
|
+
|
|
135
|
+
sentinels_found = [
|
|
136
|
+
{
|
|
137
|
+
"value": sentinel_originals[val_lower],
|
|
138
|
+
"count": count,
|
|
139
|
+
"normalized": val_lower,
|
|
140
|
+
}
|
|
141
|
+
for val_lower, count in sentinel_hits.most_common()
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
evidence = {
|
|
145
|
+
"sentinels_found": sentinels_found,
|
|
146
|
+
"column_majority_type": majority_type,
|
|
147
|
+
"total_non_null": total_non_null,
|
|
148
|
+
"sentinel_pct": sentinel_pct,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
findings.append(Finding(
|
|
152
|
+
field_name=col_name,
|
|
153
|
+
finding_type=FindingType.SENTINEL_VALUE,
|
|
154
|
+
evidence=evidence,
|
|
155
|
+
))
|
|
156
|
+
|
|
157
|
+
return findings
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Type-inconsistency detector.
|
|
2
|
+
|
|
3
|
+
Scans each column's ``cell_types`` for mixed Python types that pandas would
|
|
4
|
+
silently coerce. Produces a :class:`~datascope.models.Finding` for every
|
|
5
|
+
column where more than one non-null type is present after normalization.
|
|
6
|
+
|
|
7
|
+
Severity is *not* assigned here -- that is the severity classifier's job.
|
|
8
|
+
The ``evidence`` dict carries all the raw counts and examples that
|
|
9
|
+
downstream stages (severity classifier, NL composer) need.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections import Counter
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from datascope.models import Finding, FindingType, LoaderResult
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Helpers
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
def normalize_type(t: type) -> str:
|
|
25
|
+
"""Map a Python type to a canonical bucket for type-consistency analysis.
|
|
26
|
+
|
|
27
|
+
``int`` and ``float`` are both mapped to ``"numeric"`` so that the
|
|
28
|
+
openpyxl int-vs-float distinction (which is an artefact of the Excel
|
|
29
|
+
number format, not a real type difference) does not create false
|
|
30
|
+
positives.
|
|
31
|
+
|
|
32
|
+
``NoneType`` passes through unchanged -- callers filter it out before
|
|
33
|
+
counting.
|
|
34
|
+
"""
|
|
35
|
+
if t in (int, float):
|
|
36
|
+
return "numeric"
|
|
37
|
+
return t.__name__
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _examples_for_type(
|
|
41
|
+
values: list[Any],
|
|
42
|
+
cell_types: list[type],
|
|
43
|
+
target_type: str,
|
|
44
|
+
limit: int = 5,
|
|
45
|
+
) -> list[Any]:
|
|
46
|
+
"""Return up to *limit* example values whose normalized type matches *target_type*."""
|
|
47
|
+
examples: list[Any] = []
|
|
48
|
+
for val, ct in zip(values, cell_types):
|
|
49
|
+
if normalize_type(ct) == target_type:
|
|
50
|
+
examples.append(val)
|
|
51
|
+
if len(examples) >= limit:
|
|
52
|
+
break
|
|
53
|
+
return examples
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Detector
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def analyze_type_consistency(result: LoaderResult) -> list[Finding]:
|
|
61
|
+
"""Detect columns with mixed non-null types.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
result:
|
|
66
|
+
A :class:`~datascope.models.LoaderResult` whose ``cell_types``
|
|
67
|
+
dict maps column names to lists of Python ``type`` objects (one
|
|
68
|
+
per row).
|
|
69
|
+
|
|
70
|
+
Returns
|
|
71
|
+
-------
|
|
72
|
+
list[Finding]
|
|
73
|
+
One finding per column that has more than one distinct non-null
|
|
74
|
+
type after normalization. Columns that are homogeneous (or
|
|
75
|
+
entirely null) produce no finding.
|
|
76
|
+
"""
|
|
77
|
+
findings: list[Finding] = []
|
|
78
|
+
|
|
79
|
+
for col_name, types_list in result.cell_types.items():
|
|
80
|
+
# 1. Filter out NoneType (null cells don't count as a "type")
|
|
81
|
+
non_null_types = [t for t in types_list if t is not type(None)]
|
|
82
|
+
|
|
83
|
+
if not non_null_types:
|
|
84
|
+
# All null -- nothing to report
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
# 2. Normalize (collapse int/float -> "numeric")
|
|
88
|
+
normalized = [normalize_type(t) for t in non_null_types]
|
|
89
|
+
|
|
90
|
+
# 3. Count types after normalization
|
|
91
|
+
counts = Counter(normalized)
|
|
92
|
+
|
|
93
|
+
if len(counts) <= 1:
|
|
94
|
+
# Homogeneous column -- no finding
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
# 4. Multiple types detected -- build evidence
|
|
98
|
+
total_non_null = len(non_null_types)
|
|
99
|
+
majority_type = counts.most_common(1)[0][0]
|
|
100
|
+
majority_count = counts[majority_type]
|
|
101
|
+
majority_pct = round(majority_count / total_non_null * 100, 2)
|
|
102
|
+
|
|
103
|
+
# Column values for gathering examples
|
|
104
|
+
col_values = list(result.dataframe[col_name])
|
|
105
|
+
|
|
106
|
+
# Build minority_types list (everything except majority)
|
|
107
|
+
minority_types = []
|
|
108
|
+
for type_name, count in counts.most_common():
|
|
109
|
+
if type_name == majority_type:
|
|
110
|
+
continue
|
|
111
|
+
examples = _examples_for_type(
|
|
112
|
+
col_values, types_list, type_name, limit=5,
|
|
113
|
+
)
|
|
114
|
+
minority_types.append({
|
|
115
|
+
"type_name": type_name,
|
|
116
|
+
"count": count,
|
|
117
|
+
"examples": examples,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
evidence = {
|
|
121
|
+
"majority_type": majority_type,
|
|
122
|
+
"majority_count": majority_count,
|
|
123
|
+
"minority_types": minority_types,
|
|
124
|
+
"total_non_null": total_non_null,
|
|
125
|
+
"majority_pct": majority_pct,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
findings.append(Finding(
|
|
129
|
+
field_name=col_name,
|
|
130
|
+
finding_type=FindingType.TYPE_INCONSISTENCY,
|
|
131
|
+
evidence=evidence,
|
|
132
|
+
))
|
|
133
|
+
|
|
134
|
+
return findings
|