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 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,10 @@
1
+ """Entry point for ``python3 -m sheet_lint``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
@@ -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
+ ]