sheetdelta 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.
- sheetdelta-0.1.0/.github/workflows/ci.yml +23 -0
- sheetdelta-0.1.0/.github/workflows/release.yml +20 -0
- sheetdelta-0.1.0/.gitignore +12 -0
- sheetdelta-0.1.0/AGENTS.md +61 -0
- sheetdelta-0.1.0/CHANGELOG.md +26 -0
- sheetdelta-0.1.0/LICENSE +21 -0
- sheetdelta-0.1.0/PKG-INFO +250 -0
- sheetdelta-0.1.0/README.md +217 -0
- sheetdelta-0.1.0/pyproject.toml +80 -0
- sheetdelta-0.1.0/src/sheetdelta/__init__.py +65 -0
- sheetdelta-0.1.0/src/sheetdelta/__main__.py +8 -0
- sheetdelta-0.1.0/src/sheetdelta/audit.py +169 -0
- sheetdelta-0.1.0/src/sheetdelta/cli.py +140 -0
- sheetdelta-0.1.0/src/sheetdelta/differ.py +361 -0
- sheetdelta-0.1.0/src/sheetdelta/errors.py +19 -0
- sheetdelta-0.1.0/src/sheetdelta/model.py +162 -0
- sheetdelta-0.1.0/src/sheetdelta/reader.py +410 -0
- sheetdelta-0.1.0/src/sheetdelta/references.py +155 -0
- sheetdelta-0.1.0/src/sheetdelta/report.py +181 -0
- sheetdelta-0.1.0/tests/__init__.py +0 -0
- sheetdelta-0.1.0/tests/test_cli.py +224 -0
- sheetdelta-0.1.0/tests/test_differ.py +224 -0
- sheetdelta-0.1.0/tests/test_interop.py +81 -0
- sheetdelta-0.1.0/tests/test_reader.py +200 -0
- sheetdelta-0.1.0/tests/xlsx_fixtures.py +171 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- run: python -m pip install --upgrade pip
|
|
20
|
+
- run: python -m pip install -e ".[dev]"
|
|
21
|
+
- run: python -m ruff check src tests
|
|
22
|
+
- run: python -m mypy
|
|
23
|
+
- run: python -m pytest
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
environment: pypi
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write # mint the OIDC token PyPI trusts
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
- run: python -m pip install --upgrade build
|
|
19
|
+
- run: python -m build
|
|
20
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
Repository notes for anyone (human or agent) working on sheetdelta.
|
|
4
|
+
|
|
5
|
+
## What this is
|
|
6
|
+
|
|
7
|
+
A headless `.xlsx` differ. It compares two workbooks cell by cell, including
|
|
8
|
+
the formulas and the values Excel cached, then follows each change through the
|
|
9
|
+
workbook's dependency graph to report what it reaches downstream. Read-only,
|
|
10
|
+
standard library only, no Excel.
|
|
11
|
+
|
|
12
|
+
## Commands
|
|
13
|
+
|
|
14
|
+
```console
|
|
15
|
+
python -m pip install -e ".[dev]" # install with dev tools
|
|
16
|
+
python -m pytest # tests
|
|
17
|
+
python -m ruff check src tests # lint
|
|
18
|
+
python -m mypy # types (strict on src)
|
|
19
|
+
python -m build # wheel + sdist
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Run all three checks before pushing; CI runs them on 3.10 through 3.13.
|
|
23
|
+
|
|
24
|
+
## Layout
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
src/sheetdelta/
|
|
28
|
+
model.py dataclasses: CellRef, RangeRef, Cell, Sheet, Workbook
|
|
29
|
+
errors.py the exception hierarchy, all under SheetDeltaError
|
|
30
|
+
references.py pulls cell/range references out of a formula
|
|
31
|
+
reader.py parses .xlsx (zipfile + xml.etree)
|
|
32
|
+
differ.py compares two workbooks, builds the dependency graph
|
|
33
|
+
audit.py checks one workbook for broken and circular references
|
|
34
|
+
report.py text and JSON rendering, exit codes
|
|
35
|
+
cli.py argparse entry point
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Things worth knowing before you change something
|
|
39
|
+
|
|
40
|
+
- **The reader has no dependencies on purpose.** It is `zipfile` and
|
|
41
|
+
`xml.etree`. Do not add openpyxl as a runtime dependency; it is a dev-only
|
|
42
|
+
tool used to write reference workbooks for `tests/test_interop.py`.
|
|
43
|
+
- **Formulas are never evaluated.** Only the value Excel cached is compared.
|
|
44
|
+
There is no calculation engine and adding one would defeat the point.
|
|
45
|
+
- **Ranges are never expanded.** `SUM(A:A)` covers a million cells; the graph
|
|
46
|
+
asks `RangeRef.contains` instead of materialising them.
|
|
47
|
+
- **The dependency graph spans the whole workbook**, not one sheet, so a change
|
|
48
|
+
in one sheet is followed into the sheet that reads it. A formula that reads
|
|
49
|
+
its own cell (a total inside its own range) is excluded from the graph.
|
|
50
|
+
- **Strings are not always in `<v>`.** openpyxl writes text as
|
|
51
|
+
`<is><t>...</t></is>`. Both shapes are handled in `_raw_value`; the interop
|
|
52
|
+
test is what caught this.
|
|
53
|
+
- **Shared formulas must be shifted.** Excel stores a repeated formula once and
|
|
54
|
+
refers to it from other cells with relative offsets applied. See
|
|
55
|
+
`shift_formula`.
|
|
56
|
+
|
|
57
|
+
## Test fixtures
|
|
58
|
+
|
|
59
|
+
`tests/xlsx_fixtures.py` writes `.xlsx` files with the standard library, so the
|
|
60
|
+
suite runs with no dependencies. `tests/test_interop.py` checks the same reader
|
|
61
|
+
against workbooks written by openpyxl, and skips if openpyxl is absent.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-10-07
|
|
8
|
+
|
|
9
|
+
First release.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `sheetdelta diff OLD NEW`: compares two `.xlsx` workbooks cell by cell,
|
|
14
|
+
including formulas and the values Excel cached, and follows each change
|
|
15
|
+
through the workbook's dependency graph to the cells it reaches.
|
|
16
|
+
- `sheetdelta audit FILE`: reports formulas whose references do not resolve --
|
|
17
|
+
a missing sheet, or a range outside the used area.
|
|
18
|
+
- `--fail-on {never,any,breaking}` to set the exit code, so the tool works as a
|
|
19
|
+
CI check without extra scripting.
|
|
20
|
+
- `--json` output for both subcommands.
|
|
21
|
+
- A standard-library `.xlsx` reader: no runtime dependencies.
|
|
22
|
+
- Rename detection, so renaming a sheet does not report every cell as changed.
|
|
23
|
+
- Stale-cell detection, for a formula edited without the workbook being
|
|
24
|
+
recalculated.
|
|
25
|
+
|
|
26
|
+
[0.1.0]: https://github.com/sqmyou/sheetdelta/releases/tag/v0.1.0
|
sheetdelta-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sqm
|
|
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,250 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sheetdelta
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Diff Excel workbooks without Excel: formulas, values and dependency impact.
|
|
5
|
+
Project-URL: Homepage, https://github.com/sqmyou/sheetdelta
|
|
6
|
+
Project-URL: Repository, https://github.com/sqmyou/sheetdelta
|
|
7
|
+
Project-URL: Changelog, https://github.com/sqmyou/sheetdelta/blob/main/CHANGELOG.md
|
|
8
|
+
Author: sqm
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ci,cli,dependency-graph,diff,excel,spreadsheet,xlsx,xlsx-diff
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Office/Business :: Financial :: Spreadsheet
|
|
24
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
29
|
+
Requires-Dist: openpyxl>=3.1; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# sheetdelta
|
|
35
|
+
|
|
36
|
+
Compare two Excel workbooks and find out what actually changed.
|
|
37
|
+
|
|
38
|
+
`sheetdelta` reads two `.xlsx` files, shows you the difference cell by cell, and
|
|
39
|
+
then tells you which of those changes reach other cells. It runs on Linux, in
|
|
40
|
+
CI, with no copy of Excel anywhere near it.
|
|
41
|
+
|
|
42
|
+
```console
|
|
43
|
+
$ sheetdelta diff budget_v1.xlsx budget_v2.xlsx
|
|
44
|
+
budget_v1.xlsx -> budget_v2.xlsx
|
|
45
|
+
|
|
46
|
+
Sheet 'Revenue' (1 change, 1 breaking)
|
|
47
|
+
!! D12 formula changed
|
|
48
|
+
- =SUM(D2:D11)
|
|
49
|
+
+ =SUM(D2:D13)
|
|
50
|
+
affects 3 cells downstream: Revenue!E12, Revenue!F12, Summary!B4
|
|
51
|
+
|
|
52
|
+
Sheet 'Summary' (1 change)
|
|
53
|
+
! B7 value changed
|
|
54
|
+
- 1200
|
|
55
|
+
+ 1450
|
|
56
|
+
|
|
57
|
+
1 formula; 1 value across 2 edited sheet(s). 1 breaking change reaching 3 downstream cells.
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Why
|
|
61
|
+
|
|
62
|
+
Two things go wrong when a spreadsheet changes and nobody looks closely.
|
|
63
|
+
|
|
64
|
+
The first is the change itself. `git diff` on an `.xlsx` gives you binary
|
|
65
|
+
noise, so the usual answer is to open both files side by side and squint.
|
|
66
|
+
|
|
67
|
+
The second is worse. A formula edit looks harmless on the sheet you are
|
|
68
|
+
looking at, but the cell feeds a summary three sheets away. The number that
|
|
69
|
+
matters is now wrong, and nothing tells you.
|
|
70
|
+
|
|
71
|
+
`sheetdelta` answers both. Every change is followed through the dependency
|
|
72
|
+
graph, so a formula edit reports the cells it reaches, and a CI job can fail
|
|
73
|
+
the build on exactly that.
|
|
74
|
+
|
|
75
|
+
## Install
|
|
76
|
+
|
|
77
|
+
```console
|
|
78
|
+
pip install sheetdelta
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
No dependencies. The reader is `zipfile` and `xml.etree` from the standard
|
|
82
|
+
library, so it drops into any environment, including a build container that
|
|
83
|
+
has nothing but Python.
|
|
84
|
+
|
|
85
|
+
## Usage
|
|
86
|
+
|
|
87
|
+
### Compare two workbooks
|
|
88
|
+
|
|
89
|
+
```console
|
|
90
|
+
sheetdelta diff old.xlsx new.xlsx
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Exit status is `0` when nothing breaking changed, `1` when something did, `2`
|
|
94
|
+
on an error. That makes it a CI check without any extra scripting:
|
|
95
|
+
|
|
96
|
+
```yaml
|
|
97
|
+
- run: pip install sheetdelta
|
|
98
|
+
- run: sheetdelta diff before.xlsx after.xlsx --fail-on breaking
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`--fail-on` takes three values:
|
|
102
|
+
|
|
103
|
+
| Value | Fails when |
|
|
104
|
+
|---|---|
|
|
105
|
+
| `breaking` (default) | a change reaches another cell |
|
|
106
|
+
| `any` | anything changed at all |
|
|
107
|
+
| `never` | never; useful for a report-only run |
|
|
108
|
+
|
|
109
|
+
### Machine-readable output
|
|
110
|
+
|
|
111
|
+
```console
|
|
112
|
+
sheetdelta diff old.xlsx new.xlsx --json
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"old": "budget_v1.xlsx",
|
|
118
|
+
"new": "budget_v2.xlsx",
|
|
119
|
+
"has_changes": true,
|
|
120
|
+
"summary": {
|
|
121
|
+
"cell_changes": {"formula": 1, "value": 1},
|
|
122
|
+
"breaking": 1
|
|
123
|
+
},
|
|
124
|
+
"sheets": [
|
|
125
|
+
{
|
|
126
|
+
"kind": "changed",
|
|
127
|
+
"name": "Revenue",
|
|
128
|
+
"old_name": "Revenue",
|
|
129
|
+
"changes": [
|
|
130
|
+
{
|
|
131
|
+
"cell": "Revenue!D12",
|
|
132
|
+
"a1": "D12",
|
|
133
|
+
"kind": "formula",
|
|
134
|
+
"severity": "breaking",
|
|
135
|
+
"detail": "formula changed",
|
|
136
|
+
"old": "=SUM(D2:D11)",
|
|
137
|
+
"new": "=SUM(D2:D13)",
|
|
138
|
+
"affected": ["Revenue!E12", "Revenue!F12", "Summary!B4"]
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Check one workbook
|
|
147
|
+
|
|
148
|
+
```console
|
|
149
|
+
sheetdelta audit workbook.xlsx
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
An audit looks for the two defects that make a workbook unsound:
|
|
153
|
+
|
|
154
|
+
- a formula pointing at a sheet that does not exist, which Excel turns into
|
|
155
|
+
`#REF!` when it recalculates; and
|
|
156
|
+
- a circular reference, a cell that depends on itself through a chain of other
|
|
157
|
+
cells, which Excel refuses to calculate at all.
|
|
158
|
+
|
|
159
|
+
```console
|
|
160
|
+
$ sheetdelta audit model.xlsx
|
|
161
|
+
model.xlsx
|
|
162
|
+
3 sheet(s), 412 cell(s), 118 formula(s)
|
|
163
|
+
|
|
164
|
+
1 broken reference(s):
|
|
165
|
+
!! C4 Removed Sheet!B2
|
|
166
|
+
sheet 'Removed Sheet' does not exist
|
|
167
|
+
='Removed Sheet'!B2*1.2
|
|
168
|
+
|
|
169
|
+
1 circular reference(s):
|
|
170
|
+
!! Revenue!D12 -> Revenue!E12 -> Summary!B4 -> Revenue!D12
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
A reference to a cell that is simply empty is not reported. That is normal in
|
|
174
|
+
a spreadsheet, and flagging it would make the audit useless on real files.
|
|
175
|
+
|
|
176
|
+
## What it reports
|
|
177
|
+
|
|
178
|
+
| Change | Severity |
|
|
179
|
+
|---|---|
|
|
180
|
+
| Formula changed, and other cells read it | breaking |
|
|
181
|
+
| Value changed, and other cells read it | breaking |
|
|
182
|
+
| Cell deleted, and other cells read it | breaking |
|
|
183
|
+
| Formula or value changed, nothing reads it | warning |
|
|
184
|
+
| Formula changed but the cached value did not move | stale |
|
|
185
|
+
| New cell | info |
|
|
186
|
+
| Sheet added, removed or renamed | info |
|
|
187
|
+
|
|
188
|
+
The **stale** case is worth explaining. Excel stores both a formula and the
|
|
189
|
+
last value it calculated for it. When a file is edited by something that does
|
|
190
|
+
not recalculate, the two disagree. Excel will happily show you the new
|
|
191
|
+
formula next to the old number, which is a quiet way to ship a wrong total.
|
|
192
|
+
`sheetdelta` flags it.
|
|
193
|
+
|
|
194
|
+
A **rename** is detected by matching the contents of a removed sheet against
|
|
195
|
+
an added one. Without that, renaming a sheet would look like every cell in it
|
|
196
|
+
changed and bury the real diff.
|
|
197
|
+
|
|
198
|
+
## What it does not do
|
|
199
|
+
|
|
200
|
+
Being clear about this saves you time.
|
|
201
|
+
|
|
202
|
+
- **It does not evaluate formulas.** There is no calculation engine, and
|
|
203
|
+
there will not be one. Excel already stored the value it last computed, and
|
|
204
|
+
that is what gets compared. This is why the tool needs no Excel and no
|
|
205
|
+
dependencies, and why it is fast on large files.
|
|
206
|
+
- **It does not write or edit workbooks.** Read-only, by design.
|
|
207
|
+
- **It only reads `.xlsx` and `.xlsm`.** The old `.xls` and binary `.xlsb`
|
|
208
|
+
formats are rejected with a clear message rather than half-parsed.
|
|
209
|
+
- **It does not align rows across an insertion.** If a row is inserted near
|
|
210
|
+
the top, every cell below it reports as changed. Matching rows by content
|
|
211
|
+
is the hard part of spreadsheet diffing and is not implemented yet.
|
|
212
|
+
- **It does not read VBA macros** inside `.xlsm` files.
|
|
213
|
+
|
|
214
|
+
## Using it as a library
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
from sheetdelta import diff_workbooks, read_workbook
|
|
218
|
+
|
|
219
|
+
result = diff_workbooks(read_workbook("old.xlsx"), read_workbook("new.xlsx"))
|
|
220
|
+
|
|
221
|
+
for change in result.breaking:
|
|
222
|
+
print(change.ref, "->", change.affected)
|
|
223
|
+
print(" was", change.old)
|
|
224
|
+
print(" now", change.new)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Every type is exported from the package root, and the whole thing is typed.
|
|
228
|
+
|
|
229
|
+
## How it works
|
|
230
|
+
|
|
231
|
+
An `.xlsx` file is a zip containing XML. `sheetdelta` reads the sheet list, the
|
|
232
|
+
shared string table, the number formats and each sheet's cells, then stops.
|
|
233
|
+
|
|
234
|
+
Each formula is scanned for the cells and ranges it mentions. That scan is
|
|
235
|
+
more careful than it looks: `LOG10(...)` looks exactly like a reference to
|
|
236
|
+
column LOG row 10, and `"see A1"` looks like a reference to A1. Both are
|
|
237
|
+
handled, because a dependency graph built on false references is worse than
|
|
238
|
+
no graph.
|
|
239
|
+
|
|
240
|
+
References are kept as ranges rather than expanded. `SUM(A:A)` covers a
|
|
241
|
+
million cells, and expanding it would cost more than the entire diff; the
|
|
242
|
+
graph asks whether a range contains a cell instead.
|
|
243
|
+
|
|
244
|
+
## Requirements
|
|
245
|
+
|
|
246
|
+
Python 3.10 or newer. No runtime dependencies.
|
|
247
|
+
|
|
248
|
+
## License
|
|
249
|
+
|
|
250
|
+
MIT.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# sheetdelta
|
|
2
|
+
|
|
3
|
+
Compare two Excel workbooks and find out what actually changed.
|
|
4
|
+
|
|
5
|
+
`sheetdelta` reads two `.xlsx` files, shows you the difference cell by cell, and
|
|
6
|
+
then tells you which of those changes reach other cells. It runs on Linux, in
|
|
7
|
+
CI, with no copy of Excel anywhere near it.
|
|
8
|
+
|
|
9
|
+
```console
|
|
10
|
+
$ sheetdelta diff budget_v1.xlsx budget_v2.xlsx
|
|
11
|
+
budget_v1.xlsx -> budget_v2.xlsx
|
|
12
|
+
|
|
13
|
+
Sheet 'Revenue' (1 change, 1 breaking)
|
|
14
|
+
!! D12 formula changed
|
|
15
|
+
- =SUM(D2:D11)
|
|
16
|
+
+ =SUM(D2:D13)
|
|
17
|
+
affects 3 cells downstream: Revenue!E12, Revenue!F12, Summary!B4
|
|
18
|
+
|
|
19
|
+
Sheet 'Summary' (1 change)
|
|
20
|
+
! B7 value changed
|
|
21
|
+
- 1200
|
|
22
|
+
+ 1450
|
|
23
|
+
|
|
24
|
+
1 formula; 1 value across 2 edited sheet(s). 1 breaking change reaching 3 downstream cells.
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Why
|
|
28
|
+
|
|
29
|
+
Two things go wrong when a spreadsheet changes and nobody looks closely.
|
|
30
|
+
|
|
31
|
+
The first is the change itself. `git diff` on an `.xlsx` gives you binary
|
|
32
|
+
noise, so the usual answer is to open both files side by side and squint.
|
|
33
|
+
|
|
34
|
+
The second is worse. A formula edit looks harmless on the sheet you are
|
|
35
|
+
looking at, but the cell feeds a summary three sheets away. The number that
|
|
36
|
+
matters is now wrong, and nothing tells you.
|
|
37
|
+
|
|
38
|
+
`sheetdelta` answers both. Every change is followed through the dependency
|
|
39
|
+
graph, so a formula edit reports the cells it reaches, and a CI job can fail
|
|
40
|
+
the build on exactly that.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```console
|
|
45
|
+
pip install sheetdelta
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
No dependencies. The reader is `zipfile` and `xml.etree` from the standard
|
|
49
|
+
library, so it drops into any environment, including a build container that
|
|
50
|
+
has nothing but Python.
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
### Compare two workbooks
|
|
55
|
+
|
|
56
|
+
```console
|
|
57
|
+
sheetdelta diff old.xlsx new.xlsx
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Exit status is `0` when nothing breaking changed, `1` when something did, `2`
|
|
61
|
+
on an error. That makes it a CI check without any extra scripting:
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
- run: pip install sheetdelta
|
|
65
|
+
- run: sheetdelta diff before.xlsx after.xlsx --fail-on breaking
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`--fail-on` takes three values:
|
|
69
|
+
|
|
70
|
+
| Value | Fails when |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `breaking` (default) | a change reaches another cell |
|
|
73
|
+
| `any` | anything changed at all |
|
|
74
|
+
| `never` | never; useful for a report-only run |
|
|
75
|
+
|
|
76
|
+
### Machine-readable output
|
|
77
|
+
|
|
78
|
+
```console
|
|
79
|
+
sheetdelta diff old.xlsx new.xlsx --json
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"old": "budget_v1.xlsx",
|
|
85
|
+
"new": "budget_v2.xlsx",
|
|
86
|
+
"has_changes": true,
|
|
87
|
+
"summary": {
|
|
88
|
+
"cell_changes": {"formula": 1, "value": 1},
|
|
89
|
+
"breaking": 1
|
|
90
|
+
},
|
|
91
|
+
"sheets": [
|
|
92
|
+
{
|
|
93
|
+
"kind": "changed",
|
|
94
|
+
"name": "Revenue",
|
|
95
|
+
"old_name": "Revenue",
|
|
96
|
+
"changes": [
|
|
97
|
+
{
|
|
98
|
+
"cell": "Revenue!D12",
|
|
99
|
+
"a1": "D12",
|
|
100
|
+
"kind": "formula",
|
|
101
|
+
"severity": "breaking",
|
|
102
|
+
"detail": "formula changed",
|
|
103
|
+
"old": "=SUM(D2:D11)",
|
|
104
|
+
"new": "=SUM(D2:D13)",
|
|
105
|
+
"affected": ["Revenue!E12", "Revenue!F12", "Summary!B4"]
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Check one workbook
|
|
114
|
+
|
|
115
|
+
```console
|
|
116
|
+
sheetdelta audit workbook.xlsx
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
An audit looks for the two defects that make a workbook unsound:
|
|
120
|
+
|
|
121
|
+
- a formula pointing at a sheet that does not exist, which Excel turns into
|
|
122
|
+
`#REF!` when it recalculates; and
|
|
123
|
+
- a circular reference, a cell that depends on itself through a chain of other
|
|
124
|
+
cells, which Excel refuses to calculate at all.
|
|
125
|
+
|
|
126
|
+
```console
|
|
127
|
+
$ sheetdelta audit model.xlsx
|
|
128
|
+
model.xlsx
|
|
129
|
+
3 sheet(s), 412 cell(s), 118 formula(s)
|
|
130
|
+
|
|
131
|
+
1 broken reference(s):
|
|
132
|
+
!! C4 Removed Sheet!B2
|
|
133
|
+
sheet 'Removed Sheet' does not exist
|
|
134
|
+
='Removed Sheet'!B2*1.2
|
|
135
|
+
|
|
136
|
+
1 circular reference(s):
|
|
137
|
+
!! Revenue!D12 -> Revenue!E12 -> Summary!B4 -> Revenue!D12
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
A reference to a cell that is simply empty is not reported. That is normal in
|
|
141
|
+
a spreadsheet, and flagging it would make the audit useless on real files.
|
|
142
|
+
|
|
143
|
+
## What it reports
|
|
144
|
+
|
|
145
|
+
| Change | Severity |
|
|
146
|
+
|---|---|
|
|
147
|
+
| Formula changed, and other cells read it | breaking |
|
|
148
|
+
| Value changed, and other cells read it | breaking |
|
|
149
|
+
| Cell deleted, and other cells read it | breaking |
|
|
150
|
+
| Formula or value changed, nothing reads it | warning |
|
|
151
|
+
| Formula changed but the cached value did not move | stale |
|
|
152
|
+
| New cell | info |
|
|
153
|
+
| Sheet added, removed or renamed | info |
|
|
154
|
+
|
|
155
|
+
The **stale** case is worth explaining. Excel stores both a formula and the
|
|
156
|
+
last value it calculated for it. When a file is edited by something that does
|
|
157
|
+
not recalculate, the two disagree. Excel will happily show you the new
|
|
158
|
+
formula next to the old number, which is a quiet way to ship a wrong total.
|
|
159
|
+
`sheetdelta` flags it.
|
|
160
|
+
|
|
161
|
+
A **rename** is detected by matching the contents of a removed sheet against
|
|
162
|
+
an added one. Without that, renaming a sheet would look like every cell in it
|
|
163
|
+
changed and bury the real diff.
|
|
164
|
+
|
|
165
|
+
## What it does not do
|
|
166
|
+
|
|
167
|
+
Being clear about this saves you time.
|
|
168
|
+
|
|
169
|
+
- **It does not evaluate formulas.** There is no calculation engine, and
|
|
170
|
+
there will not be one. Excel already stored the value it last computed, and
|
|
171
|
+
that is what gets compared. This is why the tool needs no Excel and no
|
|
172
|
+
dependencies, and why it is fast on large files.
|
|
173
|
+
- **It does not write or edit workbooks.** Read-only, by design.
|
|
174
|
+
- **It only reads `.xlsx` and `.xlsm`.** The old `.xls` and binary `.xlsb`
|
|
175
|
+
formats are rejected with a clear message rather than half-parsed.
|
|
176
|
+
- **It does not align rows across an insertion.** If a row is inserted near
|
|
177
|
+
the top, every cell below it reports as changed. Matching rows by content
|
|
178
|
+
is the hard part of spreadsheet diffing and is not implemented yet.
|
|
179
|
+
- **It does not read VBA macros** inside `.xlsm` files.
|
|
180
|
+
|
|
181
|
+
## Using it as a library
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
from sheetdelta import diff_workbooks, read_workbook
|
|
185
|
+
|
|
186
|
+
result = diff_workbooks(read_workbook("old.xlsx"), read_workbook("new.xlsx"))
|
|
187
|
+
|
|
188
|
+
for change in result.breaking:
|
|
189
|
+
print(change.ref, "->", change.affected)
|
|
190
|
+
print(" was", change.old)
|
|
191
|
+
print(" now", change.new)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Every type is exported from the package root, and the whole thing is typed.
|
|
195
|
+
|
|
196
|
+
## How it works
|
|
197
|
+
|
|
198
|
+
An `.xlsx` file is a zip containing XML. `sheetdelta` reads the sheet list, the
|
|
199
|
+
shared string table, the number formats and each sheet's cells, then stops.
|
|
200
|
+
|
|
201
|
+
Each formula is scanned for the cells and ranges it mentions. That scan is
|
|
202
|
+
more careful than it looks: `LOG10(...)` looks exactly like a reference to
|
|
203
|
+
column LOG row 10, and `"see A1"` looks like a reference to A1. Both are
|
|
204
|
+
handled, because a dependency graph built on false references is worse than
|
|
205
|
+
no graph.
|
|
206
|
+
|
|
207
|
+
References are kept as ranges rather than expanded. `SUM(A:A)` covers a
|
|
208
|
+
million cells, and expanding it would cost more than the entire diff; the
|
|
209
|
+
graph asks whether a range contains a cell instead.
|
|
210
|
+
|
|
211
|
+
## Requirements
|
|
212
|
+
|
|
213
|
+
Python 3.10 or newer. No runtime dependencies.
|
|
214
|
+
|
|
215
|
+
## License
|
|
216
|
+
|
|
217
|
+
MIT.
|