sheet-lint 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.
- sheet_lint/__init__.py +38 -0
- sheet_lint/__main__.py +10 -0
- sheet_lint/checks/__init__.py +163 -0
- sheet_lint/checks/duplicates.py +303 -0
- sheet_lint/checks/formulas.py +290 -0
- sheet_lint/checks/headers.py +374 -0
- sheet_lint/checks/keys.py +425 -0
- sheet_lint/checks/rows.py +237 -0
- sheet_lint/checks/types.py +151 -0
- sheet_lint/cli.py +170 -0
- sheet_lint/context.py +104 -0
- sheet_lint/engine.py +177 -0
- sheet_lint/errors.py +14 -0
- sheet_lint/infer.py +223 -0
- sheet_lint/loaders/__init__.py +47 -0
- sheet_lint/loaders/csv_loader.py +127 -0
- sheet_lint/loaders/xlsx_loader.py +482 -0
- sheet_lint/model.py +367 -0
- sheet_lint/profile.py +461 -0
- sheet_lint/py.typed +0 -0
- sheet_lint/report.py +191 -0
- sheet_lint/textutil.py +30 -0
- sheet_lint-0.1.0.dist-info/METADATA +471 -0
- sheet_lint-0.1.0.dist-info/RECORD +28 -0
- sheet_lint-0.1.0.dist-info/WHEEL +5 -0
- sheet_lint-0.1.0.dist-info/entry_points.txt +2 -0
- sheet_lint-0.1.0.dist-info/licenses/LICENSE +21 -0
- sheet_lint-0.1.0.dist-info/top_level.txt +1 -0
sheet_lint/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""sheet-lint - find the quiet defects in a CSV or Excel workbook.
|
|
2
|
+
|
|
3
|
+
The command-line tool is the product; this module is the same audit exposed as
|
|
4
|
+
a library so it can be run from a script or a notebook::
|
|
5
|
+
|
|
6
|
+
from sheet_lint import lint_file
|
|
7
|
+
|
|
8
|
+
result = lint_file("book.xlsx")
|
|
9
|
+
for finding in result.findings:
|
|
10
|
+
print(finding.severity, finding.code, finding.summary)
|
|
11
|
+
for location in finding.locations:
|
|
12
|
+
print(" ", location.render())
|
|
13
|
+
|
|
14
|
+
Standard library only. No dependencies, no network, no state.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from .engine import LintResult, SheetSummary, lint_file, lint_sheets
|
|
20
|
+
from .errors import SheetLintError
|
|
21
|
+
from .model import Finding, Location, Severity
|
|
22
|
+
from .report import render_json, render_text
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"__version__",
|
|
28
|
+
"lint_file",
|
|
29
|
+
"lint_sheets",
|
|
30
|
+
"LintResult",
|
|
31
|
+
"SheetSummary",
|
|
32
|
+
"Finding",
|
|
33
|
+
"Location",
|
|
34
|
+
"Severity",
|
|
35
|
+
"SheetLintError",
|
|
36
|
+
"render_text",
|
|
37
|
+
"render_json",
|
|
38
|
+
]
|
sheet_lint/__main__.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""The registry of checks.
|
|
2
|
+
|
|
3
|
+
Each check is a small function of a :class:`~sheet_lint.context.SheetContext`
|
|
4
|
+
that returns findings. Keeping the registry in one place means the CLI can
|
|
5
|
+
list exactly what it runs, and the README can be checked against it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Callable, List, Sequence, Tuple
|
|
12
|
+
|
|
13
|
+
from ..context import SheetContext
|
|
14
|
+
from ..model import Finding
|
|
15
|
+
from . import duplicates, formulas, headers, keys, rows, types
|
|
16
|
+
|
|
17
|
+
__all__ = ["Check", "CHECKS", "checks_for", "run_checks", "XLSX_ONLY_CODES"]
|
|
18
|
+
|
|
19
|
+
Formats = Tuple[str, ...]
|
|
20
|
+
CheckFunction = Callable[[SheetContext], List[Finding]]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Check:
|
|
25
|
+
"""One named check and the file formats it can run on."""
|
|
26
|
+
|
|
27
|
+
code: str
|
|
28
|
+
title: str
|
|
29
|
+
formats: Formats
|
|
30
|
+
run: CheckFunction
|
|
31
|
+
|
|
32
|
+
def applies_to(self, kind: str) -> bool:
|
|
33
|
+
"""True when this check can run against a sheet of the given kind."""
|
|
34
|
+
return kind in self.formats
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
BOTH: Formats = ("csv", "xlsx")
|
|
38
|
+
XLSX: Formats = ("xlsx",)
|
|
39
|
+
|
|
40
|
+
CHECKS: Tuple[Check, ...] = (
|
|
41
|
+
Check(
|
|
42
|
+
"duplicate_key", "Duplicate identifier values", BOTH,
|
|
43
|
+
duplicates.check_duplicate_key,
|
|
44
|
+
),
|
|
45
|
+
Check(
|
|
46
|
+
"duplicate_rows", "Whole rows repeated", BOTH,
|
|
47
|
+
duplicates.check_exact_duplicate_rows,
|
|
48
|
+
),
|
|
49
|
+
Check(
|
|
50
|
+
"near_duplicate_records", "Near-duplicate records", BOTH,
|
|
51
|
+
duplicates.check_near_duplicates,
|
|
52
|
+
),
|
|
53
|
+
Check(
|
|
54
|
+
"missing_key_column", "No identifier column", BOTH,
|
|
55
|
+
keys.check_missing_key_column,
|
|
56
|
+
),
|
|
57
|
+
Check(
|
|
58
|
+
"blank_key_values", "Records with no identifier", BOTH,
|
|
59
|
+
keys.check_blank_key_values,
|
|
60
|
+
),
|
|
61
|
+
Check(
|
|
62
|
+
"padded_key_values", "Identifier values with stray whitespace", BOTH,
|
|
63
|
+
keys.check_padded_key_values,
|
|
64
|
+
),
|
|
65
|
+
Check(
|
|
66
|
+
"inconsistent_key_format", "Identifier column with mixed conventions",
|
|
67
|
+
BOTH, keys.check_inconsistent_key_format,
|
|
68
|
+
),
|
|
69
|
+
Check(
|
|
70
|
+
"key_sequence_gap", "Gaps in the identifier sequence", BOTH,
|
|
71
|
+
keys.check_key_sequence_gaps,
|
|
72
|
+
),
|
|
73
|
+
Check(
|
|
74
|
+
"numbers_stored_as_text", "Numbers held as text", BOTH,
|
|
75
|
+
types.check_numbers_stored_as_text,
|
|
76
|
+
),
|
|
77
|
+
Check(
|
|
78
|
+
"mixed_types_in_column", "Mixed types in one column", BOTH,
|
|
79
|
+
types.check_mixed_types,
|
|
80
|
+
),
|
|
81
|
+
Check(
|
|
82
|
+
"rows_after_gap", "Blank rows with data underneath", BOTH,
|
|
83
|
+
rows.check_rows_after_gap,
|
|
84
|
+
),
|
|
85
|
+
Check(
|
|
86
|
+
"total_row_not_last", "A total row is not the last row", BOTH,
|
|
87
|
+
rows.check_total_row_not_last,
|
|
88
|
+
),
|
|
89
|
+
Check(
|
|
90
|
+
"hidden_rows", "Hidden rows or columns", XLSX,
|
|
91
|
+
rows.check_hidden_rows,
|
|
92
|
+
),
|
|
93
|
+
Check(
|
|
94
|
+
"blank_header", "Columns with data and no header", BOTH,
|
|
95
|
+
headers.check_blank_headers,
|
|
96
|
+
),
|
|
97
|
+
Check(
|
|
98
|
+
"duplicate_header", "Two columns with the same header", BOTH,
|
|
99
|
+
headers.check_duplicate_headers,
|
|
100
|
+
),
|
|
101
|
+
Check(
|
|
102
|
+
"header_row_missing", "No header row", BOTH,
|
|
103
|
+
headers.check_header_row_missing,
|
|
104
|
+
),
|
|
105
|
+
Check(
|
|
106
|
+
"header_content_mismatch", "Header does not match the column", BOTH,
|
|
107
|
+
headers.check_header_content_mismatch,
|
|
108
|
+
),
|
|
109
|
+
Check(
|
|
110
|
+
"possible_title_row", "A title row above the header row", BOTH,
|
|
111
|
+
headers.check_possible_title_row,
|
|
112
|
+
),
|
|
113
|
+
Check(
|
|
114
|
+
"range_stops_short", "A formula range stops before the data ends", XLSX,
|
|
115
|
+
formulas.check_range_stops_short,
|
|
116
|
+
),
|
|
117
|
+
Check(
|
|
118
|
+
"hardcoded_in_formula_column",
|
|
119
|
+
"A typed value inside a calculated column", XLSX,
|
|
120
|
+
formulas.check_hardcoded_in_formula_column,
|
|
121
|
+
),
|
|
122
|
+
Check(
|
|
123
|
+
"literal_constant_in_formulas",
|
|
124
|
+
"A constant typed into several formulas", XLSX,
|
|
125
|
+
formulas.check_literal_constant_in_formulas,
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
#: Codes that only ever fire on a workbook. Listed in the report so a CSV
|
|
130
|
+
#: result does not look more complete than it is.
|
|
131
|
+
XLSX_ONLY_CODES: Tuple[str, ...] = tuple(
|
|
132
|
+
check.code for check in CHECKS if check.formats == XLSX
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def checks_for(kind: str) -> List[Check]:
|
|
137
|
+
"""Every check that can run against a sheet of the given kind."""
|
|
138
|
+
return [check for check in CHECKS if check.applies_to(kind)]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def run_checks(ctx: SheetContext) -> List[Finding]:
|
|
142
|
+
"""Run every applicable check against one sheet.
|
|
143
|
+
|
|
144
|
+
A check that raises is not allowed to take the report down with it: it is
|
|
145
|
+
recorded as a note and the rest of the audit continues.
|
|
146
|
+
"""
|
|
147
|
+
findings: List[Finding] = []
|
|
148
|
+
for check in checks_for(ctx.sheet.kind):
|
|
149
|
+
try:
|
|
150
|
+
findings.extend(check.run(ctx))
|
|
151
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
152
|
+
ctx.add_note(
|
|
153
|
+
"check %s could not complete on sheet %r: %s: %s"
|
|
154
|
+
% (check.code, ctx.name, type(exc).__name__, exc)
|
|
155
|
+
)
|
|
156
|
+
return findings
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def check_rows() -> Sequence[Tuple[str, str, str]]:
|
|
160
|
+
"""``(code, formats, title)`` for every check, for ``--list-checks``."""
|
|
161
|
+
return [
|
|
162
|
+
(check.code, ", ".join(check.formats), check.title) for check in CHECKS
|
|
163
|
+
]
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Duplicate detection.
|
|
2
|
+
|
|
3
|
+
Duplicates are counted on the identifier column, not on rows. "48 rows, 43
|
|
4
|
+
invoices" is a fact a client can act on; "48 rows" is not. When a sheet has no
|
|
5
|
+
identifier column at all, the fallback is whole-row duplicates, and the report
|
|
6
|
+
says which rule was used.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import difflib
|
|
12
|
+
import re
|
|
13
|
+
from typing import Dict, List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
from .. import infer
|
|
16
|
+
from ..context import (
|
|
17
|
+
MAX_LOCATIONS,
|
|
18
|
+
MAX_PAIR_COMPARISONS,
|
|
19
|
+
NEAR_DUPLICATE_RATIO,
|
|
20
|
+
SheetContext,
|
|
21
|
+
)
|
|
22
|
+
from ..model import Finding, Location, Severity
|
|
23
|
+
from ..textutil import plural, verb
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"check_duplicate_key",
|
|
27
|
+
"check_exact_duplicate_rows",
|
|
28
|
+
"check_near_duplicates",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
_TOKEN = re.compile(r"[a-z0-9]+")
|
|
32
|
+
|
|
33
|
+
#: Rows must share this much of their vocabulary before the slow comparison
|
|
34
|
+
#: runs, and their lengths must be within this fraction of each other.
|
|
35
|
+
_JACCARD_FLOOR = 0.40
|
|
36
|
+
_LENGTH_TOLERANCE = 0.25
|
|
37
|
+
|
|
38
|
+
#: A row with fewer than this many distinct tokens is too thin to compare.
|
|
39
|
+
_MIN_TOKENS = 2
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _key_cells(ctx: SheetContext) -> List[Tuple[int, str]]:
|
|
43
|
+
"""Return ``(row, text)`` for every populated identifier cell."""
|
|
44
|
+
assert ctx.key is not None
|
|
45
|
+
cells: List[Tuple[int, str]] = []
|
|
46
|
+
for row in ctx.sheet.data_row_numbers():
|
|
47
|
+
cell = ctx.sheet.cell(row, ctx.key.index)
|
|
48
|
+
if cell is None or cell.is_empty:
|
|
49
|
+
continue
|
|
50
|
+
cells.append((row, cell.text))
|
|
51
|
+
return cells
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def check_duplicate_key(ctx: SheetContext) -> List[Finding]:
|
|
55
|
+
"""Report rows that repeat an identifier already used by another row."""
|
|
56
|
+
key = ctx.key
|
|
57
|
+
if key is None:
|
|
58
|
+
return []
|
|
59
|
+
cells = _key_cells(ctx)
|
|
60
|
+
if len(cells) < 2:
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
groups: Dict[str, List[Tuple[int, str]]] = {}
|
|
64
|
+
for row, text in cells:
|
|
65
|
+
groups.setdefault(infer.identity_key(text), []).append((row, text))
|
|
66
|
+
|
|
67
|
+
repeated = [entries for entries in groups.values() if len(entries) > 1]
|
|
68
|
+
if not repeated:
|
|
69
|
+
return []
|
|
70
|
+
repeated.sort(key=lambda entries: entries[0][0])
|
|
71
|
+
extra_rows = sum(len(entries) - 1 for entries in repeated)
|
|
72
|
+
|
|
73
|
+
locations: List[Location] = []
|
|
74
|
+
for entries in repeated:
|
|
75
|
+
first_row, first_raw = entries[0]
|
|
76
|
+
for row, raw in entries[1:]:
|
|
77
|
+
detail = "key %r repeats the value at %s" % (
|
|
78
|
+
raw,
|
|
79
|
+
ctx.cell_ref(first_row, key.index),
|
|
80
|
+
)
|
|
81
|
+
if infer.fold(raw) != infer.fold(first_raw):
|
|
82
|
+
detail += " (written differently: %r)" % (first_raw,)
|
|
83
|
+
locations.append(
|
|
84
|
+
Location(
|
|
85
|
+
sheet=ctx.name,
|
|
86
|
+
ref=ctx.cell_ref(row, key.index),
|
|
87
|
+
detail=detail,
|
|
88
|
+
row=row,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
if len(locations) >= MAX_LOCATIONS:
|
|
92
|
+
break
|
|
93
|
+
if len(locations) >= MAX_LOCATIONS:
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
return [
|
|
97
|
+
Finding(
|
|
98
|
+
code="duplicate_key",
|
|
99
|
+
title="Duplicate values in the identifier column",
|
|
100
|
+
severity=Severity.HIGH,
|
|
101
|
+
sheet=ctx.name,
|
|
102
|
+
summary=(
|
|
103
|
+
"%d populated %s values collapse to %d distinct identifiers; "
|
|
104
|
+
"%s %s a key that is already in use"
|
|
105
|
+
% (
|
|
106
|
+
len(cells),
|
|
107
|
+
key.header or key.letter,
|
|
108
|
+
len(groups),
|
|
109
|
+
plural(extra_rows, "row"),
|
|
110
|
+
verb(extra_rows, "repeats", "repeat"),
|
|
111
|
+
)
|
|
112
|
+
),
|
|
113
|
+
locations=locations,
|
|
114
|
+
why=(
|
|
115
|
+
"Every total, join and export that reads this column counts the "
|
|
116
|
+
"repeated key twice, so one record quietly becomes two."
|
|
117
|
+
),
|
|
118
|
+
count=extra_rows,
|
|
119
|
+
)
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def check_exact_duplicate_rows(ctx: SheetContext) -> List[Finding]:
|
|
124
|
+
"""Report byte-identical rows, used only when there is no identifier column."""
|
|
125
|
+
if ctx.key is not None:
|
|
126
|
+
return []
|
|
127
|
+
sheet = ctx.sheet
|
|
128
|
+
groups: Dict[str, List[int]] = {}
|
|
129
|
+
for row in sheet.data_row_numbers():
|
|
130
|
+
if sheet.row_is_blank(row):
|
|
131
|
+
continue
|
|
132
|
+
signature = "\x1f".join(
|
|
133
|
+
infer.fold(sheet.cell(row, col).text)
|
|
134
|
+
for col in range(1, sheet.n_cols + 1)
|
|
135
|
+
if sheet.cell(row, col) is not None and not sheet.cell(row, col).is_empty
|
|
136
|
+
)
|
|
137
|
+
if not signature.strip("\x1f"):
|
|
138
|
+
continue
|
|
139
|
+
groups.setdefault(signature, []).append(row)
|
|
140
|
+
|
|
141
|
+
repeated = [rows for rows in groups.values() if len(rows) > 1]
|
|
142
|
+
if not repeated:
|
|
143
|
+
return []
|
|
144
|
+
repeated.sort(key=lambda rows: rows[0])
|
|
145
|
+
extra_rows = sum(len(rows) - 1 for rows in repeated)
|
|
146
|
+
locations: List[Location] = []
|
|
147
|
+
for rows in repeated:
|
|
148
|
+
first = rows[0]
|
|
149
|
+
for row in rows[1:]:
|
|
150
|
+
locations.append(
|
|
151
|
+
Location(
|
|
152
|
+
sheet=ctx.name,
|
|
153
|
+
ref=ctx.row_ref(row),
|
|
154
|
+
detail="identical to row %d in every column" % first,
|
|
155
|
+
row=row,
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
if len(locations) >= MAX_LOCATIONS:
|
|
159
|
+
break
|
|
160
|
+
if len(locations) >= MAX_LOCATIONS:
|
|
161
|
+
break
|
|
162
|
+
|
|
163
|
+
return [
|
|
164
|
+
Finding(
|
|
165
|
+
code="duplicate_rows",
|
|
166
|
+
title="Whole rows repeated",
|
|
167
|
+
severity=Severity.HIGH,
|
|
168
|
+
sheet=ctx.name,
|
|
169
|
+
summary=(
|
|
170
|
+
"%s %s another row in all %d populated columns, once case and "
|
|
171
|
+
"spacing are ignored"
|
|
172
|
+
% (
|
|
173
|
+
plural(extra_rows, "row"),
|
|
174
|
+
verb(extra_rows, "matches", "match"),
|
|
175
|
+
sheet.n_cols,
|
|
176
|
+
)
|
|
177
|
+
),
|
|
178
|
+
locations=locations,
|
|
179
|
+
why=(
|
|
180
|
+
"With no identifier column there is nothing to count records on, "
|
|
181
|
+
"so repeated rows are counted twice by anything that totals this "
|
|
182
|
+
"sheet."
|
|
183
|
+
),
|
|
184
|
+
count=extra_rows,
|
|
185
|
+
)
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _record_text(ctx: SheetContext, row: int, skip_col: Optional[int]) -> str:
|
|
190
|
+
"""Fold one row into a comparable string, ignoring the identifier column."""
|
|
191
|
+
parts: List[str] = []
|
|
192
|
+
for col in range(1, ctx.sheet.n_cols + 1):
|
|
193
|
+
if col == skip_col:
|
|
194
|
+
continue
|
|
195
|
+
cell = ctx.sheet.cell(row, col)
|
|
196
|
+
if cell is None or cell.is_empty:
|
|
197
|
+
continue
|
|
198
|
+
parts.append(infer.fold(cell.text))
|
|
199
|
+
return " ".join(part for part in parts if part)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def check_near_duplicates(ctx: SheetContext) -> List[Finding]:
|
|
203
|
+
"""Report rows that are nearly identical outside the identifier column.
|
|
204
|
+
|
|
205
|
+
Comparison is quadratic in the worst case, so rows are first blocked by
|
|
206
|
+
vocabulary overlap and length; the pair budget is finite and, if it runs
|
|
207
|
+
out, the report says so instead of quietly returning a short answer.
|
|
208
|
+
"""
|
|
209
|
+
sheet = ctx.sheet
|
|
210
|
+
skip_col = ctx.key.index if ctx.key is not None else None
|
|
211
|
+
records: List[Tuple[int, str, str, frozenset]] = []
|
|
212
|
+
for row in sheet.data_row_numbers():
|
|
213
|
+
if sheet.row_is_blank(row):
|
|
214
|
+
continue
|
|
215
|
+
text = _record_text(ctx, row, skip_col)
|
|
216
|
+
tokens = frozenset(_TOKEN.findall(text))
|
|
217
|
+
if len(tokens) < _MIN_TOKENS:
|
|
218
|
+
continue
|
|
219
|
+
key_text = ""
|
|
220
|
+
if ctx.key is not None:
|
|
221
|
+
cell = sheet.cell(row, ctx.key.index)
|
|
222
|
+
key_text = infer.identity_key(cell.text) if cell is not None else ""
|
|
223
|
+
records.append((row, text, key_text, tokens))
|
|
224
|
+
|
|
225
|
+
if len(records) < 2:
|
|
226
|
+
return []
|
|
227
|
+
|
|
228
|
+
pairs: List[Tuple[int, int, float]] = []
|
|
229
|
+
comparisons = 0
|
|
230
|
+
truncated = False
|
|
231
|
+
for i in range(len(records) - 1):
|
|
232
|
+
row_a, text_a, key_a, tokens_a = records[i]
|
|
233
|
+
for j in range(i + 1, len(records)):
|
|
234
|
+
row_b, text_b, key_b, tokens_b = records[j]
|
|
235
|
+
if comparisons >= MAX_PAIR_COMPARISONS:
|
|
236
|
+
truncated = True
|
|
237
|
+
break
|
|
238
|
+
comparisons += 1
|
|
239
|
+
if key_a and key_a == key_b:
|
|
240
|
+
continue # already reported as a duplicate key
|
|
241
|
+
if ctx.key is None and text_a == text_b:
|
|
242
|
+
continue # a whole-row duplicate, which duplicate_rows reports
|
|
243
|
+
longest = max(len(text_a), len(text_b)) or 1
|
|
244
|
+
if abs(len(text_a) - len(text_b)) / float(longest) > _LENGTH_TOLERANCE:
|
|
245
|
+
continue
|
|
246
|
+
union = tokens_a | tokens_b
|
|
247
|
+
if not union:
|
|
248
|
+
continue
|
|
249
|
+
overlap = len(tokens_a & tokens_b) / float(len(union))
|
|
250
|
+
if overlap < _JACCARD_FLOOR:
|
|
251
|
+
continue
|
|
252
|
+
matcher = difflib.SequenceMatcher(None, text_a, text_b)
|
|
253
|
+
if matcher.real_quick_ratio() < NEAR_DUPLICATE_RATIO:
|
|
254
|
+
continue
|
|
255
|
+
ratio = matcher.ratio()
|
|
256
|
+
if ratio >= NEAR_DUPLICATE_RATIO:
|
|
257
|
+
pairs.append((row_a, row_b, ratio))
|
|
258
|
+
if truncated:
|
|
259
|
+
break
|
|
260
|
+
|
|
261
|
+
if truncated:
|
|
262
|
+
ctx.add_note(
|
|
263
|
+
"near-duplicate scan stopped after %d row comparisons; %d rows were "
|
|
264
|
+
"compared and the tail of the sheet was not cross-checked"
|
|
265
|
+
% (MAX_PAIR_COMPARISONS, len(records))
|
|
266
|
+
)
|
|
267
|
+
if not pairs:
|
|
268
|
+
return []
|
|
269
|
+
|
|
270
|
+
pairs.sort(key=lambda pair: (-pair[2], pair[0], pair[1]))
|
|
271
|
+
locations = [
|
|
272
|
+
Location(
|
|
273
|
+
sheet=ctx.name,
|
|
274
|
+
ref=ctx.row_ref(row_a),
|
|
275
|
+
detail="%d%% identical to row %d outside the identifier column"
|
|
276
|
+
% (round(ratio * 100), row_b),
|
|
277
|
+
row=row_a,
|
|
278
|
+
)
|
|
279
|
+
for row_a, row_b, ratio in pairs[:MAX_LOCATIONS]
|
|
280
|
+
]
|
|
281
|
+
return [
|
|
282
|
+
Finding(
|
|
283
|
+
code="near_duplicate_records",
|
|
284
|
+
title="Near-duplicate records",
|
|
285
|
+
severity=Severity.MEDIUM,
|
|
286
|
+
sheet=ctx.name,
|
|
287
|
+
summary=(
|
|
288
|
+
"%s %s at %d%% or better outside the identifier column (case, "
|
|
289
|
+
"spacing and punctuation ignored)"
|
|
290
|
+
% (
|
|
291
|
+
plural(len(pairs), "row pair"),
|
|
292
|
+
verb(len(pairs), "matches", "match"),
|
|
293
|
+
round(NEAR_DUPLICATE_RATIO * 100),
|
|
294
|
+
)
|
|
295
|
+
),
|
|
296
|
+
locations=locations,
|
|
297
|
+
why=(
|
|
298
|
+
"Two spellings of one record are two records to every total and "
|
|
299
|
+
"every join, so one customer's spend is split across two rows."
|
|
300
|
+
),
|
|
301
|
+
count=len(pairs),
|
|
302
|
+
)
|
|
303
|
+
]
|