sheet-lint
Point it at a CSV or an Excel workbook and it tells you, in ranked order, what is quietly wrong with it - with the sheet, column and row behind every claim.
Python 3.9+ - standard library only, zero dependencies - and no network access at any point.
python3 -m pip install git+https://github.com/devops-gm88/sheet-lint.git
Not on PyPI yet - the GitHub URL above is the install. It is one console script with no dependencies behind it.
$ sheet-lint invoices.xlsx
Why this exists
Most spreadsheet problems announce themselves. A deleted tab breaks a formula
with #REF!. A bad paste produces #VALUE!. A missing file fails loudly.
The expensive ones do not. They are the ones where a number comes out, looks plausible, and is wrong:
- a
SUMrange that was sized before somebody appended thirty rows, so the total is short by exactly those rows and nothing on screen says so; - a column of amounts where a few values were pasted in as text, so
SUMskips them whileCOUNT,AVERAGEand the sort order each disagree with the others; - one invoice key typed twice, so that customer's spend is counted twice in one report and once in another;
- two blank rows in the middle of a table, so everything below them fell outside the filter, the pivot range and the last paste-down;
- a column headed
Amountthat holds something else entirely; - an identifier carrying a trailing space, so an exact-match lookup silently finds nothing and returns a confident blank.
None of these raise an error. They produce a number that somebody takes to a meeting.
sheet-lint reads a file the way a careful auditor would - judging each cell against the whole column it sits in - and reports what it found, worst first, with a locator for every finding. It is a static analysis pass. It never modifies your file.
Install
sheet-lint is not published to PyPI. Install it from a clone:
git clone https://github.com/devops-gm88/sheet-lint.git
cd sheet-lint
python3 -m pip install -e .
sheet-lint path/to/your-workbook.xlsx
That installs a sheet-lint console script. Nothing else gets installed: the
project declares no dependencies.
To try it without installing anything at all, run it straight from the source tree:
$ PYTHONPATH=src python3 -m sheet_lint path/to/your-workbook.xlsx
Or run it on the fixtures that ship with the repository:
$ sheet-lint tests/fixtures/messy.xlsx
What it looks like
Real output, from the committed fixture tests/fixtures/duplicates.csv
(19 records, two repeated invoice keys and one customer entered twice):
sheet-lint 0.1.0
file tests/fixtures/duplicates.csv (csv, 1 sheet)
sheet duplicates columns A-G | 19 populated row(s) | identifier: 'Invoice ID' (A)
2 findings (1 high, 1 medium)
[1] HIGH duplicate_key
19 populated Invoice ID values collapse to 17 distinct identifiers; 2 rows repeat a key that is already in use
why Every total, join and export that reads this column counts the repeated key twice, so one record quietly becomes two.
where
duplicates!A8 key 'INV-1004' repeats the value at A5
duplicates!A15 key 'INV-1011' repeats the value at A13
[2] MEDIUM near_duplicate_records
1 row pair matches at 90% or better outside the identifier column (case, spacing and punctuation ignored)
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.
where
duplicates!A7:G7 96% identical to row 17 outside the identifier column
notes
- 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
Exit code 1, because findings exist.
Here is the workbook fixture, tests/fixtures/messy.xlsx, trimmed after the
first four findings:
sheet-lint 0.1.0
file tests/fixtures/messy.xlsx (xlsx, 2 sheets)
sheet Orders columns A-G | 16 populated row(s) | identifier: 'Invoice ID' (A)
sheet Rates columns A-C | 5 populated row(s) | identifier: 'Label' (A)
9 findings (4 high, 4 medium, 1 low)
[1] HIGH blank_key_values
1 of 15 records has an empty Invoice ID
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.
where
Orders!A9 row has data but no Invoice ID
[2] HIGH duplicate_key
15 populated Invoice ID values collapse to 14 distinct identifiers; 1 row repeats a key that is already in use
why Every total, join and export that reads this column counts the repeated key twice, so one record quietly becomes two.
where
Orders!A11 key 'INV-1004' repeats the value at A5
[3] HIGH range_stops_short
1 formula range ends above rows that hold values, so those rows are outside the calculation
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.
where
Orders!F14 = SUM(F2:F12) covers F2:F12, but column F holds data through row 17; first value outside the range is F13
... (four more findings follow, including:)
[7] MEDIUM hardcoded_in_formula_column
column F (Amount) is built from formulas in 15 of its 16 populated rows, but 1 cell holds a number typed by hand
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.
where
Orders!F7 column F is calculated in 15 of 16 populated rows, but this cell holds the typed value '612.5'
[9] LOW literal_constant_in_formulas
the value 1.13 is written into 5 formulas in this sheet instead of being held in one cell
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.
where
Rates!C2 =B2*1.13
Rates!C3 =B3*1.13
Rates!C4 =B4*1.13
Rates!C5 =B5*1.13
Rates!C6 =B6*1.13
A file with nothing wrong with it says so, and exits 0:
sheet-lint 0.1.0
file tests/fixtures/clean.csv (csv, 1 sheet)
sheet clean columns A-G | 40 populated row(s) | identifier: 'Invoice ID' (A)
no findings
notes
- 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
Exit codes
| Code | Meaning |
|---|---|
0 |
The file was read and nothing was found |
1 |
The file was read and at least one finding was reported |
2 |
The file could not be read or linted |
--fail-on {high,medium,low} sets the lowest severity that produces exit code
1, so a pipeline can ignore cosmetic findings:
$ sheet-lint --fail-on high invoices.xlsx
Options
usage: sheet-lint [-h] [--header-row N] [--json] [--max-examples N]
[--fail-on {high,medium,low}] [--color {auto,always,never}]
[--quiet] [--list-checks] [--version]
[path]
Audit one CSV or Excel workbook for the defects that make a spreadsheet return
a slightly wrong number: duplicate and near-duplicate records, unusable key
columns, numbers stored as text, rows that are silently excluded, formula
ranges that stop short, and headers that do not match their column.
positional arguments:
path the .csv, .tsv, .txt, .xlsx or .xlsm file to audit
(required unless --list-checks is used)
options:
-h, --help show this help message and exit
--header-row N 1-based row holding the column headers (default: 1)
--json write the report as JSON instead of text
--max-examples N how many locations to list per finding (default: 8)
--fail-on {high,medium,low}
lowest severity that produces exit code 1 (default:
low)
--color {auto,always,never}
colour the severity column (default: auto)
--quiet print nothing; use the exit code only
--list-checks list every check and the formats it runs on, then exit
--version show program's version number and exit
exit codes: 0 clean, 1 findings, 2 error. Every finding is reported with the
sheet, column and row or range it came from.
| Flag | What it does |
|---|---|
--header-row N |
Use row N as the header row (default 1). The report says when a title row above the header looks likely. |
--json |
Emit the same report as JSON, for a script or a findings spreadsheet. |
--max-examples N |
How many locations to list per finding (default 8). The true count is always reported. |
--fail-on |
Lowest severity producing exit code 1 (default low). |
--color |
auto (default, honours NO_COLOR), always, never. JSON is never coloured. |
--quiet |
Print nothing; use the exit code only. |
--list-checks |
List every check and the formats it runs on. |
--version |
Print the version. |
JSON
--json writes one object carrying the same information as the text report,
including every locator:
{
"tool": "sheet-lint",
"version": "0.1.0",
"file": "tests/fixtures/blank_key.csv",
"format": "csv",
"sheets": [
{
"name": "blank_key",
"kind": "csv",
"columns": 7,
"data_rows": 15,
"records": 15,
"first_data_row": 2,
"last_data_row": 16,
"identifier_column": {
"letter": "A",
"header": "Invoice ID",
"reason": "column A header \"Invoice ID\" names an identifier",
"distinct_values": 12
}
}
],
"summary": {
"findings": 1,
"high": 1,
"medium": 0,
...
}
Use it as a library
The CLI is a thin wrapper over the same code:
from sheet_lint import lint_file
result = lint_file("book.xlsx", header_row=1)
print(result.clean, len(result.findings), result.severity_counts())
for finding in result.findings:
print(finding.severity.value.upper(), finding.code, "-", finding.summary)
for location in finding.locations:
print(" ", location.render()) # e.g. Orders!F14 = SUM(F2:F12) ...
What it checks
21 checks, as listed by sheet-lint --list-checks:
sheet-lint 0.1.0 runs 21 checks:
duplicate_key csv, xlsx Duplicate identifier values
duplicate_rows csv, xlsx Whole rows repeated
near_duplicate_records csv, xlsx Near-duplicate records
missing_key_column csv, xlsx No identifier column
blank_key_values csv, xlsx Records with no identifier
padded_key_values csv, xlsx Identifier values with stray whitespace
inconsistent_key_format csv, xlsx Identifier column with mixed conventions
key_sequence_gap csv, xlsx Gaps in the identifier sequence
numbers_stored_as_text csv, xlsx Numbers held as text
mixed_types_in_column csv, xlsx Mixed types in one column
rows_after_gap csv, xlsx Blank rows with data underneath
total_row_not_last csv, xlsx A total row is not the last row
hidden_rows xlsx Hidden rows or columns
blank_header csv, xlsx Columns with data and no header
duplicate_header csv, xlsx Two columns with the same header
header_row_missing csv, xlsx No header row
header_content_mismatch csv, xlsx Header does not match the column
possible_title_row csv, xlsx A title row above the header row
range_stops_short xlsx A formula range stops before the data ends
hardcoded_in_formula_column xlsx A typed value inside a calculated column
literal_constant_in_formulas xlsx A constant typed into several formulas
A CSV run is honest about what it skipped: a CSV has no formulas and no hidden
rows, so the .xlsx-only checks do not run, and the report says so in its
notes section instead of implying a clean bill of health.
How the checks decide
- Records are counted on an identifier, not on rows. sheet-lint picks one
identifier column per sheet - by header (
Invoice ID,Order Number,SKU,Ref) or, failing that, the leftmost column whose values are all distinct. Findings quote figures like "19 populated Invoice ID values collapse to 17 distinct identifiers". Identity ignores case, spacing, punctuation and leading zeros, so007and7are one identifier written two ways. - A sheet with no usable identifier is reported as such
(
missing_key_column) rather than keyed on an arbitrary column. - Header checks use a vocabulary of header words.
Amount,Total,Qty,Date,Email,Invoice IDand similar words carry an expectation; a header sheet-lint does not recognise is left alone instead of guessed at. - Near-duplicate records are compared on everything except the identifier column, at 90% text similarity or better, after blocking rows by vocabulary overlap and length so that the comparison stays affordable.
- Every finding carries a locator - a cell such as
Orders!F14or a range such asOrders!A14:G17. TheFindingtype refuses to be built without one, so an unlocatable claim cannot reach the report. - Excel dates are read through the cell format. A workbook stores a date
as a serial number plus a date number format, so
45659is recognised as a date because the cell is formatted as one - which is whydates.xlsx, a clean workbook of real dates, produces no findings. - Nothing is recalculated. Formulas are read as stored, cached results included.
What it does not do
- It never changes your file. It only reads.
- It does not recalculate formulas. Cached values are used as stored, so a stale cached value is linted exactly as the file holds it.
- It does not know your business rules. It cannot tell that a formula multiplies the wrong two columns, that a price is out of date, or that a total is impossible for your business. It finds structural defects, not wrong intentions.
- It does not read every spreadsheet format.
.csv,.tsv,.txt,.xlsxand.xlsmonly - not the legacy binary.xls, not.ods, not a Google Sheets URL, not a password-protected workbook. .xlsxsupport is a defined subset: cell values, formulas, shared strings, hidden rows and columns, and number formats (used to recognise real dates and Text-formatted cells). Charts, pivot tables, defined names, conditional formatting and data validation are outside it and are skipped without complaint. It never fakes a part it did not read.- CSV has no types, so type checks work by inference. A CSV cell is text; sheet-lint infers what it looks like and reports what it observed.
- The near-duplicate scan has a finite pair budget. On a very large sheet the report says when that budget ran out instead of quietly returning a short answer.
- Silence is not certification. A check that reports nothing found nothing; it is not a statement that the column is correct.
- It stops rather than samples. A file with more than 50,000 rows, or more
than 1,024 populated columns in a sheet, is reported as an error (exit code
2) instead of being analysed in part. A partial audit that looks complete is worse than no audit. - No telemetry, no network, no state. It reads one file and prints a report.
Tests
The suite uses unittest from the standard library and needs no runner:
$ PYTHONPATH=src python3 -m unittest discover -s tests -v
= 152 tests, all passing on Python 3.9 and Python 3.14. After
pip install -e . the same command runs without PYTHONPATH.
The fixtures in tests/fixtures/ are the suite's evidence, and each one is a
small deliberate case:
| Fixture | What it demonstrates |
|---|---|
clean.csv |
40 tidy records: the tool must find nothing at all |
duplicates.csv |
Two repeated keys and one customer entered twice, one word apart |
blank_key.csv |
Three records with no identifier, on non-sequential account numbers |
trailing_rows.csv |
Two blank rows with six records underneath them |
key_gaps.csv |
An unbroken invoice sequence with three numbers missing |
key_format.csv |
Identifiers with stray whitespace, and two separator conventions |
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 |
dates.xlsx |
Real Excel dates - serial numbers wearing a date format - in a workbook that must be reported clean |
Every fixture is invented. No real workbook contributed to this repository, and no organisation or person is named anywhere in it.
Regenerating the fixtures
The .xlsx fixture is a real workbook written by a small script that builds
the OOXML parts with zipfile and xml.etree - no spreadsheet library
involved, because the project has no dependencies:
$ python3 tools/make_fixtures.py
The output is deterministic, and a test asserts that the committed fixtures match what the generator produces byte for byte.
Layout
src/sheet_lint/
cli.py argument parsing, exit codes, stream handling
engine.py load, run every applicable check, rank the findings
model.py Cell, Sheet, Location, Finding, Severity
infer.py what a value looks like it is (number, date, email, text)
profile.py per-column measurements, identifier column selection
context.py the facts handed to each check
loaders/ csv_loader.py, xlsx_loader.py (zipfile + xml.etree)
checks/ one module per family of defects
report.py ranked plain text, or JSON
tools/
make_fixtures.py regenerates tests/fixtures
tests/
test_*.py unittest suite, one module per area
License
MIT. See LICENSE.
Metadata
Release files for sheet-lint 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| sheet_lint-0.1.0.tar.gz | 71.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sheet_lint-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 133.5 kB
Release files / sheet_lint-0.1.0.tar.gz
| Download URL | sheet_lint-0.1.0.tar.gz |
|---|---|
| Size | 71.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
285f2186debd43b22892cc80c80d8b7c9ed38d3725c5134eb50e69a7c759ef31
|
|
BLAKE2b-256 checksum How to use checksums |
1079f95f9bcce1121417ad264d5c48b650f683c6d4de5d759186f9716f050cd3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|
Release files / sheet_lint-0.1.0-py3-none-any.whl
| Download URL | sheet_lint-0.1.0-py3-none-any.whl |
|---|---|
| Size | 61.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
abec6a88485aedaa99e0b927e91dfed0e54dc0cba0e26681fba5285cb0f63c00
|
|
BLAKE2b-256 checksum How to use checksums |
288979ba52e793340c0a2dd9ac77c255110bf3722072f64ec6125659dc86401f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|