sheet-lint 0.1.0__tar.gz

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.
Files changed (42) hide show
  1. sheet_lint-0.1.0/LICENSE +21 -0
  2. sheet_lint-0.1.0/PKG-INFO +471 -0
  3. sheet_lint-0.1.0/README.md +439 -0
  4. sheet_lint-0.1.0/pyproject.toml +67 -0
  5. sheet_lint-0.1.0/setup.cfg +4 -0
  6. sheet_lint-0.1.0/src/sheet_lint/__init__.py +38 -0
  7. sheet_lint-0.1.0/src/sheet_lint/__main__.py +10 -0
  8. sheet_lint-0.1.0/src/sheet_lint/checks/__init__.py +163 -0
  9. sheet_lint-0.1.0/src/sheet_lint/checks/duplicates.py +303 -0
  10. sheet_lint-0.1.0/src/sheet_lint/checks/formulas.py +290 -0
  11. sheet_lint-0.1.0/src/sheet_lint/checks/headers.py +374 -0
  12. sheet_lint-0.1.0/src/sheet_lint/checks/keys.py +425 -0
  13. sheet_lint-0.1.0/src/sheet_lint/checks/rows.py +237 -0
  14. sheet_lint-0.1.0/src/sheet_lint/checks/types.py +151 -0
  15. sheet_lint-0.1.0/src/sheet_lint/cli.py +170 -0
  16. sheet_lint-0.1.0/src/sheet_lint/context.py +104 -0
  17. sheet_lint-0.1.0/src/sheet_lint/engine.py +177 -0
  18. sheet_lint-0.1.0/src/sheet_lint/errors.py +14 -0
  19. sheet_lint-0.1.0/src/sheet_lint/infer.py +223 -0
  20. sheet_lint-0.1.0/src/sheet_lint/loaders/__init__.py +47 -0
  21. sheet_lint-0.1.0/src/sheet_lint/loaders/csv_loader.py +127 -0
  22. sheet_lint-0.1.0/src/sheet_lint/loaders/xlsx_loader.py +482 -0
  23. sheet_lint-0.1.0/src/sheet_lint/model.py +367 -0
  24. sheet_lint-0.1.0/src/sheet_lint/profile.py +461 -0
  25. sheet_lint-0.1.0/src/sheet_lint/py.typed +0 -0
  26. sheet_lint-0.1.0/src/sheet_lint/report.py +191 -0
  27. sheet_lint-0.1.0/src/sheet_lint/textutil.py +30 -0
  28. sheet_lint-0.1.0/src/sheet_lint.egg-info/PKG-INFO +471 -0
  29. sheet_lint-0.1.0/src/sheet_lint.egg-info/SOURCES.txt +40 -0
  30. sheet_lint-0.1.0/src/sheet_lint.egg-info/dependency_links.txt +1 -0
  31. sheet_lint-0.1.0/src/sheet_lint.egg-info/entry_points.txt +2 -0
  32. sheet_lint-0.1.0/src/sheet_lint.egg-info/requires.txt +4 -0
  33. sheet_lint-0.1.0/src/sheet_lint.egg-info/top_level.txt +1 -0
  34. sheet_lint-0.1.0/tests/test_checks.py +296 -0
  35. sheet_lint-0.1.0/tests/test_cli.py +142 -0
  36. sheet_lint-0.1.0/tests/test_docs.py +51 -0
  37. sheet_lint-0.1.0/tests/test_fixtures.py +107 -0
  38. sheet_lint-0.1.0/tests/test_infer.py +104 -0
  39. sheet_lint-0.1.0/tests/test_loaders.py +234 -0
  40. sheet_lint-0.1.0/tests/test_model.py +220 -0
  41. sheet_lint-0.1.0/tests/test_profile.py +144 -0
  42. sheet_lint-0.1.0/tests/test_report.py +143 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 sheet-lint contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,471 @@
1
+ Metadata-Version: 2.4
2
+ Name: sheet-lint
3
+ Version: 0.1.0
4
+ Summary: Audit one CSV or Excel workbook for the quiet defects that make a spreadsheet return a slightly wrong number.
5
+ Author: devops-gm88
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/devops-gm88/sheet-lint
8
+ Project-URL: Source, https://github.com/devops-gm88/sheet-lint
9
+ Keywords: spreadsheet,csv,xlsx,excel,audit,data-quality,data-validation,cli,standard-library
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Financial and Insurance Industry
13
+ Classifier: Intended Audience :: Information Technology
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Office/Business :: Financial :: Spreadsheet
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.9
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=8.0; extra == "dev"
30
+ Requires-Dist: ruff>=0.6; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # sheet-lint
34
+
35
+ [![CI](https://github.com/devops-gm88/sheet-lint/actions/workflows/ci.yml/badge.svg)](https://github.com/devops-gm88/sheet-lint/actions/workflows/ci.yml)
36
+ [![Python versions](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)](pyproject.toml)
37
+ [![License: MIT](https://img.shields.io/badge/license-MIT-black.svg)](LICENSE)
38
+
39
+ Point it at a CSV or an Excel workbook and it tells you, in ranked order, what
40
+ is quietly wrong with it - with the sheet, column and row behind every claim.
41
+
42
+ Python 3.9+ - **standard library only, zero dependencies** - and no network
43
+ access at any point.
44
+
45
+ ```bash
46
+ python3 -m pip install git+https://github.com/devops-gm88/sheet-lint.git
47
+ ```
48
+
49
+ Not on PyPI yet - the GitHub URL above is the install. It is one console script
50
+ with no dependencies behind it.
51
+
52
+ ```console
53
+ $ sheet-lint invoices.xlsx
54
+ ```
55
+
56
+ ## Why this exists
57
+
58
+ Most spreadsheet problems announce themselves. A deleted tab breaks a formula
59
+ with `#REF!`. A bad paste produces `#VALUE!`. A missing file fails loudly.
60
+
61
+ The expensive ones do not. They are the ones where a number comes out, looks
62
+ plausible, and is wrong:
63
+
64
+ - a `SUM` range that was sized before somebody appended thirty rows, so the
65
+ total is short by exactly those rows and nothing on screen says so;
66
+ - a column of amounts where a few values were pasted in as text, so `SUM`
67
+ skips them while `COUNT`, `AVERAGE` and the sort order each disagree with
68
+ the others;
69
+ - one invoice key typed twice, so that customer's spend is counted twice in
70
+ one report and once in another;
71
+ - two blank rows in the middle of a table, so everything below them fell
72
+ outside the filter, the pivot range and the last paste-down;
73
+ - a column headed `Amount` that holds something else entirely;
74
+ - an identifier carrying a trailing space, so an exact-match lookup silently
75
+ finds nothing and returns a confident blank.
76
+
77
+ None of these raise an error. They produce a number that somebody takes to a
78
+ meeting.
79
+
80
+ sheet-lint reads a file the way a careful auditor would - judging each cell
81
+ against the whole column it sits in - and reports what it found, worst first,
82
+ with a locator for every finding. It is a static analysis pass. It never
83
+ modifies your file.
84
+
85
+ ## Install
86
+
87
+ sheet-lint is not published to PyPI. Install it from a clone:
88
+
89
+ ```console
90
+ git clone https://github.com/devops-gm88/sheet-lint.git
91
+ cd sheet-lint
92
+ python3 -m pip install -e .
93
+ sheet-lint path/to/your-workbook.xlsx
94
+ ```
95
+
96
+ That installs a `sheet-lint` console script. Nothing else gets installed: the
97
+ project declares no dependencies.
98
+
99
+ To try it without installing anything at all, run it straight from the source
100
+ tree:
101
+
102
+ ```console
103
+ $ PYTHONPATH=src python3 -m sheet_lint path/to/your-workbook.xlsx
104
+ ```
105
+
106
+ Or run it on the fixtures that ship with the repository:
107
+
108
+ ```console
109
+ $ sheet-lint tests/fixtures/messy.xlsx
110
+ ```
111
+
112
+ ## What it looks like
113
+
114
+ Real output, from the committed fixture `tests/fixtures/duplicates.csv`
115
+ (19 records, two repeated invoice keys and one customer entered twice):
116
+
117
+ ```console
118
+ sheet-lint 0.1.0
119
+ file tests/fixtures/duplicates.csv (csv, 1 sheet)
120
+ sheet duplicates columns A-G | 19 populated row(s) | identifier: 'Invoice ID' (A)
121
+
122
+ 2 findings (1 high, 1 medium)
123
+
124
+ [1] HIGH duplicate_key
125
+ 19 populated Invoice ID values collapse to 17 distinct identifiers; 2 rows repeat a key that is already in use
126
+ why Every total, join and export that reads this column counts the repeated key twice, so one record quietly becomes two.
127
+ where
128
+ duplicates!A8 key 'INV-1004' repeats the value at A5
129
+ duplicates!A15 key 'INV-1011' repeats the value at A13
130
+
131
+ [2] MEDIUM near_duplicate_records
132
+ 1 row pair matches at 90% or better outside the identifier column (case, spacing and punctuation ignored)
133
+ why Two spellings of one record are two records to every total and every join, so one customer's spend is split across two rows.
134
+ where
135
+ duplicates!A7:G7 96% identical to row 17 outside the identifier column
136
+
137
+ notes
138
+ - 4 checks apply only to .xlsx and did not run on this file: hidden_rows, range_stops_short, hardcoded_in_formula_column, literal_constant_in_formulas
139
+ ```
140
+
141
+ Exit code `1`, because findings exist.
142
+
143
+ Here is the workbook fixture, `tests/fixtures/messy.xlsx`, trimmed after the
144
+ first four findings:
145
+
146
+ ```console
147
+ sheet-lint 0.1.0
148
+ file tests/fixtures/messy.xlsx (xlsx, 2 sheets)
149
+ sheet Orders columns A-G | 16 populated row(s) | identifier: 'Invoice ID' (A)
150
+ sheet Rates columns A-C | 5 populated row(s) | identifier: 'Label' (A)
151
+
152
+ 9 findings (4 high, 4 medium, 1 low)
153
+
154
+ [1] HIGH blank_key_values
155
+ 1 of 15 records has an empty Invoice ID
156
+ why A record with no key cannot be looked up or joined, and an export or pivot that keys on this column drops it without saying so.
157
+ where
158
+ Orders!A9 row has data but no Invoice ID
159
+
160
+ [2] HIGH duplicate_key
161
+ 15 populated Invoice ID values collapse to 14 distinct identifiers; 1 row repeats a key that is already in use
162
+ why Every total, join and export that reads this column counts the repeated key twice, so one record quietly becomes two.
163
+ where
164
+ Orders!A11 key 'INV-1004' repeats the value at A5
165
+
166
+ [3] HIGH range_stops_short
167
+ 1 formula range ends above rows that hold values, so those rows are outside the calculation
168
+ why This is the quiet one: the formula is valid, the result is a number, and it is simply missing the rows below the range, so the sheet understates a total with nothing to indicate it.
169
+ where
170
+ Orders!F14 = SUM(F2:F12) covers F2:F12, but column F holds data through row 17; first value outside the range is F13
171
+
172
+ ... (four more findings follow, including:)
173
+
174
+ [7] MEDIUM hardcoded_in_formula_column
175
+ column F (Amount) is built from formulas in 15 of its 16 populated rows, but 1 cell holds a number typed by hand
176
+ why The typed cell keeps whatever it was given: when the inputs around it change it stays put, so one row of the column quietly disagrees with the other 15.
177
+ where
178
+ Orders!F7 column F is calculated in 15 of 16 populated rows, but this cell holds the typed value '612.5'
179
+
180
+
181
+ [9] LOW literal_constant_in_formulas
182
+ the value 1.13 is written into 5 formulas in this sheet instead of being held in one cell
183
+ why A rate or threshold typed into formulas cannot be found by searching cells, so changing it means changing every formula and missing one has no visible effect.
184
+ where
185
+ Rates!C2 =B2*1.13
186
+ Rates!C3 =B3*1.13
187
+ Rates!C4 =B4*1.13
188
+ Rates!C5 =B5*1.13
189
+ Rates!C6 =B6*1.13
190
+ ```
191
+
192
+ A file with nothing wrong with it says so, and exits `0`:
193
+
194
+ ```console
195
+ sheet-lint 0.1.0
196
+ file tests/fixtures/clean.csv (csv, 1 sheet)
197
+ sheet clean columns A-G | 40 populated row(s) | identifier: 'Invoice ID' (A)
198
+
199
+ no findings
200
+
201
+ notes
202
+ - 4 checks apply only to .xlsx and did not run on this file: hidden_rows, range_stops_short, hardcoded_in_formula_column, literal_constant_in_formulas
203
+ ```
204
+
205
+ ## Exit codes
206
+
207
+ | Code | Meaning |
208
+ | ---- | ------- |
209
+ | `0` | The file was read and nothing was found |
210
+ | `1` | The file was read and at least one finding was reported |
211
+ | `2` | The file could not be read or linted |
212
+
213
+ `--fail-on {high,medium,low}` sets the lowest severity that produces exit code
214
+ `1`, so a pipeline can ignore cosmetic findings:
215
+
216
+ ```console
217
+ $ sheet-lint --fail-on high invoices.xlsx
218
+ ```
219
+
220
+ ## Options
221
+
222
+ ```
223
+ usage: sheet-lint [-h] [--header-row N] [--json] [--max-examples N]
224
+ [--fail-on {high,medium,low}] [--color {auto,always,never}]
225
+ [--quiet] [--list-checks] [--version]
226
+ [path]
227
+
228
+ Audit one CSV or Excel workbook for the defects that make a spreadsheet return
229
+ a slightly wrong number: duplicate and near-duplicate records, unusable key
230
+ columns, numbers stored as text, rows that are silently excluded, formula
231
+ ranges that stop short, and headers that do not match their column.
232
+
233
+ positional arguments:
234
+ path the .csv, .tsv, .txt, .xlsx or .xlsm file to audit
235
+ (required unless --list-checks is used)
236
+
237
+ options:
238
+ -h, --help show this help message and exit
239
+ --header-row N 1-based row holding the column headers (default: 1)
240
+ --json write the report as JSON instead of text
241
+ --max-examples N how many locations to list per finding (default: 8)
242
+ --fail-on {high,medium,low}
243
+ lowest severity that produces exit code 1 (default:
244
+ low)
245
+ --color {auto,always,never}
246
+ colour the severity column (default: auto)
247
+ --quiet print nothing; use the exit code only
248
+ --list-checks list every check and the formats it runs on, then exit
249
+ --version show program's version number and exit
250
+
251
+ exit codes: 0 clean, 1 findings, 2 error. Every finding is reported with the
252
+ sheet, column and row or range it came from.
253
+ ```
254
+
255
+ | Flag | What it does |
256
+ | ---- | ------------ |
257
+ | `--header-row N` | Use row `N` as the header row (default `1`). The report says when a title row above the header looks likely. |
258
+ | `--json` | Emit the same report as JSON, for a script or a findings spreadsheet. |
259
+ | `--max-examples N` | How many locations to list per finding (default `8`). The true count is always reported. |
260
+ | `--fail-on` | Lowest severity producing exit code `1` (default `low`). |
261
+ | `--color` | `auto` (default, honours `NO_COLOR`), `always`, `never`. JSON is never coloured. |
262
+ | `--quiet` | Print nothing; use the exit code only. |
263
+ | `--list-checks` | List every check and the formats it runs on. |
264
+ | `--version` | Print the version. |
265
+
266
+ ### JSON
267
+
268
+ `--json` writes one object carrying the same information as the text report,
269
+ including every locator:
270
+
271
+ ```json
272
+ {
273
+ "tool": "sheet-lint",
274
+ "version": "0.1.0",
275
+ "file": "tests/fixtures/blank_key.csv",
276
+ "format": "csv",
277
+ "sheets": [
278
+ {
279
+ "name": "blank_key",
280
+ "kind": "csv",
281
+ "columns": 7,
282
+ "data_rows": 15,
283
+ "records": 15,
284
+ "first_data_row": 2,
285
+ "last_data_row": 16,
286
+ "identifier_column": {
287
+ "letter": "A",
288
+ "header": "Invoice ID",
289
+ "reason": "column A header \"Invoice ID\" names an identifier",
290
+ "distinct_values": 12
291
+ }
292
+ }
293
+ ],
294
+ "summary": {
295
+ "findings": 1,
296
+ "high": 1,
297
+ "medium": 0,
298
+ ...
299
+ }
300
+ ```
301
+
302
+ ## Use it as a library
303
+
304
+ The CLI is a thin wrapper over the same code:
305
+
306
+ ```python
307
+ from sheet_lint import lint_file
308
+
309
+ result = lint_file("book.xlsx", header_row=1)
310
+
311
+ print(result.clean, len(result.findings), result.severity_counts())
312
+ for finding in result.findings:
313
+ print(finding.severity.value.upper(), finding.code, "-", finding.summary)
314
+ for location in finding.locations:
315
+ print(" ", location.render()) # e.g. Orders!F14 = SUM(F2:F12) ...
316
+ ```
317
+
318
+ ## What it checks
319
+
320
+ 21 checks, as listed by `sheet-lint --list-checks`:
321
+
322
+ ```console
323
+ sheet-lint 0.1.0 runs 21 checks:
324
+ duplicate_key csv, xlsx Duplicate identifier values
325
+ duplicate_rows csv, xlsx Whole rows repeated
326
+ near_duplicate_records csv, xlsx Near-duplicate records
327
+ missing_key_column csv, xlsx No identifier column
328
+ blank_key_values csv, xlsx Records with no identifier
329
+ padded_key_values csv, xlsx Identifier values with stray whitespace
330
+ inconsistent_key_format csv, xlsx Identifier column with mixed conventions
331
+ key_sequence_gap csv, xlsx Gaps in the identifier sequence
332
+ numbers_stored_as_text csv, xlsx Numbers held as text
333
+ mixed_types_in_column csv, xlsx Mixed types in one column
334
+ rows_after_gap csv, xlsx Blank rows with data underneath
335
+ total_row_not_last csv, xlsx A total row is not the last row
336
+ hidden_rows xlsx Hidden rows or columns
337
+ blank_header csv, xlsx Columns with data and no header
338
+ duplicate_header csv, xlsx Two columns with the same header
339
+ header_row_missing csv, xlsx No header row
340
+ header_content_mismatch csv, xlsx Header does not match the column
341
+ possible_title_row csv, xlsx A title row above the header row
342
+ range_stops_short xlsx A formula range stops before the data ends
343
+ hardcoded_in_formula_column xlsx A typed value inside a calculated column
344
+ literal_constant_in_formulas xlsx A constant typed into several formulas
345
+ ```
346
+
347
+ A CSV run is honest about what it skipped: a CSV has no formulas and no hidden
348
+ rows, so the `.xlsx`-only checks do not run, and the report says so in its
349
+ `notes` section instead of implying a clean bill of health.
350
+
351
+ ### How the checks decide
352
+
353
+ - **Records are counted on an identifier, not on rows.** sheet-lint picks one
354
+ identifier column per sheet - by header (`Invoice ID`, `Order Number`,
355
+ `SKU`, `Ref`) or, failing that, the leftmost column whose values are all
356
+ distinct. Findings quote figures like "19 populated Invoice ID values
357
+ collapse to 17 distinct identifiers". Identity ignores case, spacing,
358
+ punctuation and leading zeros, so `007` and `7` are one identifier written
359
+ two ways.
360
+ - **A sheet with no usable identifier is reported as such**
361
+ (`missing_key_column`) rather than keyed on an arbitrary column.
362
+ - **Header checks use a vocabulary of header words.** `Amount`, `Total`,
363
+ `Qty`, `Date`, `Email`, `Invoice ID` and similar words carry an expectation;
364
+ a header sheet-lint does not recognise is left alone instead of guessed at.
365
+ - **Near-duplicate records** are compared on everything except the identifier
366
+ column, at 90% text similarity or better, after blocking rows by vocabulary
367
+ overlap and length so that the comparison stays affordable.
368
+ - **Every finding carries a locator** - a cell such as `Orders!F14` or a range
369
+ such as `Orders!A14:G17`. The `Finding` type refuses to be built without
370
+ one, so an unlocatable claim cannot reach the report.
371
+ - **Excel dates are read through the cell format.** A workbook stores a date
372
+ as a serial number plus a date number format, so `45659` is recognised as a
373
+ date because the cell is formatted as one - which is why `dates.xlsx`, a
374
+ clean workbook of real dates, produces no findings.
375
+ - **Nothing is recalculated.** Formulas are read as stored, cached results
376
+ included.
377
+
378
+ ## What it does not do
379
+
380
+ - **It never changes your file.** It only reads.
381
+ - **It does not recalculate formulas.** Cached values are used as stored, so a
382
+ stale cached value is linted exactly as the file holds it.
383
+ - **It does not know your business rules.** It cannot tell that a formula
384
+ multiplies the wrong two columns, that a price is out of date, or that a
385
+ total is impossible for your business. It finds structural defects, not
386
+ wrong intentions.
387
+ - **It does not read every spreadsheet format.** `.csv`, `.tsv`, `.txt`,
388
+ `.xlsx` and `.xlsm` only - not the legacy binary `.xls`, not `.ods`, not a
389
+ Google Sheets URL, not a password-protected workbook.
390
+ - **`.xlsx` support is a defined subset**: cell values, formulas, shared
391
+ strings, hidden rows and columns, and number formats (used to recognise real
392
+ dates and Text-formatted cells). Charts, pivot tables,
393
+ defined names, conditional formatting and data validation are outside it and
394
+ are skipped without complaint. It never fakes a part it did not read.
395
+ - **CSV has no types, so type checks work by inference.** A CSV cell is text;
396
+ sheet-lint infers what it looks like and reports what it observed.
397
+ - **The near-duplicate scan has a finite pair budget.** On a very large sheet
398
+ the report says when that budget ran out instead of quietly returning a
399
+ short answer.
400
+ - **Silence is not certification.** A check that reports nothing found
401
+ nothing; it is not a statement that the column is correct.
402
+ - **It stops rather than samples.** A file with more than 50,000 rows, or more
403
+ than 1,024 populated columns in a sheet, is reported as an error (exit code
404
+ `2`) instead of being analysed in part. A partial audit that looks complete
405
+ is worse than no audit.
406
+ - **No telemetry, no network, no state.** It reads one file and prints a
407
+ report.
408
+
409
+ ## Tests
410
+
411
+ The suite uses `unittest` from the standard library and needs no runner:
412
+
413
+ ```console
414
+ $ PYTHONPATH=src python3 -m unittest discover -s tests -v
415
+ ```
416
+
417
+ = 152 tests, all passing on Python 3.9 and Python 3.14. After
418
+ `pip install -e .` the same command runs without `PYTHONPATH`.
419
+
420
+ The fixtures in `tests/fixtures/` are the suite's evidence, and each one is a
421
+ small deliberate case:
422
+
423
+ | Fixture | What it demonstrates |
424
+ | ------- | -------------------- |
425
+ | `clean.csv` | 40 tidy records: the tool must find nothing at all |
426
+ | `duplicates.csv` | Two repeated keys and one customer entered twice, one word apart |
427
+ | `blank_key.csv` | Three records with no identifier, on non-sequential account numbers |
428
+ | `trailing_rows.csv` | Two blank rows with six records underneath them |
429
+ | `key_gaps.csv` | An unbroken invoice sequence with three numbers missing |
430
+ | `key_format.csv` | Identifiers with stray whitespace, and two separator conventions |
431
+ | `messy.xlsx` | A workbook with a blank key, a repeated key, numbers stored as text, a typed-over formula cell, a `SUM` that stops five rows short, a hidden row, a misleading header, and a hardcoded rate |
432
+ | `dates.xlsx` | Real Excel dates - serial numbers wearing a date format - in a workbook that must be reported clean |
433
+
434
+ Every fixture is invented. No real workbook contributed to this repository,
435
+ and no organisation or person is named anywhere in it.
436
+
437
+ ### Regenerating the fixtures
438
+
439
+ The `.xlsx` fixture is a real workbook written by a small script that builds
440
+ the OOXML parts with `zipfile` and `xml.etree` - no spreadsheet library
441
+ involved, because the project has no dependencies:
442
+
443
+ ```console
444
+ $ python3 tools/make_fixtures.py
445
+ ```
446
+
447
+ The output is deterministic, and a test asserts that the committed fixtures
448
+ match what the generator produces byte for byte.
449
+
450
+ ## Layout
451
+
452
+ ```
453
+ src/sheet_lint/
454
+ cli.py argument parsing, exit codes, stream handling
455
+ engine.py load, run every applicable check, rank the findings
456
+ model.py Cell, Sheet, Location, Finding, Severity
457
+ infer.py what a value looks like it is (number, date, email, text)
458
+ profile.py per-column measurements, identifier column selection
459
+ context.py the facts handed to each check
460
+ loaders/ csv_loader.py, xlsx_loader.py (zipfile + xml.etree)
461
+ checks/ one module per family of defects
462
+ report.py ranked plain text, or JSON
463
+ tools/
464
+ make_fixtures.py regenerates tests/fixtures
465
+ tests/
466
+ test_*.py unittest suite, one module per area
467
+ ```
468
+
469
+ ## License
470
+
471
+ MIT. See `LICENSE`.